Compare commits

...
22 Commits
Author SHA1 Message Date
zyycnandCodex 5168f6a924 fix(openai): replace stale passive rate-limit snapshots
Treat each passive response-header observation as a complete rate-limit bucket snapshot, preventing stale secondary windows from surviving into a newer primary-only update.\n\nExtract duplicate OAuth callback parameter rejection to keep the release quality gate warning-free.

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

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

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

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

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

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

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

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

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

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

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

Cover the provider replay path and transport classification with tests.

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

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

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

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

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

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

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

* fix: enable ed25519 PKCS8 allocation support

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

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

---------

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

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

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

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

* fix: adapt jsonwebtoken 11 header extras API

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

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

---------

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


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

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

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

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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Codex <noreply@openai.com>
2026-07-27 11:30:59 +08:00
124 changed files with 2418 additions and 2174 deletions
+2 -2
View File
@@ -40,7 +40,7 @@ jobs:
} >> "$GITHUB_ENV"
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref }}
@@ -76,7 +76,7 @@ jobs:
${{ hashFiles('trivy-results.sarif') != '' &&
(github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository) }}
uses: github/codeql-action/upload-sarif@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
with:
sarif_file: trivy-results.sarif
category: runtime-container
+3 -3
View File
@@ -61,7 +61,7 @@ jobs:
--health-retries 10
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref }}
# PR 场景需要 base 提交做冻结清单的 append-only 比对。
@@ -152,7 +152,7 @@ jobs:
CI: 'true'
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref }}
@@ -191,7 +191,7 @@ jobs:
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref }}
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
container: ${{ steps.filter.outputs.container }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
+8 -8
View File
@@ -41,7 +41,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
fetch-depth: 0
@@ -177,7 +177,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.release-info.outputs.git_sha }}
@@ -237,7 +237,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.release-info.outputs.git_sha }}
@@ -289,7 +289,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.release-info.outputs.git_sha }}
@@ -408,7 +408,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.release-info.outputs.git_sha }}
@@ -454,7 +454,7 @@ jobs:
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Login to GHCR
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -508,7 +508,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.release-info.outputs.git_sha }}
@@ -525,7 +525,7 @@ jobs:
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Login to GHCR
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
+2 -2
View File
@@ -23,7 +23,7 @@ jobs:
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
@@ -47,7 +47,7 @@ jobs:
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
+124 -59
View File
@@ -142,7 +142,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -208,7 +208,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"base64",
"base64 0.22.1",
"bytes",
"form_urlencoded",
"futures-util",
@@ -275,6 +275,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9"
[[package]]
name = "base64ct"
version = "1.8.3"
@@ -444,7 +450,7 @@ dependencies = [
"provider-openai",
"provider-xai",
"serde",
"syn 2.0.118",
"syn 3.0.3",
"tempfile",
"thiserror",
"tokio",
@@ -669,7 +675,7 @@ dependencies = [
"aead",
"blake2",
"crypto_secretbox",
"curve25519-dalek",
"curve25519-dalek 4.1.3",
"salsa20",
"subtle",
"zeroize",
@@ -708,8 +714,23 @@ dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"curve25519-dalek-derive",
"digest 0.10.7",
"fiat-crypto",
"fiat-crypto 0.2.9",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek"
version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"curve25519-dalek-derive",
"digest 0.11.3",
"fiat-crypto 0.3.0",
"rustc_version",
"subtle",
"zeroize",
@@ -723,7 +744,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -761,6 +782,16 @@ dependencies = [
"zeroize",
]
[[package]]
name = "der"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d"
dependencies = [
"const-oid 0.10.2",
"zeroize",
]
[[package]]
name = "deranged"
version = "0.5.8"
@@ -799,7 +830,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -829,34 +860,35 @@ version = "0.16.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
"der",
"der 0.7.10",
"digest 0.10.7",
"elliptic-curve",
"rfc6979",
"signature",
"spki",
"signature 2.2.0",
"spki 0.7.3",
]
[[package]]
name = "ed25519"
version = "2.2.3"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
dependencies = [
"pkcs8",
"signature",
"pkcs8 0.11.0",
"signature 3.0.0",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de"
dependencies = [
"curve25519-dalek",
"curve25519-dalek 5.0.0",
"ed25519",
"serde",
"sha2 0.10.9",
"sha2 0.11.0",
"signature 3.0.0",
"subtle",
"zeroize",
]
@@ -883,7 +915,7 @@ dependencies = [
"generic-array",
"group",
"pem-rfc7468",
"pkcs8",
"pkcs8 0.10.2",
"rand_core 0.6.4",
"sec1",
"subtle",
@@ -968,6 +1000,12 @@ version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "fiat-crypto"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
[[package]]
name = "filetime"
version = "0.2.29"
@@ -1100,7 +1138,7 @@ checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -1144,7 +1182,7 @@ version = "0.0.0"
dependencies = [
"argon2",
"async-trait",
"base64",
"base64 0.23.0",
"chrono",
"futures",
"gateway-core",
@@ -1165,7 +1203,7 @@ version = "0.1.0"
dependencies = [
"async-trait",
"axum",
"base64",
"base64 0.23.0",
"bytes",
"chrono",
"futures",
@@ -1371,7 +1409,7 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb"
dependencies = [
"base64",
"base64 0.22.1",
"bytes",
"headers-core",
"http",
@@ -1538,7 +1576,7 @@ version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"base64",
"base64 0.22.1",
"bytes",
"futures-channel",
"futures-util",
@@ -1736,19 +1774,20 @@ dependencies = [
[[package]]
name = "jsonwebtoken"
version = "10.3.0"
version = "11.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1"
checksum = "881733cbc631fc9e472e24447ce32a64bedf2da498d6d8570b08edc87de71f65"
dependencies = [
"aws-lc-rs",
"base64",
"base64 0.22.1",
"getrandom 0.2.17",
"js-sys",
"pem",
"serde",
"serde_json",
"signature",
"signature 2.2.0",
"simple_asn1",
"zeroize",
]
[[package]]
@@ -2015,7 +2054,7 @@ version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64",
"base64 0.22.1",
"serde_core",
]
@@ -2046,8 +2085,18 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
"der 0.7.10",
"spki 0.7.3",
]
[[package]]
name = "pkcs8"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"
dependencies = [
"der 0.8.1",
"spki 0.8.0",
]
[[package]]
@@ -2115,7 +2164,7 @@ version = "0.0.0"
dependencies = [
"async-stream",
"async-trait",
"base64",
"base64 0.23.0",
"bytes",
"chrono",
"cookie",
@@ -2125,7 +2174,7 @@ dependencies = [
"gateway-admin",
"gateway-core",
"gateway-protocol",
"getrandom 0.3.4",
"getrandom 0.4.3",
"hex",
"jsonwebtoken",
"reqwest",
@@ -2157,14 +2206,14 @@ version = "0.0.0"
dependencies = [
"async-stream",
"async-trait",
"base64",
"base64 0.23.0",
"bytes",
"chrono",
"futures",
"gateway-admin",
"gateway-core",
"gateway-protocol",
"getrandom 0.3.4",
"getrandom 0.4.3",
"jsonwebtoken",
"p256",
"reqwest",
@@ -2409,7 +2458,7 @@ version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64",
"base64 0.22.1",
"bytes",
"cookie",
"cookie_store",
@@ -2598,9 +2647,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [
"base16ct",
"der",
"der 0.7.10",
"generic-array",
"pkcs8",
"pkcs8 0.10.2",
"subtle",
"zeroize",
]
@@ -2796,6 +2845,12 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "signature"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5"
[[package]]
name = "simd-adler32"
version = "0.3.9"
@@ -2855,7 +2910,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
"der 0.7.10",
]
[[package]]
name = "spki"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f"
dependencies = [
"base64ct",
"der 0.8.1",
]
[[package]]
@@ -2877,7 +2942,7 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb"
dependencies = [
"base64",
"base64 0.22.1",
"bytes",
"cfg-if",
"chrono",
@@ -2918,7 +2983,7 @@ dependencies = [
"quote",
"sqlx-core",
"sqlx-macros-core",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -2941,7 +3006,7 @@ dependencies = [
"sqlx-mysql",
"sqlx-postgres",
"sqlx-sqlite",
"syn 2.0.118",
"syn 2.0.119",
"thiserror",
"tokio",
"url",
@@ -2981,7 +3046,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e"
dependencies = [
"atoi",
"base64",
"base64 0.22.1",
"bitflags",
"byteorder",
"chrono",
@@ -3066,9 +3131,9 @@ checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
[[package]]
name = "syn"
version = "2.0.118"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
@@ -3103,7 +3168,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -3124,7 +3189,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
@@ -3238,7 +3303,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -3414,7 +3479,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -3691,7 +3756,7 @@ dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
"wasm-bindgen-shared",
]
@@ -3773,7 +3838,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -3784,7 +3849,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -3909,7 +3974,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031"
dependencies = [
"assert-json-diff",
"base64",
"base64 0.22.1",
"deadpool",
"futures",
"http",
@@ -3983,7 +4048,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
"synstructure",
]
@@ -4004,7 +4069,7 @@ checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -4024,7 +4089,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
"synstructure",
]
@@ -4045,7 +4110,7 @@ checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
@@ -4078,7 +4143,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 2.0.119",
]
[[package]]
+1 -1
View File
@@ -25,7 +25,7 @@ tokio = { version = "1.52.3", default-features = false, features = ["rt-multi-th
[dev-dependencies]
config = { version = "0.15.23", default-features = false, features = ["yaml"] }
syn = { version = "2.0.118", features = ["full"] }
syn = { version = "3.0.3", features = ["full"] }
tempfile = "3.27.0"
[lints]
+1 -1
View File
@@ -14,7 +14,7 @@ path = "tests/main.rs"
[dependencies]
argon2 = "0.5.3"
async-trait = "0.1.89"
base64 = "0.22.1"
base64 = "0.23.0"
chrono = "0.4.45"
futures = "0.3.32"
gateway-core = { path = "../gateway-core" }
@@ -9,17 +9,8 @@ use gateway_core::routing::ProviderKind;
use super::{PageSize, Revision, observability::TimeRange};
/// Provider 账号当前可用性。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccountAvailability {
Unknown,
Ready,
Cooldown,
QuotaExhausted,
Expired,
Banned,
Invalid,
}
/// Provider 账号当前可用性;权威定义与字符串编解码在 gateway-core,此处仅复用。
pub use gateway_core::engine::credential::AccountAvailability;
/// 管理页使用的归一化账号状态。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -189,7 +180,6 @@ pub struct AccountSummary {
/// 账号启停写入命令。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetAccountEnabled {
pub expected_config_revision: Revision,
pub account_id: String,
pub enabled: bool,
}
@@ -197,7 +187,6 @@ pub struct SetAccountEnabled {
/// 账号批量删除命令。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteAccounts {
pub expected_config_revision: Revision,
pub account_ids: Vec<String>,
}
@@ -141,7 +141,6 @@ impl fmt::Debug for ClientKeySecret {
/// API 提交的 Client Key 创建命令。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateClientKey {
pub expected_config_revision: Revision,
pub name: String,
pub label: Option<String>,
pub provider_kind: ProviderKind,
@@ -151,7 +150,6 @@ pub struct CreateClientKey {
/// 管理用例生成 ID 与明文后的持久化命令。
#[derive(Clone, PartialEq, Eq)]
pub struct NewClientKey {
pub expected_config_revision: Revision,
pub id: ClientApiKeyId,
pub name: String,
pub label: Option<String>,
@@ -175,7 +173,6 @@ impl fmt::Debug for NewClientKey {
/// 修改 Client Key 的公开策略字段。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateClientKey {
pub expected_config_revision: Revision,
pub id: ClientApiKeyId,
pub name: String,
pub label: Option<String>,
@@ -186,7 +183,6 @@ pub struct UpdateClientKey {
/// 修改 Client Key 启用状态。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetClientKeyEnabled {
pub expected_config_revision: Revision,
pub id: ClientApiKeyId,
pub enabled: bool,
}
@@ -194,7 +190,6 @@ pub struct SetClientKeyEnabled {
/// 删除 Client Key。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteClientKey {
pub expected_config_revision: Revision,
pub id: ClientApiKeyId,
}
@@ -101,7 +101,6 @@ pub struct CredentialDetails {
/// Provider 正式文档批量导入命令。
pub struct ImportCredentials {
pub context: MutationContext,
pub expected_config_revision: Revision,
pub document: ProviderDocument,
}
@@ -110,7 +109,6 @@ impl fmt::Debug for ImportCredentials {
formatter
.debug_struct("ImportCredentials")
.field("context", &self.context)
.field("expected_config_revision", &self.expected_config_revision)
.field("document", &self.document)
.finish()
}
@@ -169,7 +167,6 @@ pub struct PreparedCredentialImport {
/// Admin 交给 Store 的导入事务命令。
#[derive(Debug, Clone, PartialEq)]
pub struct CredentialImportCommit {
pub expected_config_revision: Revision,
pub prepared: PreparedCredentialImport,
}
@@ -254,7 +251,6 @@ pub enum AuthorizationMutationTarget {
/// 必须完整进入 Provider opaque pending payload、并在 complete 后原样恢复的事务信封。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingAuthorizationMutation {
expected_config_revision: Revision,
provider_kind: ProviderKind,
target: AuthorizationMutationTarget,
owner_binding: AuthorizationOwnerBinding,
@@ -263,24 +259,17 @@ pub struct PendingAuthorizationMutation {
impl PendingAuthorizationMutation {
#[must_use]
pub const fn new(
expected_config_revision: Revision,
provider_kind: ProviderKind,
target: AuthorizationMutationTarget,
owner_binding: AuthorizationOwnerBinding,
) -> Self {
Self {
expected_config_revision,
provider_kind,
target,
owner_binding,
}
}
#[must_use]
pub const fn expected_config_revision(&self) -> Revision {
self.expected_config_revision
}
#[must_use]
pub const fn provider_kind(&self) -> &ProviderKind {
&self.provider_kind
@@ -301,7 +290,6 @@ impl PendingAuthorizationMutation {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StartAuthorization {
pub context: MutationContext,
pub expected_config_revision: Revision,
pub name: String,
pub reauthorization: Option<ReauthorizationTarget>,
}
@@ -406,7 +394,6 @@ impl fmt::Debug for CompleteAuthorization {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CredentialMutation {
pub context: MutationContext,
pub expected_config_revision: Revision,
pub account_id: ProviderAccountId,
}
@@ -422,7 +409,6 @@ pub struct CredentialMutationResult {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CredentialDeletion {
pub context: MutationContext,
pub expected_config_revision: Revision,
pub account_ids: Vec<ProviderAccountId>,
}
@@ -520,7 +506,6 @@ impl fmt::Debug for PreparedCredentialRotation {
/// Admin 交给 Store 的轮换或 refresh 事务命令。
#[derive(Debug, Clone, PartialEq)]
pub struct CredentialRotationCommit {
pub expected_config_revision: Revision,
pub prepared: PreparedCredentialRotationFacts,
}
@@ -39,7 +39,6 @@ pub struct RuntimeSettings {
/// 原子替换运行设置的命令。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReplaceRuntimeSettings {
pub expected_config_revision: Revision,
pub model_mappings: ModelMappings,
pub refresh_margin_seconds: u64,
pub refresh_concurrency: u32,
@@ -179,7 +179,7 @@ pub trait AuthStore: Send + Sync {
async fn append_audit_event(&self, event: AdminAuditEvent) -> AdminStoreResult<()>;
}
/// Client API Key 管理及其 revision CAS 写入。
/// Client API Key 管理写入。
#[async_trait]
pub trait ClientKeyStore: Send + Sync {
async fn list_client_keys(&self, query: ClientKeyListQuery) -> AdminStoreResult<ClientKeyPage>;
@@ -265,7 +265,7 @@ pub trait ObservabilityStore: Send + Sync {
async fn list_ops_errors(&self, query: OpsErrorQuery) -> AdminStoreResult<OpsErrorPage>;
}
/// Runtime settings 与管理员 API Key 的 revision CAS 写入。
/// Runtime settings 与管理员 API Key 写入。
#[async_trait]
pub trait SettingsStore: Send + Sync {
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings>;
@@ -280,14 +280,12 @@ pub trait SettingsStore: Send + Sync {
async fn replace_admin_api_key(
&self,
expected_config_revision: Revision,
key: AdminApiKey,
context: &MutationContext,
) -> AdminStoreResult<AdminApiKeyMutation>;
async fn delete_admin_api_key(
&self,
expected_config_revision: Revision,
context: &MutationContext,
) -> AdminStoreResult<AdminApiKeyMutation>;
}
@@ -15,7 +15,7 @@ use gateway_core::{
use crate::{
model::{
AdminError, MutationContext, Revision,
AdminError, MutationContext,
accounts::{
AccountConnectionTestEvent, AccountConnectionTestEventStream, AccountListQuery,
AccountRecord, AccountStatus, AccountUsageWindowQuery,
@@ -54,7 +54,6 @@ pub trait AccountsService: Send + Sync {
async fn refresh(
&self,
context: &MutationContext,
expected_config_revision: Revision,
account_id: ProviderAccountId,
) -> Result<AccountRefreshResult, AdminError>;
@@ -354,7 +353,6 @@ impl AccountsService for DefaultAccountsService {
async fn refresh(
&self,
context: &MutationContext,
expected_config_revision: Revision,
account_id: ProviderAccountId,
) -> Result<AccountRefreshResult, AdminError> {
let (account, provider) = self.provider_for_account(&account_id).await?;
@@ -367,7 +365,6 @@ impl AccountsService for DefaultAccountsService {
validate_prepared_rotation(&account, &prepared, "provider credential refresh")?;
let result = commit_credential_refresh(
self.accounts.as_ref(),
expected_config_revision,
prepared,
context,
"provider credential refresh",
@@ -94,7 +94,6 @@ impl ClientKeyService for DefaultClientKeyService {
.store
.create_client_key(
NewClientKey {
expected_config_revision: command.expected_config_revision,
id,
name: command.name,
label: command.label,
@@ -108,7 +108,6 @@ async fn pending_authorization(
},
};
Ok(PendingAuthorizationMutation::new(
command.expected_config_revision,
provider_kind.clone(),
target,
crate::model::provider_credentials::AuthorizationOwnerBinding::from_context(
@@ -215,20 +214,13 @@ async fn commit_authorization(
async fn commit_credential_rotation(
accounts: &dyn AccountStore,
expected_config_revision: crate::model::Revision,
prepared: PreparedCredentialRotation,
context: &MutationContext,
resource: &'static str,
) -> Result<CredentialMutationResult, AdminError> {
let (facts, guard) = prepared.into_parts();
match accounts
.commit_credential_rotation(
CredentialRotationCommit {
expected_config_revision,
prepared: facts,
},
context,
)
.commit_credential_rotation(CredentialRotationCommit { prepared: facts }, context)
.await
{
Ok(result) => {
@@ -244,20 +236,13 @@ async fn commit_credential_rotation(
async fn commit_credential_refresh(
accounts: &dyn AccountStore,
expected_config_revision: crate::model::Revision,
prepared: PreparedCredentialRotation,
context: &MutationContext,
resource: &'static str,
) -> Result<CredentialMutationResult, AdminError> {
let (facts, guard) = prepared.into_parts();
match accounts
.commit_credential_refresh(
CredentialRotationCommit {
expected_config_revision,
prepared: facts,
},
context,
)
.commit_credential_refresh(CredentialRotationCommit { prepared: facts }, context)
.await
{
Ok(result) => {
@@ -289,7 +274,6 @@ async fn set_credential_enabled(
let revision = accounts
.set_account_enabled(
SetAccountEnabled {
expected_config_revision: command.expected_config_revision,
account_id: account_id.as_str().to_owned(),
enabled,
},
@@ -320,7 +304,6 @@ async fn delete_credentials(
let revision = accounts
.delete_accounts(
DeleteAccounts {
expected_config_revision: command.expected_config_revision,
account_ids: account_ids
.iter()
.map(|account_id| account_id.as_str().to_owned())
@@ -112,7 +112,6 @@ impl OpenAiService for DefaultOpenAiService {
command: ImportCredentials,
) -> Result<CredentialImportResult, AdminError> {
let context = command.context;
let expected_config_revision = command.expected_config_revision;
let prepared = self
.provider
.prepare_import(PrepareCredentialImport {
@@ -127,13 +126,7 @@ impl OpenAiService for DefaultOpenAiService {
)?;
let result = self
.accounts
.commit_credential_import(
CredentialImportCommit {
expected_config_revision,
prepared,
},
&context,
)
.commit_credential_import(CredentialImportCommit { prepared }, &context)
.await
.map_err(|error| map_store_error(error, "OpenAI credential import"))?;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
@@ -200,7 +193,6 @@ impl OpenAiService for DefaultOpenAiService {
command: RotateCredential,
) -> Result<CredentialMutationResult, AdminError> {
let context = command.mutation.context;
let expected_config_revision = command.mutation.expected_config_revision;
let account_id = command.mutation.account_id;
let details = required_credential(
self.accounts.as_ref(),
@@ -227,7 +219,6 @@ impl OpenAiService for DefaultOpenAiService {
validate_prepared_rotation(&account, &prepared, "OpenAI credential rotation")?;
let result = commit_credential_rotation(
self.accounts.as_ref(),
expected_config_revision,
prepared,
&context,
"OpenAI credential rotation",
@@ -86,13 +86,12 @@ impl SettingsService for DefaultSettingsService {
&self,
context: &MutationContext,
) -> Result<RegeneratedAdminApiKey, AdminError> {
let settings = self.load().await?;
let mut bytes = [0_u8; 32];
OsRng.fill_bytes(&mut bytes);
let key = AdminApiKey::new(format!("admin-{}", hex::encode(bytes)));
let mutation = self
.store
.replace_admin_api_key(settings.config_revision, key.clone(), context)
.replace_admin_api_key(key.clone(), context)
.await
.map_err(|error| map_store_error(error, "administrator API key"))?;
publish_committed(self.snapshot.as_ref(), mutation.config_revision).await?;
@@ -103,10 +102,9 @@ impl SettingsService for DefaultSettingsService {
&self,
context: &MutationContext,
) -> Result<AdminApiKeyMutation, AdminError> {
let settings = self.load().await?;
let mutation = self
.store
.delete_admin_api_key(settings.config_revision, context)
.delete_admin_api_key(context)
.await
.map_err(|error| map_store_error(error, "administrator API key"))?;
publish_committed(self.snapshot.as_ref(), mutation.config_revision).await?;
@@ -89,7 +89,6 @@ impl XaiService for DefaultXaiService {
command: ImportCredentials,
) -> Result<CredentialImportResult, AdminError> {
let context = command.context;
let expected_config_revision = command.expected_config_revision;
let prepared = self
.provider
.prepare_import(PrepareCredentialImport {
@@ -104,13 +103,7 @@ impl XaiService for DefaultXaiService {
)?;
let result = self
.accounts
.commit_credential_import(
CredentialImportCommit {
expected_config_revision,
prepared,
},
&context,
)
.commit_credential_import(CredentialImportCommit { prepared }, &context)
.await
.map_err(|error| map_store_error(error, "xAI credential import"))?;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
@@ -342,18 +342,20 @@ pub(super) struct FakeAccountStore {
account: AccountRecord,
fail_commit: Mutex<bool>,
audit_requests: Mutex<Vec<String>>,
authorization_revision: Mutex<Option<Revision>>,
quota_window_usage: Mutex<Vec<AccountUsageWindowResult>>,
}
impl FakeAccountStore {
pub(super) fn new(kind: &str, events: EventLog) -> Arc<Self> {
Self::with_account(account_record(kind), events)
}
fn with_account(account: AccountRecord, events: EventLog) -> Arc<Self> {
Arc::new(Self {
events,
account: account_record(kind),
account,
fail_commit: Mutex::new(false),
audit_requests: Mutex::new(Vec::new()),
authorization_revision: Mutex::new(None),
quota_window_usage: Mutex::new(Vec::new()),
})
}
@@ -366,13 +368,6 @@ impl FakeAccountStore {
self.audit_requests.lock().expect("audit requests").clone()
}
pub(super) fn authorization_revision(&self) -> Option<Revision> {
*self
.authorization_revision
.lock()
.expect("authorization revision")
}
pub(super) fn set_quota_window_usage(&self, usage: Vec<AccountUsageWindowResult>) {
*self.quota_window_usage.lock().expect("quota window usage") = usage;
}
@@ -519,10 +514,6 @@ impl AccountStore for FakeAccountStore {
) -> AdminStoreResult<CredentialMutationResult> {
self.record("store.commit_authorization");
self.record_context(context);
*self
.authorization_revision
.lock()
.expect("authorization revision") = Some(command.pending.expected_config_revision());
self.require_commit()?;
let (account_id, credential_revision) = match command.credential {
AuthorizationCredentialCommit::Create(credential) => (credential.account_id, None),
@@ -627,7 +618,6 @@ impl SettingsStore for StaticSettingsStore {
async fn replace_admin_api_key(
&self,
_: Revision,
_: AdminApiKey,
_: &MutationContext,
) -> AdminStoreResult<AdminApiKeyMutation> {
@@ -636,7 +626,6 @@ impl SettingsStore for StaticSettingsStore {
async fn delete_admin_api_key(
&self,
_: Revision,
_: &MutationContext,
) -> AdminStoreResult<AdminApiKeyMutation> {
Err(store_unavailable())
@@ -701,9 +690,11 @@ fn provider_registry_should_reject_duplicate_kind() {
}
#[tokio::test]
async fn connection_test_should_preserve_safe_provider_error_details() {
async fn connection_test_should_probe_unavailable_account() {
let provider = FakeProviderAdmin::new("xai", events());
let store = FakeAccountStore::new("xai", events());
let mut account = account_record("xai");
account.availability = AccountAvailability::QuotaExhausted;
let store = FakeAccountStore::with_account(account, events());
let services =
accounts_service_with_probe(provider, store, Arc::new(FailingAccountProbe)).await;
@@ -724,7 +715,7 @@ async fn connection_test_should_preserve_safe_provider_error_details() {
message,
provider_error_code: Some(code),
provider_error_type: Some(error_type),
..
account_status: gateway_admin::model::accounts::AccountStatus::QuotaExhausted,
}) if message == "included usage exhausted"
&& code == "usage_exhausted"
&& error_type == "invalid_request_error"
@@ -769,7 +760,6 @@ async fn accounts_refresh_should_keep_guard_through_store_commit() {
.accounts()
.refresh(
&context("refresh-request"),
revision(1),
ProviderAccountId::new("acct_test").expect("account ID"),
)
.await
@@ -882,7 +872,6 @@ async fn accounts_refresh_provider_failure_should_not_call_store_commit() {
.accounts()
.refresh(
&context("refresh-provider-error"),
revision(1),
ProviderAccountId::new("acct_test").expect("account ID"),
)
.await
@@ -895,7 +884,7 @@ async fn accounts_refresh_provider_failure_should_not_call_store_commit() {
}
#[tokio::test]
async fn accounts_refresh_store_failure_should_drop_guard_after_cas_attempt() {
async fn accounts_refresh_store_failure_should_drop_guard_after_commit_attempt() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
let store = FakeAccountStore::new("openai", events.clone());
@@ -906,11 +895,10 @@ async fn accounts_refresh_store_failure_should_drop_guard_after_cas_attempt() {
.accounts()
.refresh(
&context("refresh-store-error"),
revision(1),
ProviderAccountId::new("acct_test").expect("account ID"),
)
.await
.expect_err("Store CAS must fail");
.expect_err("Store commit must fail");
assert_eq!(
recorded(&events),
@@ -1039,7 +1027,7 @@ fn prepared_create_with_id(
fn rotation_result(command: CredentialRotationCommit) -> CredentialMutationResult {
CredentialMutationResult {
config_revision: revision(command.expected_config_revision.get() + 1),
config_revision: revision(2),
account_id: command.prepared.account_id,
credential_revision: Some(revision(
command.prepared.expected_credential_revision.get() + 1,
@@ -449,7 +449,6 @@ impl SettingsStore for UnavailableStore {
async fn replace_admin_api_key(
&self,
_: Revision,
_: AdminApiKey,
_: &MutationContext,
) -> AdminStoreResult<AdminApiKeyMutation> {
@@ -458,7 +457,6 @@ impl SettingsStore for UnavailableStore {
async fn delete_admin_api_key(
&self,
_: Revision,
_: &MutationContext,
) -> AdminStoreResult<AdminApiKeyMutation> {
Err(unavailable("admin API key"))
@@ -518,7 +518,6 @@ impl SettingsStore for FixtureSettingsStore {
async fn replace_admin_api_key(
&self,
_: Revision,
_: AdminApiKey,
_: &MutationContext,
) -> AdminStoreResult<AdminApiKeyMutation> {
@@ -527,7 +526,6 @@ impl SettingsStore for FixtureSettingsStore {
async fn delete_admin_api_key(
&self,
_: Revision,
_: &MutationContext,
) -> AdminStoreResult<AdminApiKeyMutation> {
Err(super::unavailable("admin API key"))
@@ -114,7 +114,6 @@ async fn openai_import_should_prepare_before_atomic_store_commit() {
.openai()
.import_document(ImportCredentials {
context: context("import-openai"),
expected_config_revision: revision(1),
document: document(),
})
.await
@@ -151,7 +150,6 @@ async fn openai_import_should_refresh_quota_for_every_imported_account() {
.openai()
.import_document(ImportCredentials {
context: context("import-openai-batch"),
expected_config_revision: revision(1),
document: document(),
})
.await
@@ -193,7 +191,6 @@ async fn openai_import_should_remain_successful_when_quota_refresh_fails() {
.openai()
.import_document(ImportCredentials {
context: context("import-openai-quota-failure"),
expected_config_revision: revision(1),
document: document(),
})
.await
@@ -218,7 +215,6 @@ async fn openai_import_provider_error_should_not_touch_store_transaction() {
.openai()
.import_document(ImportCredentials {
context: context("import-openai-error"),
expected_config_revision: revision(1),
document: document(),
})
.await
@@ -237,7 +233,6 @@ async fn openai_reauthorization_should_restore_pending_envelope_and_hold_guard_t
.openai()
.start_authorization(StartAuthorization {
context: context("oauth-start-openai"),
expected_config_revision: revision(7),
name: "reauthorize".to_owned(),
reauthorization: Some(ReauthorizationTarget {
account_id: ProviderAccountId::new("acct_test").expect("account ID"),
@@ -248,7 +243,6 @@ async fn openai_reauthorization_should_restore_pending_envelope_and_hold_guard_t
.expect("start reauthorization");
let pending = provider.pending().expect("pending envelope");
assert_eq!(pending.expected_config_revision(), revision(7));
assert_eq!(pending.provider_kind().as_str(), "openai");
assert_eq!(
pending.owner_binding().started_request_id(),
@@ -284,7 +278,6 @@ async fn openai_reauthorization_should_restore_pending_envelope_and_hold_guard_t
"guard.finish",
]
);
assert_eq!(store.authorization_revision(), Some(revision(7)));
assert_eq!(store.audit_requests(), ["oauth-complete-openai"]);
}
@@ -299,7 +292,6 @@ async fn service(provider: Arc<FakeProviderAdmin>, store: Arc<FakeAccountStore>)
fn mutation(account_id: &str) -> CredentialMutation {
CredentialMutation {
context: context("request-openai"),
expected_config_revision: revision(1),
account_id: ProviderAccountId::new(account_id).expect("account ID"),
}
}
@@ -307,7 +299,6 @@ fn mutation(account_id: &str) -> CredentialMutation {
fn deletion(account_id: &str) -> CredentialDeletion {
CredentialDeletion {
context: context("request-openai"),
expected_config_revision: revision(1),
account_ids: vec![ProviderAccountId::new(account_id).expect("account ID")],
}
}
@@ -2,7 +2,7 @@ use async_trait::async_trait;
use gateway_admin::{
model::{
AdminErrorKind, MutationContext, Revision,
AdminErrorKind, MutationContext,
settings::{
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy,
RuntimeSettings,
@@ -33,7 +33,6 @@ impl SettingsStore for UnusedSettingsStore {
async fn replace_admin_api_key(
&self,
_: Revision,
_: AdminApiKey,
_: &MutationContext,
) -> AdminStoreResult<AdminApiKeyMutation> {
@@ -42,7 +41,6 @@ impl SettingsStore for UnusedSettingsStore {
async fn delete_admin_api_key(
&self,
_: Revision,
_: &MutationContext,
) -> AdminStoreResult<AdminApiKeyMutation> {
Err(unused())
@@ -63,7 +61,6 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
request_id: "request-settings".to_owned(),
},
ReplaceRuntimeSettings {
expected_config_revision: Revision::new(1).expect("revision"),
model_mappings: Default::default(),
refresh_margin_seconds: 0,
refresh_concurrency: 1,
@@ -8,9 +8,7 @@ use gateway_admin::{
ports::provider::ProviderAdminErrorKind,
};
use super::accounts::{
FakeAccountStore, FakeProviderAdmin, context, document, events, recorded, revision,
};
use super::accounts::{FakeAccountStore, FakeProviderAdmin, context, document, events, recorded};
#[tokio::test]
async fn xai_disable_should_commit_then_notify_the_stateless_provider() {
@@ -47,7 +45,6 @@ async fn xai_import_should_prepare_before_atomic_store_commit() {
.xai()
.import_document(ImportCredentials {
context: context("import-xai"),
expected_config_revision: revision(3),
document: document(),
})
.await
@@ -84,7 +81,6 @@ async fn xai_import_should_refresh_quota_for_every_imported_account() {
.xai()
.import_document(ImportCredentials {
context: context("import-xai-batch"),
expected_config_revision: revision(1),
document: document(),
})
.await
@@ -119,7 +115,6 @@ async fn xai_import_should_remain_successful_when_quota_refresh_fails() {
.xai()
.import_document(ImportCredentials {
context: context("import-xai-quota-failure"),
expected_config_revision: revision(1),
document: document(),
})
.await
@@ -140,7 +135,6 @@ async fn service(provider: Arc<FakeProviderAdmin>, store: Arc<FakeAccountStore>)
fn mutation(account_id: &str) -> CredentialMutation {
CredentialMutation {
context: context("request-xai"),
expected_config_revision: revision(1),
account_id: ProviderAccountId::new(account_id).expect("account ID"),
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ path = "tests/main.rs"
[dependencies]
axum = { version = "0.8.9", default-features = false, features = ["json", "query", "ws"] }
base64 = "0.22.1"
base64 = "0.23.0"
bytes = "1.11.1"
chrono = { version = "0.4.45", features = ["serde"] }
futures = "0.3.32"
@@ -18,9 +18,9 @@ use futures::{Stream, StreamExt as _};
use gateway_admin::model::{
AdminError as AdminServiceError, AdminErrorKind, PageSize, Revision,
accounts::{
AccountAvailability, AccountConnectionTestEvent as DomainConnectionTestEvent, AccountCost,
AccountListQuery, AccountModelUsage, AccountSort, AccountSortField,
AccountStatus as DomainAccountStatus, AccountUsage, SortDirection,
AccountConnectionTestEvent as DomainConnectionTestEvent, AccountCost, AccountListQuery,
AccountModelUsage, AccountSort, AccountSortField, AccountStatus as DomainAccountStatus,
AccountUsage, SortDirection,
},
provider_credentials::{
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
@@ -161,7 +161,6 @@ fn parse_sort_direction(value: &str) -> Option<SortDirection> {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountPageData {
pub config_revision: u64,
pub items: Vec<AccountView>,
pub page: PageMeta,
pub summary: AccountSummaryView,
@@ -391,31 +390,21 @@ impl AccountActionRequest {
}
}
/// 手工 OAuth 刷新会变更持久 credential,因此必须携带配置 CAS revision。
/// 手工 OAuth 刷新会变更持久 credential。
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountRefreshRequest {
pub account_id: String,
pub expected_config_revision: u64,
}
impl AccountRefreshRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
require_account_id(&self.account_id, "accountId")?;
if self.expected_config_revision == 0 {
return Err(WireValidationError::new("expectedConfigRevision"));
}
Ok(())
require_account_id(&self.account_id, "accountId")
}
fn into_command(self) -> Result<(Revision, ProviderAccountId), WireValidationError> {
fn into_command(self) -> Result<ProviderAccountId, WireValidationError> {
self.validate()?;
Ok((
Revision::new(self.expected_config_revision)
.map_err(|_| WireValidationError::new("expectedConfigRevision"))?,
ProviderAccountId::new(self.account_id)
.map_err(|_| WireValidationError::new("accountId"))?,
))
ProviderAccountId::new(self.account_id).map_err(|_| WireValidationError::new("accountId"))
}
}
@@ -460,7 +449,6 @@ pub struct AccountModelsData {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountRefreshData {
pub config_revision: u64,
pub account: AccountView,
}
@@ -579,14 +567,12 @@ impl AccountProvider {
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountImportRequest {
pub provider: String,
pub expected_config_revision: u64,
pub data: Value,
}
impl AccountImportRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
AccountProvider::parse(&self.provider)?;
require_positive(self.expected_config_revision, "expectedConfigRevision")?;
if !self.data.is_object()
|| serde_json::to_vec(&self.data)
.map_or(true, |encoded| encoded.len() > MAX_IMPORT_DATA_BYTES)
@@ -606,10 +592,6 @@ impl AccountImportRequest {
provider,
ImportCredentials {
context,
expected_config_revision: revision(
self.expected_config_revision,
"expectedConfigRevision",
)?,
document: provider_document(self.data, "data")?,
},
))
@@ -620,7 +602,6 @@ impl AccountImportRequest {
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct StartAccountAuthorizationRequest {
pub provider: String,
pub expected_config_revision: u64,
pub name: String,
pub account_id: Option<String>,
pub expected_credential_revision: Option<u64>,
@@ -629,7 +610,6 @@ pub struct StartAccountAuthorizationRequest {
impl StartAccountAuthorizationRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
AccountProvider::parse(&self.provider)?;
require_positive(self.expected_config_revision, "expectedConfigRevision")?;
require_text(&self.name, MAX_NAME_BYTES, "name")?;
match (
self.account_id.as_deref(),
@@ -663,10 +643,6 @@ impl StartAccountAuthorizationRequest {
provider,
StartAuthorization {
context,
expected_config_revision: revision(
self.expected_config_revision,
"expectedConfigRevision",
)?,
name: self.name,
reauthorization,
},
@@ -721,14 +697,12 @@ impl CompleteAccountAuthorizationRequest {
pub struct AccountMutationRequest {
pub provider: String,
pub account_id: String,
pub expected_config_revision: u64,
}
impl AccountMutationRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
AccountProvider::parse(&self.provider)?;
require_account_id(&self.account_id, "accountId")?;
require_positive(self.expected_config_revision, "expectedConfigRevision")
require_account_id(&self.account_id, "accountId")
}
fn into_command(
@@ -741,10 +715,6 @@ impl AccountMutationRequest {
provider,
CredentialMutation {
context,
expected_config_revision: revision(
self.expected_config_revision,
"expectedConfigRevision",
)?,
account_id: ProviderAccountId::new(self.account_id)
.map_err(|_| WireValidationError::new("accountId"))?,
},
@@ -757,7 +727,6 @@ impl AccountMutationRequest {
pub struct AccountDeletionRequest {
pub provider: String,
pub account_ids: Vec<String>,
pub expected_config_revision: u64,
}
impl AccountDeletionRequest {
@@ -773,7 +742,7 @@ impl AccountDeletionRequest {
return Err(WireValidationError::new("accountIds"));
}
}
require_positive(self.expected_config_revision, "expectedConfigRevision")
Ok(())
}
fn into_command(
@@ -786,10 +755,6 @@ impl AccountDeletionRequest {
provider,
CredentialDeletion {
context,
expected_config_revision: revision(
self.expected_config_revision,
"expectedConfigRevision",
)?,
account_ids: self
.account_ids
.into_iter()
@@ -806,7 +771,6 @@ impl AccountDeletionRequest {
pub struct RotateAccountRequest {
pub provider: String,
pub account_id: String,
pub expected_config_revision: u64,
pub expected_credential_revision: u64,
pub access_token: String,
pub refresh_token: Option<String>,
@@ -818,7 +782,6 @@ impl RotateAccountRequest {
return Err(WireValidationError::new("provider"));
}
require_account_id(&self.account_id, "accountId")?;
require_positive(self.expected_config_revision, "expectedConfigRevision")?;
require_positive(
self.expected_credential_revision,
"expectedCredentialRevision",
@@ -840,10 +803,6 @@ impl RotateAccountRequest {
Ok(RotateCredential {
mutation: CredentialMutation {
context,
expected_config_revision: revision(
self.expected_config_revision,
"expectedConfigRevision",
)?,
account_id: ProviderAccountId::new(self.account_id)
.map_err(|_| WireValidationError::new("accountId"))?,
},
@@ -859,7 +818,6 @@ impl RotateAccountRequest {
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountImportData {
pub config_revision: u64,
pub imported_count: usize,
pub account_ids: Vec<String>,
}
@@ -872,7 +830,6 @@ impl From<CredentialImportResult> for AccountImportData {
.map(|account_id| account_id.to_string())
.collect::<Vec<_>>();
Self {
config_revision: result.config_revision.get(),
imported_count: account_ids.len(),
account_ids,
}
@@ -900,7 +857,6 @@ impl From<AuthorizationStarted> for AccountAuthorizationData {
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountMutationData {
pub config_revision: u64,
pub account_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub credential_revision: Option<u64>,
@@ -909,7 +865,6 @@ pub struct AccountMutationData {
impl From<CredentialMutationResult> for AccountMutationData {
fn from(result: CredentialMutationResult) -> Self {
Self {
config_revision: result.config_revision.get(),
account_id: result.account_id.to_string(),
credential_revision: result.credential_revision.map(Revision::get),
}
@@ -919,7 +874,6 @@ impl From<CredentialMutationResult> for AccountMutationData {
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountDeletionData {
pub config_revision: u64,
pub deleted_count: usize,
pub account_ids: Vec<String>,
}
@@ -932,7 +886,6 @@ impl From<CredentialDeletionResult> for AccountDeletionData {
.map(|account_id| account_id.to_string())
.collect::<Vec<_>>();
Self {
config_revision: result.config_revision.get(),
deleted_count: account_ids.len(),
account_ids,
}
@@ -1264,15 +1217,11 @@ async fn refresh_account<S>(
where
S: AdminSessionState + Send + Sync,
{
let (expected_config_revision, account_id) = request.into_command().map_err(map_wire_error)?;
let account_id = request.into_command().map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.refresh(
&auth.context().mutation_context(),
expected_config_revision,
account_id,
)
.refresh(&auth.context().mutation_context(), account_id)
.await
.map_err(map_service_error)?;
let data = account_refresh_data(result, Utc::now());
@@ -1394,7 +1343,6 @@ fn account_page_data(
u32::try_from(result.total.div_ceil(u64::from(page_size))).unwrap_or(u32::MAX)
};
AccountPageData {
config_revision: result.config_revision.get(),
items: result
.items
.into_iter()
@@ -1412,7 +1360,6 @@ fn account_page_data(
fn account_refresh_data(result: AccountRefreshResult, now: DateTime<Utc>) -> AccountRefreshData {
AccountRefreshData {
config_revision: result.config_revision.get(),
account: account_view(result.account, now),
}
}
@@ -1460,7 +1407,7 @@ fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> AccountView {
status: status.clone(),
display_status: status,
token_refreshing: false,
availability: account_availability_name(account.availability).to_owned(),
availability: account.availability.as_str().to_owned(),
enabled: account.enabled,
credential_revision: account.credential_revision.get(),
state_revision: None,
@@ -1805,18 +1752,6 @@ fn account_status_name(status: DomainAccountStatus) -> &'static str {
}
}
fn account_availability_name(availability: AccountAvailability) -> &'static str {
match availability {
AccountAvailability::Unknown => "unknown",
AccountAvailability::Ready => "ready",
AccountAvailability::Cooldown => "cooldown",
AccountAvailability::QuotaExhausted => "quota_exhausted",
AccountAvailability::Expired => "expired",
AccountAvailability::Banned => "banned",
AccountAvailability::Invalid => "invalid",
}
}
fn format_number(value: u64) -> String {
let text = value.to_string();
let mut output = String::with_capacity(text.len() + text.len() / 3);
@@ -4,14 +4,11 @@ use std::fmt;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{DateTime, Utc};
use gateway_admin::model::{
Revision,
client_keys::{
ClientKeyCursor, ClientKeyCursorValue as DomainCursorValue, ClientKeyListQuery,
ClientKeyMutation, ClientKeyPage, ClientKeyPageSize, ClientKeyRecord, ClientKeySecret,
ClientKeySort as DomainSort, ClientKeySortField as DomainSortField, CreateClientKey,
CreatedClientKey, DeleteClientKey, SetClientKeyEnabled, SortDirection, UpdateClientKey,
},
use gateway_admin::model::client_keys::{
ClientKeyCursor, ClientKeyCursorValue as DomainCursorValue, ClientKeyListQuery,
ClientKeyMutation, ClientKeyPage, ClientKeyPageSize, ClientKeyRecord, ClientKeySecret,
ClientKeySort as DomainSort, ClientKeySortField as DomainSortField, CreateClientKey,
CreatedClientKey, DeleteClientKey, SetClientKeyEnabled, SortDirection, UpdateClientKey,
};
use gateway_core::{
policy::{ClientApiKeyId, RateLimits},
@@ -138,7 +135,6 @@ impl ClientKeySort {
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CreateClientKeyRequest {
expected_config_revision: u64,
name: String,
label: Option<String>,
provider_kind: String,
@@ -149,14 +145,12 @@ pub struct CreateClientKeyRequest {
impl CreateClientKeyRequest {
/// 校验 wire 边界并直接构造管理用例命令。
pub fn into_command(self) -> Result<CreateClientKey, WireValidationError> {
validate_revision(self.expected_config_revision)?;
validate_required_text(&self.name, "name")?;
validate_optional_text(self.label.as_deref(), "label")?;
validate_provider_kind(&self.provider_kind)?;
validate_limit(self.max_concurrency, "maxConcurrency")?;
validate_limit(self.requests_per_minute, "requestsPerMinute")?;
Ok(CreateClientKey {
expected_config_revision: revision(self.expected_config_revision)?,
name: self.name,
label: self.label,
provider_kind: ProviderKind::new(self.provider_kind)
@@ -174,7 +168,6 @@ impl CreateClientKeyRequest {
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateClientKeyRequest {
id: String,
expected_config_revision: u64,
name: String,
label: Option<String>,
provider_kind: String,
@@ -186,14 +179,12 @@ impl UpdateClientKeyRequest {
/// 校验 wire 边界并直接构造管理用例命令。
pub fn into_command(self) -> Result<UpdateClientKey, WireValidationError> {
validate_required_text(&self.id, "id")?;
validate_revision(self.expected_config_revision)?;
validate_required_text(&self.name, "name")?;
validate_optional_text(self.label.as_deref(), "label")?;
validate_provider_kind(&self.provider_kind)?;
validate_limit(self.max_concurrency, "maxConcurrency")?;
validate_limit(self.requests_per_minute, "requestsPerMinute")?;
Ok(UpdateClientKey {
expected_config_revision: revision(self.expected_config_revision)?,
id: client_key_id(self.id, "clientKeyMutationNotFound")?,
name: self.name,
label: self.label,
@@ -207,12 +198,11 @@ impl UpdateClientKeyRequest {
}
}
/// 只携带 ID 与配置 revision 的 Client Key mutation 请求。
/// 只携带 ID 的 Client Key mutation 请求。
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ClientKeyRevisionRequest {
pub struct ClientKeyMutationRequest {
id: String,
expected_config_revision: u64,
}
/// 读取一次完整 Key 的 ID query。
@@ -233,20 +223,15 @@ impl ClientKeyIdQuery {
}
}
impl ClientKeyRevisionRequest {
/// 校验请求并取出 ID 与 revision。
pub fn into_parts(self) -> Result<(String, u64), WireValidationError> {
impl ClientKeyMutationRequest {
/// 校验请求并取出 ID。
pub fn into_id(self) -> Result<String, WireValidationError> {
validate_required_text(&self.id, "id")?;
validate_revision(self.expected_config_revision)?;
Ok((self.id, self.expected_config_revision))
Ok(self.id)
}
fn into_command_parts(self) -> Result<(ClientApiKeyId, Revision), WireValidationError> {
let (id, expected_config_revision) = self.into_parts()?;
Ok((
client_key_id(id, "clientKeyMutationNotFound")?,
revision(expected_config_revision)?,
))
fn into_domain_id(self) -> Result<ClientApiKeyId, WireValidationError> {
client_key_id(self.into_id()?, "clientKeyMutationNotFound")
}
}
@@ -289,7 +274,6 @@ impl From<ClientKeyRecord> for ClientKeyView {
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientKeyListData {
config_revision: u64,
items: Vec<ClientKeyView>,
next_cursor: Option<String>,
total: u64,
@@ -298,14 +282,8 @@ pub struct ClientKeyListData {
impl ClientKeyListData {
/// 构造 Client Key 列表响应。
#[must_use]
pub fn new(
config_revision: u64,
items: Vec<ClientKeyView>,
next_cursor: Option<String>,
total: u64,
) -> Self {
pub fn new(items: Vec<ClientKeyView>, next_cursor: Option<String>, total: u64) -> Self {
Self {
config_revision,
items,
next_cursor,
total,
@@ -325,7 +303,6 @@ impl TryFrom<ClientKeyPage> for ClientKeyListData {
.map(encode_client_key_cursor)
.transpose()?;
Ok(Self::new(
page.config_revision.get(),
page.items.into_iter().map(Into::into).collect(),
next_cursor,
page.total,
@@ -337,7 +314,6 @@ impl TryFrom<ClientKeyPage> for ClientKeyListData {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreatedClientKeyData {
config_revision: u64,
id: String,
prefix: String,
plaintext_key: String,
@@ -346,9 +322,8 @@ pub struct CreatedClientKeyData {
impl CreatedClientKeyData {
/// 构造一次性创建响应。
#[must_use]
pub fn new(config_revision: u64, id: String, prefix: String, plaintext_key: String) -> Self {
pub fn new(id: String, prefix: String, plaintext_key: String) -> Self {
Self {
config_revision,
id,
prefix,
plaintext_key,
@@ -359,7 +334,6 @@ impl CreatedClientKeyData {
impl From<CreatedClientKey> for CreatedClientKeyData {
fn from(created: CreatedClientKey) -> Self {
Self::new(
created.config_revision.get(),
created.secret.record.id.to_string(),
created.secret.record.prefix.clone(),
created.secret.expose_for_response().to_owned(),
@@ -371,7 +345,6 @@ impl fmt::Debug for CreatedClientKeyData {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CreatedClientKeyData")
.field("config_revision", &self.config_revision)
.field("id", &self.id)
.field("prefix", &self.prefix)
.field("plaintext_key", &"[REDACTED]")
@@ -417,24 +390,20 @@ impl fmt::Debug for RevealedClientKeyData {
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MutatedClientKeyData {
config_revision: u64,
id: String,
}
impl MutatedClientKeyData {
/// 构造 mutation 响应。
#[must_use]
pub fn new(config_revision: u64, id: String) -> Self {
Self {
config_revision,
id,
}
pub fn new(id: String) -> Self {
Self { id }
}
}
impl From<ClientKeyMutation> for MutatedClientKeyData {
fn from(mutation: ClientKeyMutation) -> Self {
Self::new(mutation.config_revision.get(), mutation.id.to_string())
Self::new(mutation.id.to_string())
}
}
@@ -582,18 +551,6 @@ fn wire_cursor(cursor: ClientKeyCursor) -> Result<ClientKeyCursorData, WireValid
Ok(cursor)
}
fn validate_revision(value: u64) -> Result<(), WireValidationError> {
if value == 0 {
return Err(WireValidationError::new("expectedConfigRevision"));
}
Ok(())
}
fn revision(value: u64) -> Result<Revision, WireValidationError> {
validate_revision(value)?;
Revision::new(value).map_err(|_| WireValidationError::new("expectedConfigRevision"))
}
fn client_key_id(
value: String,
field: &'static str,
@@ -768,23 +725,19 @@ where
async fn disable_client_key<S>(
auth: AdminAuth,
State(state): State<S>,
Json(payload): Json<ClientKeyRevisionRequest>,
Json(payload): Json<ClientKeyMutationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let (id, expected_config_revision) = payload.into_command_parts().map_err(map_wire_error)?;
let id = payload.into_domain_id().map_err(map_wire_error)?;
mutation_response(
state
.admin_services()
.client_keys()
.set_enabled(
&auth.context().mutation_context(),
SetClientKeyEnabled {
expected_config_revision,
id,
enabled: false,
},
SetClientKeyEnabled { id, enabled: false },
)
.await,
)
@@ -793,23 +746,19 @@ where
async fn enable_client_key<S>(
auth: AdminAuth,
State(state): State<S>,
Json(payload): Json<ClientKeyRevisionRequest>,
Json(payload): Json<ClientKeyMutationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let (id, expected_config_revision) = payload.into_command_parts().map_err(map_wire_error)?;
let id = payload.into_domain_id().map_err(map_wire_error)?;
mutation_response(
state
.admin_services()
.client_keys()
.set_enabled(
&auth.context().mutation_context(),
SetClientKeyEnabled {
expected_config_revision,
id,
enabled: true,
},
SetClientKeyEnabled { id, enabled: true },
)
.await,
)
@@ -818,23 +767,17 @@ where
async fn delete_client_key<S>(
auth: AdminAuth,
State(state): State<S>,
Json(payload): Json<ClientKeyRevisionRequest>,
Json(payload): Json<ClientKeyMutationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let (id, expected_config_revision) = payload.into_command_parts().map_err(map_wire_error)?;
let id = payload.into_domain_id().map_err(map_wire_error)?;
mutation_response(
state
.admin_services()
.client_keys()
.delete(
&auth.context().mutation_context(),
DeleteClientKey {
expected_config_revision,
id,
},
)
.delete(&auth.context().mutation_context(), DeleteClientKey { id })
.await,
)
}
@@ -854,9 +797,6 @@ fn map_wire_error(error: WireValidationError) -> AdminError {
"cursor" => AdminError::bad_request("Invalid client key cursor"),
"clientKeyRevealNotFound" => AdminError::not_found("Client API key was not found"),
"clientKeyMutationNotFound" => AdminError::not_found("client API key was not found"),
"expectedConfigRevision" => {
AdminError::bad_request("expectedConfigRevision must be positive")
}
_ => AdminError::bad_request("Invalid client API key request"),
}
}
@@ -10,12 +10,8 @@ use axum::{
routing::{get, post},
};
use chrono::{DateTime, Utc};
use gateway_admin::model::{
Revision,
settings::{
ModelMappings as DomainModelMappings, ReplaceRuntimeSettings, RotationStrategy,
RuntimeSettings,
},
use gateway_admin::model::settings::{
ModelMappings as DomainModelMappings, ReplaceRuntimeSettings, RotationStrategy, RuntimeSettings,
};
use gateway_core::routing::{PublicModelId, UpstreamModelId};
use serde::{Deserialize, Serialize};
@@ -32,7 +28,6 @@ pub type ModelMappings = BTreeMap<String, String>;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeSettingsView {
pub config_revision: u64,
pub model_mappings: ModelMappings,
pub refresh_margin_seconds: u64,
pub refresh_concurrency: u64,
@@ -49,7 +44,6 @@ pub struct RuntimeSettingsView {
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateRuntimeSettingsRequest {
pub expected_config_revision: u64,
pub model_mappings: ModelMappings,
pub refresh_margin_seconds: u64,
pub refresh_concurrency: u64,
@@ -64,9 +58,6 @@ pub struct UpdateRuntimeSettingsRequest {
impl UpdateRuntimeSettingsRequest {
/// 校验公共运行参数。
pub fn validate(&self) -> Result<(), WireValidationError> {
if self.expected_config_revision == 0 {
return Err(WireValidationError::new("expectedConfigRevision"));
}
validate_model_mappings(&self.model_mappings)?;
for (value, field) in [
(self.refresh_margin_seconds, "refreshMarginSeconds"),
@@ -93,8 +84,6 @@ impl UpdateRuntimeSettingsRequest {
fn into_command(self) -> Result<ReplaceRuntimeSettings, WireValidationError> {
self.validate()?;
Ok(ReplaceRuntimeSettings {
expected_config_revision: Revision::new(self.expected_config_revision)
.map_err(|_| WireValidationError::new("settingsRevisionOverflow"))?,
model_mappings: domain_model_mappings(self.model_mappings)?,
refresh_margin_seconds: self.refresh_margin_seconds,
refresh_concurrency: u32::try_from(self.refresh_concurrency)
@@ -117,7 +106,6 @@ impl UpdateRuntimeSettingsRequest {
impl From<RuntimeSettings> for RuntimeSettingsView {
fn from(settings: RuntimeSettings) -> Self {
Self {
config_revision: settings.config_revision.get(),
model_mappings: wire_model_mappings(settings.model_mappings),
refresh_margin_seconds: settings.refresh_margin_seconds,
refresh_concurrency: u64::from(settings.refresh_concurrency),
@@ -358,7 +346,6 @@ fn valid_model_name(value: &str, max_len: usize) -> bool {
fn map_wire_error(error: WireValidationError) -> AdminError {
let message = match error.field() {
"settingsRevisionOverflow" => "expectedConfigRevision must be positive".to_owned(),
"settingsRefreshConcurrencyOverflow" => "Invalid refreshConcurrency".to_owned(),
"settingsMaxConcurrencyOverflow" => "Invalid maxConcurrentPerAccount".to_owned(),
"settingsUsageRetentionOverflow" => "Invalid usageRetentionDays".to_owned(),
@@ -75,10 +75,14 @@ pub fn gateway_error_from_engine(error: &EngineError) -> GatewayError {
if error.kind() == ProviderErrorKind::Unavailable
&& error.send_state() == UpstreamSendState::NotSent =>
{
GatewayError::new(
let gateway = GatewayError::new(
GatewayErrorKind::NoAvailableProvider,
"no upstream provider is currently available for this request",
)
);
match error.client_visible_upstream_error() {
Some(upstream) => gateway.with_client_visible_upstream_error(upstream.clone()),
None => gateway,
}
}
EngineError::Provider(error) => GatewayError::from_provider(error),
EngineError::Cancelled => {
@@ -189,6 +189,22 @@ fn codex_model_json(profile: &PublicModelProfile, index: usize) -> Value {
} else {
vec!["text"]
};
let additional_speed_tiers = presentation
.service_tiers()
.iter()
.filter_map(|tier| tier.speed_tier())
.collect::<Vec<_>>();
let service_tiers = presentation
.service_tiers()
.iter()
.map(|tier| {
json!({
"id": tier.id(),
"name": tier.name(),
"description": tier.description(),
})
})
.collect::<Vec<_>>();
json!({
"slug": id,
@@ -200,8 +216,8 @@ fn codex_model_json(profile: &PublicModelProfile, index: usize) -> Value {
"visibility": if presentation.hidden() { "hide" } else { "list" },
"supported_in_api": true,
"priority": index.saturating_add(1),
"additional_speed_tiers": [],
"service_tiers": [],
"additional_speed_tiers": additional_speed_tiers,
"service_tiers": service_tiers,
"availability_nux": Value::Null,
"upgrade": Value::Null,
"base_instructions": CODEX_BASE_INSTRUCTIONS,
@@ -147,7 +147,7 @@ mod actions {
}
#[test]
fn account_actions_should_require_frozen_account_ids_and_revision() {
fn account_actions_should_require_frozen_account_ids_and_reject_unknown_revision() {
let id: AccountIdQuery =
serde_json::from_value(json!({ "accountId": "acct_1" })).expect("decode ID query");
assert!(id.validate().is_ok());
@@ -155,13 +155,16 @@ mod actions {
serde_json::from_value(json!({ "accountId": "legacy-id" })).expect("decode action");
assert_eq!(action.validate().unwrap_err().field(), "accountId");
let refresh: AccountRefreshRequest = serde_json::from_value(json!({
"accountId": "acct_1",
"expectedConfigRevision": 0
"accountId": "acct_1"
}))
.expect("decode refresh");
assert_eq!(
refresh.validate().unwrap_err().field(),
"expectedConfigRevision"
assert!(refresh.validate().is_ok());
assert!(
serde_json::from_value::<AccountRefreshRequest>(json!({
"accountId": "acct_1",
"expectedConfigRevision": 0
}))
.is_err()
);
}
@@ -169,7 +172,6 @@ mod actions {
fn account_import_should_use_provider_and_opaque_data_fields() {
let valid: AccountImportRequest = serde_json::from_value(json!({
"provider": "openai",
"expectedConfigRevision": 7,
"data": {
"providerOwnedUnknownField": {"nested": [1, 2, 3]},
"accounts": [{"credentials": {"access_token": "provider-validates-this"}}]
@@ -180,7 +182,6 @@ mod actions {
let invalid: AccountImportRequest = serde_json::from_value(json!({
"provider": "xai",
"expectedConfigRevision": 7,
"data": []
}))
.expect("decode invalid account import");
@@ -188,27 +189,32 @@ mod actions {
assert!(
serde_json::from_value::<AccountImportRequest>(json!({
"provider": "openai",
"expectedConfigRevision": 7,
"document": {}
}))
.is_err()
);
assert!(
serde_json::from_value::<AccountImportRequest>(json!({
"provider": "openai",
"expectedConfigRevision": 7,
"data": {}
}))
.is_err()
);
}
#[test]
fn account_deletion_should_validate_one_provider_batch_and_emit_account_ids() {
let request: AccountDeletionRequest = serde_json::from_value(json!({
"provider": "xai",
"accountIds": ["acct_1", "acct_2"],
"expectedConfigRevision": 8
"accountIds": ["acct_1", "acct_2"]
}))
.expect("decode account deletion");
assert!(request.validate().is_ok());
let duplicate: AccountDeletionRequest = serde_json::from_value(json!({
"provider": "xai",
"accountIds": ["acct_1", "acct_1"],
"expectedConfigRevision": 8
"accountIds": ["acct_1", "acct_1"]
}))
.expect("decode duplicate account deletion");
assert_eq!(duplicate.validate().unwrap_err().field(), "accountIds");
@@ -223,7 +229,6 @@ mod actions {
assert_eq!(
serde_json::to_value(response).expect("serialize account deletion"),
json!({
"configRevision": 9,
"deletedCount": 2,
"accountIds": ["acct_1", "acct_2"]
})
@@ -239,7 +244,6 @@ mod actions {
assert_eq!(
serde_json::to_value(response).expect("serialize account import"),
json!({
"configRevision": 8,
"importedCount": 1,
"accountIds": ["acct_imported"]
})
@@ -1,6 +1,6 @@
use chrono::{DateTime, TimeZone as _, Utc};
use gateway_api::admin::client_keys::{
self, ClientKeyCursorData, ClientKeyCursorValue, ClientKeyListData, ClientKeyRevisionRequest,
self, ClientKeyCursorData, ClientKeyCursorValue, ClientKeyListData, ClientKeyMutationRequest,
ClientKeySort, ClientKeySortDirection, ClientKeySortField, ClientKeyView,
CreateClientKeyRequest, CreatedClientKeyData, ListClientKeysQuery, MutatedClientKeyData,
RevealedClientKeyData, UpdateClientKeyRequest, decode_client_key_cursor,
@@ -101,9 +101,8 @@ fn client_key_queries_should_reject_unknown_zero_and_oversized_fields() {
}
#[test]
fn client_key_mutations_should_validate_revision_text_limits_and_unknown_fields() {
fn client_key_mutations_should_validate_text_limits_and_unknown_fields() {
let valid = serde_json::from_value::<CreateClientKeyRequest>(json!({
"expectedConfigRevision": 7,
"name": "terminal key",
"label": "production",
"providerKind": "openai",
@@ -113,22 +112,11 @@ fn client_key_mutations_should_validate_revision_text_limits_and_unknown_fields(
.expect("deserialize create")
.into_command()
.expect("validate create");
assert_eq!(valid.expected_config_revision.get(), 7);
assert_eq!(valid.name, "terminal key");
for (payload, field) in [
(
json!({
"expectedConfigRevision": 0,
"name": "key",
"providerKind": "openai",
"maxConcurrency": 0,
"requestsPerMinute": 0
}),
"expectedConfigRevision",
),
(
json!({
"expectedConfigRevision": 1,
"name": " ",
"providerKind": "openai",
"maxConcurrency": 0,
@@ -138,7 +126,6 @@ fn client_key_mutations_should_validate_revision_text_limits_and_unknown_fields(
),
(
json!({
"expectedConfigRevision": 1,
"name": "key",
"providerKind": "openai",
"maxConcurrency": u64::MAX,
@@ -170,14 +157,23 @@ fn client_key_mutations_should_validate_revision_text_limits_and_unknown_fields(
}))
.is_err()
);
let revision = serde_json::from_value::<ClientKeyRevisionRequest>(json!({
"id": "key_1",
"expectedConfigRevision": 3
assert!(
serde_json::from_value::<CreateClientKeyRequest>(json!({
"expectedConfigRevision": 7,
"name": "terminal key",
"providerKind": "openai",
"maxConcurrency": 2,
"requestsPerMinute": 60
}))
.is_err()
);
let mutation_id = serde_json::from_value::<ClientKeyMutationRequest>(json!({
"id": "key_1"
}))
.expect("deserialize revision mutation")
.into_parts()
.expect("validate revision mutation");
assert_eq!(revision, ("key_1".to_owned(), 3));
.expect("deserialize mutation")
.into_id()
.expect("validate mutation");
assert_eq!(mutation_id, "key_1");
}
#[test]
@@ -225,9 +221,9 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
updated_at: created_at,
last_used_at: Some(created_at),
});
let list = serde_json::to_value(ClientKeyListData::new(9, vec![view], None, 1))
.expect("serialize list");
assert_eq!(list["configRevision"], 9);
let list =
serde_json::to_value(ClientKeyListData::new(vec![view], None, 1)).expect("serialize list");
assert!(list.get("configRevision").is_none());
assert_eq!(list["total"], 1);
assert_eq!(list["items"][0]["id"], "key_visible");
assert_eq!(list["items"][0]["providerKind"], "openai");
@@ -248,7 +244,6 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
let plaintext = format!("sk_{}", "a".repeat(43));
let created = CreatedClientKeyData::new(
10,
"key_created".to_owned(),
"sk_aaaaaaaaa".to_owned(),
plaintext.clone(),
@@ -263,9 +258,9 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
json!({ "id": "key_created", "plaintextKey": plaintext })
);
assert_eq!(
serde_json::to_value(MutatedClientKeyData::new(11, "key_created".to_owned()))
serde_json::to_value(MutatedClientKeyData::new("key_created".to_owned()))
.expect("serialize mutation"),
json!({ "configRevision": 11, "id": "key_created" })
json!({ "id": "key_created" })
);
}
@@ -279,8 +279,9 @@ impl SettingsStore for MemorySettingsStore {
command: ReplaceRuntimeSettings,
_: &MutationContext,
) -> AdminStoreResult<RuntimeSettings> {
let mut settings = self.settings.lock().expect("settings");
let updated = RuntimeSettings {
config_revision: next_revision(command.expected_config_revision),
config_revision: next_revision(settings.config_revision),
model_mappings: command.model_mappings,
refresh_margin_seconds: command.refresh_margin_seconds,
refresh_concurrency: command.refresh_concurrency,
@@ -292,31 +293,31 @@ impl SettingsStore for MemorySettingsStore {
audit_retention_days: command.audit_retention_days,
updated_at: Utc::now(),
};
*self.settings.lock().expect("settings") = updated.clone();
*settings = updated.clone();
Ok(updated)
}
async fn replace_admin_api_key(
&self,
expected_config_revision: Revision,
key: AdminApiKey,
_: &MutationContext,
) -> AdminStoreResult<AdminApiKeyMutation> {
*self.api_key.lock().expect("API key") = Some(key);
let revision = self.settings.lock().expect("settings").config_revision;
Ok(AdminApiKeyMutation {
config_revision: next_revision(expected_config_revision),
config_revision: next_revision(revision),
exists: true,
})
}
async fn delete_admin_api_key(
&self,
expected_config_revision: Revision,
_: &MutationContext,
) -> AdminStoreResult<AdminApiKeyMutation> {
*self.api_key.lock().expect("API key") = None;
let revision = self.settings.lock().expect("settings").config_revision;
Ok(AdminApiKeyMutation {
config_revision: next_revision(expected_config_revision),
config_revision: next_revision(revision),
exists: false,
})
}
@@ -37,7 +37,6 @@ async fn response_json(response: axum::response::Response) -> Value {
fn update_body() -> Value {
json!({
"expectedConfigRevision": 7,
"modelMappings": {
"gpt-5.4": "gpt-5.5",
"grok-latest": "grok-4.5"
@@ -64,22 +63,11 @@ fn settings_request_should_reject_unknown_rotation_strategy() {
}
#[test]
fn settings_request_should_require_positive_revision() {
fn settings_request_should_reject_unknown_revision_field() {
let mut body = update_body();
body["expectedConfigRevision"] = json!(0);
let request: UpdateRuntimeSettingsRequest =
serde_json::from_value(body).expect("decode settings");
assert_eq!(
request.validate().unwrap_err().field(),
"expectedConfigRevision"
);
body["expectedConfigRevision"] = json!(7);
let mut missing = update_body();
missing
.as_object_mut()
.expect("settings object")
.remove("expectedConfigRevision");
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(missing).is_err());
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body).is_err());
}
#[test]
@@ -124,7 +112,7 @@ async fn settings_post_should_replace_global_model_mappings() {
.expect("settings update response");
let data = response_json(response).await["data"].clone();
assert_eq!(data["configRevision"], 8);
assert!(data.get("configRevision").is_none());
assert_eq!(data["modelMappings"]["gpt-5.4"], "gpt-5.5");
assert_eq!(data["modelMappings"]["grok-latest"], "grok-4.5");
}
@@ -135,6 +135,36 @@ fn local_provider_capacity_exhaustion_should_map_to_service_unavailable() {
);
}
#[test]
fn local_provider_capacity_exhaustion_should_preserve_safe_provider_detail() {
let detail = ClientVisibleUpstreamError::new(
"no account is eligible for the requested model",
Some("no_eligible_account".to_owned()),
Some("account_unavailable_error".to_owned()),
)
.expect("safe provider detail");
let error = EngineError::Provider(
ProviderError::new(ProviderErrorKind::Unavailable, UpstreamSendState::NotSent)
.with_client_visible_upstream_error(detail),
);
let gateway = gateway_error_from_engine(&error);
assert_eq!(gateway.kind(), GatewayErrorKind::NoAvailableProvider);
assert_eq!(
gateway.client_message(),
"no account is eligible for the requested model"
);
assert_eq!(gateway.client_error_code(), Some("no_eligible_account"));
assert_eq!(
gateway.client_error_type(),
Some("account_unavailable_error")
);
assert_eq!(
gateway.safe_message(),
"no upstream provider is currently available for this request"
);
}
#[test]
fn no_eligible_provider_account_should_map_to_service_unavailable() {
let error = EngineError::Provider(ProviderError::new(
@@ -10,7 +10,9 @@ use gateway_core::engine::execution::{
StartedExecution,
};
use gateway_core::error::{GatewayError, GatewayErrorKind};
use gateway_core::routing::{ModelPresentation, PublicModelId, PublicModelProfile};
use gateway_core::routing::{
ModelPresentation, ModelServiceTier, PublicModelId, PublicModelProfile,
};
use tower::ServiceExt;
use super::{api_router, authenticated_client};
@@ -73,7 +75,15 @@ impl ExecutionService for ModelsExecution {
)
.with_context_window_tokens(Some(500_000))
.with_image_input(true)
.with_agent_tools(true, true),
.with_agent_tools(true, true)
.with_service_tiers(vec![
ModelServiceTier::new(
"priority",
"Fast",
"Route the request through Codex fast mode.",
)
.with_speed_tier("fast"),
]),
)]
}
@@ -145,7 +155,15 @@ async fn models_should_encode_provider_profiles_for_current_codex_clients() {
assert_eq!(model["default_reasoning_level"], "medium");
assert_eq!(model["context_window"], 500_000);
assert_eq!(model["apply_patch_tool_type"], "freeform");
assert_eq!(model["service_tiers"], serde_json::json!([]));
assert_eq!(model["additional_speed_tiers"], serde_json::json!(["fast"]));
assert_eq!(
model["service_tiers"],
serde_json::json!([{
"id": "priority",
"name": "Fast",
"description": "Route the request through Codex fast mode."
}])
);
assert_eq!(
model["supported_reasoning_levels"],
serde_json::json!([
@@ -152,9 +152,61 @@ pub struct ModelPresentation {
search_tool: bool,
image_detail_original: bool,
verbosity: bool,
service_tiers: Vec<ModelServiceTier>,
hidden: bool,
}
/// Provider 声明给 Codex 客户端的服务档位。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelServiceTier {
id: String,
name: String,
description: String,
speed_tier: Option<String>,
}
impl ModelServiceTier {
#[must_use]
pub fn new(
id: impl Into<String>,
name: impl Into<String>,
description: impl Into<String>,
) -> Self {
Self {
id: id.into(),
name: name.into(),
description: description.into(),
speed_tier: None,
}
}
#[must_use]
pub fn with_speed_tier(mut self, speed_tier: impl Into<String>) -> Self {
self.speed_tier = Some(speed_tier.into());
self
}
#[must_use]
pub fn id(&self) -> &str {
&self.id
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn description(&self) -> &str {
&self.description
}
#[must_use]
pub fn speed_tier(&self) -> Option<&str> {
self.speed_tier.as_deref()
}
}
impl ModelPresentation {
#[must_use]
pub fn new(display_name: Option<String>, description: Option<String>) -> Self {
@@ -213,6 +265,12 @@ impl ModelPresentation {
self
}
#[must_use]
pub fn with_service_tiers(mut self, service_tiers: Vec<ModelServiceTier>) -> Self {
self.service_tiers = service_tiers;
self
}
#[must_use]
pub const fn with_hidden(mut self, hidden: bool) -> Self {
self.hidden = hidden;
@@ -274,6 +332,11 @@ impl ModelPresentation {
self.verbosity
}
#[must_use]
pub fn service_tiers(&self) -> &[ModelServiceTier] {
&self.service_tiers
}
#[must_use]
pub const fn hidden(&self) -> bool {
self.hidden
@@ -0,0 +1,126 @@
use serde_json::{Map, Value};
const MULTI_AGENT_MODE_OPEN_TAG: &str = "<multi_agent_mode>";
const MULTI_AGENT_MODE_CLOSE_TAG: &str = "</multi_agent_mode>";
const PROACTIVE_MULTI_AGENT_MODE_PREFIX: &str = "Proactive multi-agent delegation is active.";
/// 从 OpenAI Responses 请求中提取的稳定 Codex 请求语义。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CodexResponsesRequestSemantics {
/// 客户端原始选择的推理强度。
pub reasoning_effort: Option<String>,
/// Codex turn metadata 中的请求类型。
pub request_kind: Option<String>,
/// Codex turn metadata 中的子代理类型。
pub subagent_kind: Option<String>,
/// 网关侧派生出的推理预设。
pub reasoning_preset: Option<&'static str>,
/// 请求是否为 Codex 压缩请求。
pub compact: bool,
}
/// 从 Responses body 与非 wire 协议上下文中提取 Codex 语义。
#[must_use]
pub fn codex_responses_request_semantics(
body: &Map<String, Value>,
context: &Map<String, Value>,
) -> CodexResponsesRequestSemantics {
codex_responses_request_semantics_with_turn_metadata(
body,
non_empty_string(context.get("turn_metadata")),
)
}
/// 调用方已解析权威 turn metadata 时,从 Responses body 中提取 Codex 语义。
#[must_use]
pub fn codex_responses_request_semantics_with_turn_metadata(
body: &Map<String, Value>,
turn_metadata: Option<&str>,
) -> CodexResponsesRequestSemantics {
let reasoning_effort = body
.get("reasoning")
.and_then(Value::as_object)
.and_then(|reasoning| non_empty_string(reasoning.get("effort")))
.map(ToOwned::to_owned);
let parsed_turn_metadata = turn_metadata
.or_else(|| request_turn_metadata(body))
.and_then(|value| serde_json::from_str::<Value>(value).ok());
let request_kind = parsed_turn_metadata
.as_ref()
.and_then(|metadata| non_empty_string(metadata.get("request_kind")))
.map(ToOwned::to_owned);
let subagent_kind = parsed_turn_metadata
.as_ref()
.and_then(|metadata| non_empty_string(metadata.get("subagent_kind")))
.map(ToOwned::to_owned);
let proactive_multi_agent = latest_multi_agent_mode(input_items(body).iter())
.is_some_and(|mode| mode.starts_with(PROACTIVE_MULTI_AGENT_MODE_PREFIX));
let compact_trigger = input_items(body)
.iter()
.any(|item| non_empty_string(item.get("type")) == Some("compaction_trigger"));
let reasoning_preset = (subagent_kind.is_none()
&& reasoning_effort.as_deref() == Some("max")
&& proactive_multi_agent)
.then_some("ultra");
let compact = request_kind.as_deref() == Some("compaction") || compact_trigger;
CodexResponsesRequestSemantics {
reasoning_effort,
request_kind,
subagent_kind,
reasoning_preset,
compact,
}
}
fn request_turn_metadata(body: &Map<String, Value>) -> Option<&str> {
non_empty_string(body.get("turnMetadata"))
.or_else(|| non_empty_string(body.get("turn_metadata")))
.or_else(|| non_empty_string(body.get("x-codex-turn-metadata")))
.or_else(|| client_metadata_string(body, "x-codex-turn-metadata"))
.or_else(|| client_metadata_string(body, "turnMetadata"))
.or_else(|| client_metadata_string(body, "turn_metadata"))
}
fn client_metadata_string<'a>(body: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
body.get("client_metadata")
.and_then(Value::as_object)
.and_then(|metadata| non_empty_string(metadata.get(key)))
}
fn input_items(body: &Map<String, Value>) -> &[Value] {
body.get("input")
.and_then(Value::as_array)
.map_or(&[], Vec::as_slice)
}
fn latest_multi_agent_mode<'a>(
input: impl DoubleEndedIterator<Item = &'a Value>,
) -> Option<&'a str> {
input.rev().find_map(|item| {
if non_empty_string(item.get("role")) != Some("developer") {
return None;
}
item.get("content")?
.as_array()?
.iter()
.rev()
.filter_map(|content| non_empty_string(content.get("text")))
.find_map(multi_agent_mode_from_text)
})
}
fn multi_agent_mode_from_text(text: &str) -> Option<&str> {
let close = text.rfind(MULTI_AGENT_MODE_CLOSE_TAG)?;
let before_close = &text[..close];
let open = before_close.rfind(MULTI_AGENT_MODE_OPEN_TAG)?;
let body = &before_close[open + MULTI_AGENT_MODE_OPEN_TAG.len()..];
Some(body.trim())
}
fn non_empty_string(value: Option<&Value>) -> Option<&str> {
value
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
}
@@ -9,7 +9,14 @@ pub const WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY: &str =
/// Codex memory consolidation 请求标记。
pub const X_OPENAI_MEMGEN_REQUEST_HEADER: &str = "x-openai-memgen-request";
/// OpenAI Responses 请求中携带的 Codex 专属语义。
pub mod codex;
/// OpenAI/Codex 事件语义、用量与限流字段编解码。
pub mod events;
/// Server-Sent Events 帧的解析与编码。
pub mod sse;
pub use codex::{
CodexResponsesRequestSemantics, codex_responses_request_semantics,
codex_responses_request_semantics_with_turn_metadata,
};
@@ -0,0 +1,61 @@
use gateway_protocol::openai::codex_responses_request_semantics;
use serde_json::{Map, Value, json};
#[test]
fn codex_semantics_should_use_protocol_context_before_body_metadata() {
let body = json!({
"model": "grok-4.5",
"reasoning": {"effort": " xhigh "},
"client_metadata": {
"x-codex-turn-metadata": "{\"request_kind\":\"review\"}"
},
"input": [
{"type": "message", "role": "user", "content": "history"},
{"type": "compaction_trigger"}
]
});
let body = object(body);
let context = Map::from_iter([(
"turn_metadata".to_owned(),
json!("{\"request_kind\":\"compaction\",\"subagent_kind\":\"compact\"}"),
)]);
let semantics = codex_responses_request_semantics(&body, &context);
assert_eq!(semantics.reasoning_effort.as_deref(), Some("xhigh"));
assert_eq!(semantics.request_kind.as_deref(), Some("compaction"));
assert_eq!(semantics.subagent_kind.as_deref(), Some("compact"));
assert_eq!(semantics.reasoning_preset, None);
assert!(semantics.compact);
}
#[test]
fn codex_semantics_should_detect_ultra_preset_from_proactive_multi_agent() {
let body = object(json!({
"model": "gpt-test",
"reasoning": {"effort": "max"},
"input": [
{
"type": "message",
"role": "developer",
"content": [{
"type": "input_text",
"text": "<multi_agent_mode>Proactive multi-agent delegation is active.</multi_agent_mode>"
}]
}
]
}));
let semantics = codex_responses_request_semantics(&body, &Map::new());
assert_eq!(semantics.reasoning_effort.as_deref(), Some("max"));
assert_eq!(semantics.reasoning_preset, Some("ultra"));
assert!(!semantics.compact);
}
fn object(value: Value) -> Map<String, Value> {
let Value::Object(object) = value else {
panic!("test value must be an object");
};
object
}
@@ -1,2 +1,3 @@
mod codex;
mod events;
mod sse;
+3 -13
View File
@@ -582,7 +582,6 @@ impl SettingsStore for AdminSettingsStoreAdapter {
};
let snapshot = postgres::ControlPlaneRepository::replace_control_plane(
&self.control_plane,
store_revision(command.expected_config_revision)?,
replacement,
)
.await
@@ -592,32 +591,24 @@ impl SettingsStore for AdminSettingsStoreAdapter {
async fn replace_admin_api_key(
&self,
expected_config_revision: AdminRevision,
key: AdminApiKey,
context: &MutationContext,
) -> AdminStoreResult<AdminApiKeyMutation> {
self.replace_admin_api_key_value(
expected_config_revision,
Some(key.expose_for_auth().to_owned()),
context,
)
.await
self.replace_admin_api_key_value(Some(key.expose_for_auth().to_owned()), context)
.await
}
async fn delete_admin_api_key(
&self,
expected_config_revision: AdminRevision,
context: &MutationContext,
) -> AdminStoreResult<AdminApiKeyMutation> {
self.replace_admin_api_key_value(expected_config_revision, None, context)
.await
self.replace_admin_api_key_value(None, context).await
}
}
impl AdminSettingsStoreAdapter {
async fn replace_admin_api_key_value(
&self,
expected_config_revision: AdminRevision,
admin_api_key: Option<String>,
context: &MutationContext,
) -> AdminStoreResult<AdminApiKeyMutation> {
@@ -639,7 +630,6 @@ impl AdminSettingsStoreAdapter {
};
let snapshot = postgres::ControlPlaneRepository::replace_control_plane(
&self.control_plane,
store_revision(expected_config_revision)?,
postgres::ControlPlaneReplacement {
settings: update,
audit: mutation_audit(
@@ -36,7 +36,7 @@ use sqlx::{PgPool, Postgres, QueryBuilder, Transaction};
use crate::{
StoreError, StoreResult, admin_revision, admin_store_error, mutation_audit,
postgres_unavailable, require_nonempty, store_revision,
postgres_unavailable, require_nonempty,
};
use super::{ControlPlaneRepository, PgControlPlaneRepository};
@@ -633,7 +633,6 @@ impl ClientKeyStore for PgAdminClientKeyStore {
let revision = self
.control_plane
.create_client_api_key(
store_revision(command.expected_config_revision)?,
NewClientApiKey {
id: id.as_str().to_owned(),
name: command.name,
@@ -676,7 +675,6 @@ impl ClientKeyStore for PgAdminClientKeyStore {
let revision = self
.control_plane
.update_client_api_key(
store_revision(command.expected_config_revision)?,
UpdateClientApiKeyDetails {
id: id.as_str().to_owned(),
name: command.name,
@@ -716,7 +714,6 @@ impl ClientKeyStore for PgAdminClientKeyStore {
let revision = self
.control_plane
.set_client_api_key_enabled(
store_revision(command.expected_config_revision)?,
id.as_str(),
command.enabled,
mutation_audit(
@@ -739,7 +736,6 @@ impl ClientKeyStore for PgAdminClientKeyStore {
) -> AdminStoreResult<gateway_admin::model::Revision> {
self.control_plane
.delete_client_api_key(
store_revision(command.expected_config_revision)?,
command.id.as_str(),
mutation_audit(
context,
@@ -74,27 +74,23 @@ pub trait ControlPlaneRepository: Send + Sync {
async fn replace_control_plane(
&self,
expected_revision: Revision,
replacement: ControlPlaneReplacement,
) -> StoreResult<ControlPlaneSnapshot>;
async fn create_client_api_key(
&self,
expected_revision: Revision,
key: NewClientApiKey,
audit: AdminAuditEvent,
) -> StoreResult<Revision>;
async fn update_client_api_key(
&self,
expected_revision: Revision,
key: UpdateClientApiKeyDetails,
audit: AdminAuditEvent,
) -> StoreResult<Revision>;
async fn set_client_api_key_enabled(
&self,
expected_revision: Revision,
id: &str,
enabled: bool,
audit: AdminAuditEvent,
@@ -102,7 +98,6 @@ pub trait ControlPlaneRepository: Send + Sync {
async fn delete_client_api_key(
&self,
expected_revision: Revision,
id: &str,
audit: AdminAuditEvent,
) -> StoreResult<Revision>;
@@ -142,7 +137,6 @@ impl ControlPlaneRepository for PgControlPlaneRepository {
async fn replace_control_plane(
&self,
expected_revision: Revision,
replacement: ControlPlaneReplacement,
) -> StoreResult<ControlPlaneSnapshot> {
replacement.settings.validate()?;
@@ -152,12 +146,9 @@ impl ControlPlaneRepository for PgControlPlaneRepository {
.await
.map_err(|_| postgres_unavailable("begin control plane replacement"))?;
let result = async {
let revision = update_runtime_settings_in_transaction(
&mut transaction,
expected_revision,
&replacement.settings,
)
.await?;
let revision =
update_runtime_settings_in_transaction(&mut transaction, &replacement.settings)
.await?;
append_admin_audit_event_in_transaction(&mut transaction, replacement.audit, revision)
.await?;
load_control_plane_in_transaction(&mut transaction).await
@@ -183,41 +174,29 @@ impl ControlPlaneRepository for PgControlPlaneRepository {
async fn create_client_api_key(
&self,
expected_revision: Revision,
key: NewClientApiKey,
audit: AdminAuditEvent,
) -> StoreResult<Revision> {
self.apply_targeted_mutation(
expected_revision,
ControlPlaneMutation::CreateClientApiKey(key),
audit,
)
.await
self.apply_targeted_mutation(ControlPlaneMutation::CreateClientApiKey(key), audit)
.await
}
async fn update_client_api_key(
&self,
expected_revision: Revision,
key: UpdateClientApiKeyDetails,
audit: AdminAuditEvent,
) -> StoreResult<Revision> {
self.apply_targeted_mutation(
expected_revision,
ControlPlaneMutation::UpdateClientApiKey(key),
audit,
)
.await
self.apply_targeted_mutation(ControlPlaneMutation::UpdateClientApiKey(key), audit)
.await
}
async fn set_client_api_key_enabled(
&self,
expected_revision: Revision,
id: &str,
enabled: bool,
audit: AdminAuditEvent,
) -> StoreResult<Revision> {
self.apply_targeted_mutation(
expected_revision,
ControlPlaneMutation::SetClientApiKeyEnabled {
id: id.to_owned(),
enabled,
@@ -229,12 +208,10 @@ impl ControlPlaneRepository for PgControlPlaneRepository {
async fn delete_client_api_key(
&self,
expected_revision: Revision,
id: &str,
audit: AdminAuditEvent,
) -> StoreResult<Revision> {
self.apply_targeted_mutation(
expected_revision,
ControlPlaneMutation::DeleteClientApiKey(id.to_owned()),
audit,
)
@@ -252,7 +229,6 @@ enum ControlPlaneMutation {
impl PgControlPlaneRepository {
async fn apply_targeted_mutation(
&self,
expected_revision: Revision,
mutation: ControlPlaneMutation,
audit: AdminAuditEvent,
) -> StoreResult<Revision> {
@@ -262,8 +238,7 @@ impl PgControlPlaneRepository {
.await
.map_err(|_| postgres_unavailable("begin targeted control plane mutation"))?;
let result = async {
let revision =
bump_config_revision_in_transaction(&mut transaction, expected_revision).await?;
let revision = bump_config_revision_in_transaction(&mut transaction).await?;
match mutation {
ControlPlaneMutation::CreateClientApiKey(key) => {
insert_client_api_key_in_transaction(&mut transaction, &key).await?;
@@ -12,8 +12,7 @@ use gateway_admin::{
model::{
MutationContext, Revision as AdminRevision,
accounts::{
AccountAvailability as AdminAccountAvailability, AccountCost,
AccountListQuery as AdminAccountListQuery, AccountModelUsage, AccountPage,
AccountCost, AccountListQuery as AdminAccountListQuery, AccountModelUsage, AccountPage,
AccountRecord, AccountRequestBucket, AccountSort as AdminAccountSort,
AccountSortField as AdminAccountSortField, AccountStatus as AdminAccountStatus,
AccountSummary, AccountUsage, AccountUsageWindowQuery, AccountUsageWindowResult,
@@ -35,8 +34,8 @@ use gateway_admin::{
use sqlx::{PgPool, Postgres, Row, Transaction};
use gateway_core::engine::credential::{
AccountAvailability as CoreAccountAvailability, AccountStateChange, CredentialCasOutcome,
CredentialCasUpdate, CredentialRevision as CoreCredentialRevision, LoadedCredential,
AccountAvailability, AccountStateChange, CredentialCasOutcome, CredentialCasUpdate,
CredentialRevision as CoreCredentialRevision, LoadedCredential,
NewProviderAccount as CoreNewProviderAccount, OpaqueProviderData, PlaintextCredential,
ProviderAccount as CoreProviderAccount, ProviderAccountId as CoreProviderAccountId,
ProviderAccountStore, ProviderAccountUpdate as CoreProviderAccountUpdate, QuotaObservation,
@@ -121,43 +120,9 @@ impl ProviderAccountAdminScope {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderAccountAvailability {
Unknown,
Ready,
Cooldown,
QuotaExhausted,
Expired,
Banned,
Invalid,
}
impl ProviderAccountAvailability {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Unknown => "unknown",
Self::Ready => "ready",
Self::Cooldown => "cooldown",
Self::QuotaExhausted => "quota_exhausted",
Self::Expired => "expired",
Self::Banned => "banned",
Self::Invalid => "invalid",
}
}
fn parse(value: &str) -> StoreResult<Self> {
match value {
"unknown" => Ok(Self::Unknown),
"ready" => Ok(Self::Ready),
"cooldown" => Ok(Self::Cooldown),
"quota_exhausted" => Ok(Self::QuotaExhausted),
"expired" => Ok(Self::Expired),
"banned" => Ok(Self::Banned),
"invalid" => Ok(Self::Invalid),
_ => Err(invalid("unknown availability value")),
}
}
/// 解析数据库 availability 列;字符串映射本体唯一归属 gateway-core。
fn parse_availability(value: &str) -> StoreResult<AccountAvailability> {
AccountAvailability::parse(value).ok_or_else(|| invalid("unknown availability value"))
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -175,7 +140,7 @@ pub struct ProviderAccountSummary {
pub access_token_expires_at: Option<DateTime<Utc>>,
pub next_refresh_at: Option<DateTime<Utc>>,
pub enabled: bool,
pub availability: ProviderAccountAvailability,
pub availability: AccountAvailability,
pub availability_reason: Option<String>,
pub cooldown_until: Option<DateTime<Utc>>,
pub availability_observed_at: DateTime<Utc>,
@@ -220,7 +185,7 @@ pub struct NewProviderAccount {
pub access_token_expires_at: Option<DateTime<Utc>>,
pub next_refresh_at: Option<DateTime<Utc>>,
pub enabled: bool,
pub availability: ProviderAccountAvailability,
pub availability: AccountAvailability,
pub availability_reason: Option<String>,
pub cooldown_until: Option<DateTime<Utc>>,
pub availability_observed_at: DateTime<Utc>,
@@ -259,9 +224,7 @@ impl NewProviderAccount {
if !self.has_refresh_token && self.next_refresh_at.is_some() {
return Err(invalid("next_refresh_at requires a refresh token"));
}
if (self.availability == ProviderAccountAvailability::Cooldown)
!= self.cooldown_until.is_some()
{
if (self.availability == AccountAvailability::Cooldown) != self.cooldown_until.is_some() {
return Err(invalid(
"cooldown_until must be present exactly for cooldown availability",
));
@@ -394,7 +357,7 @@ impl fmt::Debug for ProviderCredentialUpdate {
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderAccountObservation {
pub account_id: String,
pub availability: ProviderAccountAvailability,
pub availability: AccountAvailability,
pub availability_reason: Option<String>,
pub cooldown_until: Option<DateTime<Utc>>,
pub provider_quota_json: Option<JsonObject>,
@@ -406,7 +369,7 @@ pub struct ProviderAccountObservation {
pub struct ProviderAccountStateUpdate {
pub account_id: String,
pub expected_revision: Revision,
pub availability: ProviderAccountAvailability,
pub availability: AccountAvailability,
pub availability_reason: Option<String>,
pub cooldown_until: Option<DateTime<Utc>>,
pub availability_observed_at: DateTime<Utc>,
@@ -415,9 +378,7 @@ pub struct ProviderAccountStateUpdate {
impl ProviderAccountStateUpdate {
pub fn validate(&self) -> StoreResult<()> {
require_nonempty(ENTITY, "account_id", &self.account_id)?;
if (self.availability == ProviderAccountAvailability::Cooldown)
!= self.cooldown_until.is_some()
{
if (self.availability == AccountAvailability::Cooldown) != self.cooldown_until.is_some() {
return Err(invalid(
"cooldown availability and cooldown_until must agree",
));
@@ -429,9 +390,7 @@ impl ProviderAccountStateUpdate {
impl ProviderAccountObservation {
pub fn validate(&self) -> StoreResult<()> {
require_nonempty(ENTITY, "account_id", &self.account_id)?;
if (self.availability == ProviderAccountAvailability::Cooldown)
!= self.cooldown_until.is_some()
{
if (self.availability == AccountAvailability::Cooldown) != self.cooldown_until.is_some() {
return Err(invalid(
"cooldown availability and cooldown_until must agree",
));
@@ -485,25 +444,21 @@ pub trait ProviderAccountAdminRepository: Send + Sync {
async fn import_provider_accounts(
&self,
expected_config_revision: Revision,
command: ImportProviderAccounts,
) -> StoreResult<ProviderAccountAdminImport>;
async fn rotate_provider_account(
&self,
expected_config_revision: Revision,
command: RotateProviderAccount,
) -> StoreResult<ProviderAccountAdminRotation>;
async fn set_provider_account_enabled_admin(
&self,
expected_config_revision: Revision,
command: SetProviderAccountEnabled,
) -> StoreResult<Revision>;
async fn delete_provider_accounts_admin(
&self,
expected_config_revision: Revision,
command: DeleteProviderAccounts,
) -> StoreResult<Revision>;
}
@@ -767,7 +722,6 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
async fn import_provider_accounts(
&self,
expected_config_revision: Revision,
command: ImportProviderAccounts,
) -> StoreResult<ProviderAccountAdminImport> {
command.validate()?;
@@ -777,9 +731,7 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
.await
.map_err(|_| postgres_unavailable("begin provider account admin import"))?;
let result = async {
let revision =
bump_config_revision_in_transaction(&mut transaction, expected_config_revision)
.await?;
let revision = bump_config_revision_in_transaction(&mut transaction).await?;
let mut account_ids = Vec::with_capacity(command.accounts.len());
for account in &command.accounts {
account_ids
@@ -798,7 +750,6 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
async fn rotate_provider_account(
&self,
expected_config_revision: Revision,
command: RotateProviderAccount,
) -> StoreResult<ProviderAccountAdminRotation> {
command.scope.validate()?;
@@ -814,9 +765,7 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
.await
.map_err(|_| postgres_unavailable("begin provider account admin rotation"))?;
let result = async {
let config_revision =
bump_config_revision_in_transaction(&mut transaction, expected_config_revision)
.await?;
let config_revision = bump_config_revision_in_transaction(&mut transaction).await?;
let credential_revision = rotate_provider_account_in_transaction(
&mut transaction,
&command.scope,
@@ -841,7 +790,6 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
async fn set_provider_account_enabled_admin(
&self,
expected_config_revision: Revision,
command: SetProviderAccountEnabled,
) -> StoreResult<Revision> {
command.scope.validate()?;
@@ -852,9 +800,7 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
.await
.map_err(|_| postgres_unavailable("begin provider account admin state change"))?;
let result = async {
let revision =
bump_config_revision_in_transaction(&mut transaction, expected_config_revision)
.await?;
let revision = bump_config_revision_in_transaction(&mut transaction).await?;
set_provider_account_enabled_in_transaction(
&mut transaction,
&command.scope,
@@ -872,7 +818,6 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
async fn delete_provider_accounts_admin(
&self,
expected_config_revision: Revision,
command: DeleteProviderAccounts,
) -> StoreResult<Revision> {
command.scope.validate()?;
@@ -883,9 +828,7 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
.await
.map_err(|_| postgres_unavailable("begin provider account admin deletion"))?;
let result = async {
let revision =
bump_config_revision_in_transaction(&mut transaction, expected_config_revision)
.await?;
let revision = bump_config_revision_in_transaction(&mut transaction).await?;
delete_provider_accounts_in_transaction(
&mut transaction,
&command.scope,
@@ -1032,7 +975,6 @@ impl PgAdminAccountStore {
async fn commit_prepared_import(
&self,
expected_config_revision: AdminRevision,
prepared: PreparedCredentialImport,
context: &MutationContext,
action: &str,
@@ -1046,22 +988,19 @@ impl PgAdminAccountStore {
.map_err(|error| admin_store_error(ENTITY, error))?;
let imported = self
.accounts
.import_provider_accounts(
store_revision(expected_config_revision)?,
ImportProviderAccounts {
scope: ProviderAccountAdminScope {
provider_kind: provider_kind.clone(),
},
accounts,
audit: mutation_audit(
context,
action,
"provider_account",
&provider_kind,
vec!["credentials".to_owned()],
),
.import_provider_accounts(ImportProviderAccounts {
scope: ProviderAccountAdminScope {
provider_kind: provider_kind.clone(),
},
)
accounts,
audit: mutation_audit(
context,
action,
"provider_account",
&provider_kind,
vec!["credentials".to_owned()],
),
})
.await
.map_err(|error| admin_store_error(ENTITY, error))?;
Ok(CredentialImportResult {
@@ -1083,7 +1022,6 @@ impl PgAdminAccountStore {
async fn commit_prepared_rotation(
&self,
expected_config_revision: AdminRevision,
prepared: PreparedCredentialRotationFacts,
context: &MutationContext,
action: &str,
@@ -1094,36 +1032,31 @@ impl PgAdminAccountStore {
};
let rotation = self
.accounts
.rotate_provider_account(
store_revision(expected_config_revision)?,
RotateProviderAccount {
scope,
profile: UpdateProviderAccount {
id: account_id.as_str().to_owned(),
name: prepared.name,
email: prepared.email,
plan_type: prepared.plan_type,
},
credential: ProviderCredentialUpdate {
account_id: account_id.as_str().to_owned(),
expected_revision: store_revision(prepared.expected_credential_revision)?,
provider_credentials_json: provider_document_json(
prepared.provider_material,
)
.map_err(|error| admin_store_error(ENTITY, error))?,
has_refresh_token: prepared.has_refresh_token,
access_token_expires_at: prepared.access_token_expires_at,
next_refresh_at: prepared.next_refresh_at,
},
audit: mutation_audit(
context,
action,
"provider_account",
account_id.as_str(),
vec!["credentials".to_owned()],
),
.rotate_provider_account(RotateProviderAccount {
scope,
profile: UpdateProviderAccount {
id: account_id.as_str().to_owned(),
name: prepared.name,
email: prepared.email,
plan_type: prepared.plan_type,
},
)
credential: ProviderCredentialUpdate {
account_id: account_id.as_str().to_owned(),
expected_revision: store_revision(prepared.expected_credential_revision)?,
provider_credentials_json: provider_document_json(prepared.provider_material)
.map_err(|error| admin_store_error(ENTITY, error))?,
has_refresh_token: prepared.has_refresh_token,
access_token_expires_at: prepared.access_token_expires_at,
next_refresh_at: prepared.next_refresh_at,
},
audit: mutation_audit(
context,
action,
"provider_account",
account_id.as_str(),
vec!["credentials".to_owned()],
),
})
.await
.map_err(|error| admin_store_error(ENTITY, error))?;
Ok(CredentialMutationResult {
@@ -1251,9 +1184,10 @@ impl AccountStore for PgAdminAccountStore {
.into_iter()
.filter(|account| account.provider_kind == provider_kind.as_str())
.filter(|account| {
query.availability.as_ref().is_none_or(|expected| {
expected.matches(admin_account_availability(account.availability))
})
query
.availability
.as_ref()
.is_none_or(|expected| expected.matches(account.availability))
})
.filter(|account| {
query
@@ -1383,13 +1317,8 @@ impl AccountStore for PgAdminAccountStore {
command: CredentialImportCommit,
context: &MutationContext,
) -> AdminStoreResult<CredentialImportResult> {
self.commit_prepared_import(
command.expected_config_revision,
command.prepared,
context,
"import_document",
)
.await
self.commit_prepared_import(command.prepared, context, "import_document")
.await
}
async fn commit_authorization(
@@ -1397,13 +1326,11 @@ impl AccountStore for PgAdminAccountStore {
command: AuthorizationCommit,
context: &MutationContext,
) -> AdminStoreResult<CredentialMutationResult> {
let expected_config_revision = command.pending.expected_config_revision();
match command.credential {
AuthorizationCredentialCommit::Create(credential) => {
let account_id = credential.account_id.clone();
let result = self
.commit_prepared_import(
expected_config_revision,
PreparedCredentialImport {
provider_kind: credential.provider_kind.clone(),
credentials: vec![credential],
@@ -1431,13 +1358,8 @@ impl AccountStore for PgAdminAccountStore {
})
}
AuthorizationCredentialCommit::Reauthorize(prepared) => {
self.commit_prepared_rotation(
expected_config_revision,
prepared,
context,
"reauthorize",
)
.await
self.commit_prepared_rotation(prepared, context, "reauthorize")
.await
}
}
}
@@ -1447,13 +1369,8 @@ impl AccountStore for PgAdminAccountStore {
command: CredentialRotationCommit,
context: &MutationContext,
) -> AdminStoreResult<CredentialMutationResult> {
self.commit_prepared_rotation(
command.expected_config_revision,
command.prepared,
context,
"rotate_credential",
)
.await
self.commit_prepared_rotation(command.prepared, context, "rotate_credential")
.await
}
async fn commit_credential_refresh(
@@ -1461,13 +1378,8 @@ impl AccountStore for PgAdminAccountStore {
command: CredentialRotationCommit,
context: &MutationContext,
) -> AdminStoreResult<CredentialMutationResult> {
self.commit_prepared_rotation(
command.expected_config_revision,
command.prepared,
context,
"refresh_credential",
)
.await
self.commit_prepared_rotation(command.prepared, context, "refresh_credential")
.await
}
async fn set_account_enabled(
@@ -1478,21 +1390,18 @@ impl AccountStore for PgAdminAccountStore {
let scope = self.required_scope(&command.account_id).await?;
let action = if command.enabled { "enable" } else { "disable" };
self.accounts
.set_provider_account_enabled_admin(
store_revision(command.expected_config_revision)?,
SetProviderAccountEnabled {
scope,
account_id: command.account_id.clone(),
enabled: command.enabled,
audit: mutation_audit(
context,
action,
"provider_account",
&command.account_id,
vec!["enabled".to_owned()],
),
},
)
.set_provider_account_enabled_admin(SetProviderAccountEnabled {
scope,
account_id: command.account_id.clone(),
enabled: command.enabled,
audit: mutation_audit(
context,
action,
"provider_account",
&command.account_id,
vec!["enabled".to_owned()],
),
})
.await
.map_err(|error| admin_store_error(ENTITY, error))
.and_then(admin_revision)
@@ -1517,20 +1426,17 @@ impl AccountStore for PgAdminAccountStore {
"provider_accounts".to_owned()
};
self.accounts
.delete_provider_accounts_admin(
store_revision(command.expected_config_revision)?,
DeleteProviderAccounts {
scope,
account_ids: command.account_ids,
audit: mutation_audit(
context,
"delete",
"provider_account",
&audit_target,
Vec::new(),
),
},
)
.delete_provider_accounts_admin(DeleteProviderAccounts {
scope,
account_ids: command.account_ids,
audit: mutation_audit(
context,
"delete",
"provider_account",
&audit_target,
Vec::new(),
),
})
.await
.map_err(|error| admin_store_error(ENTITY, error))
.and_then(admin_revision)
@@ -1656,12 +1562,12 @@ fn admin_account_status(
) -> AdminAccountStatus {
if !account.enabled {
AdminAccountStatus::Disabled
} else if account.availability == ProviderAccountAvailability::Banned {
} else if account.availability == AccountAvailability::Banned {
AdminAccountStatus::Banned
} else if account.availability == ProviderAccountAvailability::QuotaExhausted {
} else if account.availability == AccountAvailability::QuotaExhausted {
AdminAccountStatus::QuotaExhausted
} else if account.availability == ProviderAccountAvailability::Expired
|| account.availability == ProviderAccountAvailability::Invalid
} else if account.availability == AccountAvailability::Expired
|| account.availability == AccountAvailability::Invalid
|| account
.access_token_expires_at
.is_some_and(|expires_at| expires_at <= now)
@@ -1786,7 +1692,7 @@ fn admin_account_record(summary: ProviderAccountSummary) -> AdminStoreResult<Acc
access_token_expires_at: summary.access_token_expires_at,
next_refresh_at: summary.next_refresh_at,
enabled: summary.enabled,
availability: admin_account_availability(summary.availability),
availability: summary.availability,
availability_reason: summary.availability_reason,
cooldown_until: summary.cooldown_until,
availability_observed_at: summary.availability_observed_at,
@@ -1811,7 +1717,7 @@ fn prepared_account(credential: PreparedCredentialCreate) -> StoreResult<NewProv
access_token_expires_at: credential.access_token_expires_at,
next_refresh_at: credential.next_refresh_at,
enabled: credential.enabled,
availability: provider_account_availability(credential.availability),
availability: credential.availability,
availability_reason: credential.availability_reason,
cooldown_until: credential.cooldown_until,
availability_observed_at: credential.availability_observed_at,
@@ -1826,34 +1732,6 @@ fn provider_document_json(document: ProviderDocument) -> StoreResult<JsonObject>
)
}
const fn provider_account_availability(
availability: AdminAccountAvailability,
) -> ProviderAccountAvailability {
match availability {
AdminAccountAvailability::Unknown => ProviderAccountAvailability::Unknown,
AdminAccountAvailability::Ready => ProviderAccountAvailability::Ready,
AdminAccountAvailability::Cooldown => ProviderAccountAvailability::Cooldown,
AdminAccountAvailability::QuotaExhausted => ProviderAccountAvailability::QuotaExhausted,
AdminAccountAvailability::Expired => ProviderAccountAvailability::Expired,
AdminAccountAvailability::Banned => ProviderAccountAvailability::Banned,
AdminAccountAvailability::Invalid => ProviderAccountAvailability::Invalid,
}
}
const fn admin_account_availability(
availability: ProviderAccountAvailability,
) -> AdminAccountAvailability {
match availability {
ProviderAccountAvailability::Unknown => AdminAccountAvailability::Unknown,
ProviderAccountAvailability::Ready => AdminAccountAvailability::Ready,
ProviderAccountAvailability::Cooldown => AdminAccountAvailability::Cooldown,
ProviderAccountAvailability::QuotaExhausted => AdminAccountAvailability::QuotaExhausted,
ProviderAccountAvailability::Expired => AdminAccountAvailability::Expired,
ProviderAccountAvailability::Banned => AdminAccountAvailability::Banned,
ProviderAccountAvailability::Invalid => AdminAccountAvailability::Invalid,
}
}
fn admin_account_usage(usage: ProviderAccountUsageObservation) -> AdminStoreResult<AccountUsage> {
Ok(AccountUsage {
account_id: usage.account_id,
@@ -2268,7 +2146,7 @@ impl ProviderAccountStore for PgProviderAccountRepository {
.map(DateTime::<Utc>::from),
next_refresh_at: account.account.next_refresh_at().map(DateTime::<Utc>::from),
enabled: account.account.enabled(),
availability: availability_from_core(account.account.availability()),
availability: account.account.availability(),
availability_reason: None,
cooldown_until: account.account.cooldown_until().map(DateTime::<Utc>::from),
availability_observed_at: Utc::now(),
@@ -2470,7 +2348,7 @@ impl ProviderAccountStore for PgProviderAccountRepository {
account_id: change.account_id.as_str().to_owned(),
expected_revision: Revision::new(change.expected_revision.get())
.map_err(core_store_error)?,
availability: availability_from_core(change.availability),
availability: change.availability,
availability_reason: change.reason,
cooldown_until: change.cooldown_until.map(DateTime::<Utc>::from),
availability_observed_at: DateTime::<Utc>::from(change.observed_at),
@@ -2581,7 +2459,7 @@ fn core_account_from_summary(
)
.with_runtime_state(
summary.enabled,
availability_to_core(summary.availability),
summary.availability,
summary.cooldown_until.map(Into::into),
)
.with_refresh_schedule(
@@ -2590,30 +2468,6 @@ fn core_account_from_summary(
))
}
const fn availability_to_core(value: ProviderAccountAvailability) -> CoreAccountAvailability {
match value {
ProviderAccountAvailability::Unknown => CoreAccountAvailability::Unknown,
ProviderAccountAvailability::Ready => CoreAccountAvailability::Ready,
ProviderAccountAvailability::Cooldown => CoreAccountAvailability::Cooldown,
ProviderAccountAvailability::QuotaExhausted => CoreAccountAvailability::QuotaExhausted,
ProviderAccountAvailability::Expired => CoreAccountAvailability::Expired,
ProviderAccountAvailability::Banned => CoreAccountAvailability::Banned,
ProviderAccountAvailability::Invalid => CoreAccountAvailability::Invalid,
}
}
const fn availability_from_core(value: CoreAccountAvailability) -> ProviderAccountAvailability {
match value {
CoreAccountAvailability::Unknown => ProviderAccountAvailability::Unknown,
CoreAccountAvailability::Ready => ProviderAccountAvailability::Ready,
CoreAccountAvailability::Cooldown => ProviderAccountAvailability::Cooldown,
CoreAccountAvailability::QuotaExhausted => ProviderAccountAvailability::QuotaExhausted,
CoreAccountAvailability::Expired => ProviderAccountAvailability::Expired,
CoreAccountAvailability::Banned => ProviderAccountAvailability::Banned,
CoreAccountAvailability::Invalid => ProviderAccountAvailability::Invalid,
}
}
fn core_store_error(error: StoreError) -> CoreStoreError {
let kind = match error {
StoreError::Unavailable { .. } => CoreStoreErrorKind::Unavailable,
@@ -2654,7 +2508,7 @@ fn account_summary_from_row(row: sqlx::postgres::PgRow) -> StoreResult<ProviderA
access_token_expires_at: get(&row, "access_token_expires_at")?,
next_refresh_at: get(&row, "next_refresh_at")?,
enabled: get(&row, "enabled")?,
availability: ProviderAccountAvailability::parse(&availability)?,
availability: parse_availability(&availability)?,
availability_reason: get(&row, "availability_reason")?,
cooldown_until: get(&row, "cooldown_until")?,
availability_observed_at: get(&row, "availability_observed_at")?,
@@ -12,7 +12,7 @@ use gateway_core::provider_ports::{
ProviderRefreshPolicy, ProviderRuntimePolicyPort, ProviderStoreError, ProviderStoreErrorKind,
};
use crate::{ConflictKind, Revision, StoreError, StoreResult, postgres_unavailable};
use crate::{Revision, StoreError, StoreResult, postgres_unavailable};
#[derive(Clone, PartialEq, Eq)]
pub struct RuntimeSettings {
@@ -111,11 +111,8 @@ impl RuntimeSettingsUpdate {
pub trait RuntimeSettingsRepository: Send + Sync {
async fn load_runtime_settings(&self) -> StoreResult<RuntimeSettings>;
async fn update_runtime_settings(
&self,
expected_revision: Revision,
update: RuntimeSettingsUpdate,
) -> StoreResult<Revision>;
async fn update_runtime_settings(&self, update: RuntimeSettingsUpdate)
-> StoreResult<Revision>;
}
#[derive(Clone)]
@@ -169,7 +166,6 @@ impl RuntimeSettingsRepository for PgRuntimeSettingsRepository {
async fn update_runtime_settings(
&self,
expected_revision: Revision,
update: RuntimeSettingsUpdate,
) -> StoreResult<Revision> {
let mut transaction = self
@@ -177,9 +173,7 @@ impl RuntimeSettingsRepository for PgRuntimeSettingsRepository {
.begin()
.await
.map_err(|_| postgres_unavailable("begin runtime settings update"))?;
let revision =
update_runtime_settings_in_transaction(&mut transaction, expected_revision, &update)
.await?;
let revision = update_runtime_settings_in_transaction(&mut transaction, &update).await?;
transaction
.commit()
.await
@@ -228,28 +222,26 @@ pub(crate) async fn load_runtime_settings_in_transaction(
pub(crate) async fn update_runtime_settings_in_transaction(
transaction: &mut Transaction<'_, Postgres>,
expected_revision: Revision,
update: &RuntimeSettingsUpdate,
) -> StoreResult<Revision> {
update.validate()?;
let next = sqlx::query_scalar::<_, i64>(
"update runtime_settings
set config_revision = config_revision + 1,
admin_api_key = $2,
refresh_margin_seconds = $3,
refresh_concurrency = $4,
max_concurrent_per_account = $5,
request_interval_ms = $6,
rotation_strategy = $7,
model_mappings_json = $8,
usage_retention_days = $9,
ops_event_retention_days = $10,
audit_retention_days = $11,
updated_at = now()
where id = 1 and config_revision = $1
returning config_revision",
admin_api_key = $1,
refresh_margin_seconds = $2,
refresh_concurrency = $3,
max_concurrent_per_account = $4,
request_interval_ms = $5,
rotation_strategy = $6,
model_mappings_json = $7,
usage_retention_days = $8,
ops_event_retention_days = $9,
audit_retention_days = $10,
updated_at = now()
where id = 1
returning config_revision",
)
.bind(i64::try_from(expected_revision.get()).map_err(|_| invalid_numeric())?)
.bind(update.admin_api_key.as_deref())
.bind(i64::try_from(update.refresh_margin_seconds).map_err(|_| invalid_numeric())?)
.bind(i64::from(update.refresh_concurrency))
@@ -263,32 +255,28 @@ pub(crate) async fn update_runtime_settings_in_transaction(
.fetch_optional(&mut **transaction)
.await
.map_err(|_| postgres_unavailable("update runtime settings in transaction"))?
.ok_or_else(|| StoreError::Conflict {
.ok_or_else(|| StoreError::NotFound {
entity: "runtime settings",
id: "1".to_owned(),
kind: ConflictKind::StaleRevision,
})?;
Revision::new(u64::try_from(next).map_err(|_| invalid_numeric())?)
}
pub(crate) async fn bump_config_revision_in_transaction(
transaction: &mut Transaction<'_, Postgres>,
expected_revision: Revision,
) -> StoreResult<Revision> {
let next = sqlx::query_scalar::<_, i64>(
"update runtime_settings
set config_revision = config_revision + 1, updated_at = now()
where id = 1 and config_revision = $1
where id = 1
returning config_revision",
)
.bind(i64::try_from(expected_revision.get()).map_err(|_| invalid_numeric())?)
.fetch_optional(&mut **transaction)
.await
.map_err(|_| postgres_unavailable("bump config revision in transaction"))?
.ok_or_else(|| StoreError::Conflict {
.ok_or_else(|| StoreError::NotFound {
entity: "runtime settings",
id: "1".to_owned(),
kind: ConflictKind::StaleRevision,
})?;
Revision::new(u64::try_from(next).map_err(|_| invalid_numeric())?)
}
@@ -55,10 +55,7 @@ impl CredentialStateCache {
"provider_account_id",
&self.provider_account_id,
)?;
if !matches!(
self.availability.as_str(),
"unknown" | "ready" | "cooldown" | "quota_exhausted" | "expired" | "banned" | "invalid"
) {
if AccountAvailability::parse(&self.availability).is_none() {
return Err(invalid("availability is not registered"));
}
Ok(())
@@ -60,7 +60,7 @@ end
return 1
"#;
/// Redis-backed, best-effort native response continuation affinity.
/// 基于 Redis 的 best-effort 原生响应 continuation 亲和。
#[derive(Clone)]
pub struct RedisNativeContinuationRepository {
connection: ConnectionManager,
@@ -58,12 +58,6 @@ impl TestDatabase {
.execute(&mut *migration)
.await
.expect("apply terminal migration");
sqlx::raw_sql(include_str!(
"../../../../migrations/0002_retention_delete_indexes.sql"
))
.execute(&mut *migration)
.await
.expect("apply retention index migration");
migration.commit().await.expect("commit terminal migration");
Some(Self {
admin,
@@ -86,7 +80,7 @@ impl TestDatabase {
}
#[tokio::test]
async fn connect_and_migrate_should_apply_0001_once_and_reopen_cleanly() {
async fn connect_and_migrate_should_apply_initial_migration_once_and_reopen_cleanly() {
let Some(database_url) = crate::support::test_env("CPR_TEST_DATABASE_URL") else {
return;
};
@@ -110,7 +104,7 @@ async fn connect_and_migrate_should_apply_0001_once_and_reopen_cleanly() {
.to_string();
let first = connect_and_migrate(&isolated_url, gateway_store::StorePoolConfig::default())
.await
.expect("apply 0001 through production migrator");
.expect("apply initial migration through production migrator");
let first_table_count = sqlx::query_scalar::<_, i64>(
"select count(*) from information_schema.tables where table_schema = 'public'",
)
@@ -137,7 +131,7 @@ async fn connect_and_migrate_should_apply_0001_once_and_reopen_cleanly() {
.expect("drop migration test database");
admin.close().await;
assert_eq!((first_table_count, migration_count), (8, 2));
assert_eq!((first_table_count, migration_count), (8, 1));
}
#[test]
@@ -3,7 +3,7 @@ use std::time::{Duration, SystemTime};
use chrono::{TimeDelta, Utc};
use gateway_admin::{
model::{
MutationActor, MutationContext, PageSize, Revision as AdminRevision,
MutationActor, MutationContext, PageSize,
accounts::{
AccountListQuery, AccountSort, AccountSortField, AccountStatus,
AccountUsageWindowQuery, DeleteAccounts, SetAccountEnabled as AdminSetAccountEnabled,
@@ -14,12 +14,12 @@ use gateway_admin::{
CredentialAvailabilityFilter, CredentialListQuery, CredentialListWindow,
},
},
ports::store::{AccountStore, AdminStoreErrorKind},
ports::store::AccountStore,
};
use gateway_core::engine::credential::{
CredentialCasOutcome, CredentialCasUpdate, CredentialRevision, OpaqueProviderData,
PlaintextCredential, ProviderAccountId, ProviderAccountStore, ProviderAccountUpdate,
QuotaObservation, QuotaWriteOutcome,
AccountAvailability, CredentialCasOutcome, CredentialCasUpdate, CredentialRevision,
OpaqueProviderData, PlaintextCredential, ProviderAccountId, ProviderAccountStore,
ProviderAccountUpdate, QuotaObservation, QuotaWriteOutcome,
};
use gateway_core::routing::ProviderKind;
use gateway_store::{
@@ -27,9 +27,9 @@ use gateway_store::{
postgres::{
AdminAuditActorKind, AdminAuditEvent, DeleteProviderAccounts, ImportProviderAccounts,
NewProviderAccount, PgAdminAccountStore, PgProviderAccountRepository,
ProviderAccountAdminRepository, ProviderAccountAdminScope, ProviderAccountAvailability,
ProviderAccountObservation, ProviderAccountRepository, ProviderCredentialUpdate,
RotateProviderAccount, SetProviderAccountEnabled, UpdateProviderAccount,
ProviderAccountAdminRepository, ProviderAccountAdminScope, ProviderAccountObservation,
ProviderAccountRepository, ProviderCredentialUpdate, RotateProviderAccount,
SetProviderAccountEnabled, UpdateProviderAccount,
},
};
use serde_json::json;
@@ -123,15 +123,15 @@ async fn terminal_admin_list_filters_and_sorts_before_pagination_with_retained_u
let mut beta = account("acct_beta", "user-beta");
beta.provider_kind = "xai".to_owned();
beta.email = Some("beta@example.invalid".to_owned());
beta.availability = ProviderAccountAvailability::Banned;
beta.availability = AccountAvailability::Banned;
let mut charlie = account("acct_charlie", "user-charlie");
charlie.email = Some("charlie@example.invalid".to_owned());
let mut invalid = account("acct_invalid", "user-invalid");
invalid.email = Some("invalid@example.invalid".to_owned());
invalid.availability = ProviderAccountAvailability::Invalid;
invalid.availability = AccountAvailability::Invalid;
let mut quota_exhausted = account("acct_quota_exhausted", "user-quota-exhausted");
quota_exhausted.email = Some("quota-exhausted@example.invalid".to_owned());
quota_exhausted.availability = ProviderAccountAvailability::QuotaExhausted;
quota_exhausted.availability = AccountAvailability::QuotaExhausted;
for account in [alpha, beta, charlie, invalid, quota_exhausted] {
repository
.insert_provider_account(account)
@@ -292,10 +292,10 @@ async fn terminal_credential_list_preserves_grouped_filters_and_unpaged_collecti
);
credential.provider_kind = "xai".to_owned();
credential.availability = match index {
0 => ProviderAccountAvailability::Expired,
1 => ProviderAccountAvailability::Banned,
2 => ProviderAccountAvailability::Invalid,
_ => ProviderAccountAvailability::Ready,
0 => AccountAvailability::Expired,
1 => AccountAvailability::Banned,
2 => AccountAvailability::Invalid,
_ => AccountAvailability::Ready,
};
repository
.insert_provider_account(credential)
@@ -525,7 +525,6 @@ async fn terminal_admin_mutations_keep_revision_account_and_audit_atomic() {
let revision = store
.set_account_enabled(
AdminSetAccountEnabled {
expected_config_revision: AdminRevision::new(1).expect("initial revision"),
account_id: "acct_terminal_mutation".to_owned(),
enabled: false,
},
@@ -542,32 +541,9 @@ async fn terminal_admin_mutations_keep_revision_account_and_audit_atomic() {
.expect("read disabled state");
assert!(!enabled);
let error = store
.delete_accounts(
DeleteAccounts {
expected_config_revision: AdminRevision::new(1).expect("stale revision"),
account_ids: vec!["acct_terminal_mutation".to_owned()],
},
&context,
)
.await
.expect_err("stale delete must roll back");
assert_eq!(error.kind(), AdminStoreErrorKind::StaleRevision);
assert_eq!(current_revision(&database.pool).await, 2);
assert_eq!(
account_count(&database.pool, "acct_terminal_mutation").await,
1
);
let audit_count: i64 = sqlx::query_scalar("select count(*) from admin_audit_events")
.fetch_one(&database.pool)
.await
.expect("count audit rows after rollback");
assert_eq!(audit_count, 1);
let revision = store
.delete_accounts(
DeleteAccounts {
expected_config_revision: revision,
account_ids: vec!["acct_terminal_mutation".to_owned()],
},
&context,
@@ -616,7 +592,6 @@ async fn terminal_admin_delete_removes_enabled_accounts_in_one_transaction() {
let revision = PgAdminAccountStore::new(database.pool.clone())
.delete_accounts(
DeleteAccounts {
expected_config_revision: AdminRevision::new(1).expect("initial revision"),
account_ids: vec![
"acct_enabled_delete_a".to_owned(),
"acct_enabled_delete_b".to_owned(),
@@ -649,18 +624,14 @@ async fn admin_import_updates_the_same_verified_identity_without_rebinding_it()
provider_kind: "openai".to_owned(),
};
let imported = repository
.import_provider_accounts(
Revision::new(1).expect("initial revision"),
ImportProviderAccounts {
scope: scope.clone(),
accounts: vec![account("acct_admin_upsert", "user-admin-upsert")],
audit: audit("audit_admin_upsert_create", "import", "acct_admin_upsert"),
},
)
.import_provider_accounts(ImportProviderAccounts {
scope: scope.clone(),
accounts: vec![account("acct_admin_upsert", "user-admin-upsert")],
audit: audit("audit_admin_upsert_create", "import", "acct_admin_upsert"),
})
.await
.expect("create imported account");
assert_eq!(imported.account_ids, ["acct_admin_upsert"]);
let revision = imported.config_revision;
sqlx::query(
"update provider_accounts
set provider_quota_json = '{}'::jsonb, quota_observed_at = now(), updated_at = now()
@@ -673,17 +644,14 @@ async fn admin_import_updates_the_same_verified_identity_without_rebinding_it()
let mut updated = account("acct_admin_reimport", "user-admin-upsert");
updated.name = "updated import".to_owned();
updated.provider_credentials_json = credential_json("updated-import-secret");
updated.availability = ProviderAccountAvailability::Banned;
updated.availability = AccountAvailability::Banned;
updated.availability_reason = Some("upstream_account_deactivated".to_owned());
let imported = repository
.import_provider_accounts(
revision,
ImportProviderAccounts {
scope: scope.clone(),
accounts: vec![updated],
audit: audit("audit_admin_upsert_update", "import", "acct_admin_upsert"),
},
)
.import_provider_accounts(ImportProviderAccounts {
scope: scope.clone(),
accounts: vec![updated],
audit: audit("audit_admin_upsert_update", "import", "acct_admin_upsert"),
})
.await
.expect("update the same imported identity");
assert_eq!(imported.account_ids, ["acct_admin_upsert"]);
@@ -715,14 +683,11 @@ async fn admin_import_updates_the_same_verified_identity_without_rebinding_it()
assert_eq!(row.5.as_deref(), Some("upstream_account_deactivated"));
repository
.import_provider_accounts(
revision,
ImportProviderAccounts {
scope,
accounts: vec![account("acct_admin_upsert", "another-user")],
audit: audit("audit_admin_upsert_rebind", "import", "acct_admin_upsert"),
},
)
.import_provider_accounts(ImportProviderAccounts {
scope,
accounts: vec![account("acct_admin_upsert", "another-user")],
audit: audit("audit_admin_upsert_rebind", "import", "acct_admin_upsert"),
})
.await
.expect_err("an existing account ID must not be rebound");
assert_eq!(current_revision(&database.pool).await, 3);
@@ -751,7 +716,7 @@ async fn core_refresh_cas_updates_profile_and_credential_under_one_revision() {
access_token_expires_at: Some(Utc::now() + TimeDelta::minutes(5)),
next_refresh_at: None,
enabled: true,
availability: ProviderAccountAvailability::Ready,
availability: AccountAvailability::Ready,
availability_reason: None,
cooldown_until: None,
availability_observed_at: Utc::now(),
@@ -856,21 +821,17 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() {
};
let mut cooldown_account = account("acct_admin_b", "user-admin-b");
cooldown_account.availability = ProviderAccountAvailability::Cooldown;
cooldown_account.availability = AccountAvailability::Cooldown;
cooldown_account.cooldown_until = Some(Utc::now() + TimeDelta::minutes(10));
let imported = repository
.import_provider_accounts(
Revision::new(1).expect("initial revision"),
ImportProviderAccounts {
scope: scope.clone(),
accounts: vec![account("acct_admin_a", "user-admin-a"), cooldown_account],
audit: audit("audit_account_batch", "import_batch", "provider_accounts"),
},
)
.import_provider_accounts(ImportProviderAccounts {
scope: scope.clone(),
accounts: vec![account("acct_admin_a", "user-admin-a"), cooldown_account],
audit: audit("audit_account_batch", "import_batch", "provider_accounts"),
})
.await
.expect("import provider accounts");
let revision = imported.config_revision;
assert_eq!(revision.get(), 2);
assert_eq!(imported.config_revision.get(), 2);
let ready: (bool, String, Option<chrono::DateTime<Utc>>) = sqlx::query_as(
"select enabled, availability, cooldown_until from provider_accounts where id = $1",
)
@@ -891,21 +852,18 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() {
assert!(cooldown.2.is_some());
repository
.import_provider_accounts(
revision,
ImportProviderAccounts {
scope: scope.clone(),
accounts: vec![
account("acct_admin_transient", "user-admin-transient"),
account("acct_admin_a", "user-admin-duplicate"),
],
audit: audit(
"audit_account_failed_batch",
"import_batch",
"provider_accounts",
),
},
)
.import_provider_accounts(ImportProviderAccounts {
scope: scope.clone(),
accounts: vec![
account("acct_admin_transient", "user-admin-transient"),
account("acct_admin_a", "user-admin-duplicate"),
],
audit: audit(
"audit_account_failed_batch",
"import_batch",
"provider_accounts",
),
})
.await
.expect_err("duplicate batch row must roll back the entire import");
assert_eq!(current_revision(&database.pool).await, 2);
@@ -918,15 +876,12 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() {
provider_kind: "xai".to_owned(),
};
let wrong_scope_error = repository
.rotate_provider_account(
revision,
RotateProviderAccount {
scope: wrong_scope,
profile: profile("acct_admin_a", "wrong scope"),
credential: credential_update("acct_admin_a", 1, "wrong-scope-secret"),
audit: audit("audit_wrong_scope", "rotate", "acct_admin_a"),
},
)
.rotate_provider_account(RotateProviderAccount {
scope: wrong_scope,
profile: profile("acct_admin_a", "wrong scope"),
credential: credential_update("acct_admin_a", 1, "wrong-scope-secret"),
audit: audit("audit_wrong_scope", "rotate", "acct_admin_a"),
})
.await
.expect_err("Provider endpoint must not rotate another Provider account");
assert!(matches!(
@@ -939,32 +894,27 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() {
assert_eq!(current_revision(&database.pool).await, 2);
let rotation = repository
.rotate_provider_account(
revision,
RotateProviderAccount {
scope: scope.clone(),
profile: profile("acct_admin_a", "rotated account"),
credential: credential_update("acct_admin_a", 1, "rotated-secret"),
audit: audit("audit_account_rotate", "rotate", "acct_admin_a"),
},
)
.rotate_provider_account(RotateProviderAccount {
scope: scope.clone(),
profile: profile("acct_admin_a", "rotated account"),
credential: credential_update("acct_admin_a", 1, "rotated-secret"),
audit: audit("audit_account_rotate", "rotate", "acct_admin_a"),
})
.await
.expect("rotate provider account");
assert_eq!(rotation.config_revision.get(), 3);
assert_eq!(rotation.credential_revision.get(), 2);
let revision = repository
.set_provider_account_enabled_admin(
rotation.config_revision,
SetProviderAccountEnabled {
scope: scope.clone(),
account_id: "acct_admin_a".to_owned(),
enabled: false,
audit: audit("audit_account_disable", "disable", "acct_admin_a"),
},
)
.set_provider_account_enabled_admin(SetProviderAccountEnabled {
scope: scope.clone(),
account_id: "acct_admin_a".to_owned(),
enabled: false,
audit: audit("audit_account_disable", "disable", "acct_admin_a"),
})
.await
.expect("disable provider account");
assert_eq!(revision.get(), 4);
let exports = repository
.export_provider_accounts(
scope.clone(),
@@ -977,14 +927,11 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() {
assert!(!format!("{exports:?}").contains("rotated-secret"));
let revision = repository
.delete_provider_accounts_admin(
revision,
DeleteProviderAccounts {
scope,
account_ids: vec!["acct_admin_a".to_owned(), "acct_admin_b".to_owned()],
audit: audit("audit_account_delete", "delete", "provider_accounts"),
},
)
.delete_provider_accounts_admin(DeleteProviderAccounts {
scope,
account_ids: vec!["acct_admin_a".to_owned(), "acct_admin_b".to_owned()],
audit: audit("audit_account_delete", "delete", "provider_accounts"),
})
.await
.expect("delete selected accounts regardless of enabled state");
assert_eq!(revision.get(), 5);
@@ -1014,7 +961,7 @@ fn account(id: &str, upstream_user_id: &str) -> NewProviderAccount {
access_token_expires_at: Some(Utc::now() + TimeDelta::hours(1)),
next_refresh_at: None,
enabled: true,
availability: ProviderAccountAvailability::Ready,
availability: AccountAvailability::Ready,
availability_reason: None,
cooldown_until: None,
availability_observed_at: Utc::now(),
@@ -1147,7 +1094,7 @@ fn provider_credentials_are_redacted_from_debug() {
fn cooldown_observation_requires_expiry() {
let observation = ProviderAccountObservation {
account_id: "account-1".to_owned(),
availability: ProviderAccountAvailability::Cooldown,
availability: AccountAvailability::Cooldown,
availability_reason: None,
cooldown_until: None,
provider_quota_json: None,
+4 -4
View File
@@ -10,19 +10,19 @@ autotests = false
[dependencies]
async-stream = "0.3.6"
async-trait = "0.1.89"
base64 = "0.22.1"
base64 = "0.23.0"
bytes = "1.11.1"
chrono = { version = "0.4.45", features = ["clock", "serde"] }
cookie = "0.18.1"
crypto_box = { version = "0.9.1", features = ["seal"] }
ed25519-dalek = { version = "2.2.0", features = ["pkcs8"] }
ed25519-dalek = { version = "3.0.0", features = ["alloc", "pkcs8"] }
futures = "0.3.32"
gateway-admin = { path = "../../gateway-admin" }
gateway-core = { path = "../../gateway-core" }
gateway-protocol = { path = "../../gateway-protocol" }
getrandom = "0.3.4"
getrandom = "0.4.3"
hex = "0.4.3"
jsonwebtoken = { version = "=10.3.0", default-features = false, features = ["aws_lc_rs", "use_pem"] }
jsonwebtoken = { version = "=11.0.0", default-features = false, features = ["aws_lc_rs", "use_pem"] }
reqwest = { version = "=0.12.28", default-features = false, features = ["json", "stream", "cookies", "rustls-tls-native-roots", "gzip", "brotli", "zstd", "deflate", "http2"] }
roxmltree = "0.21.1"
semver = "1.0.27"
+79 -42
View File
@@ -5,9 +5,7 @@ use std::time::SystemTime;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use gateway_admin::model::accounts::{
AccountAvailability as AdminAccountAvailability, AccountRecord,
};
use gateway_admin::model::accounts::AccountRecord;
use gateway_admin::model::observability::{
CalculatedBillingBreakdown, CurrencyCost, DashboardDesktopRelease, DashboardWireAttribute,
DashboardWireProfile, DashboardWireTarget, DecimalAmount, DesktopReleaseStatus,
@@ -27,9 +25,8 @@ use gateway_admin::model::{MutationActor, MutationContext, Revision};
use gateway_admin::ports::provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind};
use gateway_core::accounting::Money;
use gateway_core::engine::credential::{
AccountAvailability, CredentialRevision, LoadedCredential, NewProviderAccount,
OpaqueProviderData, PlaintextCredential, ProviderAccount, ProviderAccountId,
ProviderAccountStore,
CredentialRevision, LoadedCredential, NewProviderAccount, OpaqueProviderData,
PlaintextCredential, ProviderAccount, ProviderAccountId, ProviderAccountStore,
};
use gateway_core::error::StoreErrorKind;
use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload};
@@ -253,11 +250,22 @@ impl ProviderAdmin for OpenAiAdminProvider {
command.document.into_provider_data().into_inner(),
))
.await
.inspect_err(|error| {
log_import_failure("prepare_document", credential_admin_error_code(*error));
})
.map_err(map_credential_admin_error)?;
let observed_at = Utc::now();
let mut credentials = Vec::with_capacity(prepared.accounts().len());
for account in prepared.into_accounts() {
let account = self.preserve_existing_installation_id(account).await?;
let account = self
.preserve_existing_installation_id(account)
.await
.inspect_err(|error| {
log_import_failure(
"preserve_installation_id",
provider_admin_error_code(error.kind()),
);
})?;
credentials.push(prepared_create(account, observed_at)?);
}
Ok(PreparedCredentialImport {
@@ -289,6 +297,7 @@ impl ProviderAdmin for OpenAiAdminProvider {
&self,
command: CompleteAuthorization,
) -> Result<PreparedAuthorizationCommit, ProviderAdminError> {
let request_id = command.context.request_id.clone();
let binding = AuthorizationOwnerBinding::from_context(&command.context);
let completed = self
.oauth
@@ -298,6 +307,14 @@ impl ProviderAdmin for OpenAiAdminProvider {
callback_url: SecretString::from(command.callback_url),
})
.await
.inspect_err(|error| {
tracing::warn!(
request_id = %request_id,
oauth_stage = "complete_authorization",
oauth_error = oauth_error_code(error),
"OpenAI OAuth authorization completion failed"
);
})
.map_err(map_oauth_error)?;
let credential = match completed.credential {
CompletedCodexOAuthCredential::Create(credential) => {
@@ -519,7 +536,7 @@ fn prepared_create(
account,
credential,
} = prepared;
let availability = admin_availability(account.availability());
let availability = account.availability();
Ok(PreparedCredentialCreate {
account_id: account.id().clone(),
provider_kind: account.provider().clone(),
@@ -639,7 +656,7 @@ fn account_from_record(account: &AccountRecord) -> Result<ProviderAccount, Provi
)
.with_runtime_state(
account.enabled,
core_availability(account.availability),
account.availability,
account.cooldown_until.map(SystemTime::from),
)
.with_refresh_schedule(
@@ -698,7 +715,7 @@ fn account_matches_record(account: &ProviderAccount, record: &AccountRecord) ->
&& account.plan_type() == record.plan_type.as_deref()
&& account.authentication_kind() == record.authentication_kind
&& account.enabled() == record.enabled
&& admin_availability(account.availability()) == record.availability
&& account.availability() == record.availability
&& account.cooldown_until().map(DateTime::<Utc>::from) == record.cooldown_until
&& account.access_token_expires_at().map(DateTime::<Utc>::from)
== record.access_token_expires_at
@@ -706,30 +723,6 @@ fn account_matches_record(account: &ProviderAccount, record: &AccountRecord) ->
&& account.has_refresh_token() == record.has_refresh_token
}
const fn admin_availability(value: AccountAvailability) -> AdminAccountAvailability {
match value {
AccountAvailability::Unknown => AdminAccountAvailability::Unknown,
AccountAvailability::Ready => AdminAccountAvailability::Ready,
AccountAvailability::Cooldown => AdminAccountAvailability::Cooldown,
AccountAvailability::QuotaExhausted => AdminAccountAvailability::QuotaExhausted,
AccountAvailability::Expired => AdminAccountAvailability::Expired,
AccountAvailability::Banned => AdminAccountAvailability::Banned,
AccountAvailability::Invalid => AdminAccountAvailability::Invalid,
}
}
const fn core_availability(value: AdminAccountAvailability) -> AccountAvailability {
match value {
AdminAccountAvailability::Unknown => AccountAvailability::Unknown,
AdminAccountAvailability::Ready => AccountAvailability::Ready,
AdminAccountAvailability::Cooldown => AccountAvailability::Cooldown,
AdminAccountAvailability::QuotaExhausted => AccountAvailability::QuotaExhausted,
AdminAccountAvailability::Expired => AccountAvailability::Expired,
AdminAccountAvailability::Banned => AccountAvailability::Banned,
AdminAccountAvailability::Invalid => AccountAvailability::Invalid,
}
}
fn empty_quota() -> ProviderQuota {
ProviderQuota {
observed_at: None,
@@ -942,7 +935,6 @@ struct PendingDocument {
#[serde(deny_unknown_fields)]
struct PendingMutationDocument {
schema_version: u64,
expected_config_revision: u64,
provider_kind: String,
target: PendingTargetDocument,
owner: PendingOwnerDocument,
@@ -1025,10 +1017,6 @@ fn encode_mutation(mutation: &PendingAuthorizationMutation) -> Map<String, Value
"schema_version".to_owned(),
Value::Number(Number::from(PENDING_DOCUMENT_SCHEMA_VERSION)),
);
document.insert(
"expected_config_revision".to_owned(),
Value::Number(Number::from(mutation.expected_config_revision().get())),
);
document.insert(
"provider_kind".to_owned(),
Value::String(mutation.provider_kind().as_str().to_owned()),
@@ -1119,8 +1107,6 @@ fn decode_mutation(
if document.schema_version != PENDING_DOCUMENT_SCHEMA_VERSION {
return Err(CodexOAuthPendingStoreError::InvalidValue);
}
let expected_config_revision = Revision::new(document.expected_config_revision)
.map_err(|_| CodexOAuthPendingStoreError::InvalidValue)?;
let provider_kind = ProviderKind::new(document.provider_kind)
.map_err(|_| CodexOAuthPendingStoreError::InvalidValue)?;
let target = match document.target {
@@ -1147,7 +1133,6 @@ fn decode_mutation(
request_id: document.started_request_id,
};
Ok(PendingAuthorizationMutation::new(
expected_config_revision,
provider_kind,
target,
AuthorizationOwnerBinding::from_context(&context),
@@ -1246,6 +1231,44 @@ fn map_credential_admin_error(error: CodexCredentialAdminError) -> ProviderAdmin
})
}
const fn credential_admin_error_code(error: CodexCredentialAdminError) -> &'static str {
match error {
CodexCredentialAdminError::InvalidInput => "invalid_input",
CodexCredentialAdminError::IdentityMismatch => "identity_mismatch",
CodexCredentialAdminError::InvalidCredential => "invalid_credential",
CodexCredentialAdminError::NotFound => "not_found",
CodexCredentialAdminError::RevisionConflict => "revision_conflict",
CodexCredentialAdminError::StoreUnavailable => "store_unavailable",
CodexCredentialAdminError::MissingRefreshToken => "missing_refresh_token",
CodexCredentialAdminError::RefreshLeaseUnavailable => "refresh_lease_unavailable",
CodexCredentialAdminError::RefreshRejected => "refresh_rejected",
CodexCredentialAdminError::AccountBanned => "account_banned",
CodexCredentialAdminError::RefreshUnavailable => "refresh_unavailable",
CodexCredentialAdminError::RefreshAmbiguous => "refresh_ambiguous",
CodexCredentialAdminError::IdentityRejected => "identity_rejected",
CodexCredentialAdminError::IdentityUnavailable => "identity_unavailable",
}
}
const fn provider_admin_error_code(kind: ProviderAdminErrorKind) -> &'static str {
match kind {
ProviderAdminErrorKind::Invalid => "invalid",
ProviderAdminErrorKind::Unsupported => "unsupported",
ProviderAdminErrorKind::NotFound => "not_found",
ProviderAdminErrorKind::Conflict => "conflict",
ProviderAdminErrorKind::Unavailable => "unavailable",
ProviderAdminErrorKind::Internal => "internal",
}
}
fn log_import_failure(stage: &'static str, error: &'static str) {
tracing::warn!(
import_stage = stage,
import_error = error,
"OpenAI credential import preparation failed"
);
}
fn map_oauth_error(error: CodexOAuthAdminError) -> ProviderAdminError {
use CodexOAuthAdminError as Error;
provider_admin_error(match error {
@@ -1260,6 +1283,20 @@ fn map_oauth_error(error: CodexOAuthAdminError) -> ProviderAdminError {
})
}
const fn oauth_error_code(error: &CodexOAuthAdminError) -> &'static str {
match error {
CodexOAuthAdminError::InvalidInput => "invalid_input",
CodexOAuthAdminError::NotFound => "not_found",
CodexOAuthAdminError::Conflict => "conflict",
CodexOAuthAdminError::FlowExpired => "flow_expired",
CodexOAuthAdminError::UpstreamRejected => "upstream_rejected",
CodexOAuthAdminError::UpstreamUnavailable => "upstream_unavailable",
CodexOAuthAdminError::Ambiguous => "ambiguous",
CodexOAuthAdminError::StorageUnavailable => "storage_unavailable",
CodexOAuthAdminError::Credential => "credential",
}
}
fn map_quota_error(error: CodexCredentialQuotaError) -> ProviderAdminError {
use CodexCredentialQuotaError as Error;
provider_admin_error(match error {
@@ -962,7 +962,7 @@ fn valid_signed_token_header(header: &jsonwebtoken::Header) -> bool {
&& header.zip.is_none()
&& header.url.is_none()
&& header.nonce.is_none()
&& header.extras.is_empty()
&& header.extras.inner().is_empty()
}
fn constant_time_equal(left: &[u8], right: &[u8]) -> bool {
@@ -1,4 +1,4 @@
//! Codex credential domain exports。
//! Codex credential 领域导出。
mod admin;
mod affinity;
@@ -635,9 +635,11 @@ fn callback_parts(value: &str) -> Result<(SecretString, SecretString), CodexOAut
let mut state = None;
for (key, value) in url.query_pairs() {
match key.as_ref() {
"code" if code.is_none() => code = Some(value.into_owned()),
"state" if state.is_none() => state = Some(value.into_owned()),
_ => return Err(CodexOAuthAdminError::UpstreamRejected),
"code" => set_unique_callback_parameter(&mut code, value.into_owned())?,
"state" => set_unique_callback_parameter(&mut state, value.into_owned())?,
// OAuth 服务可能附加 `scope`、`iss` 等标准参数。
// 固定回调地址与唯一的 `code`/`state` 才是安全边界,无关参数不参与校验。
_ => {}
}
}
let code = code.filter(|value| valid_secret(value));
@@ -648,6 +650,16 @@ fn callback_parts(value: &str) -> Result<(SecretString, SecretString), CodexOAut
}
}
fn set_unique_callback_parameter(
target: &mut Option<String>,
value: String,
) -> Result<(), CodexOAuthAdminError> {
if target.replace(value).is_some() {
return Err(CodexOAuthAdminError::UpstreamRejected);
}
Ok(())
}
fn random_secret() -> Result<String, CodexOAuthAdminError> {
let mut bytes = [0_u8; 32];
getrandom::fill(&mut bytes).map_err(|_| CodexOAuthAdminError::StorageUnavailable)?;
@@ -718,8 +730,11 @@ fn map_exchange_error(error: AuthorizationCodeExchangeError) -> CodexOAuthAdminE
}
}
fn map_identity_error(_: CodexIdentityVerificationError) -> CodexOAuthAdminError {
CodexOAuthAdminError::UpstreamRejected
fn map_identity_error(error: CodexIdentityVerificationError) -> CodexOAuthAdminError {
match error {
CodexIdentityVerificationError::Rejected => CodexOAuthAdminError::UpstreamRejected,
CodexIdentityVerificationError::Unavailable => CodexOAuthAdminError::UpstreamUnavailable,
}
}
fn map_admin_error(_: CodexCredentialAdminError) -> CodexOAuthAdminError {
@@ -973,13 +973,11 @@ fn merge_passive_quota(
.and_then(|value| value.as_array().cloned())
.unwrap_or_default();
for (limit_id, details) in &rate_limits.limits {
let Some(rate_limit) = passive_rate_limit_snapshot(details) else {
continue;
};
if Some(limit_id.as_str()) == active_limit {
let mut value = quota
.remove("rate_limit")
.and_then(|value| value.as_object().cloned())
.unwrap_or_default();
merge_rate_limit_details(&mut value, details);
quota.insert("rate_limit".to_owned(), Value::Object(value));
quota.insert("rate_limit".to_owned(), Value::Object(rate_limit));
continue;
}
let index = additional.iter().position(|item| {
@@ -1000,12 +998,7 @@ fn merge_passive_quota(
if let Some(name) = details.limit_name.as_ref() {
item.insert("limit_name".to_owned(), Value::String(name.clone()));
}
let mut value = item
.remove("rate_limit")
.and_then(|value| value.as_object().cloned())
.unwrap_or_default();
merge_rate_limit_details(&mut value, details);
item.insert("rate_limit".to_owned(), Value::Object(value));
item.insert("rate_limit".to_owned(), Value::Object(rate_limit));
match index {
Some(index) => additional[index] = Value::Object(item),
None => additional.push(Value::Object(item)),
@@ -1018,12 +1011,14 @@ fn merge_passive_quota(
quota
}
fn merge_rate_limit_details(target: &mut Map<String, Value>, details: &RateLimitDetails) {
fn passive_rate_limit_snapshot(details: &RateLimitDetails) -> Option<Map<String, Value>> {
// 响应头的窗口属于同一次上游观测;跨响应合并会制造不存在的额度窗口。
let mut snapshot = Map::new();
if let Some(allowed) = details.allowed {
target.insert("allowed".to_owned(), Value::Bool(allowed));
snapshot.insert("allowed".to_owned(), Value::Bool(allowed));
}
if let Some(limit_reached) = details.limit_reached {
target.insert("limit_reached".to_owned(), Value::Bool(limit_reached));
snapshot.insert("limit_reached".to_owned(), Value::Bool(limit_reached));
}
for (field, window) in [
("primary_window", details.primary),
@@ -1032,28 +1027,29 @@ fn merge_rate_limit_details(target: &mut Map<String, Value>, details: &RateLimit
let Some(window) = window else {
continue;
};
let mut value = target
.remove(field)
.and_then(|value| value.as_object().cloned())
.unwrap_or_default();
merge_rate_limit_window(&mut value, window);
target.insert(field.to_owned(), Value::Object(value));
snapshot.insert(
field.to_owned(),
Value::Object(passive_rate_limit_window(window)),
);
}
(!snapshot.is_empty()).then_some(snapshot)
}
fn merge_rate_limit_window(target: &mut Map<String, Value>, window: RateLimitWindow) {
fn passive_rate_limit_window(window: RateLimitWindow) -> Map<String, Value> {
let mut snapshot = Map::new();
if let Some(number) = serde_json::Number::from_f64(window.used_percent) {
target.insert("used_percent".to_owned(), Value::Number(number));
snapshot.insert("used_percent".to_owned(), Value::Number(number));
}
if let Some(seconds) = window
.window_minutes
.and_then(|minutes| minutes.checked_mul(60))
{
target.insert("limit_window_seconds".to_owned(), Value::from(seconds));
snapshot.insert("limit_window_seconds".to_owned(), Value::from(seconds));
}
if let Some(reset_at) = window.reset_at {
target.insert("reset_at".to_owned(), Value::from(reset_at));
snapshot.insert("reset_at".to_owned(), Value::from(reset_at));
}
snapshot
}
fn quota_success_availability(
@@ -177,14 +177,9 @@ impl Provider for CodexProvider {
input.extend(encoded.input().iter().cloned());
encoded.set_input(input);
}
let reasoning_effort = encoded
.reasoning()
.and_then(|reasoning| reasoning.get("effort"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_owned);
(encoded.semantics(), reasoning_effort)
let semantics = encoded.semantics();
let reasoning_effort = semantics.reasoning_effort.clone();
(semantics, reasoning_effort)
})
.unwrap_or_default();
ProviderRequestObservation {
@@ -35,7 +35,7 @@ use super::websocket::{
};
// ---------------------------------------------------------------------------
// Constants
// 常量
// ---------------------------------------------------------------------------
const MAX_UPSTREAM_ERROR_BODY_BYTES: usize = 1024 * 1024;
@@ -82,7 +82,7 @@ pub fn build_reqwest_client() -> Result<Client, CustomCaError> {
}
// ---------------------------------------------------------------------------
// Error types
// 错误类型
// ---------------------------------------------------------------------------
/// Codex 上游 HTTP 客户端错误。
@@ -245,7 +245,7 @@ pub type CodexBackendSseStream =
Pin<Box<dyn Stream<Item = CodexClientResult<Bytes>> + Send + 'static>>;
// ---------------------------------------------------------------------------
// Request context & response types
// 请求上下文与响应类型
// ---------------------------------------------------------------------------
/// 单次 Codex 上游请求的上下文。
@@ -253,7 +253,7 @@ pub type CodexBackendSseStream =
pub struct CodexRequestContext<'a> {
/// Provider 已构造并脱敏持有的完整 Authorization 值。
pub authorization: &'a str,
/// ChatGPT account id。
/// ChatGPT 账号 ID。
pub account_id: Option<&'a str>,
/// 代理请求 ID。
pub request_id: &'a str,
@@ -261,11 +261,11 @@ pub struct CodexRequestContext<'a> {
pub turn_state: Option<&'a str>,
/// 客户端 turn metadata;其中 installation ID 已按当前账号处理。
pub turn_metadata: Option<&'a str>,
/// x-codex-beta-features。
/// x-codex-beta-features 头值。
pub beta_features: Option<&'a str>,
/// x-responsesapi-include-timing-metrics。
/// x-responsesapi-include-timing-metrics 头值。
pub include_timing_metrics: Option<&'a str>,
/// version。
/// 客户端版本号。
pub version: Option<&'a str>,
/// 客户端 window ID。
pub codex_window_id: Option<&'a str>,
@@ -335,9 +335,9 @@ impl fmt::Debug for CodexRequestContext<'_> {
/// Codex Responses 实际使用的上游传输。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CodexBackendTransport {
/// HTTP SSE transport.
/// HTTP SSE 传输。
HttpSse,
/// WebSocket transport.
/// WebSocket 传输。
WebSocket,
}
@@ -382,10 +382,10 @@ pub struct CodexTransportMetrics {
pub http_version: Option<String>,
}
/// Live stream rate-limit updates captured after the response headers.
/// 响应头之后在 live 流中采集的限流更新。
pub type CodexRateLimitHeaderUpdates = CodexWebSocketRateLimitHeaderUpdates;
/// Live stream turn-state updates captured after the response headers.
/// 响应头之后在 live 流中采集的 turn state 更新。
pub type CodexTurnStateUpdate = CodexWebSocketTurnStateUpdate;
/// Codex Responses 上游 live SSE 响应。
@@ -449,7 +449,7 @@ pub(super) struct PreparedWebSocketRoute {
}
// ---------------------------------------------------------------------------
// Response helpers
// 响应辅助函数
// ---------------------------------------------------------------------------
pub(super) fn log_websocket_pool_decision(
@@ -587,7 +587,7 @@ pub(super) async fn read_capped_error_body(
}
// ---------------------------------------------------------------------------
// Request helpers
// 请求辅助函数
// ---------------------------------------------------------------------------
pub(super) fn websocket_upstream_request(request: &CodexResponsesRequest) -> CodexResponsesRequest {
@@ -649,7 +649,7 @@ pub(super) fn openai_subagent_from_metadata(client_metadata: Option<&Value>) ->
}
// ---------------------------------------------------------------------------
// Error conversion helpers
// 错误转换辅助函数
// ---------------------------------------------------------------------------
pub(super) fn websocket_exchange_error_to_client_error(
@@ -1,4 +1,4 @@
//! Upstream response diagnostics captured at the transport boundary.
//! 在 transport 边界采集的上游响应诊断信息。
use super::protocol::responses::ResponsesSseFailure;
use base64::{Engine as _, engine::general_purpose::STANDARD};
@@ -1,13 +1,12 @@
use std::fmt;
use gateway_protocol::openai::events;
use gateway_protocol::openai::{
CodexResponsesRequestSemantics as CodexRequestSemantics,
codex_responses_request_semantics_with_turn_metadata, events,
};
use serde::Serialize;
use serde_json::{Map, Value};
const MULTI_AGENT_MODE_OPEN_TAG: &str = "<multi_agent_mode>";
const MULTI_AGENT_MODE_CLOSE_TAG: &str = "</multi_agent_mode>";
const PROACTIVE_MULTI_AGENT_MODE_PREFIX: &str = "Proactive multi-agent delegation is active.";
/// Codex Responses 上游请求体。
///
/// 发往上游的 Responses 请求。`body` 持有客户端原始 JSON object,逐字段(含顺序、
@@ -60,7 +59,7 @@ pub struct CodexResponsesRequest {
pub responses_lite: Option<String>,
/// Memory consolidation 请求语义;HTTP 与 WebSocket opening 均使用 header。
pub memgen_request: Option<String>,
/// codex window id。
/// Codex window ID。
pub codex_window_id: Option<String>,
/// 父线程 ID。
pub parent_thread_id: Option<String>,
@@ -93,19 +92,6 @@ pub enum PreviousResponseScope {
ExternalUnknown,
}
/// Responses 请求携带的 Codex 运行语义。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CodexRequestSemantics {
/// Codex turn metadata 中的请求类型。
pub request_kind: Option<String>,
/// Codex turn metadata 中的子代理类型。
pub subagent_kind: Option<String>,
/// Codex 客户端选择的推理预设。
pub reasoning_preset: Option<&'static str>,
/// 请求是否为远端压缩。
pub compact: bool,
}
impl Serialize for CodexResponsesRequest {
/// 上游 body 序列化即原始 `body` map(HTTP SSE 直发;WebSocket 在外层前置 `type`)。
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
@@ -608,34 +594,12 @@ impl CodexResponsesRequest {
/// 提取 Codex 请求类型、子代理类型、推理预设与压缩语义。
pub fn semantics(&self) -> CodexRequestSemantics {
let turn_metadata = self.turn_metadata.as_deref().or_else(|| {
self.client_metadata()?
.get("x-codex-turn-metadata")?
.as_str()
});
let effort = self
.reasoning()
.and_then(|value| value.get("effort"))
.and_then(Value::as_str);
let proactive_multi_agent = self
.latest_multi_agent_mode()
.is_some_and(|mode| mode.starts_with(PROACTIVE_MULTI_AGENT_MODE_PREFIX));
let compact_trigger = self
.input()
.iter()
.any(|item| item.get("type").and_then(Value::as_str) == Some("compaction_trigger"));
codex_request_semantics_from_parts(
turn_metadata,
effort,
proactive_multi_agent,
compact_trigger,
codex_responses_request_semantics_with_turn_metadata(
self.body(),
self.turn_metadata.as_deref(),
)
}
fn latest_multi_agent_mode(&self) -> Option<&str> {
latest_multi_agent_mode(self.input().iter())
}
/// 设置 / 合并 client metadata。
pub fn set_client_metadata(&mut self, client_metadata: Option<Value>) {
match client_metadata {
@@ -664,61 +628,3 @@ impl CodexResponsesRequest {
}
}
}
fn non_empty_owned_string(value: &str) -> Option<String> {
let value = value.trim();
(!value.is_empty()).then(|| value.to_string())
}
pub(crate) fn codex_request_semantics_from_parts(
turn_metadata: Option<&str>,
effort: Option<&str>,
proactive_multi_agent: bool,
compact_trigger: bool,
) -> CodexRequestSemantics {
let turn_metadata = turn_metadata.and_then(|value| serde_json::from_str::<Value>(value).ok());
let request_kind = turn_metadata
.as_ref()
.and_then(|metadata| metadata.get("request_kind"))
.and_then(Value::as_str)
.and_then(non_empty_owned_string);
let subagent_kind = turn_metadata
.as_ref()
.and_then(|metadata| metadata.get("subagent_kind"))
.and_then(Value::as_str)
.and_then(non_empty_owned_string);
let reasoning_preset =
(subagent_kind.is_none() && effort == Some("max") && proactive_multi_agent)
.then_some("ultra");
let compact = request_kind.as_deref() == Some("compaction") || compact_trigger;
CodexRequestSemantics {
request_kind,
subagent_kind,
reasoning_preset,
compact,
}
}
fn latest_multi_agent_mode<'a>(
input: impl DoubleEndedIterator<Item = &'a Value>,
) -> Option<&'a str> {
input.rev().find_map(|item| {
if item.get("role").and_then(Value::as_str) != Some("developer") {
return None;
}
item.get("content")?
.as_array()?
.iter()
.rev()
.filter_map(|content| content.get("text").and_then(Value::as_str))
.find_map(multi_agent_mode_from_text)
})
}
fn multi_agent_mode_from_text(text: &str) -> Option<&str> {
let close = text.rfind(MULTI_AGENT_MODE_CLOSE_TAG)?;
let before_close = &text[..close];
let open = before_close.rfind(MULTI_AGENT_MODE_OPEN_TAG)?;
let body = &before_close[open + MULTI_AGENT_MODE_OPEN_TAG.len()..];
Some(body.trim())
}
@@ -13,7 +13,7 @@ pub struct OpeningAuditSnapshot {
pub request_line: String,
/// 请求头顺序。
pub header_order: Vec<String>,
/// 红action后的请求头。
/// 脱敏后的请求头。
pub headers: Vec<OpeningAuditHeader>,
}
@@ -31,11 +31,11 @@ pub struct OpeningAuditHeader {
pub struct PayloadAuditSnapshot {
/// 按构造顺序记录的顶层字段。
pub top_level_keys: Vec<String>,
/// 红action后的 JSON payload。
/// 脱敏后的 JSON payload。
pub body: Value,
}
/// WebSocket audit artifact.
/// WebSocket 审计产物。
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct WebSocketAuditArtifact {
/// 实际选择的传输模式。
@@ -10,7 +10,7 @@ use crate::transport::diagnostics::CodexUpstreamSendPhase;
use super::PreviousResponseUnavailableReason;
/// Responses WebSocket exchange error.
/// Responses WebSocket 交互错误。
#[derive(Debug, Error)]
pub enum CodexWebSocketExchangeError {
/// opening request 无法构造。
@@ -98,7 +98,7 @@ pub enum CodexWebSocketExchangeError {
/// WebSocket 上游错误帧载荷。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodexWebSocketUpstreamError {
/// HTTP-style upstream status code.
/// 上游返回的 HTTP 风格状态码。
pub status_code: u16,
/// 推导出的重试秒数。
pub retry_after_seconds: Option<u64>,
@@ -24,11 +24,11 @@ const WEBSOCKET_STREAM_BUFFER: usize = 16;
pub(super) use self::stream::{WebSocketStreamPoolReturn, stream_websocket_response};
/// Responses WebSocket live SSE exchange result.
/// Responses WebSocket live SSE 交互结果。
pub struct CodexWebSocketStreamingExchange {
/// 关联请求日志与底层 pump 生命周期日志的连接标识。
pub(crate) websocket_connection_id: Uuid,
/// Live SSE bytes converted from WebSocket events.
/// 由 WebSocket 事件转换出的 live SSE 字节流。
pub body: CodexWebSocketSseStream,
/// 上游 metadata 帧中的最新 turn state。
pub turn_state: Option<String>,
@@ -50,12 +50,12 @@ pub struct CodexWebSocketStreamingExchange {
pub response_metadata: CodexResponseMetadata,
}
/// Responses WebSocket live SSE byte stream.
/// Responses WebSocket live SSE 字节流。
pub type CodexWebSocketSseStream =
Pin<Box<dyn Stream<Item = Result<Bytes, CodexWebSocketExchangeError>> + Send + 'static>>;
/// WebSocket live stream rate-limit header updates.
/// live 流中的限流头动态更新。
pub type CodexWebSocketRateLimitHeaderUpdates = Arc<Mutex<Vec<(String, String)>>>;
/// WebSocket live stream turn-state update.
/// live 流中的 turn state 动态更新。
pub type CodexWebSocketTurnStateUpdate = Arc<Mutex<Option<String>>>;
pub(super) fn reusable_websocket_metadata(
@@ -1,4 +1,4 @@
//! Codex HTTP Responses WebSocket transport。
//! Codex Responses WebSocket 传输。
mod audit;
mod breaker;
@@ -13,7 +13,7 @@ pub struct CodexWebSocketConnection {
pub(super) headers: Vec<(String, String)>,
}
/// Prepared Responses WebSocket request descriptor.
/// 已构造完成的 Responses WebSocket 请求描述。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodexWebSocketRequest {
pub(super) connection: CodexWebSocketConnection,
@@ -329,7 +329,6 @@ async fn openai_admin_provider_persists_the_full_pending_envelope_and_binds_owne
let started = bundle
.admin_provider()
.start_authorization(PendingAuthorizationMutation::new(
Revision::new(7).expect("revision"),
ProviderKind::new("openai").expect("provider"),
AuthorizationMutationTarget::Create {
name: "OAuth account".to_owned(),
@@ -346,12 +345,7 @@ async fn openai_admin_provider_persists_the_full_pending_envelope_and_binds_owne
.get("mutation")
.and_then(Value::as_object)
.expect("pending mutation");
assert_eq!(
mutation
.get("expected_config_revision")
.and_then(Value::as_u64),
Some(7)
);
assert!(mutation.get("expected_config_revision").is_none());
assert_eq!(
mutation.get("started_request_id").and_then(Value::as_str),
Some("request-start")
@@ -172,7 +172,6 @@ fn owner_context(request_id: &str) -> MutationContext {
fn create_mutation(request_id: &str) -> PendingAuthorizationMutation {
let context = owner_context(request_id);
PendingAuthorizationMutation::new(
Revision::new(1).expect("revision"),
ProviderKind::new("openai").expect("provider"),
AuthorizationMutationTarget::Create {
name: "OAuth Account".to_owned(),
@@ -184,7 +183,6 @@ fn create_mutation(request_id: &str) -> PendingAuthorizationMutation {
fn reauthorization_mutation(request_id: &str, account_id: &str) -> PendingAuthorizationMutation {
let context = owner_context(request_id);
PendingAuthorizationMutation::new(
Revision::new(1).expect("revision"),
ProviderKind::new("openai").expect("provider"),
AuthorizationMutationTarget::Reauthorize {
account_id: ProviderAccountId::new(account_id).expect("account id"),
@@ -267,6 +265,129 @@ async fn completion_returns_verified_core_account_without_writing_store() {
);
}
#[tokio::test]
async fn completion_accepts_unrelated_oauth_callback_parameters() {
let service = service();
let started = started(&service).await;
let state = Url::parse(&started.authorization_url)
.expect("authorization URL")
.query_pairs()
.find_map(|(key, value)| (key == "state").then(|| value.into_owned()))
.expect("state");
let prepared = service
.complete_authorization(CompleteCodexOAuthAuthorization {
owner_ref: owner_ref(),
flow_id: started.flow_id,
callback_url: SecretString::from(format!(
"http://localhost:1455/auth/callback?code=authorization-code&state={state}&scope=openid%20profile&iss=https%3A%2F%2Fauth.openai.com"
)),
})
.await
.expect("complete OAuth with standard callback parameters");
assert!(matches!(
prepared.credential,
CompletedCodexOAuthCredential::Create(_)
));
}
#[tokio::test]
async fn completion_rejects_repeated_code_callback_parameter() {
let service = service();
let started = started(&service).await;
let state = Url::parse(&started.authorization_url)
.expect("authorization URL")
.query_pairs()
.find_map(|(key, value)| (key == "state").then(|| value.into_owned()))
.expect("state");
let error = service
.complete_authorization(CompleteCodexOAuthAuthorization {
owner_ref: owner_ref(),
flow_id: started.flow_id,
callback_url: SecretString::from(format!(
"http://localhost:1455/auth/callback?code=authorization-code&code=another-code&state={state}"
)),
})
.await
.expect_err("repeated code");
assert_eq!(error, CodexOAuthAdminError::UpstreamRejected);
}
#[tokio::test]
async fn completion_rejects_repeated_state_callback_parameter() {
let service = service();
let started = started(&service).await;
let state = Url::parse(&started.authorization_url)
.expect("authorization URL")
.query_pairs()
.find_map(|(key, value)| (key == "state").then(|| value.into_owned()))
.expect("state");
let error = service
.complete_authorization(CompleteCodexOAuthAuthorization {
owner_ref: owner_ref(),
flow_id: started.flow_id,
callback_url: SecretString::from(format!(
"http://localhost:1455/auth/callback?code=authorization-code&state={state}&state=another-state-value"
)),
})
.await
.expect_err("repeated state");
assert_eq!(error, CodexOAuthAdminError::UpstreamRejected);
}
struct UnavailableVerifier;
#[async_trait]
impl CodexAccountIdentityVerifier for UnavailableVerifier {
async fn verify(
&self,
_: &CodexOAuthSecret,
_: &CodexIdentityExpectation,
) -> Result<CodexIdentityVerification, CodexIdentityVerificationError> {
Err(CodexIdentityVerificationError::Unavailable)
}
async fn verify_authorization(
&self,
_: &CodexOAuthSecret,
_: &SecretString,
_: &SecretString,
_: &CodexIdentityExpectation,
) -> Result<CodexIdentityVerification, CodexIdentityVerificationError> {
Err(CodexIdentityVerificationError::Unavailable)
}
}
#[tokio::test]
async fn completion_classifies_unavailable_identity_verification_as_upstream_unavailable() {
let store = Arc::new(MemoryAccountStore::default());
let service = service_with_store(store, Arc::new(UnavailableVerifier));
let started = started(&service).await;
let state = Url::parse(&started.authorization_url)
.expect("authorization URL")
.query_pairs()
.find_map(|(key, value)| (key == "state").then(|| value.into_owned()))
.expect("state");
let error = service
.complete_authorization(CompleteCodexOAuthAuthorization {
owner_ref: owner_ref(),
flow_id: started.flow_id,
callback_url: SecretString::from(format!(
"http://localhost:1455/auth/callback?code=authorization-code&state={state}"
)),
})
.await
.expect_err("identity verification unavailable");
assert_eq!(error, CodexOAuthAdminError::UpstreamUnavailable);
}
#[tokio::test]
async fn callback_state_mismatch_fails_closed_and_consumes_flow() {
let service = service();
@@ -361,6 +361,73 @@ async fn passive_rate_limit_headers_update_quota_and_account_state_with_revision
);
}
#[tokio::test]
async fn passive_rate_limit_headers_replace_stale_secondary_window() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_passive_quota_snapshot").await;
let account = store
.account("acct_passive_quota_snapshot")
.expect("account");
let baseline = json!({
"rate_limit": {
"primary_window": {
"used_percent": 91,
"reset_at": 1_900_000_000,
"limit_window_seconds": 2_592_000
},
"secondary_window": {
"used_percent": 43,
"reset_at": 1_900_000_000,
"limit_window_seconds": 2_592_000
}
}
});
store
.compare_and_swap_quota(QuotaObservation {
account_id: account.id().clone(),
expected_revision: account.revision(),
quota: Some(OpaqueProviderData::new(
baseline.as_object().expect("quota object").clone(),
)),
observed_at: Some(SystemTime::now()),
})
.await
.expect("persist baseline quota");
let service = quota_service(&store);
let headers = vec![
("x-codex-active-limit".to_owned(), "codex".to_owned()),
("x-codex-primary-used-percent".to_owned(), "92".to_owned()),
(
"x-codex-primary-window-minutes".to_owned(),
"43200".to_owned(),
),
(
"x-codex-primary-reset-at".to_owned(),
"1900000000".to_owned(),
),
];
assert!(
service
.synchronize_passive_headers(&account, &headers)
.await
.expect("passive quota")
);
let snapshot = service
.read_account(account.id())
.await
.expect("read quota")
.expect("quota snapshot");
let monthly = snapshot
.windows()
.iter()
.filter(|window| window.kind() == CodexQuotaWindowKind::Monthly)
.collect::<Vec<_>>();
assert_eq!(monthly.len(), 1);
assert_eq!(monthly[0].used_percent(), Some(92.0));
}
#[tokio::test]
async fn synchronize_without_accounts_is_a_noop_before_network_io() {
let store = Arc::new(MemoryAccountStore::default());
+3 -3
View File
@@ -10,15 +10,15 @@ autotests = false
[dependencies]
async-stream = "0.3.6"
async-trait = "0.1.89"
base64 = "0.22.1"
base64 = "0.23.0"
bytes = "1.9.0"
chrono = { version = "0.4.45", features = ["clock", "serde"] }
futures = "0.3.32"
gateway-admin = { path = "../../gateway-admin" }
gateway-core = { path = "../../gateway-core" }
gateway-protocol = { path = "../../gateway-protocol" }
getrandom = "0.3.4"
jsonwebtoken = { version = "=10.3.0", default-features = false, features = ["aws_lc_rs"] }
getrandom = "0.4.3"
jsonwebtoken = { version = "=11.0.0", default-features = false, features = ["aws_lc_rs"] }
reqwest = { version = "=0.12.28", default-features = false, features = ["stream", "rustls-tls-native-roots", "gzip", "brotli", "zstd", "deflate", "http2"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = { version = "1.0.150", features = ["preserve_order"] }
+6 -40
View File
@@ -7,9 +7,7 @@ use std::time::SystemTime;
use async_trait::async_trait;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{DateTime, TimeDelta, Utc};
use gateway_admin::model::accounts::{
AccountAvailability as AdminAccountAvailability, AccountRecord,
};
use gateway_admin::model::accounts::AccountRecord;
use gateway_admin::model::observability::{
CalculatedBillingBreakdown, CurrencyCost, DashboardDesktopRelease, DashboardWireAttribute,
DashboardWireProfile, DashboardWireTarget, DecimalAmount, DesktopReleaseStatus,
@@ -29,9 +27,8 @@ use gateway_admin::model::{MutationActor, MutationContext, Revision};
use gateway_admin::ports::provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind};
use gateway_core::accounting::Money;
use gateway_core::engine::credential::{
AccountAvailability, CredentialRevision, LoadedCredential, NewProviderAccount,
OpaqueProviderData, PlaintextCredential, ProviderAccount, ProviderAccountId,
ProviderAccountStore,
CredentialRevision, LoadedCredential, NewProviderAccount, OpaqueProviderData,
PlaintextCredential, ProviderAccount, ProviderAccountId, ProviderAccountStore,
};
use gateway_core::error::StoreErrorKind;
use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload};
@@ -773,7 +770,7 @@ fn prepared_create(
access_token_expires_at: account.access_token_expires_at().map(DateTime::<Utc>::from),
next_refresh_at: account.next_refresh_at().map(Into::into),
enabled: account.enabled(),
availability: admin_availability(account.availability()),
availability: account.availability(),
availability_reason: None,
cooldown_until: account.cooldown_until().map(Into::into),
availability_observed_at: observed_at,
@@ -880,7 +877,7 @@ fn account_from_record(account: &AccountRecord) -> Result<ProviderAccount, Provi
)
.with_runtime_state(
account.enabled,
core_availability(account.availability),
account.availability,
account.cooldown_until.map(Into::into),
)
.with_refresh_schedule(
@@ -899,7 +896,7 @@ fn account_matches_record(account: &ProviderAccount, record: &AccountRecord) ->
&& account.upstream_account_id() == record.upstream_account_id.as_deref()
&& account.plan_type() == record.plan_type.as_deref()
&& account.enabled() == record.enabled
&& admin_availability(account.availability()) == record.availability
&& account.availability() == record.availability
&& account.cooldown_until().map(DateTime::<Utc>::from) == record.cooldown_until
&& account.access_token_expires_at().map(DateTime::<Utc>::from)
== record.access_token_expires_at
@@ -907,30 +904,6 @@ fn account_matches_record(account: &ProviderAccount, record: &AccountRecord) ->
&& account.has_refresh_token() == record.has_refresh_token
}
const fn admin_availability(value: AccountAvailability) -> AdminAccountAvailability {
match value {
AccountAvailability::Unknown => AdminAccountAvailability::Unknown,
AccountAvailability::Ready => AdminAccountAvailability::Ready,
AccountAvailability::Cooldown => AdminAccountAvailability::Cooldown,
AccountAvailability::QuotaExhausted => AdminAccountAvailability::QuotaExhausted,
AccountAvailability::Expired => AdminAccountAvailability::Expired,
AccountAvailability::Banned => AdminAccountAvailability::Banned,
AccountAvailability::Invalid => AdminAccountAvailability::Invalid,
}
}
const fn core_availability(value: AdminAccountAvailability) -> AccountAvailability {
match value {
AdminAccountAvailability::Unknown => AccountAvailability::Unknown,
AdminAccountAvailability::Ready => AccountAvailability::Ready,
AdminAccountAvailability::Cooldown => AccountAvailability::Cooldown,
AdminAccountAvailability::QuotaExhausted => AccountAvailability::QuotaExhausted,
AdminAccountAvailability::Expired => AccountAvailability::Expired,
AdminAccountAvailability::Banned => AccountAvailability::Banned,
AdminAccountAvailability::Invalid => AccountAvailability::Invalid,
}
}
fn project_quota(
snapshot: Option<GrokQuotaSnapshot>,
refresh_token_expires_at: Option<DateTime<Utc>>,
@@ -1071,7 +1044,6 @@ struct PendingDocument {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct PendingMutationDocument {
expected_config_revision: u64,
provider_kind: String,
target: PendingTargetDocument,
owner: PendingOwnerDocument,
@@ -1126,10 +1098,6 @@ fn encode_pending(pending: &StoredXaiAuthorization) -> Map<String, Value> {
fn encode_mutation(mutation: &PendingAuthorizationMutation) -> Map<String, Value> {
let mut document = Map::new();
document.insert(
"expected_config_revision".to_owned(),
Value::Number(Number::from(mutation.expected_config_revision().get())),
);
document.insert(
"provider_kind".to_owned(),
Value::String(mutation.provider_kind().as_str().to_owned()),
@@ -1256,8 +1224,6 @@ fn decode_mutation(
request_id: document.started_request_id,
};
Ok(PendingAuthorizationMutation::new(
Revision::new(document.expected_config_revision)
.map_err(|_| provider_error(ProviderAdminErrorKind::Invalid))?,
provider_kind,
target,
AuthorizationOwnerBinding::from_context(&context),
@@ -14,7 +14,7 @@ use crate::{
const MAX_CALLBACK_VALUE_BYTES: usize = 64 * 1024;
/// Parsed callback whose code and state remain redacted in debug output.
/// 已解析的回调;code 与 state 在 debug 输出中保持脱敏。
pub struct AuthorizationCallback {
code: Option<SecretValue>,
state: Option<SecretValue>,
@@ -22,13 +22,13 @@ pub struct AuthorizationCallback {
}
impl AuthorizationCallback {
/// Parses an OAuth callback query while rejecting duplicate security fields.
/// Raw server descriptions are intentionally discarded.
/// 解析 OAuth 回调 query,并拒绝重复的安全敏感参数。
/// 服务端原始描述文本一律丢弃。
///
/// # Errors
///
/// Returns [`CallbackRejection::DuplicateParameter`] for repeated `code`,
/// `state`, or `error` keys.
/// `code`、`state` 或 `error` 键重复出现时返回
/// [`CallbackRejection::DuplicateParameter`]。
pub fn parse(query: &str) -> Result<Self, CallbackRejection> {
let mut code = None;
let mut state = None;
@@ -72,7 +72,7 @@ impl fmt::Debug for AuthorizationCallback {
}
}
/// Authorization Code + PKCE state awaiting one callback.
/// 等待一次回调的 Authorization Code + PKCE 状态。
pub struct PendingAuthorization {
authorization_url: Url,
redirect_uri: AllowedRedirectUri,
@@ -122,7 +122,7 @@ impl PendingAuthorization {
})
}
/// Returns the URL that an administrator opens at the official issuer.
/// 返回管理员需在官方 issuer 打开的 URL。
#[must_use]
pub fn authorization_url(&self) -> &Url {
&self.authorization_url
@@ -180,12 +180,11 @@ impl PendingAuthorization {
})
}
/// Consumes the one-time flow and validates mandatory callback state.
/// 消费一次性流程并校验必需的回调 state。
///
/// # Errors
///
/// Returns a callback rejection for missing/mismatched state, provider
/// denial, or a missing code.
/// state 缺失或不匹配、provider 拒绝、code 缺失时返回回调拒绝错误。
pub fn accept_callback(
self,
callback: AuthorizationCallback,
@@ -247,7 +246,7 @@ impl fmt::Debug for PendingAuthorization {
}
}
/// State-validated authorization grant ready for one token exchange.
/// 已通过 state 校验、可用于一次 token 交换的授权 grant。
pub struct AuthorizationCodeGrant {
code: SecretValue,
redirect_uri: AllowedRedirectUri,
@@ -9,7 +9,7 @@ use crate::{
VerificationMethod, VerifiedTokenSet,
};
/// Transport-agnostic official Grok Build OAuth protocol client.
/// 与 transport 无关的官方 Grok Build OAuth 协议客户端。
#[derive(Clone)]
pub struct GrokOAuthClient {
config: GrokOAuthConfig,
@@ -19,7 +19,7 @@ pub struct GrokOAuthClient {
}
impl GrokOAuthClient {
/// Creates a client with explicit HTTP and token verification trust ports.
/// 创建客户端,显式注入 HTTP 与 token 验证两个信任端口。
#[must_use]
pub fn new(
config: GrokOAuthConfig,
@@ -35,18 +35,18 @@ impl GrokOAuthClient {
}
}
/// Returns the immutable official provider configuration.
/// 返回不可变的官方 provider 配置。
#[must_use]
pub const fn config(&self) -> &GrokOAuthConfig {
&self.config
}
/// Fetches and validates official same-origin OIDC discovery.
/// 拉取并校验官方同源 OIDC 发现文档。
///
/// # Errors
///
/// Fails on transport errors, non-success status, malformed JSON, issuer
/// mismatch, cross-origin endpoints, missing JWKS, or insecure algorithms.
/// transport 失败、非成功状态、JSON 非法、issuer 不匹配、端点跨 origin、
/// 缺少 JWKS 或算法不安全时返回错误。
pub async fn discover(&self) -> Result<DiscoveryDocument, OAuthError> {
let response = self
.execute(
@@ -63,12 +63,11 @@ impl GrokOAuthClient {
DiscoveryDocument::parse(&self.config, response.body())
}
/// Starts Authorization Code + PKCE without performing network I/O.
/// 启动 Authorization Code + PKCE,不产生网络 I/O。
///
/// # Errors
///
/// Returns an entropy error if secure random state, nonce, or verifier
/// generation fails.
/// 安全随机 state、nonce 或 verifier 生成失败时返回熵不可用错误。
pub fn start_authorization_code(
&self,
discovery: &DiscoveryDocument,
@@ -78,13 +77,13 @@ impl GrokOAuthClient {
PendingAuthorization::start(&self.config, discovery, redirect_uri, principal)
}
/// Exchanges a state-validated authorization grant exactly once, then
/// requires nonce-bound ID-token verification before returning credentials.
/// 对已校验 state 的授权 grant 只做一次交换,返回凭据前强制通过
/// nonce 绑定的 ID token 验证。
///
/// # Errors
///
/// Fails on transport/protocol errors, missing ID token, failed verification,
/// or any non-success OAuth response. No automatic retry is performed.
/// transport/协议错误、缺少 ID token、验证失败或任何非成功 OAuth 响应时
/// 返回错误。不做自动重试。
pub async fn exchange_authorization_code(
&self,
discovery: &DiscoveryDocument,
@@ -216,13 +215,13 @@ impl GrokOAuthClient {
Ok(VerifiedTokenSet::new(tokens, evidence, scope))
}
/// Exchanges a refresh token once. The caller must serialize refreshes and
/// persist rotated tokens through credential-revision CAS.
/// 执行一次 refresh token 交换。调用方须串行化刷新,并通过 credential
/// revision CAS 持久化轮换后的 token。
///
/// # Errors
///
/// Returns a classified OAuth error. Ambiguous transport failures must not
/// be retried automatically because the refresh token may have rotated.
/// 返回已分类的 OAuth 错误。refresh token 可能已轮换,Ambiguous 类
/// transport 失败不得自动重试。
pub async fn refresh(
&self,
discovery: &DiscoveryDocument,
@@ -4,16 +4,16 @@ use url::{Host, Url};
use crate::ConfigError;
/// Official first-party OIDC issuer used by Grok Build.
/// Grok Build 使用的官方第一方 OIDC issuer。
pub const OFFICIAL_ISSUER: &str = "https://auth.x.ai";
/// Official public OAuth client identifier embedded in Grok Build.
/// Grok Build 内置的官方 public OAuth client 标识。
pub const OFFICIAL_CLIENT_ID: &str = "b1a00492-073a-47ea-816f-4c329264a828";
/// 官方桌面客户端使用的固定 loopback OAuth 回调地址。
pub const OFFICIAL_REDIRECT_URI: &str = "http://127.0.0.1:56121/callback";
/// Official first-party OAuth scope set used by current Grok Build clients.
/// 当前 Grok Build 客户端使用的官方第一方 OAuth scope 集合。
pub const OFFICIAL_SCOPES: &[&str] = &[
"openid",
"profile",
@@ -23,12 +23,12 @@ pub const OFFICIAL_SCOPES: &[&str] = &[
"api:access",
];
/// Exact redirect URI that has passed syntax and local allowlist checks.
/// 已通过语法与本地 allowlist 校验的精确回调 URI。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AllowedRedirectUri(Url);
impl AllowedRedirectUri {
/// Returns the exact redirect URI sent during authorization and exchange.
/// 返回授权与 token 交换时发送的精确回调 URI。
#[must_use]
pub fn as_url(&self) -> &Url {
&self.0
@@ -39,20 +39,19 @@ impl AllowedRedirectUri {
}
}
/// Explicit callback allowlist. Public HTTPS callbacks and dynamic loopback
/// callbacks must be inserted before a flow can start.
/// 显式回调 allowlist。公网 HTTPS 回调与动态 loopback 回调必须先登记才能启动流程。
#[derive(Debug, Clone)]
pub struct RedirectUriAllowlist {
entries: Vec<Url>,
}
impl RedirectUriAllowlist {
/// Validates and constructs an exact redirect allowlist.
/// 校验并构造精确回调 allowlist。
///
/// # Errors
///
/// Returns [`ConfigError::InvalidRedirectUri`] for non-HTTPS public URIs,
/// user-info, query strings, fragments, or malformed values.
/// 公网 URI 非 HTTPS、含 user-info、query、fragment 或格式非法时返回
/// [`ConfigError::InvalidRedirectUri`]。
pub fn new<I, S>(uris: I) -> Result<Self, ConfigError>
where
I: IntoIterator<Item = S>,
@@ -66,11 +65,11 @@ impl RedirectUriAllowlist {
Ok(Self { entries })
}
/// Validates a candidate and requires an exact local allowlist match.
/// 校验候选 URI,并要求与本地 allowlist 精确匹配。
///
/// # Errors
///
/// Returns a configuration error when the URI is invalid or absent.
/// URI 非法或未登记时返回配置错误。
pub fn authorize(&self, candidate: &str) -> Result<AllowedRedirectUri, ConfigError> {
let candidate = validate_redirect_uri(candidate)?;
if self.entries.iter().any(|entry| entry == &candidate) {
@@ -81,36 +80,36 @@ impl RedirectUriAllowlist {
}
}
/// Immutable official Grok Build OAuth configuration.
/// 不可变的官方 Grok Build OAuth 配置。
#[derive(Clone)]
pub struct GrokOAuthConfig {
issuer: Url,
}
impl GrokOAuthConfig {
/// Builds the fixed official issuer, client, and scope configuration.
/// 构造固定的官方 issuer、client 与 scope 配置。
///
/// # Errors
///
/// Returns an error when the fixed official issuer cannot be constructed.
/// 固定官方 issuer 无法构造时返回错误。
pub fn official() -> Result<Self, ConfigError> {
let issuer = Url::parse(OFFICIAL_ISSUER).map_err(|_| ConfigError::UntrustedIssuer)?;
Ok(Self { issuer })
}
/// Returns the fixed issuer URL.
/// 返回固定的 issuer URL。
#[must_use]
pub fn issuer(&self) -> &Url {
&self.issuer
}
/// Returns the official public client identifier.
/// 返回官方 public client 标识。
#[must_use]
pub const fn client_id(&self) -> &'static str {
OFFICIAL_CLIENT_ID
}
/// Returns the official scope set.
/// 返回官方 scope 集合。
#[must_use]
pub const fn scopes(&self) -> &'static [&'static str] {
OFFICIAL_SCOPES
@@ -5,8 +5,7 @@ use crate::{GrokOAuthConfig, OAuthError, OAuthOperation, ProtocolViolation};
pub(crate) const MAX_OAUTH_RESPONSE_BYTES: usize = 64 * 1024;
/// A discovery document whose issuer and endpoints have passed the official
/// Grok Build origin policy.
/// issuer 与端点均已通过官方 Grok Build origin 策略校验的发现文档。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiscoveryDocument {
issuer: Url,
@@ -70,38 +69,37 @@ impl DiscoveryDocument {
})
}
/// Returns the validated issuer from the discovery document.
/// 返回发现文档中已校验的 issuer。
#[must_use]
pub fn issuer(&self) -> &Url {
&self.issuer
}
/// Returns the validated authorization endpoint.
/// 返回已校验的 authorization 端点。
#[must_use]
pub fn authorization_endpoint(&self) -> &Url {
&self.authorization_endpoint
}
/// Returns the validated token endpoint.
/// 返回已校验的 token 端点。
#[must_use]
pub fn token_endpoint(&self) -> &Url {
&self.token_endpoint
}
/// Returns the validated JWKS endpoint required by an ID-token verifier.
/// 返回 ID token 校验器所需的已校验 JWKS 端点。
#[must_use]
pub fn jwks_uri(&self) -> &Url {
&self.jwks_uri
}
/// Returns the validated authoritative user-info endpoint.
/// 返回已校验的权威 user-info 端点。
#[must_use]
pub fn userinfo_endpoint(&self) -> &Url {
&self.userinfo_endpoint
}
/// Returns algorithms advertised by discovery. The verifier must still
/// enforce its own cryptographic allowlist.
/// 返回发现文档声明的算法;校验器仍须强制自身的密码学 allowlist。
#[must_use]
pub fn signing_algorithms(&self) -> &[String] {
&self.signing_algorithms
@@ -2,188 +2,188 @@ use thiserror::Error;
use crate::TransportFailureKind;
/// Static OAuth operation labels used for safe telemetry and classification.
/// OAuth 操作标签,用于脱敏遥测与错误分类。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OAuthOperation {
/// OpenID Provider discovery.
/// OpenID Provider 发现。
Discovery,
/// Authorization-code token exchange.
/// Authorization Code 换取 token。
AuthorizationCodeToken,
/// Refresh-token exchange.
/// Refresh token 换取 token。
RefreshToken,
/// 已有 OAuth token 的受控导入验证。
CredentialImport,
}
/// Stable OAuth error codes that are safe to expose to control-plane logic.
/// 可安全暴露给控制面的稳定 OAuth 错误码。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OAuthErrorCode {
/// The human rejected authorization.
/// 用户拒绝授权。
AccessDenied,
/// A code or refresh token is invalid or already consumed.
/// code 或 refresh token 无效或已被消费。
InvalidGrant,
/// The configured public client is rejected.
/// 配置的 public client 被拒绝。
InvalidClient,
/// The requested official scope set is rejected.
/// 请求的官方 scope 集合被拒绝。
InvalidScope,
/// The authorization server is temporarily unavailable.
/// 授权服务器暂时不可用。
TemporarilyUnavailable,
/// An unrecognized code whose raw server text is intentionally discarded.
/// 无法识别的错误码;不保留服务端原文。
Other,
}
/// High-level action a coordinator may take after a failure.
/// 失败后协调方可采取的处置类别。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FailureClass {
/// Safe transient failure; a later, separately coordinated attempt may run.
/// 瞬时失败,允许后续独立协调的重试。
Transient,
/// Send state is ambiguous and the one-time artifact must not be replayed.
/// 发送状态不确定,一次性凭据不得重放。
Ambiguous,
/// Credential material is permanently rejected at its current revision.
/// 当前 revision 的凭据被永久拒绝。
CredentialPermanent,
/// Provider/client configuration is permanently rejected.
/// Provider 或 client 配置被永久拒绝。
ConfigurationPermanent,
/// The human denied or must restart the interactive flow.
/// 用户已拒绝,或需要重新发起交互流程。
UserActionRequired,
/// Protocol or trust-boundary validation failed closed.
/// 协议或信任边界校验失败并 fail closed。
Security,
/// The official deployment does not offer the requested flow.
/// 官方部署不提供该流程。
Unsupported,
}
/// Configuration failures detected before any network request.
/// 发起网络请求前检出的配置错误。
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum ConfigError {
/// Redirect URI is malformed, insecure, or contains forbidden components.
/// 回调 URI 格式非法、不安全或包含禁止成分。
#[error("invalid OAuth redirect URI")]
InvalidRedirectUri,
/// Redirect URI was not explicitly registered in the local allowlist.
/// 回调 URI 未登记在本地 allowlist。
#[error("OAuth redirect URI is not allowlisted")]
RedirectUriNotAllowlisted,
/// Issuer differs from the official Grok Build issuer.
/// issuer 不是官方 Grok Build issuer。
#[error("OIDC issuer is not the official Grok Build issuer")]
UntrustedIssuer,
/// A discovered endpoint leaves the official issuer origin.
/// 发现文档中的端点离开了官方 issuer origin。
#[error("OIDC discovery returned an untrusted endpoint")]
UntrustedEndpoint,
/// Optional team principal metadata is empty or contains unsafe text.
/// 可选的 team principal 元数据为空或含不安全文本。
#[error("invalid OAuth principal metadata")]
InvalidPrincipal,
}
/// Safe, field-oriented protocol validation errors.
/// 按字段定位的脱敏协议校验错误。
#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
pub enum ProtocolViolation {
/// JSON could not be decoded into the expected wire schema.
/// JSON 无法解析为预期 wire 结构。
#[error("OAuth response is not valid JSON")]
InvalidJson,
/// A required wire field is absent.
/// 缺少必需的 wire 字段。
#[error("OAuth response is missing field `{0}`")]
MissingField(&'static str),
/// A wire field violates its format or size contract.
/// wire 字段违反格式或长度约束。
#[error("OAuth response contains invalid field `{0}`")]
InvalidField(&'static str),
/// A response exceeded the bounded parser limit.
/// 响应超出解析器的体积上限。
#[error("OAuth response exceeds the maximum body size")]
ResponseTooLarge,
/// The discovery document permits the insecure `none` signing algorithm.
/// 发现文档允许不安全的 `none` 签名算法。
#[error("OIDC discovery advertises an insecure signing algorithm")]
InsecureSigningAlgorithm,
}
/// Callback failures that never retain the authorization code or state value.
/// 回调失败原因;不保留 code 与 state 值。
#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
pub enum CallbackRejection {
/// Query contains repeated security-sensitive keys.
/// query 中出现重复的安全敏感参数。
#[error("OAuth callback contains a duplicate parameter")]
DuplicateParameter,
/// Provider callback omitted the one-time state.
/// 回调缺少一次性 state。
#[error("OAuth callback is missing state")]
MissingState,
/// Provider callback omitted the authorization code.
/// 回调缺少 authorization code。
#[error("OAuth callback is missing code")]
MissingCode,
/// One-time state does not match the pending flow.
/// 一次性 state 与 pending flow 不匹配。
#[error("OAuth callback state mismatch")]
StateMismatch,
/// Authorization server returned `access_denied`.
/// 授权服务器返回 `access_denied`。
#[error("OAuth authorization was denied")]
AccessDenied,
/// Authorization server returned another callback error.
/// 授权服务器返回其他回调错误。
#[error("OAuth authorization callback was rejected")]
ProviderRejected,
}
/// Reasons an unverified token set cannot cross the credential boundary.
/// 未验证 token 集合无法通过凭据边界的原因。
#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
pub enum VerificationFailure {
/// No JWT/JWKS or authoritative user-info verifier was wired.
/// 未注入 JWT/JWKS 或权威 user-info 校验器。
#[error("token verification is unavailable; refusing unverified credentials")]
Unavailable,
/// Authorization-code response omitted its required ID token.
/// Authorization Code 响应缺少必需的 ID token。
#[error("authorization-code response is missing an ID token")]
MissingIdToken,
/// The verifier rejected signature, issuer, audience, nonce, expiry, or identity.
/// 校验器拒绝了签名、issuer、audience、nonce、过期时间或身份。
#[error("token verification failed")]
Rejected,
/// Authorization-code flow was not verified through an ID token.
/// Authorization Code 流程未经 ID token 验证。
#[error("authorization-code flow requires verified ID-token evidence")]
WrongEvidence,
}
/// Secret-free OAuth protocol error.
/// 不含密钥的 OAuth 协议错误。
#[derive(Debug, Error)]
pub enum OAuthError {
/// Local trust or redirect configuration is invalid.
/// 本地信任或回调配置非法。
#[error(transparent)]
Configuration(#[from] ConfigError),
/// Replaceable transport failed without retaining its raw error message.
/// 可替换 transport 失败;不保留原始错误信息。
#[error("OAuth transport failed during {operation:?}: {kind:?}")]
Transport {
/// Operation being executed.
/// 执行中的操作。
operation: OAuthOperation,
/// Safe send-state classification.
/// 脱敏的发送状态分类。
kind: TransportFailureKind,
},
/// Non-success HTTP response without a recognized OAuth code.
/// 非成功 HTTP 响应,且没有可识别的 OAuth 错误码。
#[error("OAuth endpoint returned HTTP {status} during {operation:?}")]
HttpStatus {
/// Operation being executed.
/// 执行中的操作。
operation: OAuthOperation,
/// HTTP status code.
/// HTTP 状态码。
status: u16,
},
/// Recognized OAuth error response.
/// 可识别的 OAuth 错误响应。
#[error("OAuth endpoint returned {code:?} during {operation:?}")]
Server {
/// Operation being executed.
/// 执行中的操作。
operation: OAuthOperation,
/// HTTP status code.
/// HTTP 状态码。
status: u16,
/// Stable OAuth error code.
/// 稳定 OAuth 错误码。
code: OAuthErrorCode,
},
/// Callback was rejected before token exchange.
/// token 交换前回调即被拒绝。
#[error(transparent)]
Callback(#[from] CallbackRejection),
/// Wire response violated the strict parser contract.
/// wire 响应违反严格解析契约。
#[error("{operation:?} protocol violation: {violation}")]
Protocol {
/// Operation being parsed.
/// 解析中的操作。
operation: OAuthOperation,
/// Safe field-oriented reason.
/// 按字段定位的脱敏原因。
violation: ProtocolViolation,
},
/// Token set failed the mandatory verification boundary.
/// token 集合未通过强制验证边界。
#[error(transparent)]
Verification(#[from] VerificationFailure),
/// Cryptographically secure entropy was unavailable before a flow started.
/// 流程启动前无法获得安全随机数。
#[error("secure OAuth entropy is unavailable")]
EntropyUnavailable,
}
impl OAuthError {
/// Returns a low-cardinality failure class for orchestration decisions.
/// 返回供编排决策使用的低基数失败类别。
#[must_use]
pub fn class(&self) -> FailureClass {
match self {
@@ -7,16 +7,16 @@ use zeroize::Zeroizing;
use crate::SecretValue;
/// HTTP methods needed by the OAuth protocol.
/// OAuth 协议所需的 HTTP 方法。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HttpMethod {
/// Read-only discovery request.
/// 只读的发现请求。
Get,
/// Form-encoded OAuth request.
/// form 编码的 OAuth 请求。
Post,
}
/// A request header whose value is public protocol metadata.
/// 值为公开协议元数据的请求头。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HttpHeader {
name: &'static str,
@@ -24,7 +24,7 @@ pub struct HttpHeader {
}
impl HttpHeader {
/// Creates a public request header.
/// 创建公开请求头。
#[must_use]
pub fn new(name: &'static str, value: impl Into<String>) -> Self {
Self {
@@ -33,30 +33,30 @@ impl HttpHeader {
}
}
/// Returns the header name.
/// 返回请求头名称。
#[must_use]
pub fn name(&self) -> &'static str {
self.name
}
/// Returns the public header value.
/// 返回公开的请求头值。
#[must_use]
pub fn value(&self) -> &str {
&self.value
}
}
/// A public or sensitive form field value.
/// 公开或敏感的 form 字段值。
#[derive(Clone)]
pub enum FormValue {
/// Non-sensitive protocol metadata.
/// 非敏感的协议元数据。
Public(String),
/// Authorization code, verifier, or token.
/// authorization code、verifier 或 token。
Secret(SecretValue),
}
impl FormValue {
/// Exposes a value at the HTTP encoder boundary.
/// 在 HTTP 编码边界暴露值。
#[must_use]
pub fn expose(&self) -> &str {
match self {
@@ -75,7 +75,7 @@ impl fmt::Debug for FormValue {
}
}
/// One `application/x-www-form-urlencoded` field.
/// 单个 `application/x-www-form-urlencoded` 字段。
#[derive(Debug, Clone)]
pub struct FormField {
name: &'static str,
@@ -83,7 +83,7 @@ pub struct FormField {
}
impl FormField {
/// Creates a public field.
/// 创建公开字段。
#[must_use]
pub fn public(name: &'static str, value: impl Into<String>) -> Self {
Self {
@@ -92,7 +92,7 @@ impl FormField {
}
}
/// Creates a sensitive field.
/// 创建敏感字段。
#[must_use]
pub fn secret(name: &'static str, value: SecretValue) -> Self {
Self {
@@ -101,20 +101,20 @@ impl FormField {
}
}
/// Returns the form field name.
/// 返回 form 字段名。
#[must_use]
pub fn name(&self) -> &'static str {
self.name
}
/// Returns the form field value.
/// 返回 form 字段值。
#[must_use]
pub fn value(&self) -> &FormValue {
&self.value
}
}
/// A transport-neutral OAuth HTTP request.
/// 与 transport 无关的 OAuth HTTP 请求。
#[derive(Debug, Clone)]
pub struct OAuthHttpRequest {
method: HttpMethod,
@@ -142,39 +142,39 @@ impl OAuthHttpRequest {
}
}
/// Returns the request method.
/// 返回请求方法。
#[must_use]
pub const fn method(&self) -> HttpMethod {
self.method
}
/// Returns the validated destination URL.
/// 返回已校验的目标 URL。
#[must_use]
pub fn url(&self) -> &Url {
&self.url
}
/// Returns public request headers.
/// 返回公开请求头。
#[must_use]
pub fn headers(&self) -> &[HttpHeader] {
&self.headers
}
/// Returns form fields. Transport implementations must not log them.
/// 返回 form 字段。transport 实现不得记录这些值。
#[must_use]
pub fn form(&self) -> &[FormField] {
&self.form
}
}
/// A response body that is zeroized and omitted from debug output.
/// 响应体会被清零,且不出现在 debug 输出中。
pub struct OAuthHttpResponse {
status: u16,
body: Zeroizing<Vec<u8>>,
}
impl OAuthHttpResponse {
/// Constructs a response from a status code and raw body.
/// 由状态码与原始响应体构造响应。
#[must_use]
pub fn new(status: u16, body: impl Into<Vec<u8>>) -> Self {
Self {
@@ -183,7 +183,7 @@ impl OAuthHttpResponse {
}
}
/// Returns the HTTP status code.
/// 返回 HTTP 状态码。
#[must_use]
pub const fn status(&self) -> u16 {
self.status
@@ -205,51 +205,48 @@ impl fmt::Debug for OAuthHttpResponse {
}
}
/// Whether an HTTP failure definitely occurred before sending or may have
/// consumed a one-time authorization artifact.
/// 区分 HTTP 失败确定发生在发送前,还是可能已消费一次性授权凭据。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransportFailureKind {
/// No request bytes were sent, so a coordinator may safely retry.
/// 未发送任何请求字节,协调方可安全重试。
NotSent,
/// The server may have consumed a code or rotating refresh token.
/// 服务端可能已消费 code 或轮换的 refresh token。
Ambiguous,
/// A timeout occurred and send state is unknown.
/// 发生超时,发送状态未知。
Timeout,
/// TLS establishment or validation failed before an accepted response.
/// 在收到有效响应前 TLS 建立或校验失败。
Tls,
}
/// A deliberately low-cardinality, secret-free transport error.
/// 有意保持低基数且不含密钥的 transport 错误。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TransportFailure {
kind: TransportFailureKind,
}
impl TransportFailure {
/// Creates a transport failure without retaining a potentially sensitive
/// third-party error message.
/// 创建 transport 失败,不保留可能敏感的第三方错误信息。
#[must_use]
pub const fn new(kind: TransportFailureKind) -> Self {
Self { kind }
}
/// Returns the send-state classification.
/// 返回发送状态分类。
#[must_use]
pub const fn kind(self) -> TransportFailureKind {
self.kind
}
}
/// Future returned by an OAuth HTTP transport.
/// OAuth HTTP transport 返回的 Future。
pub type TransportFuture<'a> =
Pin<Box<dyn Future<Output = Result<OAuthHttpResponse, TransportFailure>> + Send + 'a>>;
/// Replaceable HTTP boundary for OAuth protocol requests.
/// OAuth 协议请求的可替换 HTTP 边界。
///
/// Implementations must enforce TLS, bounded response bodies, and proxy/IP
/// affinity outside this crate. They must never log form values or response
/// bodies.
/// 实现方需在本 crate 之外保证 TLS、有界响应体与 proxy/IP 亲和性,
/// 且不得记录 form 值或响应体。
pub trait OAuthHttpTransport: Send + Sync {
/// Executes one request without hidden retries.
/// 执行一次请求,不做隐式重试。
fn execute(&self, request: OAuthHttpRequest) -> TransportFuture<'_>;
}
@@ -43,12 +43,8 @@ pub struct ReqwestOidcTokenVerifier {
}
impl ReqwestOidcTokenVerifier {
/// 构建只允许访问 `auth.x.ai` 官方验证端点的 verifier。
///
/// # Errors
///
/// TLS client 初始化失败时返回 transport build error。
/// 构建使用显式 endpoint policy 与 JWKS cache TTL 的 verifier。
/// 构建 verifier;使用显式 endpoint policy 与 JWKS cache TTL,只允许访问
/// `auth.x.ai` 官方验证端点。
///
/// # Errors
///
@@ -440,7 +436,7 @@ fn valid_id_token_header(header: &jsonwebtoken::Header) -> bool {
&& header.zip.is_none()
&& header.url.is_none()
&& header.nonce.is_none()
&& header.extras.is_empty()
&& header.extras.inner().is_empty()
}
fn valid_audience(audience: &AudienceClaim, azp: Option<&str>) -> bool {
@@ -6,10 +6,10 @@ use std::time::SystemTime;
use chrono::{DateTime, Utc};
use gateway_core::engine::credential::{
AccountAvailability, AccountStateChange, CredentialCasOutcome, CredentialCasUpdate,
CredentialRevision, LoadedCredential, NewProviderAccount, OpaqueProviderData,
PlaintextCredential, ProviderAccount, ProviderAccountId, ProviderAccountStore,
ProviderAccountUpdate, QuotaObservation, QuotaWriteOutcome,
AccountStateChange, CredentialCasOutcome, CredentialCasUpdate, CredentialRevision,
LoadedCredential, NewProviderAccount, OpaqueProviderData, PlaintextCredential, ProviderAccount,
ProviderAccountId, ProviderAccountStore, ProviderAccountUpdate, QuotaObservation,
QuotaWriteOutcome,
};
use gateway_core::error::StoreErrorKind;
use gateway_core::provider_ports::ProviderRefreshPolicy;
@@ -170,7 +170,7 @@ impl GrokCredentialAdmin {
)
.with_runtime_state(
input.enabled,
availability(input.initial_availability),
input.initial_availability.into(),
input.initial_cooldown_until.map(to_system_time),
)
.with_refresh_schedule(true, Some(to_system_time(input.next_refresh_at)));
@@ -440,7 +440,7 @@ impl GrokCredentialRepository {
.apply_state_change(AccountStateChange {
account_id: input.account_id.clone(),
expected_revision: input.expected_revision,
availability: availability(input.availability),
availability: input.availability.into(),
reason: input.availability_reason.clone(),
cooldown_until: input.cooldown_until.map(to_system_time),
observed_at: to_system_time(input.observed_at),
@@ -839,18 +839,6 @@ fn validate_cooldown(
Ok(())
}
fn availability(value: GrokCredentialAvailability) -> AccountAvailability {
match value {
GrokCredentialAvailability::Unknown => AccountAvailability::Unknown,
GrokCredentialAvailability::Ready => AccountAvailability::Ready,
GrokCredentialAvailability::Cooldown => AccountAvailability::Cooldown,
GrokCredentialAvailability::QuotaExhausted => AccountAvailability::QuotaExhausted,
GrokCredentialAvailability::Expired => AccountAvailability::Expired,
GrokCredentialAvailability::Banned => AccountAvailability::Banned,
GrokCredentialAvailability::Invalid => AccountAvailability::Invalid,
}
}
fn ensure_xai(account: &ProviderAccount) -> Result<(), GrokCredentialRepositoryError> {
if account.provider().as_str() == XAI_PROVIDER_KIND
&& account.authentication_kind() == XAI_AUTHENTICATION_KIND_OAUTH
@@ -2,19 +2,18 @@ use std::fmt;
use zeroize::Zeroizing;
/// An in-memory secret whose debug output is always redacted and whose buffer
/// is zeroized on drop.
/// 内存中的 secret;debug 输出恒为脱敏,缓冲区在 drop 时清零。
#[derive(Clone)]
pub struct SecretValue(Zeroizing<String>);
impl SecretValue {
/// Wraps an owned secret value.
/// 包装持有所有权的 secret 值。
#[must_use]
pub fn new(value: impl Into<String>) -> Self {
Self(Zeroizing::new(value.into()))
}
/// Exposes the secret only at an explicit protocol or transport boundary.
/// 仅在明确的协议或 transport 边界暴露 secret。
#[must_use]
pub fn expose(&self) -> &str {
self.0.as_str()
@@ -12,7 +12,7 @@ use crate::{
const MAX_TOKEN_BYTES: usize = 64 * 1024;
const MAX_TOKEN_LIFETIME_SECONDS: u64 = 366 * 24 * 60 * 60;
/// Optional team principal fields sent during refresh.
/// refresh 请求可选携带的 team principal 字段。
#[derive(Clone, PartialEq, Eq)]
pub struct OAuthPrincipal {
principal_type: String,
@@ -30,12 +30,12 @@ impl fmt::Debug for OAuthPrincipal {
}
impl OAuthPrincipal {
/// Creates validated principal metadata.
/// 创建校验过的 principal 元数据。
///
/// # Errors
///
/// Returns [`ConfigError::InvalidPrincipal`] for empty, oversized, or
/// control-character-bearing values.
/// 值为空、超长或含控制字符时返回
/// [`ConfigError::InvalidPrincipal`]。
pub fn new(
principal_type: impl Into<String>,
principal_id: impl Into<String>,
@@ -52,27 +52,27 @@ impl OAuthPrincipal {
})
}
/// Returns the official principal type value.
/// 返回官方 principal 类型值。
#[must_use]
pub fn principal_type(&self) -> &str {
&self.principal_type
}
/// Returns the official principal identifier.
/// 返回官方 principal 标识。
#[must_use]
pub fn principal_id(&self) -> &str {
&self.principal_id
}
}
/// Input for one refresh-token exchange.
/// 单次 refresh token 交换的输入。
#[derive(Clone)]
pub struct RefreshTokenGrant {
refresh_token: SecretValue,
}
impl RefreshTokenGrant {
/// Creates a refresh grant for an existing verified credential.
/// 为已验证的现有凭据创建 refresh grant。
#[must_use]
pub fn new(refresh_token: SecretValue) -> Self {
Self { refresh_token }
@@ -92,7 +92,7 @@ impl fmt::Debug for RefreshTokenGrant {
}
}
/// Initial token set that crossed the mandatory verification boundary.
/// 已通过强制验证边界的初始 token set。
#[derive(Clone)]
pub struct VerifiedTokenSet {
access_token: SecretValue,
@@ -119,13 +119,13 @@ impl VerifiedTokenSet {
}
}
/// Returns the verified access token.
/// 返回已验证的 access token。
#[must_use]
pub fn access_token(&self) -> &SecretValue {
&self.access_token
}
/// Returns the optional refresh token.
/// 返回可选的 refresh token。
#[must_use]
pub fn refresh_token(&self) -> Option<&SecretValue> {
self.refresh_token.as_ref()
@@ -143,13 +143,13 @@ impl VerifiedTokenSet {
&self.scope
}
/// Returns the server-provided lifetime.
/// 返回服务端下发的有效期。
#[must_use]
pub const fn expires_in(&self) -> Option<Duration> {
self.expires_in
}
/// Returns trusted identity verification evidence.
/// 返回可信身份验证证据。
#[must_use]
pub const fn evidence(&self) -> &VerificationEvidence {
&self.evidence
@@ -173,8 +173,8 @@ impl fmt::Debug for VerifiedTokenSet {
}
}
/// Result of refreshing an already verified credential. An omitted refresh
/// token means the caller must retain the existing one under revision CAS.
/// 刷新已验证凭据的结果。未返回 refresh token 时,调用方须在 revision CAS 下
/// 保留原有 refresh token。
#[derive(Clone)]
pub struct RefreshedTokenSet {
access_token: SecretValue,
@@ -183,19 +183,19 @@ pub struct RefreshedTokenSet {
}
impl RefreshedTokenSet {
/// Returns the replacement access token.
/// 返回替换后的 access token。
#[must_use]
pub fn access_token(&self) -> &SecretValue {
&self.access_token
}
/// Returns a rotated refresh token when the server supplied one.
/// 服务端下发轮换 refresh token 时返回它。
#[must_use]
pub fn rotated_refresh_token(&self) -> Option<&SecretValue> {
self.rotated_refresh_token.as_ref()
}
/// Returns the replacement access-token lifetime.
/// 返回替换后 access token 的有效期。
#[must_use]
pub const fn expires_in(&self) -> Option<Duration> {
self.expires_in
@@ -4,8 +4,8 @@ use std::fmt;
use chrono::{DateTime, Utc};
use gateway_core::engine::credential::{
CredentialCasUpdate, CredentialRevision, LoadedCredential, ProviderAccountId,
ProviderAccountUpdate,
AccountAvailability, CredentialCasUpdate, CredentialRevision, LoadedCredential,
ProviderAccountId, ProviderAccountUpdate,
};
use gateway_core::provider_ports::ProviderLeaseGuard;
use serde::{Deserialize, Serialize};
@@ -75,6 +75,21 @@ pub enum GrokCredentialAvailability {
Invalid,
}
/// Grok 可用性语义翻入内核标准态的唯一汇入点。
impl From<GrokCredentialAvailability> for AccountAvailability {
fn from(value: GrokCredentialAvailability) -> Self {
match value {
GrokCredentialAvailability::Unknown => Self::Unknown,
GrokCredentialAvailability::Ready => Self::Ready,
GrokCredentialAvailability::Cooldown => Self::Cooldown,
GrokCredentialAvailability::QuotaExhausted => Self::QuotaExhausted,
GrokCredentialAvailability::Expired => Self::Expired,
GrokCredentialAvailability::Banned => Self::Banned,
GrokCredentialAvailability::Invalid => Self::Invalid,
}
}
}
/// 创建一个明文 OAuth Provider account。
pub struct CreateGrokCredential {
pub account_id: ProviderAccountId,
@@ -7,10 +7,10 @@ use url::Url;
use crate::{SecretValue, VerificationFailure};
/// OAuth flow whose initial access token is being verified.
/// 正在验证首个 access token 的 OAuth 流程。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VerificationFlow {
/// Authorization Code + PKCE with a mandatory nonce-bound ID token.
/// Authorization Code + PKCE;必须携带与 nonce 绑定的 ID token。
AuthorizationCode,
/// 已有 token 的导入;必须通过官方 user-info 验证当前 access token。
CredentialImport,
@@ -18,17 +18,17 @@ pub enum VerificationFlow {
CredentialImportRefreshed,
}
/// Trusted verification mechanism reported by the injected verifier.
/// 注入的校验器报告的可信验证机制。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VerificationMethod {
/// Full JWT/JWKS validation including issuer, audience, expiry, and nonce.
/// 完整 JWT/JWKS 校验,含 issuer、audience、过期时间与 nonce。
IdToken,
/// Authoritative official user-info lookup using the access token.
/// 用 access token 查询官方权威 user-info。
UserInfo,
}
/// Evidence produced by a trusted verifier. Identity is redacted from debug
/// output and is not derived by this crate through unverified JWT decoding.
/// 可信校验器产出的验证证据。身份在 debug 输出中脱敏,且本 crate 不会通过
/// 未经验证的 JWT 解码推导身份。
#[derive(Clone)]
pub struct VerificationEvidence {
method: VerificationMethod,
@@ -36,7 +36,7 @@ pub struct VerificationEvidence {
}
impl VerificationEvidence {
/// Creates evidence after full ID-token verification.
/// 完整 ID token 校验通过后创建证据。
#[must_use]
pub fn id_token(subject: String) -> Self {
Self {
@@ -45,7 +45,7 @@ impl VerificationEvidence {
}
}
/// Creates evidence after an authoritative official user-info lookup.
/// 官方权威 user-info 查询通过后创建证据。
#[must_use]
pub fn user_info(subject: String) -> Self {
Self {
@@ -54,13 +54,13 @@ impl VerificationEvidence {
}
}
/// Returns the verification method.
/// 返回验证机制。
#[must_use]
pub const fn method(&self) -> VerificationMethod {
self.method
}
/// Exposes the verified subject at the credential construction boundary.
/// 在凭据构造边界暴露已验证的 subject。
#[must_use]
pub fn subject(&self) -> &str {
self.subject.expose()
@@ -77,7 +77,7 @@ impl fmt::Debug for VerificationEvidence {
}
}
/// Immutable trust context supplied to a token verifier.
/// 传给 token 校验器的不可变信任上下文。
#[derive(Debug, Clone, Copy)]
pub struct TokenVerificationContext<'a> {
flow: VerificationFlow,
@@ -110,51 +110,51 @@ impl<'a> TokenVerificationContext<'a> {
}
}
/// Returns the flow being verified.
/// 返回正在验证的流程。
#[must_use]
pub const fn flow(&self) -> VerificationFlow {
self.flow
}
/// Returns the exact expected issuer.
/// 返回精确匹配的预期 issuer。
#[must_use]
pub fn issuer(&self) -> &Url {
self.issuer
}
/// Returns the exact expected audience/client identifier.
/// 返回精确匹配的预期 audience/client 标识。
#[must_use]
pub const fn client_id(&self) -> &str {
self.client_id
}
/// Returns the same-origin JWKS URL from validated discovery.
/// 返回已校验发现文档中的同源 JWKS URL。
#[must_use]
pub fn jwks_uri(&self) -> &Url {
self.jwks_uri
}
/// Returns the same-origin authoritative user-info URL from discovery.
/// 返回发现文档中的同源权威 user-info URL。
#[must_use]
pub fn userinfo_endpoint(&self) -> &Url {
self.userinfo_endpoint
}
/// Returns discovery-advertised algorithms. Implementations must intersect
/// them with a local cryptographic allowlist and reject `none`.
/// 返回发现文档声明的算法。实现必须与本地密码学 allowlist 取交集,并拒绝
/// `none`。
#[must_use]
pub const fn signing_algorithms(&self) -> &[String] {
self.signing_algorithms
}
/// Returns the expected nonce for authorization-code flows.
/// 返回 Authorization Code 流程的预期 nonce。
#[must_use]
pub const fn expected_nonce(&self) -> Option<&SecretValue> {
self.expected_nonce
}
}
/// Borrowed token material available only to the trusted verification port.
/// 仅向可信校验端口开放的借用 token 材料。
pub struct TokenCandidate<'a> {
access_token: &'a SecretValue,
id_token: Option<&'a SecretValue>,
@@ -174,19 +174,19 @@ impl<'a> TokenCandidate<'a> {
}
}
/// Returns the access token for authoritative user-info verification.
/// 返回用于权威 user-info 验证的 access token。
#[must_use]
pub const fn access_token(&self) -> &SecretValue {
self.access_token
}
/// Returns the ID token for full JWT/JWKS validation, when provided.
/// 返回用于完整 JWT/JWKS 校验的 ID token(若存在)。
#[must_use]
pub const fn id_token(&self) -> Option<&SecretValue> {
self.id_token
}
/// Returns the server-provided lifetime.
/// 返回服务端给出的有效期。
#[must_use]
pub const fn expires_in(&self) -> Option<Duration> {
self.expires_in
@@ -204,14 +204,14 @@ impl fmt::Debug for TokenCandidate<'_> {
}
}
/// Future returned by a token verification port.
/// token 校验端口返回的 future。
pub type VerificationFuture<'a> =
Pin<Box<dyn Future<Output = Result<VerificationEvidence, VerificationFailure>> + Send + 'a>>;
/// Trust boundary for full JWT/JWKS or authoritative user-info validation.
/// 完整 JWT/JWKS 或权威 user-info 校验的信任边界。
pub trait TokenVerifier: Send + Sync {
/// Verifies an initial token set. Implementations must never base64-decode
/// JWT claims without signature and claim validation.
/// 验证初始 token 集合。实现不得在未做签名与 claim 校验的情况下 base64 解码
/// JWT claims。
fn verify<'a>(
&'a self,
context: TokenVerificationContext<'a>,
@@ -219,7 +219,7 @@ pub trait TokenVerifier: Send + Sync {
) -> VerificationFuture<'a>;
}
/// Default verifier that makes incomplete wiring fail closed.
/// 默认校验器,未完成接线时 fail closed。
#[derive(Debug, Default)]
pub struct FailClosedTokenVerifier;
+1 -1
View File
@@ -1,4 +1,4 @@
//! Official Grok Build OAuth Provider boundary.
//! 官方 Grok Build OAuth Provider 边界。
mod admin;
pub mod config;
+39 -48
View File
@@ -1,4 +1,4 @@
//! `gateway-core` Provider adapter for official Grok Build sessions.
//! 官方 Grok Build 会话的 `gateway-core` Provider adapter。
use std::collections::{BTreeMap, BTreeSet};
use std::sync::{Arc, Mutex};
@@ -37,6 +37,7 @@ use gateway_core::task::{
WorkerKind, WorkerLeaseRequest, WorkerRegistration, WorkerRunnable, WorkerSchedule,
WorkerTaskError,
};
use gateway_protocol::openai::codex_responses_request_semantics;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use url::Url;
@@ -70,12 +71,10 @@ const MIN_REASONING_CIPHERTEXT_ENTROPY: f64 = 0.85;
const REASONING_DECODE_FAILED_CODE: &str = "reasoning_decode_failed";
const RESPONSE_NOT_FOUND_CODE: &str = "not_found";
/// Official Grok Build provider with injected session selection and HTTP SSE
/// transport ports.
/// 官方 Grok Build Provider;会话选择与 HTTP SSE transport 均由外部注入。
///
/// Each call selects exactly one OAuth session and prepares exactly one visible
/// upstream POST. Retries, credential rotation, endpoint fallback, and public
/// xAI API-key inference are deliberately outside this adapter.
/// 每次调用只选择一个 OAuth 会话,只准备一次可见的上游 POST。重试、凭据轮换、
/// endpoint fallback 以及公开 xAI API key 推理都不在该 adapter 内。
pub struct GrokBuildProvider {
selector: Arc<dyn GrokSessionSelector>,
transport: Arc<dyn GrokInferenceTransport>,
@@ -88,7 +87,7 @@ pub struct GrokBuildProvider {
}
impl GrokBuildProvider {
/// Creates a provider over explicit session and transport boundaries.
/// 在显式的会话与 transport 边界上创建 Provider。
pub fn new(
selector: Arc<dyn GrokSessionSelector>,
transport: Arc<dyn GrokInferenceTransport>,
@@ -126,19 +125,17 @@ impl Provider for GrokBuildProvider {
let Operation::Generate(request) = operation else {
return ProviderRequestObservation::default();
};
let reasoning_effort = request
.protocol_payload()
.body()
.get("reasoning")
.and_then(Value::as_object)
.and_then(|reasoning| reasoning.get("effort"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_owned);
let payload = request.protocol_payload();
if payload.protocol() != "openai" {
return ProviderRequestObservation::default();
}
let semantics = codex_responses_request_semantics(payload.body(), payload.context());
ProviderRequestObservation {
reasoning_effort,
..ProviderRequestObservation::default()
reasoning_effort: semantics.reasoning_effort,
reasoning_preset: semantics.reasoning_preset.map(str::to_owned),
request_kind: semantics.request_kind,
subagent_kind: semantics.subagent_kind,
compact: semantics.compact,
}
}
@@ -380,9 +377,8 @@ fn tool_support(evidence: GrokCatalogCapabilityEvidence) -> SupportLevel {
match evidence {
GrokCatalogCapabilityEvidence::DeclaredNative => SupportLevel::Native,
GrokCatalogCapabilityEvidence::DeclaredUnsupported => SupportLevel::Unsupported,
// Grok Build's Responses tool protocol remains available when the
// optional catalog field is omitted; the request adapter normalizes
// the client-only tool shapes before sending.
// catalog 省略该可选字段时,Grok Build 的 Responses 工具协议仍可用;
// 请求 adapter 会在发送前规范化仅客户端侧的工具结构。
GrokCatalogCapabilityEvidence::Unknown => SupportLevel::Emulated,
}
}
@@ -399,6 +395,7 @@ fn compile_grok_model_capabilities(model: GrokCatalogModel) -> ProviderModelCapa
.max_output_tokens()
.map(std::num::NonZeroU64::get),
)
.with_upstream_feature_validation()
.with_feature(
Feature::Reasoning,
support(model.capabilities().reasoning_effort()),
@@ -428,12 +425,6 @@ fn default_grok_model_presentation() -> ModelPresentation {
Some("Grok 4.5".to_owned()),
Some("xAI Grok 4.5 frontier model with reasoning and vision.".to_owned()),
)
.with_reasoning(
Some("medium".to_owned()),
["low", "medium", "high", "xhigh"]
.map(str::to_owned)
.to_vec(),
)
.with_context_window_tokens(Some(500_000))
.with_image_input(true)
.with_agent_tools(true, true)
@@ -441,10 +432,7 @@ fn default_grok_model_presentation() -> ModelPresentation {
fn grok_model_presentation(model: &GrokCatalogModel) -> ModelPresentation {
let slug = model.request_model().as_str();
let known_grok_4_5 = matches!(
slug,
DEFAULT_GROK_MODEL | "grok-4.5-latest" | "grok-4.5-build-free" | "grok-build-latest"
);
let known_grok_4_5 = is_known_grok_4_5_model(slug);
let reasoning_evidence = model.capabilities().reasoning_effort();
let catalog_reasoning_efforts = model
.capabilities()
@@ -457,25 +445,20 @@ fn grok_model_presentation(model: &GrokCatalogModel) -> ModelPresentation {
.default_reasoning_effort()
.map(|effort| effort.as_str().to_owned());
let (default_reasoning, reasoning_efforts) = match reasoning_evidence {
GrokCatalogCapabilityEvidence::DeclaredNative if !catalog_reasoning_efforts.is_empty() => (
catalog_default_reasoning.or_else(|| catalog_reasoning_efforts.first().cloned()),
catalog_reasoning_efforts,
),
GrokCatalogCapabilityEvidence::DeclaredNative => (
catalog_default_reasoning.or_else(|| Some("medium".to_owned())),
["low", "medium", "high", "xhigh"]
.map(str::to_owned)
.to_vec(),
),
GrokCatalogCapabilityEvidence::DeclaredNative if !catalog_reasoning_efforts.is_empty() => {
let default_reasoning = catalog_default_reasoning
.filter(|default| {
catalog_reasoning_efforts
.iter()
.any(|effort| effort == default)
})
.or_else(|| catalog_reasoning_efforts.first().cloned());
(default_reasoning, catalog_reasoning_efforts)
}
GrokCatalogCapabilityEvidence::DeclaredNative => (None, Vec::new()),
GrokCatalogCapabilityEvidence::DeclaredUnsupported => {
(Some("none".to_owned()), vec!["none".to_owned()])
}
GrokCatalogCapabilityEvidence::Unknown if known_grok_4_5 => (
catalog_default_reasoning.or_else(|| Some("medium".to_owned())),
["low", "medium", "high", "xhigh"]
.map(str::to_owned)
.to_vec(),
),
GrokCatalogCapabilityEvidence::Unknown => (None, Vec::new()),
};
let context_window_tokens = model
@@ -513,6 +496,13 @@ fn grok_model_presentation(model: &GrokCatalogModel) -> ModelPresentation {
.with_hidden(model.metadata().hidden().unwrap_or(false))
}
fn is_known_grok_4_5_model(slug: &str) -> bool {
matches!(
slug,
DEFAULT_GROK_MODEL | "grok-4.5-latest" | "grok-4.5-build-free" | "grok-build-latest"
)
}
#[derive(Clone, Serialize, Deserialize)]
struct XaiSessionState {
account_id: String,
@@ -1712,6 +1702,7 @@ fn explicit_rejection_is_replay_safe(kind: ProviderErrorKind, status: u16) -> bo
(ProviderErrorKind::Unauthorized, 401)
| (ProviderErrorKind::QuotaExhausted, 402)
| (ProviderErrorKind::QuotaExhausted, 403)
| (ProviderErrorKind::QuotaExhausted, 429)
| (ProviderErrorKind::RateLimited, 429)
)
}
@@ -1,4 +1,4 @@
//! Official Grok Responses SSE to gateway canonical events.
//! 官方 Grok Responses SSE 到网关 canonical 事件的转换。
use std::collections::{BTreeMap, BTreeSet};
@@ -128,7 +128,7 @@ fn usd_price_per_million(per_token_ticks: u128) -> Option<Money> {
usd_money(per_token_ticks.checked_mul(1_000_000)?)
}
/// Incremental decoder for one official Grok Responses attempt.
/// 单次官方 Grok Responses 尝试的增量解码器。
///
/// 每个上游 event 同时保留 OpenAI wire,并在可识别时附加 canonical facts。
pub struct GrokCanonicalDecoder {
@@ -158,8 +158,8 @@ impl GrokCanonicalDecoder {
}
}
/// Creates a decoder that restores request-scoped tool aliases before the
/// canonical and wire projections observe each upstream event.
/// 创建在 canonical 与 wire 投影处理每个上游 event 前先还原请求级 tool 别名的
/// 解码器。
#[must_use]
pub fn for_request(fallback_model: impl Into<String>, request: &GrokResponsesRequest) -> Self {
Self {
@@ -356,7 +356,7 @@ impl GrokBillingTransportError {
}
}
/// Billing transport future。
/// 计费 transport 的 future。
pub type GrokBillingTransportFuture<'a> = Pin<
Box<
dyn Future<Output = Result<GrokBillingTransportResponse, GrokBillingTransportError>>
@@ -901,6 +901,7 @@ struct GrokModelWire {
reasoning_effort: Option<GrokCatalogReasoningEffort>,
#[serde(default, rename = "reasoningEfforts", alias = "reasoning_efforts")]
reasoning_efforts: Vec<GrokReasoningEffortOptionWire>,
features: Option<GrokModelFeaturesWire>,
#[serde(rename = "supportsBackendSearch", alias = "supports_backend_search")]
supports_backend_search: Option<bool>,
#[serde(rename = "streamToolCalls", alias = "stream_tool_calls")]
@@ -908,6 +909,21 @@ struct GrokModelWire {
hidden: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct GrokModelFeaturesWire {
reasoning: Option<bool>,
#[serde(rename = "reasoningEffortOptions", alias = "reasoning_effort_options")]
reasoning_effort_options: Option<GrokReasoningEffortOptionsWire>,
}
#[derive(Debug, Deserialize)]
struct GrokReasoningEffortOptionsWire {
#[serde(default, rename = "supportedEfforts", alias = "supported_efforts")]
supported_efforts: Vec<GrokCatalogReasoningEffort>,
#[serde(rename = "defaultEffort", alias = "default_effort")]
default_effort: Option<GrokCatalogReasoningEffort>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum GrokReasoningEffortOptionWire {
@@ -993,29 +1009,49 @@ fn normalize_model(wire: GrokModelWire) -> Result<GrokCatalogModel, GrokModelCat
.api_backend
.filter(|backend| *backend != GrokCatalogApiBackend::Unknown);
let responses_api = responses_evidence(wire.supported_in_api, api_backend);
let mut menu_default = None;
let features = wire.features;
let feature_reasoning = features.as_ref().and_then(|features| features.reasoning);
let feature_reasoning_options = features.and_then(|features| features.reasoning_effort_options);
let mut top_level_menu_default = None;
// 未识别的 effort 值不进入能力投影,也不作为默认值候选。
let reasoning_efforts = wire
.reasoning_efforts
.into_iter()
.filter_map(|option| {
let (effort, is_default) = option.into_parts();
if effort == GrokCatalogReasoningEffort::Unknown {
return None;
}
if is_default && menu_default.is_none() {
menu_default = Some(effort);
}
Some(effort)
let top_level_reasoning_efforts =
reasoning_effort_menu(wire.reasoning_efforts, &mut top_level_menu_default);
let (feature_reasoning_efforts, feature_default_reasoning_effort) = feature_reasoning_options
.map_or_else(
|| (Vec::new(), None),
|options| {
(
options
.supported_efforts
.into_iter()
.filter(|effort| *effort != GrokCatalogReasoningEffort::Unknown)
.collect::<Vec<_>>(),
options
.default_effort
.filter(|effort| *effort != GrokCatalogReasoningEffort::Unknown),
)
},
);
let reasoning_efforts = if feature_reasoning_efforts.is_empty() {
top_level_reasoning_efforts
} else {
feature_reasoning_efforts
};
let default_reasoning_effort = feature_default_reasoning_effort
.or_else(|| {
wire.reasoning_effort
.filter(|effort| *effort != GrokCatalogReasoningEffort::Unknown)
})
.collect::<Vec<_>>();
let default_reasoning_effort = wire
.reasoning_effort
.filter(|effort| *effort != GrokCatalogReasoningEffort::Unknown)
.or(menu_default)
.or(top_level_menu_default)
.or_else(|| reasoning_efforts.first().copied());
let default_reasoning_effort = default_reasoning_effort
.filter(|default| reasoning_efforts.iter().any(|effort| effort == default))
.or_else(|| reasoning_efforts.first().copied());
let reasoning_effort = if reasoning_efforts.is_empty() {
GrokCatalogCapabilityEvidence::from_wire(wire.supports_reasoning_effort)
GrokCatalogCapabilityEvidence::from_wire(
wire.supports_reasoning_effort.or(feature_reasoning),
)
} else {
GrokCatalogCapabilityEvidence::DeclaredNative
};
@@ -1044,6 +1080,25 @@ fn normalize_model(wire: GrokModelWire) -> Result<GrokCatalogModel, GrokModelCat
})
}
fn reasoning_effort_menu(
values: Vec<GrokReasoningEffortOptionWire>,
menu_default: &mut Option<GrokCatalogReasoningEffort>,
) -> Vec<GrokCatalogReasoningEffort> {
values
.into_iter()
.filter_map(|option| {
let (effort, is_default) = option.into_parts();
if effort == GrokCatalogReasoningEffort::Unknown {
return None;
}
if is_default && menu_default.is_none() {
*menu_default = Some(effort);
}
Some(effort)
})
.collect()
}
fn responses_evidence(
supported_in_api: Option<bool>,
api_backend: Option<GrokCatalogApiBackend>,
@@ -35,17 +35,17 @@ impl fmt::Debug for GrokClientIdentity {
}
}
/// Public or sensitive official Grok request header value.
/// 官方 Grok 请求头的公开或敏感取值。
#[derive(Clone)]
pub enum GrokHeaderValue {
/// Non-sensitive protocol metadata.
/// 非敏感的协议元数据。
Public(String),
/// OAuth, identity, or session value that must be redacted.
/// 必须脱敏的 OAuth、身份或会话值。
Sensitive(SecretValue),
}
impl GrokHeaderValue {
/// Exposes a value only at the injected HTTP transport boundary.
/// 仅在注入的 HTTP transport 边界处暴露取值。
#[must_use]
pub fn expose(&self) -> &str {
match self {
@@ -64,7 +64,7 @@ impl fmt::Debug for GrokHeaderValue {
}
}
/// One official Grok CLI proxy request header.
/// 一个官方 Grok CLI 代理请求头。
#[derive(Debug, Clone)]
pub struct GrokHeader {
name: &'static str,
@@ -72,13 +72,13 @@ pub struct GrokHeader {
}
impl GrokHeader {
/// Returns the static header name.
/// 返回静态 header 名。
#[must_use]
pub const fn name(&self) -> &'static str {
self.name
}
/// Returns the typed header value.
/// 返回类型化的 header 值。
#[must_use]
pub const fn value(&self) -> &GrokHeaderValue {
&self.value
@@ -1,4 +1,4 @@
//! Grok Build CLI Responses provider boundary.
//! Grok Build CLI Responses provider 边界。
pub(crate) mod canonical;
pub(crate) mod catalog;
@@ -159,7 +159,7 @@ impl GrokDnsResolutionPolicy {
#[error("Grok official DNS resolution was rejected")]
pub struct GrokDnsResolutionError;
/// HTTP client construction and endpoint validation are one injected security boundary.
/// HTTP client 构造与端点校验合为一个可注入的安全边界。
pub trait GrokEndpointPolicy: fmt::Debug + Send + Sync {
fn build_oauth_client(
&self,
@@ -1003,7 +1003,13 @@ async fn classify_inference_status(response: Response) -> GrokInferenceTransport
StatusCode::REQUEST_TIMEOUT | StatusCode::GATEWAY_TIMEOUT => {
GrokInferenceTransportErrorKind::Timeout
}
StatusCode::TOO_MANY_REQUESTS => GrokInferenceTransportErrorKind::RateLimited,
StatusCode::TOO_MANY_REQUESTS => {
if quota_exhausted(&metadata) {
GrokInferenceTransportErrorKind::QuotaExhausted
} else {
GrokInferenceTransportErrorKind::RateLimited
}
}
status if status.is_server_error() => GrokInferenceTransportErrorKind::Unavailable,
_ => GrokInferenceTransportErrorKind::Protocol,
};
@@ -1070,17 +1076,10 @@ fn first_string(object: &serde_json::Map<String, Value>, fields: &[&str]) -> Opt
}
fn classify_forbidden(metadata: &InferenceErrorMetadata) -> GrokInferenceTransportErrorKind {
let text = forbidden_metadata_text(metadata);
let text = inference_metadata_text(metadata);
if credential_rejected(&text) {
GrokInferenceTransportErrorKind::Unauthorized
} else if contains_any(
&text,
&[
"subscription:free-usage-exhausted",
"used all the included free usage for model",
"personal-team-blocked:spending-limit",
],
) {
} else if quota_exhausted(metadata) {
GrokInferenceTransportErrorKind::QuotaExhausted
} else {
GrokInferenceTransportErrorKind::PermissionDenied
@@ -1088,7 +1087,7 @@ fn classify_forbidden(metadata: &InferenceErrorMetadata) -> GrokInferenceTranspo
}
fn forbidden_requires_recovery(metadata: &InferenceErrorMetadata) -> bool {
let text = forbidden_metadata_text(metadata);
let text = inference_metadata_text(metadata);
credential_rejected(&text)
|| text.contains("access to the chat endpoint is denied")
|| text.trim_matches([' ', '.', '!', '\t', '\r', '\n']) == "access denied"
@@ -1101,7 +1100,23 @@ fn reasoning_decode_failed(metadata: &InferenceErrorMetadata) -> bool {
})
}
fn forbidden_metadata_text(metadata: &InferenceErrorMetadata) -> String {
fn quota_exhausted(metadata: &InferenceErrorMetadata) -> bool {
contains_any(
&inference_metadata_text(metadata),
&[
"subscription:free-usage-exhausted",
"subscription_free_usage_exhausted",
"free-usage-exhausted",
"free_usage_exhausted",
"used all the included free usage",
"used all your free usage",
"personal-team-blocked:spending-limit",
"personal_team_blocked_spending_limit",
],
)
}
fn inference_metadata_text(metadata: &InferenceErrorMetadata) -> String {
[
metadata.code.as_deref().unwrap_or_default(),
metadata.error_type.as_deref().unwrap_or_default(),
@@ -1,4 +1,4 @@
//! Grok CLI runtime wire profile.
//! Grok CLI 运行时 wire profile。
use std::{
sync::{Arc, RwLock},
@@ -193,9 +193,10 @@ impl GrokResponsesRequest {
if should_consume_terminal_compaction_trigger {
consume_terminal_compaction_trigger(&mut body)?;
}
// `provider_options` 是已废弃的网关私有 envelope,不是 xAI 上游协议字段。
// 这些字段属于 Codex/OpenAI 侧请求控制,不是 xAI 上游协议字段。
// OpenAI 透明路径会保留未知字段;这里只在 xAI adapter 内做最小剥离。
body.remove("provider_options");
body.remove("service_tier");
let session_seed = explicit_session_seed(request, &body);
let enable_cache_route = allow_cache_route && session_seed.is_some();
let identity = resolve_session_identity(
@@ -270,17 +271,16 @@ impl fmt::Debug for GrokResponsesRequest {
}
}
/// Generate-to-Responses encoding error that never retains option or prompt
/// values.
/// Generate 到 Responses 的编码错误,不保留 option 与 prompt 值。
#[derive(Debug, thiserror::Error, Clone, Copy, PartialEq, Eq)]
pub enum GrokRequestEncodeError {
/// 数据面只接受 OpenAI adapter 保留的原始 Responses object。
#[error("Grok Build request is missing its OpenAI protocol payload")]
InvalidProtocolPayload,
/// JSON serialization unexpectedly failed.
/// JSON 序列化意外失败。
#[error("Grok Build request serialization failed")]
Serialization,
/// Responses compatibility fields could not be normalized safely.
/// Responses 兼容字段无法安全归一化。
#[error("Grok Build request normalization failed")]
InvalidRequestNormalization,
/// 请求中的具体字段无法安全转换为 Grok Build 接受的形态。
@@ -13,17 +13,16 @@ use sha2::{Digest as _, Sha256};
use crate::SecretValue;
/// Opaque, pseudonymous egress/session key understood by the injected
/// inference transport.
/// 注入的推理 transport 可识别的不透明假名化 egress/session 键。
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct GrokSessionBinding(String);
impl GrokSessionBinding {
/// Creates a bounded, non-secret binding reference.
/// 创建有界的非敏感绑定引用。
///
/// # Errors
///
/// Rejects empty, oversized, control-character, or reserved values.
/// 值为空、超长、含控制字符或使用保留前缀时返回错误。
pub fn new(value: impl Into<String>) -> Result<Self, GrokSessionDataError> {
let value = value.into();
if value.is_empty()
@@ -36,7 +35,7 @@ impl GrokSessionBinding {
Ok(Self(value))
}
/// Returns the pseudonymous transport lookup key.
/// 返回假名化的 transport 查找键。
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
@@ -75,12 +74,12 @@ impl fmt::Debug for GrokSessionAffinityKey {
}
}
/// Selector-owned guard for credential, capacity, and egress affinity.
/// 由 selector 持有的凭据、容量与 egress 亲和守卫。
pub trait GrokSessionLeaseGuard: Send + Sync + 'static {}
impl<T> GrokSessionLeaseGuard for T where T: Send + Sync + 'static {}
/// One selected OAuth session and its owned live lease.
/// 一个选中的 OAuth 会话及其持有的活跃 lease。
pub struct SelectedGrokSession {
account_id: ProviderAccountId,
credential_revision: CredentialRevision,
@@ -92,11 +91,11 @@ pub struct SelectedGrokSession {
}
impl SelectedGrokSession {
/// Constructs a selected session from Provider-owned plaintext OAuth material.
/// 从 Provider 持有的明文 OAuth 材料构造选中会话。
///
/// # Errors
///
/// Rejects a credential-less snapshot or malformed auth/header values.
/// 快照缺少凭据,或 auth/header 值格式非法时返回错误。
pub fn new(
account_id: ProviderAccountId,
credential_revision: CredentialRevision,
@@ -125,19 +124,19 @@ impl SelectedGrokSession {
})
}
/// Returns the selected account ID.
/// 返回选中的账号 ID。
#[must_use]
pub const fn account_id(&self) -> &ProviderAccountId {
&self.account_id
}
/// Returns the selector-frozen credential revision.
/// 返回 selector 冻结的凭据 revision。
#[must_use]
pub const fn credential_revision(&self) -> CredentialRevision {
self.credential_revision
}
/// Returns metadata recorded by Core for this upstream call.
/// 返回 Core 为本次上游调用记录的元数据。
#[must_use]
pub fn resource(&self) -> ProviderResource {
ProviderResource::Account {
@@ -146,25 +145,25 @@ impl SelectedGrokSession {
}
}
/// Returns the OAuth access token for explicit header construction.
/// 返回用于显式构造 header 的 OAuth access token。
#[must_use]
pub const fn access_token(&self) -> &SecretValue {
&self.access_token
}
/// Returns the verified user ID for official proxy headers.
/// 返回用于官方代理 header 的已验证 user ID。
#[must_use]
pub const fn user_id(&self) -> &SecretValue {
&self.user_id
}
/// Returns the optional verified email for official proxy headers.
/// 返回用于官方代理 header 的可选已验证 email。
#[must_use]
pub const fn email(&self) -> Option<&SecretValue> {
self.email.as_ref()
}
/// Returns the pseudonymous egress/session transport binding.
/// 返回假名化的 egress/session transport 绑定。
#[must_use]
pub const fn binding(&self) -> &GrokSessionBinding {
&self.binding
@@ -186,7 +185,7 @@ impl fmt::Debug for SelectedGrokSession {
}
}
/// Input to one selector call. It owns a frozen, secret-free attempt view.
/// 单次 selector 调用的入参,持有冻结且不含密钥的尝试视图。
#[derive(Debug, Clone)]
pub struct GrokSessionSelection {
upstream_model: UpstreamModelId,
@@ -198,7 +197,7 @@ pub struct GrokSessionSelection {
}
impl GrokSessionSelection {
/// Creates the immutable selection request.
/// 创建不可变的选择请求。
#[must_use]
pub fn new(
upstream_model: UpstreamModelId,
@@ -224,25 +223,25 @@ impl GrokSessionSelection {
self
}
/// Returns the frozen upstream model.
/// 返回冻结的上游模型。
#[must_use]
pub const fn upstream_model(&self) -> &UpstreamModelId {
&self.upstream_model
}
/// Returns accounts already attempted by the coordinator.
/// 返回协调方已尝试过的账号。
#[must_use]
pub const fn excluded_accounts(&self) -> &BTreeSet<ProviderAccountId> {
&self.excluded_accounts
}
/// Returns the sole account permitted for this call, when constrained by Core.
/// 返回 Core 约束下本次调用唯一允许的账号。
#[must_use]
pub const fn required_account(&self) -> Option<&ProviderAccountId> {
self.required_account.as_ref()
}
/// Returns the frozen global account scheduling policy.
/// 返回冻结的全局账号调度策略。
#[must_use]
pub const fn account_selection_policy(&self) -> AccountSelectionPolicy {
self.account_selection_policy
@@ -254,44 +253,44 @@ impl GrokSessionSelection {
self.affinity.as_ref()
}
/// Returns the absolute deadline that bounds the scheduling lease.
/// 返回限定调度租约的绝对截止时间。
#[must_use]
pub const fn deadline(&self) -> SystemTime {
self.deadline
}
}
/// Future returned by a Grok session selector.
/// Grok session selector 返回的 future。
pub type GrokSessionSelectorFuture<'a> = Pin<
Box<dyn Future<Output = Result<SelectedGrokSession, GrokSessionSelectorError>> + Send + 'a>,
>;
/// Credential-scoped upstream failure safe to persist as availability feedback.
/// 凭据维度的上游失败,可安全落库为可用性反馈。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GrokCredentialFailure {
/// The selected OAuth token was rejected.
/// 选中的 OAuth token 被拒绝。
Unauthorized,
/// The selected OAuth account was rate limited.
/// 选中的 OAuth 账号被限流。
RateLimited {
/// Already parsed and redacted delay; no raw upstream header is retained.
/// 已解析并脱敏的延迟;不保留上游原始 header。
retry_after: Option<Duration>,
},
/// The selected OAuth account exhausted its quota.
/// 选中的 OAuth 账号配额耗尽。
QuotaExhausted,
/// A successful SSE response ended before a terminal Responses event.
/// 成功的 SSE 响应在终止 Responses 事件前结束。
StreamInterrupted,
}
/// Future returned after one best-effort credential feedback write.
/// 一次 best-effort 凭据反馈写入返回的 future。
pub type GrokCredentialFeedbackFuture<'a> = Pin<Box<dyn Future<Output = ()> + Send + 'a>>;
/// Runtime port that selects exactly one eligible OAuth session and acquires
/// all credential/capacity/egress leases required for the stream lifetime.
/// 运行时端口:选出恰好一个可用 OAuth 会话,并获取流生命周期所需的全部
/// 凭据、容量与出口租约。
pub trait GrokSessionSelector: Send + Sync {
/// Performs one selection without internal provider fallback.
/// 执行一次选择,不做 Provider 内部回退。
fn select(&self, request: GrokSessionSelection) -> GrokSessionSelectorFuture<'_>;
/// Persists one classified failure without retrying or replacing the error.
/// 持久化一条已分类的失败,不重试也不替换原错误。
fn record_failure<'a>(
&'a self,
session: &'a SelectedGrokSession,
@@ -299,39 +298,39 @@ pub trait GrokSessionSelector: Send + Sync {
) -> GrokCredentialFeedbackFuture<'a>;
}
/// Secret-free selector failure.
/// 不含密钥的选择器失败。
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum GrokSessionSelectorError {
/// No session satisfies model, state, and exclusion constraints.
/// 没有会话同时满足模型、状态与排除约束。
#[error("no eligible Grok Build session is available")]
NoEligibleSession,
/// All eligible sessions are currently leased or rate-spaced.
/// 所有可用会话当前均被租出或处于限速间隔。
#[error("Grok Build session capacity is unavailable")]
CapacityUnavailable {
/// Optional selector-derived delay.
/// 选择器推导出的可选延迟。
retry_after: Option<Duration>,
},
/// Every eligible session is inside its runtime cooldown window.
/// 每个可用会话都处于运行时冷却窗口内。
#[error("Grok Build account is cooling down")]
AccountCoolingDown {
/// Remaining cooldown of the earliest recovering session.
/// 最早恢复的会话剩余冷却时长。
retry_after: Option<Duration>,
},
/// Session metadata or Provider-owned plaintext secret is invalid.
/// 会话元数据或 Provider 持有的明文密钥非法。
#[error("Grok Build session data is invalid")]
InvalidSession,
/// Selector backing service is unavailable.
/// 选择器依赖的后端服务不可用。
#[error("Grok Build session selector is unavailable")]
Unavailable,
}
/// Selected-session construction failure.
/// 构造选中会话时的失败。
#[derive(Debug, thiserror::Error, Clone, Copy, PartialEq, Eq)]
pub enum GrokSessionDataError {
/// Access token or verified identity header is malformed.
/// access token 或已验证身份头格式非法。
#[error("selected session contains an invalid secret value")]
InvalidSecretValue,
/// Egress/session binding is invalid.
/// 出口/会话绑定非法。
#[error("selected session binding is invalid")]
InvalidBinding,
}
@@ -12,7 +12,7 @@ use zeroize::Zeroizing;
use super::{GrokHeader, GrokSessionBinding};
/// Owned request handed to the injected HTTP SSE transport.
/// 交给注入的 HTTP SSE transport 的自持有请求。
pub struct GrokInferenceRequest {
endpoint: Url,
headers: Vec<GrokHeader>,
@@ -35,25 +35,25 @@ impl GrokInferenceRequest {
}
}
/// Returns the strict official Responses endpoint.
/// 返回严格限定的官方 Responses 端点。
#[must_use]
pub const fn endpoint(&self) -> &Url {
&self.endpoint
}
/// Returns typed headers; adapters must not log sensitive values.
/// 返回类型化 header;适配层不得记录敏感值。
#[must_use]
pub fn headers(&self) -> &[GrokHeader] {
&self.headers
}
/// Returns the serialized typed Responses body.
/// 返回序列化后的类型化 Responses body。
#[must_use]
pub fn body(&self) -> &[u8] {
&self.body
}
/// Returns the pseudonymous proxy/egress lookup binding.
/// 返回假名化的 proxy/egress 查找绑定。
#[must_use]
pub const fn binding(&self) -> &GrokSessionBinding {
&self.binding
@@ -75,16 +75,16 @@ impl fmt::Debug for GrokInferenceRequest {
}
}
/// Stream of raw SSE byte chunks returned after the POST is accepted.
/// POST 被接受后返回的原始 SSE 字节块流。
pub type GrokInferenceChunkStream =
Pin<Box<dyn Stream<Item = Result<bytes::Bytes, GrokInferenceTransportError>> + Send + 'static>>;
/// Whether the account-isolated inference client was already cached.
/// 账号隔离的推理 client 是否已在缓存中。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GrokInferenceClientCacheStatus {
/// The binding reused its existing account-isolated client.
/// 绑定复用了已有的账号隔离 client。
Hit,
/// The binding was absent when the request first checked the cache.
/// 请求首次查缓存时该绑定不存在。
Miss,
}
@@ -98,12 +98,12 @@ impl GrokInferenceClientCacheStatus {
}
}
/// Resolver that supplied the addresses used by the request.
/// 提供本次请求所用地址的解析器。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GrokInferenceDnsSource {
/// The system resolver returned an entirely public address set.
/// 系统解析器返回了完全公网的地址集合。
System,
/// The system result was unusable and the trusted DoH fallback supplied addresses.
/// 系统结果不可用,由受信 DoH 回退提供地址。
TrustedDoh,
}
@@ -117,7 +117,7 @@ impl GrokInferenceDnsSource {
}
}
/// DNS work observed while opening an upstream connection.
/// 建立上游连接期间观测到的 DNS 工作量。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GrokInferenceDnsObservation {
source: GrokInferenceDnsSource,
@@ -144,7 +144,7 @@ impl GrokInferenceDnsObservation {
}
}
/// Low-cardinality transport timings and pool facts for one inference request.
/// 单次推理请求的低基数 transport 耗时与连接池事实。
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct GrokInferenceTransportMetrics {
headers_ms: Option<u64>,
@@ -190,8 +190,8 @@ impl GrokInferenceTransportMetrics {
}
}
/// Accepted inference response. Non-success HTTP responses must be returned as
/// [`GrokInferenceTransportError`] instead.
/// 已被接受的推理响应。非成功 HTTP 响应必须改为返回
/// [`GrokInferenceTransportError`]。
pub struct GrokInferenceResponse {
body: GrokInferenceChunkStream,
http_version: UpstreamHttpVersion,
@@ -201,7 +201,7 @@ pub struct GrokInferenceResponse {
}
impl GrokInferenceResponse {
/// Wraps one accepted SSE response body.
/// 包装一个已被接受的 SSE 响应体。
#[must_use]
pub fn new(
body: GrokInferenceChunkStream,
@@ -265,34 +265,34 @@ impl fmt::Debug for GrokInferenceResponse {
}
}
/// Secret-free transport failure category.
/// 不含密钥的 transport 失败分类。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GrokInferenceTransportErrorKind {
/// Canonical request semantics were rejected by the official proxy.
/// 官方代理拒绝了 canonical 请求语义。
InvalidRequest,
/// The official proxy does not support a requested capability.
/// 官方代理不支持请求的某项能力。
Unsupported,
/// Access token was rejected.
/// access token 被拒绝。
Unauthorized,
/// Session lacks model or feature entitlement.
/// 会话缺少模型或特性授权。
PermissionDenied,
/// Per-session or provider rate limit.
/// 会话级或 Provider 级限流。
RateLimited,
/// Account credits or quota are exhausted.
/// 账号额度或配额耗尽。
QuotaExhausted,
/// Deadline or transport timeout.
/// 截止时间或 transport 超时。
Timeout,
/// Network/TLS/connection failure.
/// 网络/TLS/连接失败。
Transport,
/// HTTP/SSE response violates the expected contract.
/// HTTP/SSE 响应违反预期契约。
Protocol,
/// Official CLI proxy is unavailable.
/// 官方 CLI 代理不可用。
Unavailable,
/// Caller cancellation observed by the transport.
/// transport 观测到调用方取消。
Cancelled,
}
/// Classified transport error that never contains an upstream response body.
/// 已分类的 transport 错误,绝不包含上游响应体。
#[derive(Clone, PartialEq, Eq)]
pub struct GrokInferenceTransportError {
kind: GrokInferenceTransportErrorKind,
@@ -308,7 +308,7 @@ pub struct GrokInferenceTransportError {
}
impl GrokInferenceTransportError {
/// Creates a classified error with the transport's conservative send state.
/// 以 transport 保守判定的发送状态创建已分类错误。
#[must_use]
pub const fn new(kind: GrokInferenceTransportErrorKind, send_state: UpstreamSendState) -> Self {
Self {
@@ -329,7 +329,7 @@ impl GrokInferenceTransportError {
}
}
/// Attaches a valid HTTP status.
/// 附着合法的 HTTP 状态码。
#[must_use]
pub fn with_status(mut self, status: u16) -> Self {
if (100..=599).contains(&status) {
@@ -338,7 +338,7 @@ impl GrokInferenceTransportError {
self
}
/// Attaches a bounded retry delay parsed by the transport.
/// 附着 transport 解析出的有界重试延迟。
#[must_use]
pub const fn with_retry_after(mut self, retry_after: Duration) -> Self {
self.retry_after = Some(retry_after);
@@ -378,32 +378,32 @@ impl GrokInferenceTransportError {
self
}
/// Discards a possibly sensitive upstream body while retaining that fact.
/// 丢弃可能敏感的上游响应体,仅保留「已丢弃」这一事实。
#[must_use]
pub fn redact_sensitive_context(mut self, _value: impl AsRef<str>) -> Self {
self.sensitive_context_redacted = true;
self
}
/// Returns the stable transport category.
/// 返回稳定的 transport 分类。
#[must_use]
pub const fn kind(&self) -> GrokInferenceTransportErrorKind {
self.kind
}
/// Returns the conservative payload send state.
/// 返回保守判定的 payload 发送状态。
#[must_use]
pub const fn send_state(&self) -> UpstreamSendState {
self.send_state
}
/// Returns the sanitized HTTP status.
/// 返回清洗后的 HTTP 状态码。
#[must_use]
pub const fn status(&self) -> Option<u16> {
self.status
}
/// Returns the optional retry delay.
/// 返回可选的重试延迟。
#[must_use]
pub const fn retry_after(&self) -> Option<Duration> {
self.retry_after
@@ -434,7 +434,7 @@ impl GrokInferenceTransportError {
self.credential_recovery_required
}
/// Reports whether a sensitive body was discarded.
/// 报告是否丢弃过敏感响应体。
#[must_use]
pub const fn sensitive_context_was_redacted(&self) -> bool {
self.sensitive_context_redacted
@@ -477,17 +477,16 @@ impl fmt::Display for GrokInferenceTransportError {
impl std::error::Error for GrokInferenceTransportError {}
/// Future returned by the inference transport.
/// 推理 transport 返回的 future。
pub type GrokInferenceTransportFuture<'a> = Pin<
Box<
dyn Future<Output = Result<GrokInferenceResponse, GrokInferenceTransportError>> + Send + 'a,
>,
>;
/// Runtime HTTP SSE port. Implementations must issue exactly one POST, use the
/// supplied session binding for egress affinity, and never retry, switch
/// credentials, or fall back to another endpoint.
/// 运行时 HTTP SSE 端口。实现必须只发出一次 POST,用传入的 session binding
/// 维持 egress 亲和,且不得重试、切换凭据或回退到其他端点。
pub trait GrokInferenceTransport: Send + Sync {
/// Starts one official CLI proxy request.
/// 发起一次官方 CLI 代理请求。
fn execute(&self, request: GrokInferenceRequest) -> GrokInferenceTransportFuture<'_>;
}
+3 -23
View File
@@ -6,9 +6,7 @@ use std::time::{Duration, SystemTime};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{DateTime, Utc};
use futures::{StreamExt as _, future::BoxFuture};
use gateway_admin::model::accounts::{
AccountAvailability as AdminAccountAvailability, AccountRecord,
};
use gateway_admin::model::accounts::AccountRecord;
use gateway_admin::model::observability::{
CurrencyCost, DesktopReleaseStatus, ProviderBillingInput,
};
@@ -335,12 +333,7 @@ async fn xai_admin_provider_restores_full_pending_envelope_and_binds_owner() {
.get("mutation")
.and_then(Value::as_object)
.expect("pending mutation");
assert_eq!(
mutation
.get("expected_config_revision")
.and_then(Value::as_u64),
Some(7)
);
assert_eq!(mutation.get("expected_config_revision"), None);
assert_eq!(
mutation.get("started_request_id").and_then(Value::as_str),
Some("request-start")
@@ -555,7 +548,6 @@ fn pending_payload(flow_id: &str, owner_ref: &str) -> OpaqueProviderData {
"expires_at": (Utc::now() + chrono::Duration::minutes(30)).to_rfc3339(),
"server_state": server_state,
"mutation": {
"expected_config_revision": 7,
"provider_kind": "xai",
"target": {
"kind": "create",
@@ -626,7 +618,7 @@ fn account_record(account: &ProviderAccount) -> AccountRecord {
access_token_expires_at: account.access_token_expires_at().map(DateTime::<Utc>::from),
next_refresh_at: account.next_refresh_at().map(DateTime::<Utc>::from),
enabled: account.enabled(),
availability: admin_availability(account.availability()),
availability: account.availability(),
availability_reason: None,
cooldown_until: account.cooldown_until().map(DateTime::<Utc>::from),
availability_observed_at: now,
@@ -636,18 +628,6 @@ fn account_record(account: &ProviderAccount) -> AccountRecord {
}
}
const fn admin_availability(value: AccountAvailability) -> AdminAccountAvailability {
match value {
AccountAvailability::Unknown => AdminAccountAvailability::Unknown,
AccountAvailability::Ready => AdminAccountAvailability::Ready,
AccountAvailability::Cooldown => AdminAccountAvailability::Cooldown,
AccountAvailability::QuotaExhausted => AdminAccountAvailability::QuotaExhausted,
AccountAvailability::Expired => AdminAccountAvailability::Expired,
AccountAvailability::Banned => AdminAccountAvailability::Banned,
AccountAvailability::Invalid => AdminAccountAvailability::Invalid,
}
}
struct TestLeases;
impl ProviderLeasePort for TestLeases {
@@ -1,4 +1,4 @@
//! External fixture-driven contracts for the transport and verification ports.
//! transport 与验证端口的外置 fixture 驱动契约。
use std::collections::VecDeque;
use std::future::ready;
@@ -1,4 +1,4 @@
//! External OIDC verifier contracts.
//! 外置的 OIDC verifier 契约。
use std::time::Duration;
@@ -60,11 +60,11 @@ fn observed_transport_metrics() -> GrokInferenceTransportMetrics {
))
}
const CATALOG_WITHOUT_TOOL_METADATA: &[u8] = br#"{
const CATALOG_WITHOUT_FEATURE_METADATA: &[u8] = br#"{
"object": "list",
"data": [{
"id": "grok-4.5-catalog-entry",
"model": "grok-4.5",
"id": "grok-catalog-entry",
"model": "grok-catalog-entry",
"contextWindow": 1000000,
"maxCompletionTokens": 131072,
"apiBackend": "responses",
@@ -72,6 +72,27 @@ const CATALOG_WITHOUT_TOOL_METADATA: &[u8] = br#"{
"supportsReasoningEffort": true
}]
}"#;
const CATALOG_WITH_FEATURE_REASONING_OPTIONS: &[u8] = br#"{
"object": "list",
"data": [{
"id": "grok-4.5",
"model": "grok-4.5",
"contextWindow": 500000,
"maxCompletionTokens": 131072,
"apiBackend": "responses",
"supportedInApi": true,
"reasoningEfforts": ["high", {"value": "medium", "default": true}, "low"],
"features": {
"reasoning": true,
"reasoningEffortOptions": {
"supportedEfforts": ["low", "medium", "high", "xhigh"],
"defaultEffort": "high"
}
},
"supportsReasoningEffort": true,
"streamToolCalls": true
}]
}"#;
const SUCCESS_SSE: &[u8] = concat!(
"event: response.created\n",
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_xai\",\"model\":\"grok-4.5\"}}\n\n",
@@ -368,13 +389,26 @@ impl GrokModelCatalogTransport for StaticCatalogTransport {
}
}
struct CatalogWithoutToolMetadataTransport;
struct CatalogWithoutFeatureMetadataTransport;
impl GrokModelCatalogTransport for CatalogWithoutToolMetadataTransport {
impl GrokModelCatalogTransport for CatalogWithoutFeatureMetadataTransport {
fn execute(&self, _: GrokModelCatalogRequest) -> GrokModelCatalogTransportFuture<'_> {
Box::pin(async {
Ok(GrokModelCatalogTransportResponse::new(
CATALOG_WITHOUT_TOOL_METADATA,
CATALOG_WITHOUT_FEATURE_METADATA,
None,
))
})
}
}
struct CatalogWithFeatureReasoningOptionsTransport;
impl GrokModelCatalogTransport for CatalogWithFeatureReasoningOptionsTransport {
fn execute(&self, _: GrokModelCatalogRequest) -> GrokModelCatalogTransportFuture<'_> {
Box::pin(async {
Ok(GrokModelCatalogTransportResponse::new(
CATALOG_WITH_FEATURE_REASONING_OPTIONS,
None,
))
})
@@ -547,6 +581,13 @@ fn operation_with_reasoning_effort(effort: &str) -> Operation {
Operation::Generate(GenerateRequest::from_protocol_payload(payload))
}
fn object(value: serde_json::Value) -> Map<String, serde_json::Value> {
let serde_json::Value::Object(object) = value else {
panic!("request body must be an object");
};
object
}
fn operation_with_invalid_tools() -> Operation {
let payload = ProtocolPayload::json_object(
"openai",
@@ -708,6 +749,35 @@ async fn request_observation_preserves_raw_xai_reasoning_effort() {
);
}
#[tokio::test]
async fn request_observation_preserves_codex_semantics_for_xai() {
let provider = provider(StubSelector::success(), StubInferenceTransport::success()).await;
let payload = ProtocolPayload::json_object(
"openai",
object(json!({
"model": "client-model",
"reasoning": {"effort": "xhigh"},
"input": [
{"type": "message", "role": "user", "content": "history"},
{"type": "compaction_trigger"}
]
})),
)
.expect("OpenAI payload")
.with_context(Map::from_iter([(
"turn_metadata".to_owned(),
json!("{\"request_kind\":\"compaction\",\"subagent_kind\":\"compact\"}"),
)]));
let operation = Operation::Generate(GenerateRequest::from_protocol_payload(payload));
let observation = provider.request_observation(&operation);
assert_eq!(observation.reasoning_effort.as_deref(), Some("xhigh"));
assert_eq!(observation.request_kind.as_deref(), Some("compaction"));
assert_eq!(observation.subagent_kind.as_deref(), Some("compact"));
assert!(observation.compact);
}
fn operation_with_state(body: serde_json::Value, state: ProviderSessionState) -> Operation {
let serde_json::Value::Object(body) = body else {
panic!("request body must be an object");
@@ -1208,6 +1278,23 @@ async fn explicit_http_429_marks_provider_error_replay_safe() {
assert!(error.replay_is_safe());
}
#[tokio::test]
async fn explicit_quota_http_429_marks_provider_error_replay_safe() {
let error = mapped_transport_error(
GrokInferenceTransportError::new(
GrokInferenceTransportErrorKind::QuotaExhausted,
UpstreamSendState::Sent,
)
.with_status(429),
false,
)
.await;
assert_eq!(error.kind(), ProviderErrorKind::QuotaExhausted);
assert_eq!(error.upstream_status(), Some(429));
assert!(error.replay_is_safe());
}
#[tokio::test]
async fn explicit_http_408_does_not_mark_provider_error_replay_safe() {
let error = mapped_transport_error(
@@ -1907,18 +1994,44 @@ async fn provider_publishes_default_codex_profile_when_catalog_is_unavailable()
.presentation()
.expect("fallback Grok model presentation");
assert_eq!(presentation.display_name(), Some("Grok 4.5"));
assert_eq!(presentation.default_reasoning_effort(), None);
assert!(presentation.supported_reasoning_efforts().is_empty());
assert_eq!(presentation.context_window_tokens(), Some(500_000));
assert!(presentation.agent_tools());
assert!(presentation.parallel_tool_calls());
}
#[tokio::test]
async fn provider_publishes_reasoning_efforts_from_feature_options() {
let provider = provider_with_catalog_transport(
StubSelector::success(),
StubInferenceTransport::success(),
StubRecovery::new(GrokCredentialRecoveryOutcome::Unavailable),
Arc::new(CatalogWithFeatureReasoningOptionsTransport),
)
.await;
let capabilities = provider
.query_model_capabilities()
.await
.expect("capabilities");
let presentation = capabilities[0]
.presentation()
.expect("Grok model presentation");
assert_eq!(presentation.default_reasoning_effort(), Some("high"));
assert_eq!(
presentation.supported_reasoning_efforts(),
["low", "medium", "high", "xhigh"]
);
}
#[tokio::test]
async fn missing_catalog_tool_metadata_keeps_build_tools_routable() {
let provider = provider_with_catalog_transport(
StubSelector::success(),
StubInferenceTransport::success(),
StubRecovery::new(GrokCredentialRecoveryOutcome::Unavailable),
Arc::new(CatalogWithoutToolMetadataTransport),
Arc::new(CatalogWithoutFeatureMetadataTransport),
)
.await;
let capabilities = provider
@@ -1935,3 +2048,29 @@ async fn missing_catalog_tool_metadata_keeps_build_tools_routable() {
.is_some()
);
}
#[tokio::test]
async fn missing_catalog_feature_metadata_keeps_build_responses_routable() {
let provider = provider_with_catalog_transport(
StubSelector::success(),
StubInferenceTransport::success(),
StubRecovery::new(GrokCredentialRecoveryOutcome::Unavailable),
Arc::new(CatalogWithoutFeatureMetadataTransport),
)
.await;
let capabilities = provider
.query_model_capabilities()
.await
.expect("capabilities");
assert!(
capabilities[0]
.capabilities()
.match_requirements(
&gateway_core::operation::CapabilityRequirements::new(OperationKind::Generate)
.require(Feature::Vision)
.require(Feature::JsonSchema)
)
.is_some()
);
}
@@ -376,6 +376,35 @@ fn reasoning_effort_menu_should_be_capability_evidence_without_legacy_flag() {
);
}
#[test]
fn reasoning_effort_options_should_read_official_features_shape() {
let snapshot = parse_grok_model_catalog(
br#"{"object":"list","data":[{"id":"grok-4.5","reasoningEfforts":["high"],"features":{"reasoning":true,"reasoningEffortOptions":{"supportedEfforts":["low","medium","high","xhigh"],"defaultEffort":"high"}}}]}"#,
None,
)
.expect("official feature reasoning options should parse");
let capabilities = snapshot.models()[0].capabilities();
assert_eq!(
(
capabilities.reasoning_effort(),
capabilities
.reasoning_efforts()
.iter()
.map(|effort| effort.as_str())
.collect::<Vec<_>>(),
capabilities
.default_reasoning_effort()
.map(|effort| effort.as_str()),
),
(
GrokCatalogCapabilityEvidence::DeclaredNative,
vec!["low", "medium", "high", "xhigh"],
Some("high"),
)
);
}
#[test]
fn unknown_reasoning_effort_values_should_not_fail_the_snapshot() {
let snapshot = parse_grok_model_catalog(
@@ -1,4 +1,4 @@
//! External reqwest network policy contracts.
//! 外置的 reqwest 网络策略契约。
use std::cell::Cell;
use std::net::IpAddr;
@@ -358,6 +358,37 @@ async fn inference_transport_should_scope_forbidden_failures_from_safe_metadata(
}
}
#[tokio::test]
async fn inference_transport_should_classify_free_usage_429_as_quota_exhausted() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/responses"))
.respond_with(ResponseTemplate::new(429).set_body_json(json!({
"error": {
"code": "subscription:free-usage-exhausted",
"message": "You have used all your free usage"
}
})))
.expect(1)
.mount(&server)
.await;
let origin = Url::parse(&server.uri()).expect("wiremock origin");
let error = inference_transport(&origin)
.execute(inference_request(&origin))
.await
.expect_err("free usage exhaustion must fail");
assert_eq!(
error.kind(),
GrokInferenceTransportErrorKind::QuotaExhausted
);
assert_eq!(error.status(), Some(429));
assert_eq!(
error.upstream_code().map(|code| code.as_str()),
Some("subscription_free_usage_exhausted")
);
}
#[tokio::test]
async fn inference_transport_should_bound_retry_after_to_the_safe_window() {
for (header, expected) in [

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