Compare commits

..
12 Commits
Author SHA1 Message Date
ZyyandCodex 7a32c6cec2 chore(release): v3.38.2
Co-authored-by: Codex <noreply@openai.com>
2026-11-02 13:44:36 +08:00
0ff1a72919 fix(retry): limit upstream Retry-After waits (#578)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-02 13:07:20 +08:00
79083a5632 fix(openai): reconnect before websocket search activation (#577)
Track hosted search initialization on pooled Codex sockets and retire incompatible connections before sending. Preserve existing client full replay recovery for connection-local deltas.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-02 12:28:14 +08:00
ZyyandCodex 90f7456db8 fix(ui): align CPR badge text with its icon
Keep the compact line height and offset the uppercase label by one pixel to align its visible center with the brand glyph across badge sizes.

Co-authored-by: Codex <noreply@openai.com>
2026-11-02 12:27:51 +08:00
ZyyandCodex 6f71b27c3c fix(peer): include quota-backed plans in pool summaries
Reuse account plan resolution when exporting peer pool summaries. Read saved quota observations for missing plans even when quota sharing is disabled, without exposing quota data or mutating account records.

Cover unknown and missing plans, subtype preservation, plan-only limits, and quota refresh from an unknown account plan.

Co-authored-by: Codex <noreply@openai.com>
2026-11-02 12:24:57 +08:00
ZyyandCodex 7b05b14a2b fix(openai): narrow model rejection cooldown detection
Require the official model_not_found code and supported status codes for
account-model cooldowns. Treat unsupported_value as a terminal request
error and preserve account eligibility across HTTP, SSE, and WebSocket.

Co-authored-by: Codex <noreply@openai.com>
2026-11-02 11:36:58 +08:00
f9b5b5ff77 chore(deps): bump node from 26.12.1-bookworm-slim to 26.13.0-bookworm-slim in /deploy in the minor-and-patch group across 1 directory (#573)
* chore(deps): bump node

Bumps the minor-and-patch group with 1 update in the /deploy directory: node.


Updates `node` from 26.12.1-bookworm-slim to 26.13.0-bookworm-slim

---
updated-dependencies:
- dependency-name: node
  dependency-version: 26.13.0-bookworm-slim
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

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

* docs(development): align Node.js version with Docker

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 <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-02 10:33:44 +08:00
dependabot[bot] 4d5778a84f chore(deps): bump rust from 48b7f43 to 9181b56 in /deploy (#575)
Bumps rust from `48b7f43` to `9181b56`.

---
updated-dependencies:
- dependency-name: rust
  dependency-version: 1.99.1-bookworm
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-02 10:32:20 +08:00
dependabot[bot] 6d22d73506 chore(deps): bump debian from 7ff6ba3 to 99c0b25 in /deploy (#574)
Bumps debian from `7ff6ba3` to `99c0b25`.

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

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

| Package | From | To |
| --- | --- | --- |
| [@lucide/vue](https://github.com/lucide-icons/lucide/tree/HEAD/packages/vue) | `1.61.0` | `1.66.0` |
| [axios](https://github.com/axios/axios) | `1.20.0` | `1.21.0` |
| [es-toolkit](https://github.com/toss/es-toolkit) | `1.53.0` | `1.53.1` |
| [@intlify/unplugin-vue-i18n](https://github.com/intlify/bundle-tools/tree/HEAD/packages/unplugin-vue-i18n) | `11.2.7` | `11.2.8` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.6.5` | `26.6.6` |
| [eslint](https://github.com/eslint/eslint) | `10.13.0` | `10.14.0` |
| [vue-tsc](https://github.com/vuejs/language-tools/tree/HEAD/packages/tsc) | `3.3.13` | `3.3.14` |


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

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

Updates `es-toolkit` from 1.53.0 to 1.53.1
- [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.53.0...v1.53.1)

Updates `@intlify/unplugin-vue-i18n` from 11.2.7 to 11.2.8
- [Release notes](https://github.com/intlify/bundle-tools/releases)
- [Changelog](https://github.com/intlify/bundle-tools/blob/v11.2.8/CHANGELOG.md)
- [Commits](https://github.com/intlify/bundle-tools/commits/v11.2.8/packages/unplugin-vue-i18n)

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

Updates `eslint` from 10.13.0 to 10.14.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.13.0...v10.14.0)

Updates `vue-tsc` from 3.3.13 to 3.3.14
- [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.14/packages/tsc)

---
updated-dependencies:
- dependency-name: "@lucide/vue"
  dependency-version: 1.66.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: axios
  dependency-version: 1.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: es-toolkit
  dependency-version: 1.53.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@intlify/unplugin-vue-i18n"
  dependency-version: 11.2.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 26.6.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: eslint
  dependency-version: 10.14.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: vue-tsc
  dependency-version: 3.3.14
  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-11-02 10:31:59 +08:00
dependabot[bot] 3816b46ba1 chore(deps): bump the minor-and-patch group in /backend with 8 updates (#571)
Bumps the minor-and-patch group in /backend with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [uuid](https://github.com/uuid-rs/uuid) | `1.28.0` | `1.29.0` |
| [zip](https://github.com/zip-rs/zip2) | `9.0.5` | `9.1.3` |
| [jsonschema](https://github.com/Stranger6667/jsonschema) | `0.60.0` | `0.60.2` |
| [roxmltree](https://github.com/RazrFalcon/roxmltree) | `0.21.1` | `0.21.2` |
| [aws-sdk-s3](https://github.com/awslabs/aws-sdk-rust) | `1.155.0` | `1.156.0` |
| [aws-smithy-runtime-api](https://github.com/smithy-lang/smithy-rs) | `1.19.1` | `1.19.2` |
| [aws-smithy-types](https://github.com/smithy-lang/smithy-rs) | `1.9.0` | `1.9.1` |
| [redis](https://github.com/redis-rs/redis-rs) | `1.7.1` | `1.8.0` |


Updates `uuid` from 1.28.0 to 1.29.0
- [Release notes](https://github.com/uuid-rs/uuid/releases)
- [Commits](https://github.com/uuid-rs/uuid/compare/v1.28.0...v1.29.0)

Updates `zip` from 9.0.5 to 9.1.3
- [Release notes](https://github.com/zip-rs/zip2/releases)
- [Changelog](https://github.com/zip-rs/zip2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/zip-rs/zip2/compare/v9.0.5...v9.1.3)

Updates `jsonschema` from 0.60.0 to 0.60.2
- [Release notes](https://github.com/Stranger6667/jsonschema/releases)
- [Changelog](https://github.com/Stranger6667/jsonschema/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Stranger6667/jsonschema/compare/ruby-v0.60.0...ruby-v0.60.2)

Updates `roxmltree` from 0.21.1 to 0.21.2
- [Changelog](https://github.com/RazrFalcon/roxmltree/blob/master/CHANGELOG.md)
- [Commits](https://github.com/RazrFalcon/roxmltree/compare/v0.21.1...v0.21.2)

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

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

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

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

---
updated-dependencies:
- dependency-name: uuid
  dependency-version: 1.29.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: zip
  dependency-version: 9.1.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: jsonschema
  dependency-version: 0.60.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: roxmltree
  dependency-version: 0.21.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: aws-sdk-s3
  dependency-version: 1.156.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: aws-smithy-runtime-api
  dependency-version: 1.19.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: aws-smithy-types
  dependency-version: 1.9.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: redis
  dependency-version: 1.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-02 10:31:48 +08:00
dependabot[bot] 4a7c06f895 chore(deps): bump taiki-e/install-action (#569)
Bumps the minor-and-patch group with 1 update: [taiki-e/install-action](https://github.com/taiki-e/install-action).


Updates `taiki-e/install-action` from 2.87.31 to 2.88.1
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/install-action/compare/1e29709c0ec63babbe8f266397a465f29a825f91...68a389c788b2cd04016ce8c80ee23b80d2ab3d68)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.88.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-02 10:31:13 +08:00
63 changed files with 1422 additions and 480 deletions
+1 -1
View File
@@ -122,7 +122,7 @@ jobs:
run: cargo clippy --all-targets --all-features --locked -- -D warnings
- name: Setup nextest
uses: taiki-e/install-action@1e29709c0ec63babbe8f266397a465f29a825f91 # v2.87.31
uses: taiki-e/install-action@68a389c788b2cd04016ce8c80ee23b80d2ab3d68 # v2.88.1
with:
tool: cargo-nextest@0.9.143
fallback: none
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Install cargo-audit
uses: taiki-e/install-action@1e29709c0ec63babbe8f266397a465f29a825f91 # v2.87.31
uses: taiki-e/install-action@68a389c788b2cd04016ce8c80ee23b80d2ab3d68 # v2.88.1
with:
tool: cargo-audit@0.22.2
fallback: none
+40 -38
View File
@@ -286,9 +286,9 @@ dependencies = [
[[package]]
name = "aws-sdk-s3"
version = "1.155.0"
version = "1.156.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a542f0e9261b9ce56708d591a62062079fa3c58a362ffbf49a2e48a4f6ced16"
checksum = "1072c9aa20159b9b417115342d38c4c53108fc98e5359fcb86b7d4575747eb59"
dependencies = [
"arc-swap",
"aws-credential-types",
@@ -322,9 +322,9 @@ dependencies = [
[[package]]
name = "aws-sigv4"
version = "1.6.3"
version = "1.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d3d48355cb86b15ccbb38b8bc6b87500c824ee4ed8cbc10a961b435242b5e16"
checksum = "f385da9b7757ed4c8214797ad663ff534fb622006143530aa80e21f8e06ff85c"
dependencies = [
"aws-credential-types",
"aws-smithy-eventstream",
@@ -387,9 +387,9 @@ dependencies = [
[[package]]
name = "aws-smithy-http"
version = "0.64.2"
version = "0.64.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba8960956e0ee0ca3f3161e62913a61bc65a7ef638ca3ef1d4c7159f8443e398"
checksum = "19af609dd5edf8b056cd0bb001c706c8b3b1458055328b334ec13d7f46f6aaa8"
dependencies = [
"aws-smithy-eventstream",
"aws-smithy-runtime-api",
@@ -408,9 +408,9 @@ dependencies = [
[[package]]
name = "aws-smithy-http-client"
version = "1.5.1"
version = "1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aeca69f65362f7375891008ea525fd440ad6db1836458ac188c87b126804114e"
checksum = "859a81364f8185349c584fb41cd8765a400cf982f447adada590cf008a9f3b85"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api",
@@ -432,9 +432,9 @@ dependencies = [
[[package]]
name = "aws-smithy-json"
version = "0.65.0"
version = "0.65.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f6835fb38bc6fc8c91e584b0366eb4ff595e5c485134dbf4f0bdeaa18856666"
checksum = "b45f9fc77ffe92c92c341165a01a565bd30c39ecf5aa8c0719b41719009604f9"
dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-schema",
@@ -452,9 +452,9 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime"
version = "1.16.1"
version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79cef0c41e8ca40f74f68ed8ee4d1ddbde12bb847b95ef9b3c025681ddfae58a"
checksum = "4cb80fb19c084da5fca9d797845735e184445e5b958599440d67529bf6ef1b64"
dependencies = [
"aws-smithy-async",
"aws-smithy-http",
@@ -475,9 +475,9 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime-api"
version = "1.19.1"
version = "1.19.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06788004dea79018413f381891da6dcca23196acd79c4d51b767fbdacc76eae4"
checksum = "8c4e85cffe9944b10d576b075432bd8f98dbc081e8681cb37d5b2d87dc27bc84"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api-macros",
@@ -492,9 +492,9 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime-api-macros"
version = "1.1.0"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "221eaa237ddf1ca79b60d1372aad77e47f9c0ea5b3ce5099da8c61d027dc77b3"
checksum = "ce237f890a2283c9b328955c3b0918d2d5bf66a1cb6ceccd36a0047246fbc6e6"
dependencies = [
"proc-macro2",
"quote",
@@ -514,9 +514,9 @@ dependencies = [
[[package]]
name = "aws-smithy-types"
version = "1.9.0"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55752025c39c0e094962fd73f1c57014b191f678612e517f219468e575af9822"
checksum = "cf183d70774ea2cfcc101ca74a8884e4c76be8b9df65cd303f6a1175c88e8f80"
dependencies = [
"base64-simd",
"bytes",
@@ -537,9 +537,9 @@ dependencies = [
[[package]]
name = "aws-smithy-xml"
version = "0.64.0"
version = "0.64.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6581618a2fd006159016d1c36881d94d61b4a017f4a8d631310875e18252413"
checksum = "590fdc1a70546b2f7f2061c42eaf173fb6f26d935330d069edc04dbd8f01d924"
dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-schema",
@@ -2485,9 +2485,9 @@ dependencies = [
[[package]]
name = "jsonschema"
version = "0.60.0"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e7305938cac5ee144352ceb71583ffc6cfeac7d20fcb20ef865a4d5c314e499"
checksum = "a42a11b47492e6a2cc76e9c7bcc8077776cb31dac444e0795d7f61bcc6441c08"
dependencies = [
"ahash",
"bytecount",
@@ -2513,18 +2513,18 @@ dependencies = [
[[package]]
name = "jsonschema-regex"
version = "0.60.1"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8a848331614144c03e815fb931f238c9e342bd7ab501b29e35d647e7cd5688"
checksum = "107aeb6ff14b8deb5610096a8ce5c554ec89806b521147eaeb3cae05b07edb7a"
dependencies = [
"regex-syntax",
]
[[package]]
name = "jsonschema-value"
version = "0.60.1"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5a947ef798eeacacb43edf29b1d0ab64bdf5e2c9fbcaad10ee8663d922b544"
checksum = "e281d6b177bb4834f2ffd6f92aa0443cffe29c996b251c8d94eb6f456186feba"
dependencies = [
"ahash",
"bytecount",
@@ -3578,9 +3578,9 @@ dependencies = [
[[package]]
name = "redis"
version = "1.7.1"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5b98ae99461e02895c3a96a25dbe4d852a3d5e5b815d31d5d9e4b5344d99e9"
checksum = "7acd809e80afa754c63f586955699d8990efe1c4b6613183ee5f027a11fbc8b5"
dependencies = [
"arc-swap",
"arcstr",
@@ -3634,9 +3634,9 @@ dependencies = [
[[package]]
name = "referencing"
version = "0.60.1"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "922268812b6f9e20b4ab0f1efe8e07036958eed7346ceddb31c777b5531fd11f"
checksum = "f42818036d3b37cda6bd4c494543158a36e431cb717015ef7c93acc6ba214d3b"
dependencies = [
"ahash",
"fluent-uri",
@@ -3759,9 +3759,9 @@ dependencies = [
[[package]]
name = "roxmltree"
version = "0.21.1"
version = "0.21.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907"
dependencies = [
"memchr",
]
@@ -4953,9 +4953,9 @@ dependencies = [
[[package]]
name = "typed-path"
version = "0.12.3"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
[[package]]
name = "typenum"
@@ -5052,9 +5052,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.28.0"
version = "1.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606"
checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514"
dependencies = [
"getrandom 0.4.3",
"js-sys",
@@ -5646,16 +5646,18 @@ dependencies = [
[[package]]
name = "zip"
version = "9.0.5"
version = "9.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e"
checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb"
dependencies = [
"crc32fast",
"flate2",
"getrandom 0.4.3",
"indexmap",
"memchr",
"nix",
"typed-path",
"windows-sys 0.61.2",
]
[[package]]
@@ -54,6 +54,7 @@ pub struct RuntimeSettingsValues {
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub max_retry_after_seconds: u32,
pub openai_guardian_reserved_concurrency: u32,
pub openai_account_affinity: gateway_core::account::AccountAffinity,
pub max_account_rotations: u32,
@@ -15,7 +15,7 @@ use crate::{
model::{
AdminError,
peer_link::{PeerLinkExport, PeerLinkLimits},
provider_credentials::ProviderQuotaReadRequest,
provider_credentials::{ProviderQuotaReadRequest, explicit_plan_type},
},
ports::{
provider::ProviderAdminRegistry,
@@ -175,10 +175,16 @@ impl PeerLinkService for DefaultPeerLinkService {
.await
.map_err(|error| map_store_error(error, "peer account runtime"))?;
let mut quotas = BTreeMap::new();
if sharing.share_quota {
if sharing.share_quota || sharing.share_plans {
let mut window_count = 0usize;
let mut by_provider = BTreeMap::new();
for account in &accounts {
// 仅公开套餐时只补读缺失套餐的本地额度,不回源刷新或扩大公开范围
if !sharing.share_quota
&& explicit_plan_type(account.plan_type.as_deref()).is_some()
{
continue;
}
by_provider
.entry(&account.provider_kind)
.or_insert_with(Vec::new)
@@ -200,9 +206,11 @@ impl PeerLinkService for DefaultPeerLinkService {
.zip(provider.read_quotas(&requests).await)
{
if let Ok(quota) = quota {
window_count = window_count.saturating_add(quota.windows.len());
if window_count > limits.max_quota_windows {
return Err(PeerLinkError::SnapshotTooLarge);
if sharing.share_quota {
window_count = window_count.saturating_add(quota.windows.len());
if window_count > limits.max_quota_windows {
return Err(PeerLinkError::SnapshotTooLarge);
}
}
quotas.insert(account.id.clone(), quota);
}
@@ -215,6 +223,7 @@ impl PeerLinkService for DefaultPeerLinkService {
if sharing.share_pool {
result.pool = Some(projection::pool_summary(
&accounts,
&quotas,
&runtime,
&self.providers,
sharing.share_plans,
@@ -25,6 +25,7 @@ use crate::{
pub(super) fn pool_summary(
accounts: &[AccountRecord],
quotas: &BTreeMap<String, ProviderQuota>,
runtime: &AccountRuntimeSnapshot,
providers: &ProviderAdminRegistry,
share_plans: bool,
@@ -43,12 +44,13 @@ pub(super) fn pool_summary(
AccountStatus::Error => summary.error += 1,
}
if let Some(plans) = &mut summary.plans
&& let (Some(plan), Ok(provider)) = (
&account.plan_type,
providers.require(&account.provider_kind),
&& let Some(plan) = providers.resolve_account_plan(
account.provider_kind.as_str(),
&mut account.plan_type.clone(),
quotas.get(&account.id),
)
{
*plans.entry(provider.plan_type_display(plan)).or_default() += 1;
*plans.entry(plan).or_default() += 1;
}
}
summary
@@ -1166,6 +1166,7 @@ impl SettingsStore for StaticSettingsStore {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
max_retry_after_seconds: 240,
openai_guardian_reserved_concurrency: 0,
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
max_account_rotations: 3,
@@ -41,6 +41,7 @@ fn runtime_settings(enabled: bool, probe_enabled: bool) -> RuntimeSettings {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
max_retry_after_seconds: 240,
openai_guardian_reserved_concurrency: 0,
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
max_account_rotations: 3,
@@ -1384,6 +1384,7 @@ impl SettingsStore for FixtureSettingsStore {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
max_retry_after_seconds: 240,
openai_guardian_reserved_concurrency: 0,
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
max_account_rotations: 3,
@@ -334,6 +334,7 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
max_retry_after_seconds: 240,
openai_guardian_reserved_concurrency: 0,
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
max_account_rotations: 3,
@@ -45,6 +45,7 @@ pub struct RuntimeSettingsFields {
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub max_retry_after_seconds: u32,
pub openai_guardian_reserved_concurrency: u32,
pub openai_account_affinity: gateway_core::account::AccountAffinity,
pub max_account_rotations: u32,
@@ -751,6 +752,7 @@ impl RuntimeSettingsFields {
max_waiting_per_key: self.max_waiting_per_key,
max_waiting_per_account: self.max_waiting_per_account,
concurrency_wait_timeout_seconds: self.concurrency_wait_timeout_seconds,
max_retry_after_seconds: self.max_retry_after_seconds,
openai_guardian_reserved_concurrency: self.openai_guardian_reserved_concurrency,
openai_account_affinity: self.openai_account_affinity,
max_account_rotations: self.max_account_rotations,
@@ -790,6 +792,7 @@ impl From<gateway_admin::model::settings::RuntimeSettingsValues> for RuntimeSett
max_waiting_per_key: settings.max_waiting_per_key,
max_waiting_per_account: settings.max_waiting_per_account,
concurrency_wait_timeout_seconds: settings.concurrency_wait_timeout_seconds,
max_retry_after_seconds: settings.max_retry_after_seconds,
openai_guardian_reserved_concurrency: settings.openai_guardian_reserved_concurrency,
openai_account_affinity: settings.openai_account_affinity,
max_account_rotations: settings.max_account_rotations,
@@ -651,6 +651,7 @@ impl SettingsStore for MemorySettingsStore {
max_waiting_per_key: command.values.max_waiting_per_key,
max_waiting_per_account: command.values.max_waiting_per_account,
concurrency_wait_timeout_seconds: command.values.concurrency_wait_timeout_seconds,
max_retry_after_seconds: command.values.max_retry_after_seconds,
openai_guardian_reserved_concurrency: command
.values
.openai_guardian_reserved_concurrency,
@@ -1893,6 +1894,7 @@ fn test_runtime_settings() -> RuntimeSettings {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
max_retry_after_seconds: 240,
openai_guardian_reserved_concurrency: 0,
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
max_account_rotations: 3,
@@ -57,6 +57,7 @@ fn update_body() -> Value {
"maxAccountRotations": 3,
"openaiSessionAffinityTtlHours": 24,
"concurrencyWaitTimeoutSeconds": 30,
"maxRetryAfterSeconds": 240,
"responsesMaxDecompressedBodyBytes": 67108864,
"rotationStrategy": "round_robin",
"smartScheduling": gateway_core::account::SmartSchedulingConfig::default(),
@@ -261,6 +261,114 @@ async fn exports_scoped_observations_even_when_the_key_budget_is_exhausted_witho
assert!(fixture.observations.lock().unwrap().summaries.is_empty());
}
#[tokio::test]
async fn pool_plans_use_account_plan_fallback_independently_of_quota_sharing() {
for share_quota in [true, false] {
let (fixture, app) = fixture(
ClientPeerSharing {
share_plans: true,
share_quota,
..sharing()
},
RAW_KEY,
)
.await;
for (account_plan, quota_plan, expected) in [
(None, Some("free"), Some("Free")),
(Some("unknown"), Some("free"), Some("Free")),
(Some(" "), Some("free"), Some("Free")),
(Some("edu_pro"), Some("edu"), Some("EduPro")),
(Some("free"), None, Some("Free")),
(None, None, None),
(None, Some("unknown"), None),
] {
fixture
.account
.lock()
.unwrap()
.as_mut()
.unwrap()
.account
.plan_type = account_plan.map(str::to_owned);
fixture
.provider_quota
.lock()
.unwrap()
.as_mut()
.unwrap()
.plan_type = quota_plan.map(str::to_owned);
let response = query(&app, PATH, Some(RAW_KEY)).await;
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
let plans =
expected.map_or_else(|| json!([]), |name| json!([{ "name": name, "count": 1 }]));
assert_eq!(
body["pool"]["plans"], plans,
"{account_plan:?} / {quota_plan:?}"
);
assert_eq!(body["pool"]["account_count"], 1);
assert_eq!(body.get("quota").is_some(), share_quota);
assert_eq!(
fixture
.account
.lock()
.unwrap()
.as_ref()
.unwrap()
.account
.plan_type
.as_deref(),
account_plan,
);
}
}
}
#[tokio::test]
async fn plan_only_summary_limits_exported_data_and_preserves_unknown_on_quota_read_failure() {
let (fixture, app) = fixture(
ClientPeerSharing {
share_plans: true,
share_quota: false,
..sharing()
},
RAW_KEY,
)
.await;
fixture
.account
.lock()
.unwrap()
.as_mut()
.unwrap()
.account
.plan_type = None;
{
let mut quota = fixture.provider_quota.lock().unwrap();
let quota = quota.as_mut().unwrap();
quota.plan_type = Some("free".into());
quota.windows =
vec![quota.windows[0].clone(); gateway_protocol::peer::MAX_QUOTA_WINDOWS + 1];
}
let response = query(&app, PATH, Some(RAW_KEY)).await;
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(
body["pool"]["plans"],
json!([{ "name": "Free", "count": 1 }])
);
assert_eq!(body["capabilities"], json!(["key_usage", "pool_summary"]));
assert!(body.get("quota").is_none());
*fixture.provider_quota.lock().unwrap() = None;
let response = query(&app, PATH, Some(RAW_KEY)).await;
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["pool"]["account_count"], 1);
assert_eq!(body["pool"]["plans"], json!([]));
}
#[tokio::test]
async fn requires_authentication_explicit_sharing_and_bound_groups_and_rejects_query_overrides() {
let (fixture, app) = fixture(sharing(), RAW_KEY).await;
@@ -1677,6 +1677,10 @@ where
error.pre_delivery_retry(),
Some(crate::error::PreDeliveryRetry::SameAccountConnectionRetry { .. })
);
// 服务器建议不可截短;超出请求冻结的上限后沿用安全换号或原错误出口
let retry_after_exceeds_limit = error
.retry_after()
.is_some_and(|delay| delay > self.plan.max_retry_after());
// Provider 的有界同账号传输恢复是尽力重试策略,不要求把 Sent/Ambiguous
// 改写成安全证明;外部副作用、取消、预算与下游提交仍由 Core 拦截
let transport_recovery = match error.pre_delivery_retry() {
@@ -1699,7 +1703,8 @@ where
Some(crate::error::PreDeliveryRetry::SameAccountTransportRetry {
retry_index,
delay,
}) if !execution_effect_observed
}) if !retry_after_exceeds_limit
&& !execution_effect_observed
&& !self.connection_budget.exhausted()
&& !self.replay_barrier_reached
&& !self.delivery_pending =>
@@ -1707,7 +1712,8 @@ where
Some((AttemptTransport::Retry(retry_index), delay))
}
Some(crate::error::PreDeliveryRetry::SameAccountTransportFallback)
if !execution_effect_observed
if !retry_after_exceeds_limit
&& !execution_effect_observed
&& !self.connection_budget.exhausted()
&& !self.replay_barrier_reached
&& !self.delivery_pending =>
@@ -1753,7 +1759,7 @@ where
max_delay,
jitter,
..
}) if ordinary_retry && !suppress_transient_retry => {
}) if ordinary_retry && !suppress_transient_retry && !retry_after_exceeds_limit => {
let retries = self
.transient_retry_counts
.entry(current.metadata.provider_account_id().clone())
@@ -1832,6 +1838,8 @@ where
"connectionRotation": connection_rotation.is_some(),
"reserveConnectionRotation": reserve_connection_rotation,
"transientRetry": transient_retry.is_some(),
"retryAfterExceeded": retry_after_exceeds_limit,
"maxRetryAfterSeconds": self.plan.max_retry_after().as_secs(),
"clientRetrySuppressed": suppress_transient_retry,
"rotationRetry": rotation_retry, "accountRotations": self.account_rotations,
"connectionRetry": connection_retry_requested,
@@ -576,6 +576,7 @@ pub struct RoutingPlan {
account_error_protection: Arc<crate::account::CompiledAccountErrorProtection>,
account_error_protection_revision: u64,
request_body_limit: usize,
max_retry_after: std::time::Duration,
pricing: Arc<crate::metering::PricingOverrides>,
request_location: Option<crate::account::RequestLocation>,
privacy: Option<Arc<dyn crate::settings::privacy::CompiledPrivacyPolicy>>,
@@ -598,6 +599,12 @@ impl RoutingPlan {
Arc::clone(&self.account_error_protection)
}
/// 本次请求接受的单次 Retry-After 等待上限
#[must_use]
pub const fn max_retry_after(&self) -> std::time::Duration {
self.max_retry_after
}
#[must_use]
pub const fn request_body_limit(&self) -> usize {
self.request_body_limit
@@ -1408,6 +1408,9 @@ impl RuntimeSnapshot {
Ok(RoutingPlan {
request_body_limit: self.responses_max_decompressed_body_bytes(),
max_retry_after: std::time::Duration::from_secs(u64::from(
self.settings.values.max_retry_after_seconds,
)),
config_revision: self.revision,
pricing: Arc::clone(&self.settings.values.pricing),
request_location: self.settings.request_location.clone(),
@@ -1495,6 +1498,9 @@ impl RuntimeSnapshot {
};
Ok(RoutingPlan {
request_body_limit: self.responses_max_decompressed_body_bytes(),
max_retry_after: std::time::Duration::from_secs(u64::from(
self.settings.values.max_retry_after_seconds,
)),
config_revision: self.revision,
pricing: Arc::clone(&self.settings.values.pricing),
request_location: self.settings.request_location.clone(),
@@ -21,6 +21,7 @@ pub struct SettingsValues {
pub(crate) max_waiting_per_key: u32,
pub(crate) max_waiting_per_account: u32,
pub(crate) concurrency_wait_timeout_seconds: u32,
pub(crate) max_retry_after_seconds: u32,
pub(crate) openai_guardian_reserved_concurrency: u32,
pub(crate) openai_account_affinity: crate::account::AccountAffinity,
pub(crate) max_account_rotations: u32,
@@ -84,6 +85,13 @@ impl SettingsValues {
self
}
/// 网关接受的单次服务器建议等待上限,超过时交由既有换号或错误出口处理
#[must_use]
pub const fn with_max_retry_after_seconds(mut self, seconds: u32) -> Self {
self.max_retry_after_seconds = seconds;
self
}
#[must_use]
pub const fn with_max_account_rotations(mut self, rotations: u32) -> Self {
self.max_account_rotations = rotations;
@@ -178,6 +186,7 @@ impl SettingsValues {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
max_retry_after_seconds: 240,
openai_guardian_reserved_concurrency: 0,
openai_account_affinity: crate::account::AccountAffinity::default(),
max_account_rotations: 3,
@@ -1174,6 +1174,7 @@ fn plan_with_profiles(
fast_mode,
profiles,
Default::default(),
240,
)
}
@@ -1184,6 +1185,7 @@ fn plan_with_protection(
fast_mode: FastMode,
profiles: BTreeMap<ProviderKind, gateway_core::account::OpaqueProviderData>,
protection: gateway_core::account::AccountErrorProtection,
max_retry_after_seconds: u32,
) -> RoutingPlan {
let provider = ProviderKind::new("openai").expect("provider");
let public_model = PublicModelId::new("gpt-5").expect("public model");
@@ -1237,7 +1239,8 @@ fn plan_with_protection(
.unwrap(),
)
.with_request_location(request_location, true)
.with_account_error_protection(protection),
.with_account_error_protection(protection)
.with_max_retry_after_seconds(max_retry_after_seconds),
vec![provider.clone()],
vec![ProviderModel::new(
provider.clone(),
@@ -4216,6 +4219,177 @@ fn server_retry_advice_should_override_local_backoff_and_transport_fallback() {
}
}
fn retry_after_plan(operation: &Operation, limit: u32) -> RoutingPlan {
plan_with_protection(
operation,
AccountSelectionPolicy::new(RotationStrategy::Smart, NonZeroU32::MIN),
Default::default(),
FastMode::Default,
BTreeMap::new(),
Default::default(),
limit,
)
}
#[test]
fn retry_after_at_or_below_the_configured_limit_keeps_the_full_wait() {
for (limit, seconds) in [(240, 240), (60, 60), (400, 308)] {
let operation = generate_operation();
let route_plan = retry_after_plan(&operation, limit);
let (coordinator, _, provider) = coordinator(vec![Script::Stream {
account_id: "acct_first",
items: vec![Err(
transient_rejection().with_retry_after(Duration::from_secs(seconds))
)],
}]);
let cancellation = CancellationToken::new();
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_secs(600)),
operation,
route_plan,
None,
None,
cancellation.clone(),
))
.unwrap();
let trace = session.trace().clone();
let mut collection = Box::pin(session.collect_uncommitted());
assert!(block_on(async { futures::poll!(&mut collection) }).is_pending());
let trace = trace.snapshot().unwrap();
let retry = trace["events"]
.as_array()
.unwrap()
.iter()
.find(|event| event["stage"] == "retry.decided")
.unwrap();
assert_eq!(retry["data"]["delayMs"], seconds * 1000);
assert_eq!(retry["data"]["retryAfterExceeded"], false);
cancellation.cancel();
assert!(matches!(block_on(collection), Err(EngineError::Cancelled)));
assert_eq!(provider.contexts.lock().unwrap().len(), 1);
}
}
#[test]
fn retry_after_above_the_configured_limit_rotates_without_waiting() {
for (limit, seconds) in [(240, 241), (240, 308), (60, 61), (0, 1)] {
for rejection in [
transient_rejection(),
ProviderError::new(ProviderErrorKind::Transport, UpstreamSendState::NotSent)
.with_pre_delivery_transport_fallback(),
ProviderError::new(ProviderErrorKind::Transport, UpstreamSendState::NotSent)
.with_pre_delivery_transport_retry(NonZeroU32::MIN, Duration::from_secs(seconds)),
] {
let operation = generate_operation();
let route_plan = retry_after_plan(&operation, limit);
let (coordinator, _, provider) = coordinator(vec![
Script::Stream {
account_id: "acct_first",
items: vec![Err(rejection.with_retry_after(Duration::from_secs(seconds)))],
},
Script::Stream {
account_id: "acct_second",
items: complete_stream(None),
},
]);
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
operation,
route_plan,
None,
None,
CancellationToken::new(),
))
.unwrap();
let mut collection = Box::pin(session.collect_uncommitted());
assert!(matches!(
block_on(async { futures::poll!(&mut collection) }),
std::task::Poll::Ready(Ok(_))
));
drop(collection);
let contexts = provider.contexts.lock().unwrap();
assert_eq!(contexts.len(), 2);
assert_eq!(contexts[1].required_account(), None);
assert!(
contexts[1]
.excluded_accounts()
.contains(&ProviderAccountId::new("acct_first").unwrap())
);
}
}
}
#[test]
fn excessive_retry_after_preserves_the_error_when_rotation_is_unavailable_or_disallowed() {
for (rejection, rotation_allowed) in [
(transient_rejection(), true),
(
transient_rejection().with_transient_retry(
NonZeroU32::MIN,
Duration::ZERO,
Duration::ZERO,
Duration::ZERO,
TransientRetryPolicy::RetryThenReturnError,
),
false,
),
(
ProviderError::new(ProviderErrorKind::Transport, UpstreamSendState::Ambiguous)
.with_pre_delivery_transport_retry(NonZeroU32::MIN, Duration::from_secs(308)),
false,
),
] {
let operation = generate_operation();
let route_plan = plan(&operation);
let original = Bytes::from_static(
br#"{"error":{"code":"server_is_overloaded","headers":{"retry-after":"308"}}}"#,
);
let expected_kind = rejection.kind();
let rejection = rejection
.with_retry_after(Duration::from_secs(308))
.with_client_visible_upstream_response(ClientVisibleUpstreamResponse::new(
503,
Some(b"application/json".to_vec()),
original.clone(),
));
let mut scripts = vec![Script::Stream {
account_id: "acct_first",
items: vec![Err(rejection)],
}];
if rotation_allowed {
scripts.push(Script::Error(ProviderError::new(
ProviderErrorKind::NoEligibleAccount,
UpstreamSendState::NotSent,
)));
}
let (coordinator, _, provider) = coordinator(scripts);
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
operation,
route_plan,
None,
None,
CancellationToken::new(),
))
.unwrap();
let mut collection = Box::pin(session.collect_uncommitted());
let result = block_on(async { futures::poll!(&mut collection) });
let std::task::Poll::Ready(Err(EngineError::Provider(error))) = result else {
panic!("must return the original failure without waiting")
};
assert_eq!(error.kind(), expected_kind);
assert_eq!(error.retry_after(), Some(Duration::from_secs(308)));
assert_eq!(
error.client_visible_upstream_response().unwrap().body(),
&original
);
assert_eq!(
provider.contexts.lock().unwrap().len(),
if rotation_allowed { 2 } else { 1 }
);
}
}
#[test]
fn zero_server_retry_advice_should_override_nonzero_local_backoff() {
for policy in [
@@ -4223,7 +4397,7 @@ fn zero_server_retry_advice_should_override_nonzero_local_backoff() {
TransientRetryPolicy::RetryThenReturnError,
] {
let operation = generate_operation();
let route_plan = plan(&operation);
let route_plan = retry_after_plan(&operation, 0);
let (coordinator, _, _) = coordinator(vec![
Script::Stream {
account_id: "acct_first",
@@ -4944,7 +5118,9 @@ fn structural_event_before_replay_safe_failure_should_switch_account_before_comm
#[test]
fn delivered_prelude_allows_retry_without_control_until_non_replayable_delivery() {
for close_window in [false, true] {
for (close_window, excessive_retry_after) in
[(false, false), (true, false), (false, true), (true, true)]
{
let operation = generate_operation();
let route_plan = plan(&operation);
let prelude = || {
@@ -4971,11 +5147,12 @@ fn delivered_prelude_allows_retry_without_control_until_non_replayable_delivery(
// 后续前导不能撤销已经交付未知事件的事实
items.push(Ok(prelude()));
}
items.push(Err(ProviderError::new(
ProviderErrorKind::Transport,
UpstreamSendState::Sent,
)
.with_replay_safe()));
items.push(Err(if excessive_retry_after {
transient_rejection().with_retry_after(Duration::from_secs(308))
} else {
ProviderError::new(ProviderErrorKind::Transport, UpstreamSendState::Sent)
.with_replay_safe()
}));
let (coordinator, store, provider) = coordinator(vec![
Script::ObservedStream {
account_id: "acct_first",
@@ -7185,6 +7362,7 @@ fn error_protection_tracks_real_retry_attempts_and_skips_diagnostic_runs() {
FastMode::Default,
BTreeMap::new(),
policy().as_ref().clone(),
240,
);
let feedback = Arc::new(Feedback::default());
let failure = ProviderError::new(ProviderErrorKind::Unavailable, UpstreamSendState::Sent)
@@ -145,6 +145,7 @@ pub struct RuntimeSettings {
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub max_retry_after_seconds: u32,
pub openai_guardian_reserved_concurrency: u32,
pub openai_account_affinity: AccountAffinity,
pub max_account_rotations: u32,
@@ -180,6 +181,7 @@ pub struct ReplaceRuntimeSettings {
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub max_retry_after_seconds: u32,
pub openai_guardian_reserved_concurrency: u32,
pub openai_account_affinity: AccountAffinity,
pub max_account_rotations: u32,
@@ -377,6 +379,7 @@ impl From<RuntimeSettings> for ReplaceRuntimeSettings {
max_waiting_per_key: settings.max_waiting_per_key,
max_waiting_per_account: settings.max_waiting_per_account,
concurrency_wait_timeout_seconds: settings.concurrency_wait_timeout_seconds,
max_retry_after_seconds: settings.max_retry_after_seconds,
openai_guardian_reserved_concurrency: settings.openai_guardian_reserved_concurrency,
openai_account_affinity: settings.openai_account_affinity,
max_account_rotations: settings.max_account_rotations,
@@ -104,6 +104,7 @@ impl SettingsStore for AdminSettingsStoreAdapter {
"max_waiting_per_key".to_owned(),
"max_waiting_per_account".to_owned(),
"concurrency_wait_timeout_seconds".to_owned(),
"max_retry_after_seconds".to_owned(),
"openai_guardian_reserved_concurrency".to_owned(),
"openai_account_affinity".to_owned(),
"max_account_rotations".to_owned(),
@@ -781,7 +781,12 @@ impl ProxyStore for PgProxyRepository {
.bind(command.enabled).bind(command.max_websocket_connections.map(i64::from))
.execute(&mut *transaction).await.map_err(|source| store_error(unavailable(source)))?;
if changed.rows_affected() != 1 {
return Err(store_error(conflict(&command.id)));
// 返回版本冲突前释放事务锁,避免误挡紧接着的导入预留
let error = match transaction.rollback().await {
Ok(()) => conflict(&command.id),
Err(cleanup) => conflict(&command.id).with_cleanup(cleanup),
};
return Err(store_error(error));
}
if let Some(location) = &command.location {
save_location(&mut transaction, &command.id, location.as_ref())
@@ -155,7 +155,7 @@ pub(crate) async fn load_runtime_settings_from_pool(pool: &PgPool) -> StoreResul
refresh_concurrency, max_concurrent_per_account,
rotation_strategy, smart_scheduling_json, model_mappings_json, usage_retention_days, ops_event_retention_days,
audit_retention_days, min_codex_desktop_version,
min_codex_cli_version, updated_at, responses_max_decompressed_body_bytes, max_waiting_per_key, max_waiting_per_account, concurrency_wait_timeout_seconds, openai_guardian_reserved_concurrency, openai_account_affinity, max_account_rotations, openai_session_affinity_ttl_hours,
min_codex_cli_version, updated_at, responses_max_decompressed_body_bytes, max_waiting_per_key, max_waiting_per_account, concurrency_wait_timeout_seconds, max_retry_after_seconds, openai_guardian_reserved_concurrency, openai_account_affinity, max_account_rotations, openai_session_affinity_ttl_hours,
account_error_protection_json, account_error_protection_revision,
account_warmup_enabled, account_warmup_schedule_time, account_warmup_model, reset_credit_lead_minutes, reset_credit_max_weekly_remaining_percent
from runtime_settings where id = 1",
@@ -311,7 +311,7 @@ pub(crate) async fn load_runtime_settings_in_transaction(
refresh_concurrency, max_concurrent_per_account,
rotation_strategy, smart_scheduling_json, model_mappings_json, usage_retention_days, ops_event_retention_days,
audit_retention_days, min_codex_desktop_version,
min_codex_cli_version, updated_at, responses_max_decompressed_body_bytes, max_waiting_per_key, max_waiting_per_account, concurrency_wait_timeout_seconds, openai_guardian_reserved_concurrency, openai_account_affinity, max_account_rotations, openai_session_affinity_ttl_hours,
min_codex_cli_version, updated_at, responses_max_decompressed_body_bytes, max_waiting_per_key, max_waiting_per_account, concurrency_wait_timeout_seconds, max_retry_after_seconds, openai_guardian_reserved_concurrency, openai_account_affinity, max_account_rotations, openai_session_affinity_ttl_hours,
account_error_protection_json, account_error_protection_revision,
account_warmup_enabled, account_warmup_schedule_time, account_warmup_model, reset_credit_lead_minutes, reset_credit_max_weekly_remaining_percent
from runtime_settings where id = 1",
@@ -391,6 +391,7 @@ pub(crate) async fn update_runtime_settings_in_transaction(
codex_privacy_policy_json = $28,
reset_credit_lead_minutes = $29,
reset_credit_max_weekly_remaining_percent = $30,
max_retry_after_seconds = $31,
updated_at = now()
where id = 1
returning config_revision",
@@ -433,6 +434,7 @@ pub(crate) async fn update_runtime_settings_in_transaction(
.bind(sqlx::types::Json(&update.values.codex_privacy_policy))
.bind(i64::from(update.values.reset_credit_lead_minutes))
.bind(update.values.reset_credit_max_weekly_remaining_percent.map(i64::from))
.bind(i64::from(update.values.max_retry_after_seconds))
.fetch_optional(&mut **transaction)
.await
.map_err(|source| postgres_unavailable("update runtime settings in transaction", source))?
@@ -520,6 +522,7 @@ struct RuntimeSettingsRow {
max_waiting_per_key: i64,
max_waiting_per_account: i64,
concurrency_wait_timeout_seconds: i64,
max_retry_after_seconds: i64,
openai_guardian_reserved_concurrency: i64,
openai_account_affinity: String,
max_account_rotations: i64,
@@ -578,6 +581,7 @@ fn runtime_settings_from_row(row: RuntimeSettingsRow) -> StoreResult<RuntimeSett
max_waiting_per_key: to_u32(row.max_waiting_per_key)?,
max_waiting_per_account: to_u32(row.max_waiting_per_account)?,
concurrency_wait_timeout_seconds: to_u32(row.concurrency_wait_timeout_seconds)?,
max_retry_after_seconds: to_u32(row.max_retry_after_seconds)?,
openai_guardian_reserved_concurrency: to_u32(row.openai_guardian_reserved_concurrency)?,
openai_account_affinity: gateway_core::account::AccountAffinity::parse(
&row.openai_account_affinity,
@@ -228,6 +228,7 @@ struct SnapshotSettingsRow {
max_waiting_per_key: i64,
max_waiting_per_account: i64,
concurrency_wait_timeout_seconds: i64,
max_retry_after_seconds: i64,
openai_guardian_reserved_concurrency: i64,
openai_account_affinity: String,
max_account_rotations: i64,
@@ -245,7 +246,7 @@ async fn load_settings(
transaction: &mut Transaction<'_, Postgres>,
) -> StoreResult<(Revision, SettingsValues)> {
let row = sqlx::query_as::<_, SnapshotSettingsRow>(
"select config_revision, account_error_protection_revision, account_error_protection_json, max_concurrent_per_account, rotation_strategy, smart_scheduling_json, model_mappings_json, min_codex_desktop_version, min_codex_cli_version, max_waiting_per_key, max_waiting_per_account, concurrency_wait_timeout_seconds, openai_guardian_reserved_concurrency, openai_account_affinity, max_account_rotations, openai_session_affinity_ttl_hours, request_location_json, request_location_enabled, codex_privacy_policy_json, responses_max_decompressed_body_bytes, provider_request_profiles_json, pricing_overrides_json, pricing_synced_json from runtime_settings where id = 1",
"select config_revision, account_error_protection_revision, account_error_protection_json, max_concurrent_per_account, rotation_strategy, smart_scheduling_json, model_mappings_json, min_codex_desktop_version, min_codex_cli_version, max_waiting_per_key, max_waiting_per_account, concurrency_wait_timeout_seconds, max_retry_after_seconds, openai_guardian_reserved_concurrency, openai_account_affinity, max_account_rotations, openai_session_affinity_ttl_hours, request_location_json, request_location_enabled, codex_privacy_policy_json, responses_max_decompressed_body_bytes, provider_request_profiles_json, pricing_overrides_json, pricing_synced_json from runtime_settings where id = 1",
)
.fetch_optional(&mut **transaction)
.await
@@ -275,6 +276,7 @@ async fn load_settings(
.ok_or_else(|| invalid("invalid account affinity"))?,
)
.with_max_account_rotations(to_u32(row.max_account_rotations)?)
.with_max_retry_after_seconds(to_u32(row.max_retry_after_seconds)?)
.with_openai_session_affinity_ttl_hours(to_u32(row.openai_session_affinity_ttl_hours)?)
.with_smart_scheduling(row.smart_scheduling_json.0)
.with_request_profiles(decode_request_profiles(
@@ -87,17 +87,13 @@ async fn slow_settlement_does_not_block_receipt_or_concurrent_admission() {
let worker_context = WorkerDaemonContext::new(CancellationToken::new());
let running_context = worker_context.clone();
let task = tokio::spawn(async move { writer.run(running_context).await });
tokio::time::timeout(Duration::from_millis(500), async {
for result in
futures::future::join_all((0..4).map(|_| store.admit(key_id("allowed")))).await
{
result.unwrap();
}
let error = store.admit(key_id("exhausted")).await.unwrap_err();
assert_eq!(error.client_error_code(), Some("key_daily_budget_exceeded"));
})
.await
.expect("admission must not wait for settlement row locks");
// 行锁直到准入断言结束才释放,等待行锁会被准入自身的一秒期限判为失败
// 不给整组请求另设更短的期限,避免把连接建立和并发调度耗时误判为锁等待
for result in futures::future::join_all((0..4).map(|_| store.admit(key_id("allowed")))).await {
result.expect("admission must not wait for settlement row locks");
}
let error = store.admit(key_id("exhausted")).await.unwrap_err();
assert_eq!(error.client_error_code(), Some("key_daily_budget_exceeded"));
let before: i64 = sqlx::query_scalar("select count(*) from client_api_key_charge_events")
.fetch_one(&database.pool)
.await
@@ -333,7 +333,12 @@ async fn disabled_proxy_projects_direct_while_preserving_bindings_and_credential
);
assert_eq!(proxies.get(&saved.id).await.unwrap().enabled, enabled);
let reservation = proxies.reserve_import(&saved.id).await;
assert_eq!(reservation.is_ok(), enabled);
assert_eq!(
reservation.is_ok(),
enabled,
"import reservation failed: {:?}",
reservation.as_ref().err()
);
drop(reservation);
if !enabled {
let error = admin
@@ -31,6 +31,7 @@ pub(super) fn settings_with_margin(refresh_margin_seconds: u64) -> RuntimeSettin
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
max_retry_after_seconds: 240,
openai_guardian_reserved_concurrency: 0,
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
max_account_rotations: 3,
@@ -504,6 +505,56 @@ fn timestamp_micros(value: DateTime<Utc>) -> DateTime<Utc> {
DateTime::from_timestamp_micros(value.timestamp_micros()).expect("valid test timestamp")
}
#[tokio::test]
async fn retry_after_limit_defaults_and_updates_reach_the_runtime_snapshot() {
use gateway_store::postgres::{PgRuntimeSnapshotRepository, RuntimeSnapshotRepository};
let Some(database) = TestDatabase::create("retry_after_limit").await else {
return;
};
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
assert_eq!(
repository
.load_runtime_settings()
.await
.unwrap()
.values
.max_retry_after_seconds,
240
);
for limit in [60, 0, u32::MAX] {
let mut update = settings_with_margin(300);
update.values.max_retry_after_seconds = limit;
repository.update_runtime_settings(update).await.unwrap();
assert_eq!(
repository
.load_runtime_settings()
.await
.unwrap()
.values
.max_retry_after_seconds,
limit
);
let snapshot = PgRuntimeSnapshotRepository::new(database.pool.clone())
.load_runtime_snapshot()
.await
.unwrap();
assert_eq!(
serde_json::to_value(snapshot.settings).unwrap()["max_retry_after_seconds"],
limit
);
}
for invalid in [-1_i64, i64::from(u32::MAX) + 1] {
assert!(
sqlx::query("update runtime_settings set max_retry_after_seconds = $1 where id = 1")
.bind(invalid)
.execute(&database.pool)
.await
.is_err()
);
}
database.close().await;
}
#[tokio::test]
async fn concurrency_queue_settings_round_trip_into_the_runtime_snapshot() {
use gateway_store::postgres::{PgRuntimeSnapshotRepository, RuntimeSnapshotRepository};
@@ -4,12 +4,14 @@
mod headers;
mod request;
mod search_history;
mod search_tools;
use gateway_core::engine::middleware::MiddlewareHeader;
use serde_json::{Map, Value};
pub(crate) use headers::is_non_codex_request_header;
pub(crate) use request::normalize_codex_request_body;
pub(crate) use search_tools::declares_hosted_search;
/// 选定 Codex/OAuth 上游后处理客户端正文兼容
pub(crate) fn normalize_selected_codex_downstream_body(
@@ -4,6 +4,8 @@ use gateway_core::engine::middleware::MiddlewareHeader;
use gateway_protocol::openai::X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER;
use serde_json::{Map, Value, json};
use super::search_tools::tools_declare_search;
// TODO(https://github.com/openai/codex/issues/51962): 官方修复历史搜索项与工具声明不匹配后,
// 用含 web_search_call 的普通请求和 Lite 压缩请求验证直连均可完成,再移除此补偿及对应合同测试
// Codex 0.163.0 本地压缩可保留搜索历史却使用空工具;真实上游对照中补声明可消除相应 500
@@ -51,7 +53,7 @@ fn normalize_body(body: &mut Map<String, Value>, lite: bool) -> bool {
Some(Value::Array(tools)) => tools.as_slice(),
_ => return false,
};
if declares_search(tools) {
if tools_declare_search(tools) {
return false;
}
let mut has_tools = !tools.is_empty();
@@ -64,7 +66,7 @@ fn normalize_body(body: &mut Map<String, Value>, lite: bool) -> bool {
// 不修补非法工具结构,保留上游原有校验
return false;
};
if declares_search(tools) {
if tools_declare_search(tools) {
return false;
}
has_tools |= !tools.is_empty();
@@ -105,11 +107,3 @@ fn normalize_body(body: &mut Map<String, Value>, lite: bool) -> bool {
}
true
}
fn declares_search(tools: &[Value]) -> bool {
tools.iter().any(|tool| {
tool.get("type")
.and_then(Value::as_str)
.is_some_and(|kind| kind == "web_search" || kind.starts_with("web_search_preview"))
})
}
@@ -0,0 +1,31 @@
//! 识别普通与 Responses Lite 请求中的托管搜索声明,不改写工具配置
use serde_json::{Map, Value};
pub(crate) fn declares_hosted_search(body: &Map<String, Value>) -> bool {
body.get("tools")
.and_then(Value::as_array)
.is_some_and(|tools| tools_declare_search(tools))
|| body
.get("input")
.and_then(Value::as_array)
.is_some_and(|input| {
input.iter().any(|item| {
matches!(
item.get("type").and_then(Value::as_str),
Some("additional_tools" | "tool_search_output")
) && item
.get("tools")
.and_then(Value::as_array)
.is_some_and(|tools| tools_declare_search(tools))
})
})
}
pub(super) fn tools_declare_search(tools: &[Value]) -> bool {
tools.iter().any(|tool| {
tool.get("type")
.and_then(Value::as_str)
.is_some_and(|kind| kind == "web_search" || kind.starts_with("web_search_preview"))
})
}
@@ -482,15 +482,22 @@ fn classify_upstream_failure(
};
}
// 参数值不支持属于请求错误,不能因消息提及模型而推断账号无模型权限
// 官方 Codex 将流内 invalid_prompt 归为请求错误,不依赖 HTTP 状态码
if matches!(source, UpstreamFailureSource::SseFailure) && code == "invalid_prompt" {
if code == "unsupported_value"
|| (matches!(source, UpstreamFailureSource::SseFailure) && code == "invalid_prompt")
{
return CodexFailureCategory::InvalidRequest;
}
if [code.as_str(), message.as_str(), body.as_str()]
.into_iter()
.any(is_model_unsupported)
{
// 对齐官方 Codex 的模型拒绝合同,消息与正文不提供账号模型资格证据
if matches!(
(fields.code.as_deref(), status),
(
Some("model_not_found"),
None | Some(StatusCode::BAD_REQUEST | StatusCode::FORBIDDEN | StatusCode::NOT_FOUND)
)
) {
return CodexFailureCategory::ModelUnsupported;
}
let identity_signals = [
@@ -594,16 +601,6 @@ fn non_empty_owned(value: &str) -> Option<String> {
(!value.is_empty()).then(|| value.to_owned())
}
fn is_model_unsupported(value: &str) -> bool {
value.contains("model_not_supported")
|| value.contains("model_not_available")
|| (value.contains("model")
&& (value.contains("not supported")
|| value.contains("not available")
|| value.contains("not_supported")
|| value.contains("not_available")))
}
fn is_identity_verification_required(value: &str) -> bool {
matches!(
value,
@@ -154,6 +154,7 @@ pub(crate) async fn prepare_response_create_request_with_pool(
.acquire(
&key,
required_response_id,
request.codex_search_declared == Some(true),
request.connection().outbound_proxy.as_ref(),
downstream_cancellation,
)
@@ -504,6 +505,7 @@ async fn connect_websocket_connection(
metadata: websocket_connection_metadata(&response, connection),
continuation: WebSocketContinuationState::default(),
created_at: tokio::time::Instant::now(),
initial_search_declared: None,
},
started_at.elapsed(),
))
@@ -614,6 +616,7 @@ pub(crate) async fn execute_prepared_response_create_request_stream(
metadata,
continuation,
created_at,
initial_search_declared,
} = connection;
trace.record("upstream.connection", serde_json::json!({
"connectionId": websocket.connection_id().to_string(), "reused": reused,
@@ -660,6 +663,8 @@ pub(crate) async fn execute_prepared_response_create_request_stream(
lease,
created_at,
continuation,
initial_search_declared: initial_search_declared
.unwrap_or(request.codex_search_declared.unwrap_or(false)),
});
let mut exchange = stream_websocket_response(
websocket,
@@ -30,6 +30,7 @@ pub(in crate::transport::websocket) struct WebSocketStreamPoolReturn {
pub(in crate::transport::websocket) lease: WebSocketPoolLease,
pub(in crate::transport::websocket) created_at: tokio::time::Instant,
pub(in crate::transport::websocket) continuation: WebSocketContinuationState,
pub(in crate::transport::websocket) initial_search_declared: bool,
}
#[derive(Debug, Clone, Copy)]
@@ -446,6 +447,7 @@ async fn finish_stream_websocket(
metadata: reusable_websocket_metadata(metadata),
continuation,
created_at: pool_return.created_at,
initial_search_declared: Some(pool_return.initial_search_declared),
})
.await;
}
@@ -128,6 +128,8 @@ impl CodexWebSocketConnection {
connection,
payload_text: websocket_response_create_payload_text(request)?,
continuation: WebSocketContinuationRequirement::from_request(request),
codex_search_declared: (path == CODEX_RESPONSES_PATH)
.then(|| crate::compat::declares_hosted_search(request.body())),
})
}
}
@@ -51,6 +51,8 @@ pub struct CodexWebSocketRequest {
pub(crate) connection: CodexWebSocketConnection,
pub(super) payload_text: String,
pub(super) continuation: WebSocketContinuationRequirement,
// 仅 Codex backend 约束首包搜索初始化,API Key 的 Responses API 不采用此规则
pub(super) codex_search_declared: Option<bool>,
}
/// 当前 WebSocket 请求对 previous response 状态的要求
@@ -131,6 +131,7 @@ impl CodexWebSocketPool {
&self,
key: &CodexWebSocketPoolKey,
required_response_id: Option<&str>,
requires_search: bool,
proxy: Option<&gateway_core::account::OutboundProxy>,
downstream_cancellation: Option<&gateway_core::lifecycle::CancellationToken>,
) -> WebSocketPoolAcquire {
@@ -204,6 +205,18 @@ impl CodexWebSocketPool {
.observation()
.with_exit_reason("downstream_connection_closed")
})
})
.or_else(|| {
// https://github.com/zyycn/codex-proxy-rs/issues/570
// Codex 首包未初始化搜索时不能在原 socket 后加声明,generate=false 也占首包
// 上游允许后续启用搜索并经直连验证后可移除此回收条件
(requires_search && connection.initial_search_declared == Some(false))
.then(|| {
connection
.websocket
.observation()
.with_exit_reason("search_activation_required")
})
});
let Some(observation) = loss else {
connection.websocket.revoke_control();
@@ -197,6 +197,8 @@ pub(crate) struct PooledWebSocketConnection {
pub(crate) metadata: CodexWebSocketConnectionMetadata,
pub(crate) continuation: WebSocketContinuationState,
pub(crate) created_at: Instant,
// 首个已写入请求决定搜索能力,None 表示仅完成握手,后续工具变化不能覆盖此事实
pub(crate) initial_search_declared: Option<bool>,
}
/// 只随具体 WebSocket 生命周期存在的续接状态
@@ -410,42 +410,43 @@ async fn websocket_additional_rate_limit_resolves_by_name_without_touching_core(
#[tokio::test]
async fn quota_refresh_synchronizes_plan_changes_without_losing_subtypes() {
for (current_plan, observed_plan, expected) in [
("plus", Some("pro"), "pro"),
("pro", Some("plus"), "plus"),
("plus", Some("free"), "free"),
("unknown", Some("pro"), "pro"),
("plus", Some(" PRO "), "pro"),
("pro", None, "pro"),
("pro", Some("unknown"), "pro"),
("pro", Some(" "), "pro"),
(None, Some("free"), "free"),
(Some("plus"), Some("pro"), "pro"),
(Some("pro"), Some("plus"), "plus"),
(Some("plus"), Some("free"), "free"),
(Some("unknown"), Some("pro"), "pro"),
(Some("plus"), Some(" PRO "), "pro"),
(Some("pro"), None, "pro"),
(Some("pro"), Some("unknown"), "pro"),
(Some("pro"), Some(" "), "pro"),
(
"self_serve_business_prolite",
Some("self_serve_business_prolite"),
Some("team"),
"self_serve_business_prolite",
),
(
"self_serve_business_usage_based",
Some("self_serve_business_usage_based"),
Some("team"),
"self_serve_business_usage_based",
),
(
"enterprise_cbp_usage_based",
Some("enterprise_cbp_usage_based"),
Some("business"),
"enterprise_cbp_usage_based",
),
("edu_plus", Some("edu"), "edu_plus"),
("self_serve_business_prolite", Some("pro"), "pro"),
(Some("edu_plus"), Some("edu"), "edu_plus"),
(Some("self_serve_business_prolite"), Some("pro"), "pro"),
(
"team",
Some("team"),
Some("self_serve_business_prolite"),
"self_serve_business_prolite",
),
("plus", Some("future_plan"), "future_plan"),
(Some("plus"), Some("future_plan"), "future_plan"),
] {
let store = Arc::new(MemoryAccountStore::default());
let account_id = "acct_plan_refresh";
let mut verified_account = profile("chatgpt-plan-refresh");
verified_account.plan_type = Some(current_plan.to_owned());
verified_account.plan_type = current_plan.map(str::to_owned);
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: account_id.to_owned(),
@@ -480,7 +481,7 @@ async fn quota_refresh_synchronizes_plan_changes_without_losing_subtypes() {
assert_eq!(
after.plan_type(),
Some(expected),
"{current_plan} -> {observed_plan:?}"
"{current_plan:?} -> {observed_plan:?}"
);
assert_eq!(after.revision(), before.revision());
assert_eq!(after.email(), before.email());
@@ -11528,6 +11528,88 @@ async fn api_key_websocket_uses_api_path_and_bearer_without_oauth_identity() {
.unwrap();
}
#[tokio::test]
async fn api_key_websocket_search_activation_reuses_original_connection() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let store = Arc::new(MemoryAccountStore::default());
store
.seed_api_key(
"acct_provider_contract",
base,
provider_openai::credential::ResponsesTransport::PreferWebsocket,
)
.await;
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut websocket = crate::transport::accept_codex_test_websocket(stream).await;
for search in [false, true] {
let frame = websocket.next().await.unwrap().unwrap();
let payload: Value = serde_json::from_str(frame.to_text().unwrap()).unwrap();
assert_eq!(
payload["tools"].as_array().unwrap().len(),
usize::from(search)
);
websocket
.send(Message::Text(
crate::transport::completed_websocket_response("resp_api_search", 1, 1).into(),
))
.await
.unwrap();
}
});
let provider = provider(&store);
let session_state = ProviderSessionState::new(
"openai",
Map::from_iter([
("account_id".into(), json!("acct_provider_contract")),
("conversation_id".into(), json!("api-search-session")),
("continuation_scope".into(), json!("connection_local")),
]),
)
.unwrap();
for tools in [json!([]), json!([{"type":"web_search"}])] {
let operation = Operation::Generate(
GenerateRequest::from_protocol_payload(
ProtocolPayload::json_object(
"openai",
json!({
"model":"gpt-5.4", "input":"hello", "store":false,
"session_id":"api-search-session", "tools":tools
})
.as_object()
.unwrap()
.clone(),
)
.unwrap()
.with_context(Map::from_iter([
("use_websocket".into(), json!(true)),
(
"downstream_websocket_connection_id".into(),
json!("api-search-lane"),
),
])),
)
.with_provider_session_state(session_state.clone()),
);
timeout(Duration::from_secs(3), async {
let mut stream = Arc::clone(&provider)
.execute(
planned_request("openai", operation),
diagnostic_context("req_api_search", "acct_provider_contract"),
)
.await
.unwrap();
while let Some(event) = stream.next().await {
event.expect("API Key search activation should keep the same socket");
}
})
.await
.unwrap();
}
server.await.unwrap();
}
fn quota_continuation_operation(use_websocket: bool) -> Operation {
Operation::Generate(
GenerateRequest::from_protocol_payload(
@@ -453,6 +453,133 @@ async fn missing_catalog_unknown_plan_and_rotated_api_credentials_preserve_upstr
}
}
#[tokio::test]
async fn unsupported_reasoning_value_preserves_account_eligibility_across_transports() {
for transport in ["http", "sse", "websocket"] {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
let cooldowns = Arc::new(MemoryCooldownPort::new());
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let (provider, pool) = provider_with_cooldowns(
&store,
format!("http://{}", listener.local_addr().unwrap()),
cooldowns.clone(),
);
let upstream_error = json!({
"code": "unsupported_value",
"type": "invalid_request_error",
"param": "reasoning.effort",
"message": "Unsupported value: 'none' is not supported with the 'gpt-6-astra' model. Supported values are: 'low', 'medium', 'high', 'xhigh', and 'max'."
});
let expected_message = upstream_error["message"].as_str().unwrap().to_owned();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
if transport == "websocket" {
let mut socket = accept_codex_test_websocket(socket).await;
socket.next().await.unwrap().unwrap();
socket
.send(Message::Text(
json!({
"type": "error", "error": upstream_error, "status": 400
})
.to_string()
.into(),
))
.await
.unwrap();
} else {
read_http_request(&mut socket).await;
let (status, content_type, body) = if transport == "sse" {
let event = json!({"type":"response.failed","response":{
"id":"resp_bad_effort","status":"failed","output":[],"error":upstream_error
}});
(
"200 OK",
"text/event-stream",
gateway_protocol::openai::sse::encode_sse_event(
"response.failed",
&event.to_string(),
),
)
} else {
(
"400 Bad Request",
"application/json",
json!({"error":upstream_error}).to_string(),
)
};
socket.write_all(format!(
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
).as_bytes()).await.unwrap();
}
// 合法请求仍应使用同一个账号,不能被上一请求的参数拒绝隔离
let (mut socket, _) = listener.accept().await.unwrap();
read_http_request(&mut socket).await;
socket.write_all(format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{CAPTURE_COMPLETED_SSE}",
CAPTURE_COMPLETED_SSE.len()
).as_bytes()).await.unwrap();
});
let payload = ProtocolPayload::json_object(
"openai",
json!({
"model":"gpt-6-astra","input":"hello","reasoning":{"effort":"none"}
})
.as_object()
.unwrap()
.clone(),
)
.unwrap()
.with_context(Map::from_iter([(
"use_websocket".to_owned(),
json!(transport == "websocket"),
)]));
let mut stream = provider
.clone()
.execute(
planned_request_for_model(
"openai",
Operation::Generate(GenerateRequest::from_protocol_payload(payload)),
"gpt-6-astra",
),
context("req_unsupported_effort", CancellationToken::new()),
)
.await
.unwrap();
let (_, error) = super::precommit::stream_failure(&mut stream).await;
assert_eq!(
error.kind(),
ProviderErrorKind::InvalidRequest,
"{transport}"
);
assert!(error.retry_is_prohibited(), "{transport}");
assert!(!error.replay_is_safe(), "{transport}");
let visible = error.client_visible_upstream_error().unwrap();
assert_eq!(visible.code(), Some("unsupported_value"));
assert_eq!(visible.message(), expected_message);
drop(stream);
assert!(cooldowns.scoped.lock().unwrap().is_empty(), "{transport}");
let mut recovered = provider
.clone()
.execute(
planned_request_for_model("openai", operation("gpt-6-astra"), "gpt-6-astra"),
context("req_valid_effort", CancellationToken::new()),
)
.await
.unwrap();
assert_eq!(
recovered.metadata().provider_account_id().as_str(),
"acct_provider_contract"
);
while let Some(event) = recovered.next().await {
event.unwrap();
}
server.await.unwrap();
pool.shutdown().await;
}
}
#[tokio::test]
async fn upstream_model_rejection_cools_only_that_model_across_requests_and_transports() {
use gateway_core::provider_ports::{ProviderCooldownScope, ProviderScopedCooldown};
@@ -22,6 +22,59 @@ fn classify(status: u16, body: &str) -> CodexFailureCategory {
.category()
}
#[test]
fn model_rejection_requires_the_official_code_and_status() {
for status in [400, 403, 404] {
assert_eq!(
classify(
status,
r#"{"error":{"code":"model_not_found","message":"Access to this deployment ended."}}"#
),
CodexFailureCategory::ModelUnsupported
);
}
for (status, body, expected) in [
(
400,
r#"{"error":{"code":"unsupported_value","type":"invalid_request_error","param":"reasoning.effort","message":"Unsupported value: 'none' is not supported with the 'gpt-6-astra' model. Supported values are: 'low', 'medium', 'high', 'xhigh', and 'max'."}}"#,
CodexFailureCategory::InvalidRequest,
),
(
400,
r#"{"error":{"message":"The model is not supported."}}"#,
CodexFailureCategory::InvalidRequest,
),
(
400,
r#"{"error":{"code":"unknown_error","message":"model_not_supported"}}"#,
CodexFailureCategory::InvalidRequest,
),
(
400,
r#"{"error":{"code":"unknown_error"},"detail":"model_not_available"}"#,
CodexFailureCategory::InvalidRequest,
),
(
500,
r#"{"error":{"code":"model_not_found","message":"The model is not available."}}"#,
CodexFailureCategory::Unavailable,
),
] {
let failure = CodexUpstreamFailure::from_response(
reqwest::StatusCode::from_u16(status).unwrap(),
body,
None,
&CodexUpstreamDiagnostics::default(),
None,
&[],
&[],
CodexUpstreamSendPhase::AfterPayload,
);
assert_eq!(failure.category(), expected, "{body}");
assert!(!failure.replay_is_safe(), "{body}");
}
}
#[test]
fn flex_capacity_rejection_should_be_terminal_even_before_payload_send() {
for phase in [
@@ -260,6 +260,7 @@ async fn slow_pooled_openings_should_remain_healthy_and_serve_subsequent_request
let payload = websocket.next().await.unwrap().unwrap();
let payload: Value = serde_json::from_str(payload.to_text().unwrap()).unwrap();
assert_eq!(payload["input"][0]["content"], "subsequent request");
assert_eq!(payload["tools"][0]["type"], "web_search");
websocket
.send(Message::Text(
completed_websocket_response("resp_background_ready", 2, 1).into(),
@@ -300,11 +301,14 @@ async fn slow_pooled_openings_should_remain_healthy_and_serve_subsequent_request
);
websocket_ready_rx.recv().await.unwrap();
tokio::time::sleep(Duration::from_millis(20)).await;
let mut subsequent = codex_request(
let mut body = codex_request_body(
"gpt-5.5",
"be brief",
vec![json!({"role":"user","content":"subsequent request"})],
);
// 后台只完成握手,首次写入可以直接声明搜索,不应按无搜索首包回收
body.insert("tools".into(), json!([{"type":"web_search"}]));
let mut subsequent = CodexResponsesRequest::from_body(body);
subsequent.use_websocket = true;
subsequent.local_conversation_id = request.local_conversation_id.clone();
let second = backend
@@ -11,6 +11,7 @@ mod cleanup;
mod diagnostics;
mod fingerprint;
mod metadata;
mod pool;
#[test]
fn outbound_websocket_diagnostics_do_not_expose_target_credentials() {
@@ -0,0 +1,189 @@
//! 搜索能力变化时的连接复用与原生续接恢复
use super::*;
async fn search_test_server() -> (
CodexBackendClient,
Arc<CodexWebSocketPool>,
tokio::sync::mpsc::UnboundedReceiver<(usize, Value)>,
tokio::task::JoinHandle<()>,
) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let server = tokio::spawn(async move {
let mut connection = 0;
while let Ok((socket, _)) = listener.accept().await {
connection += 1;
let mut websocket = accept_codex_test_websocket(socket).await;
while let Some(Ok(message)) = websocket.next().await {
match message {
Message::Text(text) => {
let request: Value = serde_json::from_str(&text).unwrap();
tx.send((connection, request)).unwrap();
websocket
.send(Message::Text(
completed_websocket_response("resp_search", 1, 1).into(),
))
.await
.unwrap();
}
Message::Ping(_) => websocket.flush().await.unwrap(),
Message::Close(_) => {
websocket.flush().await.unwrap();
break;
}
other => panic!("unexpected frame: {other:?}"),
}
}
}
});
let pool = Arc::new(CodexWebSocketPool::default());
let client = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
test_wire_profile(),
)
.with_websocket_pool(Arc::clone(&pool));
(client, pool, rx, server)
}
fn search_request(downstream_ws: bool, overrides: Value) -> CodexResponsesRequest {
let mut body = codex_request_body("gpt-test", "be brief", vec![]);
body.insert("store".into(), json!(false));
body.extend(overrides.as_object().unwrap().clone());
let mut request = CodexResponsesRequest::from_body(body);
request.local_conversation_id = Some("search-session".into());
request.downstream_websocket_connection_id = downstream_ws.then(|| "search-lane".into());
request
}
async fn send(
client: &CodexBackendClient,
request: &CodexResponsesRequest,
) -> CodexClientResult<CollectedBackendResponse> {
timeout(
Duration::from_secs(3),
client.create_response(
request,
request_context("req_search", Some("search-account")),
),
)
.await
.expect("request must finish without waiting for pool expiry")
}
#[tokio::test]
async fn search_activation_reconnects_for_http_and_websocket_ingress() {
for downstream_ws in [false, true] {
for (warmup, mut declaration) in [
(
false,
json!({"tools":[{"type":"web_search", "search_content_types":["text","image"]}]}),
),
(
true,
json!({"tools":[{"type":"web_search", "external_web_access":false}]}),
),
(
false,
json!({"input":[{"type":"additional_tools", "role":"developer", "tools":[{"type":"web_search"}]}]}),
),
(
false,
json!({"input":[{"type":"tool_search_output", "tools":[{"type":"web_search_preview"}]}]}),
),
] {
let (client, pool, mut seen, server) = search_test_server().await;
let first = search_request(
downstream_ws,
json!({
"generate": !warmup,
"tools": [{"type":"function", "name":"web_search", "parameters":{}}]
}),
);
send(&client, &first).await.unwrap();
assert_eq!(seen.recv().await.unwrap().0, 1);
declaration
.as_object_mut()
.unwrap()
.entry("input")
.or_insert_with(|| json!([]));
declaration["input"].as_array_mut().unwrap().extend([
json!({"type":"function_call", "name":"lookup", "call_id":"call_1", "arguments":"{}"}),
json!({"type":"function_call_output", "call_id":"call_1", "output":"synthetic history"}),
]);
let second = search_request(downstream_ws, declaration);
let response = send(&client, &second).await.unwrap();
let (connection, payload) = seen.recv().await.unwrap();
pool.shutdown().await;
server.abort();
assert_eq!(connection, 2, "search activation must use a fresh socket");
assert_eq!(response.websocket_pool_decision.unwrap().kind(), "new");
assert_eq!(payload["input"], second.body()["input"]);
assert_eq!(payload.get("tools"), second.body().get("tools"));
assert!(payload.get("previous_response_id").is_none());
}
}
}
#[tokio::test]
async fn search_initialized_socket_survives_tool_removal_and_image_search_activation() {
let (client, pool, mut seen, server) = search_test_server().await;
for tools in [
json!([{"type":"web_search", "external_web_access":false}]),
json!([]),
json!([{"type":"web_search", "search_content_types":["text","image"]}]),
] {
let request = search_request(true, json!({"tools": tools}));
send(&client, &request).await.unwrap();
let (connection, payload) = seen.recv().await.unwrap();
assert_eq!(connection, 1);
assert_eq!(payload["tools"], tools);
}
pool.shutdown().await;
server.abort();
}
#[tokio::test]
async fn search_activation_rejects_native_delta_then_accepts_full_replay() {
let (client, pool, mut seen, server) = search_test_server().await;
let request = search_request(true, json!({}));
send(&client, &request).await.unwrap();
seen.recv().await.unwrap();
let mut request = search_request(true, json!({"tools":[{"type":"web_search"}]}));
request.set_previous_response_id(Some("resp_search".into()));
request.previous_response_scope = Some(PreviousResponseScope::ConnectionLocal);
let result = send(&client, &request).await;
if result.is_ok() {
pool.shutdown().await;
server.abort();
panic!("connection-local delta must require client replay before sending");
}
let CodexClientError::WebSocket(error) = result.unwrap_err() else {
panic!("expected a typed continuation error");
};
assert!(error.continuation_unavailable_reason().is_some());
assert_eq!(
error.connection_observation().unwrap().exit_reason(),
"search_activation_required"
);
assert!(seen.try_recv().is_err(), "delta must never reach upstream");
let request = search_request(
true,
json!({
"tools":[{"type":"web_search"}],
"input":[{"role":"user", "content":"complete synthetic history"}]
}),
);
send(&client, &request).await.unwrap();
let (connection, payload) = seen.recv().await.unwrap();
assert_eq!(connection, 2);
assert_eq!(payload["input"], request.body()["input"]);
assert!(payload.get("previous_response_id").is_none());
pool.shutdown().await;
server.abort();
}
+1
View File
@@ -34,3 +34,4 @@ a0e409402611dbc76bb71393b8eda7c717756c18974dbabca305fdcb4b115f31 0032_usage_and
9c15a6b6c1f9eab8ad478772f98f8fc4a997b825d58d12cffe1a15579c5785b3 0034_account_error_protection.sql
bd73379456839e8a12d7591ad2a839901af0f5ba27871248569abdfa26bc83ef 0035_cpr_link_and_concurrency_defaults.sql
d05442ee4cb85db8131e015dbed9c6ad988f93306abcda34647317674857ee44 0036_account_display_names.sql
b215ea90d5c8753aeb8192b17ffe65f7a95c1d620aaeaf9fc4337d8cf6f2634e 0037_retry_after_wait_limit.sql
@@ -0,0 +1,4 @@
-- 网关接受的单次上游重试等待上限,零表示不接受正数等待
ALTER TABLE runtime_settings
ADD COLUMN max_retry_after_seconds BIGINT NOT NULL DEFAULT 240
CHECK (max_retry_after_seconds BETWEEN 0 AND 4294967295);
+3 -3
View File
@@ -4,7 +4,7 @@
# 版本严格一致。升级数据库版本时只需同步修改这里与 deploy/compose.yaml。
ARG POSTGRES_IMAGE=postgres:18-bookworm@sha256:bbf83594798e144bff365a9c191c5d779f181fb3135902064ab8ff11918020fc
FROM node:26.12.1-bookworm-slim@sha256:28147155d47dc31abe0a46e0d12b59b2fb3d0a2dcc5dc65fce76a074180b16fe AS frontend-builder
FROM node:26.13.0-bookworm-slim@sha256:bc0a2afd3b74ce12222fc8bab99271e66257f021078f5f4a5d687a77d280c1d9 AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./
@@ -13,7 +13,7 @@ RUN npm install --global "$(node -p 'require("./package.json").packageManager')"
COPY frontend ./
RUN pnpm build
FROM rust:1.99.1-bookworm@sha256:48b7f435b8fc6378ce43b44f15e3a47fd8919b2825f835c39bd4bd3da50af5b8 AS backend-builder
FROM rust:1.99.1-bookworm@sha256:9181b56c2a21ab1d5e73cffe04fc762426d30aa7fac3c91141dd033f49a7fc8b AS backend-builder
WORKDIR /app/backend
ARG TARGETARCH
@@ -40,7 +40,7 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
# 备份客户端来源:与 compose 服务端同一固定镜像,版本一致。
FROM ${POSTGRES_IMAGE} AS pg-client
FROM debian:bookworm-slim@sha256:7ff6ba31527b9e336162fa061746c366c4b2032f4d2cff2078bea43f78efe3f8 AS runtime-base
FROM debian:bookworm-slim@sha256:99c0b25c46d0214d5a2091092e7908907dcaecd6a962cde0ac700acee222b6f3 AS runtime-base
ENV TZ=UTC
+19 -2
View File
@@ -481,6 +481,11 @@ Responses WebSocket 接受文本消息,`response.create` 在同一连接串行
活跃客户端 WS 的绑定不受普通空闲回收期限约束;上游关闭、总寿命到期或连接被回收仍可能使连接内续接失效,
客户端按既有 `previous_response_not_found` 合同完整重放。具体心跳和回收参数见[部署与运维](../deploy/README.md#公网访问)
Codex OAuth 上游 WS 首包未声明搜索时,后续新增 `web_search` 声明需要新连接,非生成预热也属于首包。
HTTP/SSE 与 WS 入站共用此判断;新链在发送前重新建连,依赖原连接的增量请求返回
`400 previous_response_not_found`,由客户端携带完整历史重试。网关保留请求中的搜索配置;
已初始化搜索的连接可以继续复用,临时移除搜索或从文本搜索增加图片搜索不触发此回收。
其余文本消息发送到当前执行绑定的上游 WebSocket,未知类型、模式和扩展字段由上游校验。
`response.interrupt` 的 `discard_partial_items` 模式若指向当前逻辑请求已交付的响应 ID,
网关会保留中断意图;重试交付新的 `response.created` 后,以新 ID 转发中断,避免换号空档丢失控制消息。
@@ -663,8 +668,14 @@ OpenAI 仅在允许安全重放且尚未交付不可重放输出时执行下表
| 普通 `503` 且 `error.code=server_error` | 5 次 | 500ms 起步的指数退避,基准最多 2 秒,每次追加 `[50ms, 250ms)` 等待 | 返回最后一次上游错误,不因此换号 |
有效 `Retry-After` 优先,接受秒数、HTTP 日期和零延迟,不受本地退避上限截断,也不追加随机等待。
流内失败事件使用相同解析规则,WS 转 HTTP 回退也遵守服务器建议;等待受请求截止时间和取消约束,
流内失败事件使用相同解析规则,WS 同账号传输重试与转 HTTP 回退也遵守服务器建议;等待受请求截止时间和取消约束,
重试受[请求总尝试次数](#账号亲和与换号)约束。
`maxRetryAfterSeconds` 限制网关接受的单次服务器建议等待,默认 240 秒,取值 0~4,294,967,295。
建议时间超过上限时跳过同账号等待,按原有耗尽动作安全换号或返回错误;无可用账号时返回最后一次上游错误,
保留原始 `Retry-After`。等于上限仍可等待,0 表示不接受正数等待;未提供有效建议时继续使用本地退避。
该设置不限制请求总时长,不改变已交付不可重放输出、续接绑定和换号次数等限制;保存后对新请求生效。
已发送的容量失败计入[智能调度健康分](architecture.md#智能调度),可用账号的会话亲和优先。
容量不足不触发 Provider 全局熔断,也不作为账号额度耗尽;账号冷却由独立的[错误保护规则](#账号错误保护)决定。
客户端错误兼容由 API 编码出口统一处理:最终交付的 `server_is_overloaded`、`slow_down` 错误码
@@ -864,6 +875,9 @@ CPR 默认路径为 `/v1/cpr/link`,使用同一数据面入口认证,不接
| `sharePlans` | 已公开账号池中的 `pool.plans` |
| `shareQuota` | `quota_windows` 与 `model_availability` |
套餐分布沿用账号页口径:优先使用账号已有的明确套餐,缺失时从已保存的额度快照补全,无需开启 `shareQuota`。
仍未知的账号只计入账号总数,不推断为 Free
共享范围同时受当前 Key 的公开开关、分组及冻结权限限制,只包含 Provider 明确声明的直连 OAuth 账号。
互联 Key 的推理也使用该直连范围,不把 API Key 转接账号再次作为共享池,不递归查询其他节点。
这些开关和账号组织方式属于 CPR 内置实现;第三方通过自身授权规则生成协议数据
@@ -1177,7 +1191,9 @@ Pro 使用 `all` 时也能参与 luna 调度。套餐名称不自动生成或修
OAuth 和 xAI 按已知套餐匹配目录,API Key 按账号与凭据版本隔离目录,不借用其他账号的模型集合。
OpenAI 使用最近 30 分钟内同步的目录,xAI 使用未过期的套餐目录缓存;未知套餐或无有效目录时交由上游验证,仍执行 `modelAccess`
原生 OpenAI Responses 上游明确拒绝某个账号使用该模型后,该账号与模型组合冷却 30 分钟;其他模型仍可使用该账号。
原生 OpenAI Responses 上游返回 `error.code=model_not_found`,且状态为 400/403/404 或流内错误未提供状态时,
该账号与模型组合冷却 30 分钟;其他模型仍可使用该账号。仅消息提及模型不支持不触发冷却;
`unsupported_value` 属于请求参数错误,原样交回客户端,不触发换号或模型冷却。
冷却按凭据版本隔离,不修改 `modelAccess`,也不解除原生续写的固定账号约束。OpenAI 的受管上游适配器不应用原生目录限制和模型拒绝冷却
单账号更新或批量更新省略 `modelAccess` 时保留原值,显式提交 `{ "mode": "all", "models": [] }` 清除限制。
@@ -1900,6 +1916,7 @@ HTTP 返回 `429`,`error.code` 为 `key_daily_budget_exceeded` 或 `key_weekly
| 模型映射 | `modelMappings` |
| 凭据刷新 | `refreshMarginSeconds`、`refreshConcurrency` |
| [并发与排队](#并发与排队) | `maxConcurrentPerAccount`、`maxWaitingPerKey`、`maxWaitingPerAccount`、`openaiGuardianReservedConcurrency`、`concurrencyWaitTimeoutSeconds` |
| [上游重试等待](#容量拒绝与重试) | `maxRetryAfterSeconds` |
| [亲和与换号](#账号亲和与换号) | `openaiAccountAffinity`、`openaiSessionAffinityTtlHours`、`maxAccountRotations` |
| [调度策略](#智能调度) | `rotationStrategy`、`smartScheduling` |
| [请求解压](#请求解压上限) | `responsesMaxDecompressedBodyBytes` |
+5 -1
View File
@@ -493,6 +493,9 @@ OpenAI 的连接池监督该取消信号,按当前归属保护绑定连接;
重新绑定或切到 HTTP 时移除失去用途的旧连接,失效 reservation 不能重新归池。
普通空闲期限复用池槽位的 `idle_since`,只在归池时重置;心跳不刷新业务空闲,建连与在途不参与空闲回收。
回收先移出可复用集合,再在锁外有界关闭并等待 pump/socket 释放,物理容量随 socket 归还;批量关闭并行执行。
Codex OAuth 上游 WS 在首个请求写入后记录搜索声明,握手完成但尚未发送请求的连接仍未初始化。
后续首次声明搜索时,连接池回收不兼容的原连接;普通新链重新建连,连接内续接交由客户端完整重放。
该判断共用于 HTTP 与 WS 入站,不修改客户端工具声明,也不保留对话正文。
协议合同与运行参数分别见 [Responses 传输](api.md#websocket-与上游传输)和[部署与运维](../deploy/README.md#公网访问)
OpenAI Provider 按 Client Key、所选 OAuth 账号和父响应 ID 关联 WS 握手的路由 Cookie 快照,
@@ -739,7 +742,8 @@ OpenAI Provider 的账号资格模块统一解释请求类型、认证与套餐
OpenAI 使用未满 30 分钟的进程目录快照,xAI 按套餐去重读取 TTL 目录缓存,选号不请求上游目录。
未知套餐、缺失或过期目录保留上游验证,不从其他账号或套餐借用目录资格
原生 OpenAI Responses 明确返回模型不支持时,通过既有模型级冷却端口记录账号、凭据版本与上游模型,30 分钟内不再选用该组合。
原生 OpenAI Responses 按[官方 Codex 0.166.0](https://github.com/openai/codex/blob/6ff1e1ca3b30fc8b914720681fcf5fe62e95f856/codex-rs/codex-api/src/error.rs#L73)的结构化 `model_not_found` 错误码与状态合同识别模型拒绝,
不从消息或任意正文推断模型权限;通过既有模型级冷却端口记录账号、凭据版本与上游模型,30 分钟内不再选用该组合。
该反馈不冻结整个账号,不改写管理员模型政策,也不占用 WS 传输恢复预算;重试仍遵守交付边界与原生续写固定账号约束。
管理员诊断旁路目录资格与模型拒绝冷却,OpenAI 的受管上游适配器不继承这两项原生限制
+1 -1
View File
@@ -14,7 +14,7 @@
## 环境与依赖
需要 Node.js 26.12.1、pnpm、[Rust 工具链](architecture.md#验证命令)、PostgreSQL 和 Redis
需要 Node.js 26.13.0、pnpm、[Rust 工具链](architecture.md#验证命令)、PostgreSQL 和 Redis
各前端的 `packageManager` 固定 pnpm 版本,宿主 CI 和 Docker 从 `frontend/package.json` 读取
+7 -7
View File
@@ -20,14 +20,14 @@
"@codex-proxy/ui": "github:zyycn/codex-proxy-ui#v0.8.0",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@lucide/vue": "^1.61.0",
"@lucide/vue": "^1.66.0",
"@vueuse/core": "^15.0.0",
"@vueuse/integrations": "^15.0.0",
"axios": "^1.20.0",
"axios": "^1.21.0",
"circle-flags": "2.8.3",
"css-tree": "^3.2.1",
"echarts": "^6.1.0",
"es-toolkit": "^1.53.0",
"es-toolkit": "^1.53.1",
"gsap": "^3.15.0",
"pinia": "^4.1.0",
"pinia-plugin-persistedstate": "^4.7.1",
@@ -40,20 +40,20 @@
"devDependencies": {
"@antfu/eslint-config": "^10.0.0",
"@intlify/eslint-plugin-vue-i18n": "4.5.1",
"@intlify/unplugin-vue-i18n": "11.2.7",
"@intlify/unplugin-vue-i18n": "11.2.8",
"@tailwindcss/vite": "^4.3.3",
"@types/css-tree": "^3.2.0",
"@types/node": "^26.6.5",
"@types/node": "^26.6.6",
"@types/semver": "^7.8.0",
"@types/sortablejs": "^1.15.9",
"@vitejs/plugin-vue": "^6.0.10",
"@vue/tsconfig": "^0.9.1",
"eslint": "^10.13.0",
"eslint": "^10.14.0",
"eslint-plugin-format": "^2.0.1",
"eslint-plugin-vuejs-accessibility": "^2.6.3",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3",
"vite": "^8.3.5",
"vue-tsc": "3.3.13"
"vue-tsc": "3.3.14"
}
}
+275 -300
View File
File diff suppressed because it is too large Load Diff
@@ -54,6 +54,7 @@ export interface RuntimeSettings {
maxWaitingPerKey: number
maxWaitingPerAccount: number
concurrencyWaitTimeoutSeconds: number
maxRetryAfterSeconds: number
responsesMaxDecompressedBodyBytes: number
rotationStrategy: RotationStrategy
minCodexDesktopVersion: string | null
@@ -15,7 +15,8 @@ const { t } = useI18n({ useScope: 'global' })
:title="t('accounts.upstream.link')"
>
<AppBrandMark variant="glyph" class="relative size-4 shrink-0" aria-hidden="true" />
<span class="relative font-mono text-[10px] leading-none font-heavy tracking-[0.05em]">CPR</span>
<!-- 大写字形在行盒内偏上,保留紧凑行高并对齐图标的视觉中心 -->
<span class="relative top-px font-mono text-[10px] leading-none font-heavy tracking-[0.05em]">CPR</span>
</span>
</template>
+10 -3
View File
@@ -241,7 +241,7 @@
"saveAndLogInAgain": "Save and log in again"
},
"concurrency": {
"title": "Concurrency",
"title": "Concurrency and retries",
"accountLimit": {
"label": "Default account concurrency",
"help": "Used unless overridden, 0 means unlimited"
@@ -258,7 +258,13 @@
"helpTitle": "About auto review concurrency"
},
"queueTimeoutHelpTitle": "About queue timeout",
"queueTimeoutDetails": "The API key and account queues share this timeout, measured from the first queue entry"
"queueTimeoutDetails": "The API key and account queues share this timeout, measured from the first queue entry",
"retryAfter": {
"label": "Upstream retry wait limit (s)",
"help": "Try another account above the limit",
"helpTitle": "About upstream retry waits",
"details": "Defaults to 240 seconds. Limits each Retry-After wait, not the total request duration. Above the limit, try another account; return the error if none is available. Set to 0 to reject positive waits. Account rotation still follows the rotation limit and request replay conditions."
}
},
"modelMapping": "Model mapping",
"routing": {
@@ -597,7 +603,8 @@
"warmupModelRequired": "Select a model to enable warmup",
"invalidWarmupModel": "Warmup model name must not exceed 128 characters",
"settingsSaved": "Settings saved",
"invalidProtectionSettings": "Use a failure threshold of 2–1,000, count validity of 60–3,600 seconds, and a cooldown of 300–604,800 seconds"
"invalidProtectionSettings": "Use a failure threshold of 2–1,000, count validity of 60–3,600 seconds, and a cooldown of 300–604,800 seconds",
"invalidRetryAfterLimit": "Upstream retry wait limit must be an integer from 0 to 4,294,967,295"
},
"page": {
"tabs": {
@@ -241,7 +241,7 @@
"saveAndLogInAgain": "保存并重新登录"
},
"concurrency": {
"title": "并发策略",
"title": "并发与重试",
"accountLimit": {
"label": "默认账号并发",
"help": "未单独设置时使用,0 不限"
@@ -258,7 +258,13 @@
"helpTitle": "自动审批独立并发说明"
},
"queueTimeoutHelpTitle": "排队超时说明",
"queueTimeoutDetails": "密钥与账号队列共用此时限,从首次入队起计时"
"queueTimeoutDetails": "密钥与账号队列共用此时限,从首次入队起计时",
"retryAfter": {
"label": "上游重试等待上限(秒)",
"help": "超限时尝试换号",
"helpTitle": "上游重试等待说明",
"details": "默认 240 秒,限制单次 Retry-After 等待,不是请求总时长。超限时尝试换号,无可用账号则返回错误。0 表示不接受正数等待。换号仍受最大换号次数和请求可重试条件限制。"
}
},
"modelMapping": "模型映射",
"routing": {
@@ -597,7 +603,8 @@
"warmupModelRequired": "启用预激活时请选择模型",
"invalidWarmupModel": "预激活模型名称不能超过 128 个字符",
"settingsSaved": "设置已保存",
"invalidProtectionSettings": "失败阈值应为 2~1000,计数有效期为 60~3600 秒,冷却时长为 300~604800 秒"
"invalidProtectionSettings": "失败阈值应为 2~1000,计数有效期为 60~3600 秒,冷却时长为 300~604800 秒",
"invalidRetryAfterLimit": "上游重试等待上限应为 0~4294967295 的整数"
},
"page": {
"tabs": {
@@ -241,7 +241,7 @@
"saveAndLogInAgain": "保存並重新登錄"
},
"concurrency": {
"title": "併發策略",
"title": "並發與重試",
"accountLimit": {
"label": "默認賬號併發",
"help": "未單獨設置時使用,0 不限"
@@ -258,7 +258,13 @@
"helpTitle": "自動審批獨立併發說明"
},
"queueTimeoutHelpTitle": "排隊超時說明",
"queueTimeoutDetails": "密鑰與賬號隊列共用此時限,從首次入隊起計時"
"queueTimeoutDetails": "密鑰與賬號隊列共用此時限,從首次入隊起計時",
"retryAfter": {
"label": "上游重試等待上限(秒)",
"help": "超限時嘗試換號",
"helpTitle": "上游重試等待說明",
"details": "預設 240 秒,限制單次 Retry-After 等待,並非請求總時長。超限時嘗試換號,無可用賬號則返回錯誤。0 表示不接受正數等待。換號仍受最大換號次數和請求可重試條件限制。"
}
},
"modelMapping": "模型映射",
"routing": {
@@ -597,7 +603,8 @@
"warmupModelRequired": "啓用預激活時請選擇模型",
"invalidWarmupModel": "預激活模型名稱不能超過 128 個字符",
"settingsSaved": "設置已保存",
"invalidProtectionSettings": "失敗閾值應爲 2~1000,計數有效期爲 60~3600 秒,冷卻時長爲 300~604800 秒"
"invalidProtectionSettings": "失敗閾值應爲 2~1000,計數有效期爲 60~3600 秒,冷卻時長爲 300~604800 秒",
"invalidRetryAfterLimit": "上游重試等待上限應爲 0~4294967295 的整數"
},
"page": {
"tabs": {
@@ -12,6 +12,7 @@ const maxWaitingPerKey = defineModel<string>('maxWaitingPerKey', { required: tru
const maxWaitingPerAccount = defineModel<string>('maxWaitingPerAccount', { required: true })
const concurrencyWaitTimeoutSeconds = defineModel<string>('concurrencyWaitTimeoutSeconds', { required: true })
const openaiGuardianReservedConcurrency = defineModel<string>('openaiGuardianReservedConcurrency', { required: true })
const maxRetryAfterSeconds = defineModel<string>('maxRetryAfterSeconds', { required: true })
</script>
<template>
@@ -70,6 +71,21 @@ const openaiGuardianReservedConcurrency = defineModel<string>('openaiGuardianRes
</template>
<ZInput v-model="openaiGuardianReservedConcurrency" :aria-label="t('settings.concurrency.autoReview.label')" type="number" min="0" max="4294967295" step="1" />
</ZFormItem>
<ZFormItem :show-message="Boolean(maxRetryAfterSeconds.trim())" prop="maxRetryAfterSeconds" :label="t('settings.concurrency.retryAfter.label')" :description="t('settings.concurrency.retryAfter.help')">
<template #label-extra>
<ZPopover class="-my-1" trigger="hover-click" placement="top-start">
<template #reference="{ open }">
<ZIconButton variant="link" :aria-label="t('settings.concurrency.retryAfter.helpTitle')" :title="undefined" :aria-expanded="open" class="size-6!">
<CircleAlert class="size-3.5" aria-hidden="true" />
</ZIconButton>
</template>
<p class="max-w-72 px-3 py-2 text-z-sm leading-relaxed text-z-text-secondary">
{{ t('settings.concurrency.retryAfter.details') }}
</p>
</ZPopover>
</template>
<ZInput v-model="maxRetryAfterSeconds" :aria-label="t('settings.concurrency.retryAfter.label')" type="number" min="0" max="4294967295" step="1" />
</ZFormItem>
</div>
</ZCard>
</template>
@@ -49,6 +49,7 @@ export function useSettingsForm() {
maxWaitingPerKey: null as number | null,
maxWaitingPerAccount: null as number | null,
concurrencyWaitTimeoutSeconds: null as number | null,
maxRetryAfterSeconds: null as number | null,
responsesMaxDecompressedBodyMiB: null as number | null,
rotationStrategy: '' as RotationStrategy | '',
@@ -90,7 +91,7 @@ export function useSettingsForm() {
mappings.value = initial.mappings
}
function numericModel(key: 'resetCreditMaxWeeklyRemainingPercent' | 'resetCreditLeadMinutes' | 'refreshMarginSeconds' | 'refreshConcurrency' | 'maxConcurrentPerAccount' | 'openaiGuardianReservedConcurrency' | 'maxAccountRotations' | 'openaiSessionAffinityTtlHours' | 'maxWaitingPerKey' | 'maxWaitingPerAccount' | 'concurrencyWaitTimeoutSeconds' | 'responsesMaxDecompressedBodyMiB' | 'usageRetentionDays') {
function numericModel(key: 'resetCreditMaxWeeklyRemainingPercent' | 'resetCreditLeadMinutes' | 'refreshMarginSeconds' | 'refreshConcurrency' | 'maxConcurrentPerAccount' | 'openaiGuardianReservedConcurrency' | 'maxAccountRotations' | 'openaiSessionAffinityTtlHours' | 'maxWaitingPerKey' | 'maxWaitingPerAccount' | 'concurrencyWaitTimeoutSeconds' | 'maxRetryAfterSeconds' | 'responsesMaxDecompressedBodyMiB' | 'usageRetentionDays') {
return computed({
get: () => (form[key] === null
? ''
@@ -121,6 +122,7 @@ export function useSettingsForm() {
const resetCreditMaxWeeklyRemainingPercentValue = numericModel('resetCreditMaxWeeklyRemainingPercent')
const usageRetentionDaysValue = numericModel('usageRetentionDays')
const concurrencyWaitTimeoutSecondsValue = numericModel('concurrencyWaitTimeoutSeconds')
const maxRetryAfterSecondsValue = numericModel('maxRetryAfterSeconds')
function applySettings(data: Awaited<ReturnType<typeof getSettings>>) {
form.codexPrivacyPolicy = cloneDeep(data.codexPrivacyPolicy)
@@ -137,6 +139,7 @@ export function useSettingsForm() {
form.maxWaitingPerKey = data.maxWaitingPerKey
form.maxWaitingPerAccount = data.maxWaitingPerAccount
form.concurrencyWaitTimeoutSeconds = data.concurrencyWaitTimeoutSeconds
form.maxRetryAfterSeconds = data.maxRetryAfterSeconds
form.responsesMaxDecompressedBodyMiB = data.responsesMaxDecompressedBodyBytes / MIB
form.smartScheduling = { ...data.smartScheduling }
@@ -235,7 +238,7 @@ export function useSettingsForm() {
const savedSettings = saved.value
if (saving.value || loading.value || detectingLocation.value || !savedSettings)
return
const { refreshMarginSeconds, refreshConcurrency, maxConcurrentPerAccount, openaiGuardianReservedConcurrency, openaiAccountAffinity, maxAccountRotations, openaiSessionAffinityTtlHours, rotationStrategy, maxWaitingPerKey, maxWaitingPerAccount, concurrencyWaitTimeoutSeconds, responsesMaxDecompressedBodyMiB } = form
const { refreshMarginSeconds, refreshConcurrency, maxConcurrentPerAccount, openaiGuardianReservedConcurrency, openaiAccountAffinity, maxAccountRotations, openaiSessionAffinityTtlHours, rotationStrategy, maxWaitingPerKey, maxWaitingPerAccount, concurrencyWaitTimeoutSeconds, maxRetryAfterSeconds, responsesMaxDecompressedBodyMiB } = form
const { resetCreditMaxWeeklyRemainingPercent, resetCreditLeadMinutes, usageRetentionDays } = form
// 关闭时保留已保存的自定义值,未完成的草稿不阻止停止覆盖。
const requestLocation = form.requestLocationEnabled
@@ -265,6 +268,7 @@ export function useSettingsForm() {
maxWaitingPerKey: maxWaitingPerKey!,
maxWaitingPerAccount: maxWaitingPerAccount!,
concurrencyWaitTimeoutSeconds: concurrencyWaitTimeoutSeconds!,
maxRetryAfterSeconds: maxRetryAfterSeconds!,
responsesMaxDecompressedBodyBytes: responsesMaxDecompressedBodyMiB! * MIB,
rotationStrategy: rotationStrategy as RotationStrategy,
smartScheduling: { ...smartScheduling },
@@ -324,6 +328,7 @@ export function useSettingsForm() {
maxWaitingPerKeyValue,
maxWaitingPerAccountValue,
concurrencyWaitTimeoutSecondsValue,
maxRetryAfterSecondsValue,
responsesMaxDecompressedBodyMiBValue,
usageRetentionDaysValue,
resetCreditLeadMinutesValue,
@@ -39,6 +39,7 @@ export function useSettingsFormRules(form: () => SettingsForm) {
'maxWaitingPerKey': integer(0, 1000, t('settings.form.invalidQueueSettings')),
'maxWaitingPerAccount': integer(0, 1000, t('settings.form.invalidQueueSettings')),
'concurrencyWaitTimeoutSeconds': integer(1, 120, t('settings.form.invalidQueueSettings')),
'maxRetryAfterSeconds': integer(0, 4_294_967_295, t('settings.form.invalidRetryAfterLimit')),
'responsesMaxDecompressedBodyMiB': integer(1, Math.floor(Number.MAX_SAFE_INTEGER / 1024 / 1024), t('settings.form.invalidBodyLimit')),
'resetCreditLeadMinutes': integer(1, 1440, t('settings.form.invalidResetLeadTime')),
'resetCreditMaxWeeklyRemainingPercent': {
+2
View File
@@ -95,6 +95,7 @@ const {
maxWaitingPerKeyValue,
maxWaitingPerAccountValue,
concurrencyWaitTimeoutSecondsValue,
maxRetryAfterSecondsValue,
responsesMaxDecompressedBodyMiBValue,
usageRetentionDaysValue,
resetCreditLeadMinutesValue,
@@ -210,6 +211,7 @@ watch(section, (value) => {
v-model:max-waiting-per-key="maxWaitingPerKeyValue"
v-model:max-waiting-per-account="maxWaitingPerAccountValue"
v-model:concurrency-wait-timeout-seconds="concurrencyWaitTimeoutSecondsValue"
v-model:max-retry-after-seconds="maxRetryAfterSecondsValue"
v-model:openai-guardian-reserved-concurrency="openaiGuardianReservedConcurrencyValue"
/>
<AccountErrorProtectionCard v-model="form.accountErrorProtection" :disabled="disabled" />
+14 -34
View File
@@ -1,39 +1,19 @@
# v3.38.1
# v3.38.2
## 模型调度修复
## 请求稳定性
- 统一 OpenAI OAuth、API Key 上游和 xAI 的原生 Responses 账号资格:同时检查有效模型目录与账号模型禁用、黑白名单,权重、会话亲和及固定账号不能绕过;通过 API Key 接入的 CPR 互联账号同样适用
- 有效目录未收录请求模型时排除该账号,避免借用其他账号或套餐的模型资格;OAuth、xAI 按已知套餐匹配,API Key 按账号与凭据版本隔离
- 未知套餐或没有有效目录时仍交由上游验证,同时保留账号模型限制;复用现有目录缓存,不增加选号路径的上游目录请求或 Redis 往返
- 修复上游返回长 `Retry-After` 时在原账号空等的问题;新增“上游重试等待上限”,默认 240 秒,超限后按原有重试规则尝试换号,无可用账号时返回上游错误并保留原始等待提示
- 在“系统设置 → 运行设置 → 并发与重试”中调整等待上限,统一作用于瞬态重试、WebSocket 传输重试和转 HTTP 回退;该值限制单次建议等待,不是请求总时长
- 修复 Codex OAuth 复用未初始化搜索的 WebSocket 连接后首次启用搜索被拒绝的问题:发送前重建不兼容连接,保留搜索参数与完整输入;依赖原连接的增量请求由客户端按既有机制完整重放
- 收紧账号模型冷却的识别条件,按结构化 `model_not_found` 错误码及状态判断,避免普通参数错误或仅在提示中提及模型不支持时误触发冷却与换号
- 修复代理编辑因版本冲突失败后,紧接着导入账号可能误报代理被占用的问题
## 互联与界面
- 修复 CPR 互联账号池套餐分布遗漏:账号缺少明确套餐时,从已保存的额度快照补全,无需同时开启额度公开;未知套餐仍保持未知
- 修正账号列表 CPR 徽标的图文对齐,更新前后端依赖及构建基础镜像
## 升级事项
- 从 v3.38.0 升级不新增数据库迁移
- 有有效目录时,未收录的模型不会参与原生 Responses 调度;使用自定义模型的上游需确保模型目录包含对应 ID
---
## v3.38.0 更新记录
### CPR 互联
- 升级至 CPR Link v2,预算、账号池、额度及模型能力可独立提供;预算改用命名窗口,支持日、周以外的周期,缺失观测保持未知
- 持久化互联识别结果,避免重启后反复探测已确认不支持互联的连接;网络错误和临时失败仍按退避规则重试
- 手动刷新等待上游查询完成,并允许重新探测此前不支持互联的连接;不支持协议作为正常识别结果展示,减少重复告警
### 模型调度与性能
- 原生 ChatGPT OAuth Responses 按有效套餐目录筛选已知模型,减少混合套餐账号池把模型分配给不支持它的账号;未知套餐、缺失或过期目录及未收录模型仍交由上游验证
- 上游明确拒绝某个账号使用模型后,对该账号与模型组合冷却 30 分钟,其他模型仍可使用该账号;冷却按凭据版本隔离,原生续写继续遵守固定账号约束
- 租约运行态改为每批最多 128 个账号读取,3,000 账号的一次完整读取由 3,000 次 Lua 调用降至 24 次,降低大账号池调度开销,并保留容量池隔离和过期清理语义
### 请求隔离与稳定性
- 出站移除下游的 `sentry-trace`、`baggage` 及 WebSocket metadata 中对应副本,避免透传客户端遥测关联信息
- 正常并发导致的响应 Cookie 凭据版本冲突降为调试日志,迟到响应继续跳过写入,避免覆盖较新的凭据
### 升级事项
- **CPR Link v2 不兼容 v1 快照**:互联两端需支持 v2,CPR 节点请协调升级至本版;升级完成后,在下游手动刷新互联账号以重新识别
- 第三方互联实现需适配 `schema_version: 2`、可选能力块及 `usage.data.windows` 等新字段,详见 [CPR 互联 API](https://github.com/zyycn/codex-proxy-rs/blob/v3.38.0/docs/api.md#cpr-互联只读协议)
- 从 v3.37.2 升级不新增数据库迁移
- 新增数据库迁移 `0037_retry_after_wait_limit.sql`,服务启动时自动执行,为已有设置填入默认 240 秒
- 等待上限设为 `0` 时不接受正数 `Retry-After`;未提供有效等待建议时仍采用原有本地退避,换号继续遵守次数、续接绑定及安全重放条件
+1 -1
View File
@@ -1 +1 @@
version: "3.38.1"
version: "3.38.2"