Compare commits

...
17 Commits
Author SHA1 Message Date
ZyyandCodex 96e8443ede chore(release): v3.39.0
Co-authored-by: Codex <noreply@openai.com>
2026-11-02 19:40:58 +08:00
ZyyandCodex 42badf64ca docs(deploy): document low-memory database tuning
Co-authored-by: Codex <noreply@openai.com>
2026-11-02 16:30:04 +08:00
35afe1123f feat(plugins): edit sensitive configuration per field (#582)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-02 15:32:32 +08:00
ZyyandCodex 2ac4265832 feat(windows): add builds and online updates
Build x64 and ARM64 release archives with a static MSVC runtime.
Support executable replacement, restart handoff and rollback on Windows.

Co-authored-by: Codex <noreply@openai.com>
2026-11-02 14:45:33 +08:00
ZyyandCodex d22ec29824 fix(usage): show CPR badges for linked accounts
Use current peer summaries for request records and account diagnostics without changing historical snapshots or database schema.

Co-authored-by: Codex <noreply@openai.com>
2026-11-02 14:39:44 +08:00
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
109 changed files with 2413 additions and 740 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
+18
View File
@@ -171,6 +171,12 @@ jobs:
toolchain: ${{ needs.release-info.outputs.rust }}
targets: ${{ matrix.target }}
- name: Configure Windows runtime linkage
if: runner.os == 'Windows'
shell: bash
# 发行包静态链接 MSVC 运行库,解压运行时无需另外安装 VC++ Redistributable
run: echo 'RUSTFLAGS=-C target-feature=+crt-static' >> "$GITHUB_ENV"
- name: Cache Cargo
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
@@ -186,6 +192,8 @@ jobs:
shell: bash
run: |
cargo build --release --locked --target "${{ matrix.target }}" --bin codex-proxy-rs
"target/${{ matrix.target }}/release/${{ matrix.binary }}" --help
"target/${{ matrix.target }}/release/${{ matrix.binary }}" --version
mkdir -p "../dist/binaries/${{ matrix.os }}-${{ matrix.arch }}"
cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "../dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/${{ matrix.binary }}"
working-directory: backend
@@ -195,6 +203,16 @@ jobs:
CPR_BUILD_TIME: ${{ needs.release-info.outputs.build_time }}
CPR_BUILD_TYPE: ${{ needs.release-info.outputs.build_type }}
- name: Verify Windows platform behavior
if: runner.os == 'Windows'
shell: bash
working-directory: backend
env:
RUST_MIN_STACK: '16777216'
run: |
cargo test --release --locked --target "${{ matrix.target }}" -p gateway-admin --test main use_case::plugins::official::
cargo test --release --locked --target "${{ matrix.target }}" -p gateway-host --test main system_update::
- name: Upload binary artifact
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
+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
+2
View File
@@ -40,6 +40,8 @@
以下命令适用于 Linux amd64/arm64,需要 Docker Engine、Docker Compose Plugin、curl 和 OpenSSL
Windows x64 / ARM64 使用发行包部署,步骤见 [Windows 二进制部署](deploy/README.md#windows-二进制部署)
已有部署请先看 [升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置
### 一键安装
+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]]
+1 -2
View File
@@ -1,7 +1,6 @@
//! 网关命令行入口,选择启动模式并创建异步运行时
use std::{
env,
error::Error,
io::{self, Write},
process::ExitCode,
@@ -21,7 +20,7 @@ fn main() -> ExitCode {
fn run() -> Result<ExitCode, Box<dyn Error + Send + Sync>> {
ensure_rustls_provider();
let mut arguments = env::args_os().skip(1);
let mut arguments = gateway_host::system_update::startup_arguments()?;
let command = arguments
.next()
.map(|value| {
@@ -739,6 +739,8 @@ pub struct UsageListRecord {
/// 请求选号时冻结的账号套餐
pub provider_account_plan_type: Option<String>,
pub provider_account_plan_type_display: Option<String>,
/// 查询时的互联识别结果,缺少当前摘要时为未知
pub provider_account_upstream_peer: Option<bool>,
pub provider_account_authentication_kind: Option<String>,
pub upstream_model_id: Option<String>,
pub upstream_transport: Option<String>,
@@ -1439,6 +1441,8 @@ pub struct DiagnosticsItem {
pub name: String,
pub account_authentication_kind: Option<String>,
pub account_plan_type: Option<String>,
/// 仅账号维度补充当前互联识别结果
pub account_upstream_peer: Option<bool>,
pub account_plan_type_display: Option<String>,
pub request_count: u64,
pub success_count: u64,
@@ -165,8 +165,9 @@ pub struct ConfigurePluginInstance {
pub artifact_sha256: String,
pub enabled: bool,
pub configuration: serde_json::Value,
/// 缺省保留旧值;显式空对象清除全部 secret
pub secrets: Option<BTreeMap<String, SecretString>>,
/// 按字段更新,省略的字段保留,null 清除对应字段
#[serde(default)]
pub secrets: BTreeMap<String, Option<SecretString>>,
pub bindings: Vec<PluginCapabilityBinding>,
}
@@ -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,
@@ -8,6 +8,7 @@ use std::{
use async_trait::async_trait;
use chrono::{DateTime, Duration, Utc};
use futures::StreamExt as _;
use gateway_core::{account::ProviderAccountId, routing::ProviderKind};
use crate::{
@@ -133,7 +134,8 @@ impl DefaultObservabilityService {
)
.map_err(|error| map_store_error(error, "dashboard"))?;
let runtime_slots = observation.runtime_slots;
self.enrich_list_records(&mut observation.recent_requests);
self.enrich_list_records(&mut observation.recent_requests)
.await;
self.enrich_dashboard_quotas(&mut observation.account_usage)
.await;
for account in &mut observation.account_usage {
@@ -259,7 +261,7 @@ impl ObservabilityService for DefaultObservabilityService {
.list_usage_records(query)
.await
.map_err(|error| map_store_error(error, "usage records"))?;
self.enrich_list_records(&mut page.items);
self.enrich_list_records(&mut page.items).await;
Ok(page)
}
@@ -336,6 +338,12 @@ impl ObservabilityService for DefaultObservabilityService {
.usage_diagnostics(range, filter, dimension, 100)
.await
.map_err(|error| map_store_error(error, "usage diagnostics"))?;
let peers = if dimension == DiagnosticDimension::Account {
self.current_upstream_peers(observation.items.iter().map(|item| item.key.as_str()))
.await
} else {
HashMap::new()
};
let items = observation
.items
.into_iter()
@@ -345,6 +353,7 @@ impl ObservabilityService for DefaultObservabilityService {
rate_or_zero(item.non_completion_count, item.request_count);
let retry_rate = rate_or_zero(item.retried_request_count, item.request_count);
DiagnosticsItem {
account_upstream_peer: peers.get(&item.key).copied(),
key: item.key,
name: item.name,
account_authentication_kind: item.account_authentication_kind,
@@ -912,6 +921,29 @@ fn usd_cost(costs: &[CurrencyCost]) -> Option<DecimalAmount> {
}
impl DefaultObservabilityService {
async fn current_upstream_peers<'a>(
&self,
ids: impl Iterator<Item = &'a str>,
) -> HashMap<String, bool> {
let Some(peer) = self.upstream_info.as_deref() else {
return HashMap::new();
};
let ids = ids
.map(str::to_owned)
.collect::<std::collections::BTreeSet<_>>();
// 同一页重复账号只读取一次摘要,单个账号不存在或读取失败不阻断历史记录
futures::stream::iter(ids)
.map(|id: String| async move {
let account_id = ProviderAccountId::new(id.clone()).ok()?;
let upstream = peer.read_summary(&account_id).await.ok()??.upstream?;
Some((id, upstream.discovered && upstream.observed_at.is_some()))
})
.buffer_unordered(8)
.filter_map(|value| async move { value })
.collect()
.await
}
async fn enrich_dashboard_quotas(&self, accounts: &mut [DashboardAccountUsage]) {
if accounts.is_empty() {
return;
@@ -976,8 +1008,22 @@ impl DefaultObservabilityService {
/// 补充当前套餐展示名称,逐条尽力把可校验的总额升级为完整分解;单条脏数据(非法 Provider kind、
/// 不支持的来源或费用规则失败)只保留该条已存的总额,不影响整页返回
fn enrich_list_records(&self, records: &mut [crate::model::observability::UsageListRecord]) {
async fn enrich_list_records(
&self,
records: &mut [crate::model::observability::UsageListRecord],
) {
let peers = self
.current_upstream_peers(
records
.iter()
.filter_map(|record| record.provider_account_ref.as_deref()),
)
.await;
for record in records {
record.provider_account_upstream_peer = record
.provider_account_ref
.as_ref()
.and_then(|id| peers.get(id).copied());
record.provider_account_plan_type_display =
record.provider_kind.as_deref().and_then(|provider| {
self.providers
@@ -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
@@ -217,7 +217,7 @@ impl PluginsService {
artifact_sha256: metadata.sha256.clone(),
enabled: !configuration_required && compatible,
configuration: configuration.clone(),
secrets: None,
secrets: Default::default(),
bindings: bindings.clone(),
};
match self.configure_instance(None, input, context).await {
@@ -28,17 +28,16 @@ pub(super) fn same_creation_request(
existing: &PluginInstance,
input: &ConfigurePluginInstance,
) -> bool {
let same_secrets = input
let mut requested_secrets = input
.secrets
.as_ref()
.map_or(existing.secrets.is_empty(), |secrets| {
secrets.len() == existing.secrets.len()
&& secrets.iter().all(|(key, value)| {
existing
.secrets
.get(key)
.is_some_and(|current| current.expose_secret() == value.expose_secret())
})
.iter()
.filter_map(|(key, value)| value.as_ref().map(|value| (key, value)));
let same_secrets = requested_secrets.clone().count() == existing.secrets.len()
&& requested_secrets.all(|(key, value)| {
existing
.secrets
.get(key)
.is_some_and(|current| current.expose_secret() == value.expose_secret())
});
existing.name == input.name.trim()
&& existing.artifact_sha256 == input.artifact_sha256
@@ -158,7 +158,6 @@ impl PluginsService {
}
let saved = if let Some(previous) = &existing
&& previous.artifact_sha256 != input.artifact_sha256
&& input.secrets.is_none()
{
self.store
.load_version_configuration(&previous.id, &input.artifact_sha256)
@@ -167,6 +166,21 @@ impl PluginsService {
} else {
None
};
let mut secrets = saved.map(|saved| saved.secrets).unwrap_or_else(|| {
existing
.as_ref()
.map_or_else(Default::default, |instance| instance.secrets.clone())
});
for (name, value) in input.secrets {
match value {
Some(value) => {
secrets.insert(name, value);
}
None => {
secrets.remove(&name);
}
}
}
let instance = PluginInstance {
id: existing.as_ref().map_or_else(
|| {
@@ -182,14 +196,7 @@ impl PluginsService {
enabled: input.enabled,
trusted_process: true,
configuration: input.configuration,
secrets: input
.secrets
.or_else(|| saved.map(|saved| saved.secrets))
.unwrap_or_else(|| {
existing
.as_ref()
.map_or_else(Default::default, |instance| instance.secrets.clone())
}),
secrets,
bindings: input.bindings,
revision: candidate_revision,
};
@@ -366,6 +366,7 @@ fn target_environment_matches(target: &str) -> bool {
match target {
"x86_64-unknown-linux-gnu" | "aarch64-unknown-linux-gnu" => cfg!(target_env = "gnu"),
"aarch64-apple-darwin" => cfg!(target_os = "macos"),
"x86_64-pc-windows-msvc" | "aarch64-pc-windows-msvc" => cfg!(target_env = "msvc"),
_ => false,
}
}
@@ -392,4 +393,18 @@ const SUPPORTED_PLATFORMS: &[Platform] = &[
runtime_os: "macos",
runtime_architecture: "aarch64",
},
Platform {
release_os: "windows",
release_architecture: "amd64",
target: "x86_64-pc-windows-msvc",
runtime_os: "windows",
runtime_architecture: "x86_64",
},
Platform {
release_os: "windows",
release_architecture: "arm64",
target: "aarch64-pc-windows-msvc",
runtime_os: "windows",
runtime_architecture: "aarch64",
},
];
@@ -69,7 +69,7 @@ impl PluginsService {
artifact_sha256: input.artifact_sha256,
enabled: existing.enabled,
configuration: saved.configuration,
secrets: Some(saved.secrets),
secrets: Default::default(),
bindings: saved.bindings,
};
// 沿用同一份快照的 CAS 和状态迁移流程,不能在检查目标后重新读取并覆盖并发配置
@@ -202,7 +202,7 @@ impl PluginsService {
artifact_sha256: input.artifact_sha256,
enabled: existing.enabled,
configuration: configuration.configuration,
secrets: Some(configuration.secrets),
secrets: Default::default(),
bindings: configuration.bindings,
};
// 同一次读取的 CAS 覆盖草稿计算、准备与提交,不能覆盖并发设置
@@ -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,
@@ -1478,6 +1479,7 @@ fn total_record(
now: DateTime<Utc>,
) -> UsageListRecord {
UsageListRecord {
provider_account_upstream_peer: None,
error_code: None,
error_message: None,
client_status_code: None,
@@ -0,0 +1,115 @@
//! 实例敏感配置的逐字段更新、版本基准与提交隔离测试
use super::*;
use secrecy::ExposeSecret;
fn secret_values(instance: &PluginInstance) -> serde_json::Value {
serde_json::Value::Object(
instance
.secrets
.iter()
.map(|(name, value)| (name.clone(), json!(value.expose_secret())))
.collect(),
)
}
#[tokio::test]
async fn secret_changes_apply_to_current_or_target_version_configuration() {
for (target, saved_version) in [
(OLD_ARTIFACT, false),
(NEW_ARTIFACT, false),
(NEW_ARTIFACT, true),
] {
let token = if saved_version {
"saved-token"
} else {
"current-token"
};
let untouched = if saved_version {
"saved-value"
} else {
"current-value"
};
for (changes, expected) in [
(json!({}), json!({"token":token,"untouched":untouched})),
(
json!({"adminKey":"new-key"}),
json!({"token":token,"untouched":untouched,"adminKey":"new-key"}),
),
(
json!({"token":"new-token"}),
json!({"token":"new-token","untouched":untouched}),
),
(json!({"token":null}), json!({"untouched":untouched})),
(
json!({"token":null,"adminKey":"new-key","absent":null}),
json!({"untouched":untouched,"adminKey":"new-key"}),
),
] {
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
{
let mut data = fixture.data.lock().unwrap();
data.snapshot.instances[0].secrets = BTreeMap::from([
("token".into(), "current-token".into()),
("untouched".into(), "current-value".into()),
]);
if saved_version {
data.history.get_mut(NEW_ARTIFACT).unwrap().secrets = BTreeMap::from([
("token".into(), "saved-token".into()),
("untouched".into(), "saved-value".into()),
]);
} else {
data.history.clear();
}
}
let before = fixture.snapshot();
let mut request = input();
request.artifact_sha256 = target.into();
request.expected_revision = Some(10);
request.secrets = serde_json::from_value(changes).unwrap();
let result = service(fixture.clone(), Arc::new(Published::default()))
.configure_instance(Some(&before.instances[0].id), request, &context())
.await
.unwrap();
assert_eq!(secret_values(&result.instance), expected);
assert_eq!(secret_values(&fixture.snapshot().instances[0]), expected);
}
}
}
#[tokio::test]
async fn failed_secret_changes_leave_saved_values_unchanged() {
for failure in ["stale", "preparation", "size"] {
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
fixture.data.lock().unwrap().snapshot.instances[0].secrets =
BTreeMap::from([("token".into(), "saved-token".into())]);
let before = fixture.snapshot();
let mut request = input();
request.artifact_sha256 = OLD_ARTIFACT.into();
request.expected_revision = Some(if failure == "stale" { 9 } else { 10 });
request.secrets = BTreeMap::from([
("token".into(), None),
(
"adminKey".into(),
Some(
if failure == "size" {
"x".repeat(8193)
} else {
"new-key".into()
}
.into(),
),
),
]);
fixture.data.lock().unwrap().fail_preparation = failure == "preparation";
let result = service(fixture.clone(), Arc::new(Published::default()))
.configure_instance(Some(&before.instances[0].id), request, &context())
.await;
assert!(result.is_err(), "{failure} must reject the update");
assert_eq!(
secret_values(&fixture.snapshot().instances[0]),
secret_values(&before.instances[0])
);
assert!(fixture.data.lock().unwrap().saves.is_empty());
}
}
@@ -49,6 +49,7 @@ use serde_json::json;
use super::TestPluginPorts;
mod configuration;
mod queries;
const OLD_ARTIFACT: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
@@ -759,7 +760,7 @@ fn input() -> ConfigurePluginInstance {
artifact_sha256: NEW_ARTIFACT.into(),
enabled: true,
configuration: json!({}),
secrets: None,
secrets: Default::default(),
bindings: vec![],
}
}
@@ -942,6 +943,8 @@ async fn configuration_retry_reuses_creation_id_and_rejects_changed_draft() {
// 此用例验证创建重试,使用不需要私有状态迁移的固定版本
value.artifact_sha256 = OLD_ARTIFACT.into();
value.enabled = false;
value.secrets =
serde_json::from_value(json!({"token": "saved-token", "absent": null})).unwrap();
value
};
let created = service
@@ -955,6 +958,12 @@ async fn configuration_retry_reuses_creation_id_and_rejects_changed_draft() {
assert_eq!(created.instance.id, creation_id);
assert_eq!(retried.instance.id, creation_id);
assert_eq!(fixture.snapshot().instances.len(), 2);
use secrecy::ExposeSecret as _;
assert_eq!(
retried.instance.secrets["token"].expose_secret(),
"saved-token"
);
assert!(!retried.instance.secrets.contains_key("absent"));
let before = fixture.snapshot().config_revision;
let mut changed = request();
changed.name = "changed draft".into();
@@ -965,6 +974,17 @@ async fn configuration_retry_reuses_creation_id_and_rejects_changed_draft() {
.unwrap();
assert_eq!(error.kind(), AdminErrorKind::Conflict);
assert_eq!(fixture.snapshot().config_revision, before);
let mut changed = request();
changed
.secrets
.insert("token".into(), Some("different-token".into()));
let error = service
.configure_instance(None, changed, &context())
.await
.err()
.unwrap();
assert_eq!(error.kind(), AdminErrorKind::Conflict);
assert_eq!(fixture.snapshot().config_revision, before);
}
#[tokio::test]
@@ -1435,23 +1455,6 @@ async fn version_switch_preparation_failure_preserves_current_and_saved_settings
);
}
#[tokio::test]
async fn version_edit_preserves_the_target_versions_secrets_when_omitted() {
use secrecy::ExposeSecret as _;
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
let before = fixture.snapshot();
let mut request = input();
request.secrets = None;
let result = service(fixture, Arc::new(Published::default()))
.configure_instance(Some(&before.instances[0].id), request, &context())
.await
.unwrap();
assert_eq!(
result.instance.secrets["token"].expose_secret(),
"old-version-secret"
);
}
#[tokio::test]
async fn rollback_requires_a_saved_configuration_instead_of_reusing_incompatible_current_settings()
{
@@ -446,6 +446,8 @@ fn release_platform() -> (&'static str, &'static str, &'static str) {
("linux", "x86_64") => ("linux", "amd64", "x86_64-unknown-linux-gnu"),
("linux", "aarch64") => ("linux", "arm64", "aarch64-unknown-linux-gnu"),
("macos", "aarch64") => ("darwin", "arm64", "aarch64-apple-darwin"),
("windows", "x86_64") => ("windows", "amd64", "x86_64-pc-windows-msvc"),
("windows", "aarch64") => ("windows", "arm64", "aarch64-pc-windows-msvc"),
platform => panic!("unsupported test platform: {platform:?}"),
}
}
@@ -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,
@@ -271,6 +271,7 @@ pub(crate) fn usage_list_record_view(
account_notes: record.provider_account_notes,
account_plan_type: record.provider_account_plan_type,
account_plan_type_display: record.provider_account_plan_type_display,
account_upstream_peer: record.provider_account_upstream_peer,
route: record.endpoint,
model,
requested_model: record.requested_model_id,
@@ -1351,6 +1352,7 @@ pub(crate) fn diagnostics_view(
request_count: item.request_count,
account_plan_type: item.account_plan_type,
account_plan_type_display: item.account_plan_type_display,
account_upstream_peer: item.account_upstream_peer,
success_count: item.success_count,
error_count: item.error_count,
error_rate: item.error_rate,
@@ -99,6 +99,7 @@ pub struct UsageListRecordView {
pub account_notes: Option<String>,
pub account_plan_type: Option<String>,
pub account_plan_type_display: Option<String>,
pub account_upstream_peer: Option<bool>,
pub route: String,
pub model: Option<String>,
pub requested_model: Option<String>,
@@ -849,6 +850,7 @@ pub struct DiagnosticItemView {
pub authentication_kind: Option<String>,
pub account_plan_type: Option<String>,
pub account_plan_type_display: Option<String>,
pub account_upstream_peer: Option<bool>,
pub request_count: u64,
pub success_count: u64,
pub error_count: u64,
@@ -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,
@@ -656,6 +656,23 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
);
}
fn peer_summary(
discovered: bool,
observed: bool,
) -> gateway_admin::model::provider_credentials::ProviderQuota {
gateway_admin::model::provider_credentials::ProviderQuota {
upstream: Some(gateway_admin::model::upstream_info::ProviderUpstreamInfo {
discovered,
observed_at: observed.then(chrono::Utc::now),
stale: true,
connection_revision: "test-connection".to_owned(),
last_error: None,
pool: None,
}),
..Default::default()
}
}
#[tokio::test]
async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
use axum::{
@@ -670,13 +687,17 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
use crate::admin::{AdminTestFixture, AdminTestState};
for (authentication_kind, plan_type, plan_display) in [
(Some("oauth"), Some("pro"), Some("Pro")),
(Some("api_key"), None, None),
(None, None, None),
for (authentication_kind, plan_type, plan_display, peer) in [
(Some("oauth"), Some("pro"), Some("Pro"), None),
(Some("api_key"), None, None, None),
(Some("api_key"), None, None, Some((true, true))),
(Some("api_key"), None, None, Some((true, false))),
(Some("api_key"), None, None, Some((false, true))),
(None, None, None, None),
] {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
*fixture.provider_quota.lock().unwrap() = peer.map(|(d, o)| peer_summary(d, o));
*fixture.diagnostics.lock().expect("diagnostics") = DiagnosticsObservation {
analytics: Default::default(),
total_request_count: 4,
@@ -718,6 +739,10 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
.await
.expect("diagnostics body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("diagnostics JSON");
assert_eq!(
value["data"]["items"][0]["accountUpstreamPeer"],
json!(peer.map(|(d, o)| d && o))
);
assert_eq!(value["data"]["items"][0]["requestShare"], 0.5);
assert_eq!(value["data"]["items"][0]["retryCount"], 3);
assert_eq!(value["data"]["items"][0]["retryRate"], 0.5);
@@ -906,6 +931,7 @@ async fn usage_route_projects_request_facts_and_billing() {
.lock()
.expect("usage records")
.push(UsageListRecord {
provider_account_upstream_peer: None,
error_code: None,
error_message: None,
client_status_code: None,
@@ -1131,6 +1157,39 @@ async fn usage_route_projects_request_facts_and_billing() {
value["data"]["items"][2]["billing"]["totalAmountDisplay"],
"$0.007"
);
// 互联标识随当前摘要变化,重复请求共享同一账号,既有请求快照保持原值
for peer in [
Some((true, true)),
Some((true, false)),
Some((false, true)),
None,
] {
*fixture.provider_quota.lock().unwrap() = peer.map(|(d, o)| peer_summary(d, o));
let response = observability::router::<AdminTestState>()
.with_state(fixture.state())
.oneshot(
Request::builder()
.uri("/api/admin/usage/records?status=all")
.header(header::COOKIE, "cpr_session=valid-session")
.header("x-request-id", "req_peer_badge")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
for item in value["data"]["items"].as_array().unwrap() {
assert_eq!(
item["accountUpstreamPeer"],
json!(peer.map(|(d, o)| d && o))
);
assert_eq!(item["accountName"], "Snapshot Alpha");
assert_eq!(item["accountPlanType"], "supergrok_plus");
}
}
}
#[tokio::test]
@@ -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(),
@@ -92,6 +92,7 @@ pub(super) async fn fixture() -> AdminTestFixture {
fn usage_record() -> UsageListRecord {
UsageListRecord {
provider_account_upstream_peer: None,
error_code: None,
error_message: None,
client_status_code: None,
@@ -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)
@@ -6,7 +6,11 @@ use std::path::{Component, Path, PathBuf};
use super::{OperationError, internal, invalid};
const APP_BINARY_NAME: &str = "codex-proxy-rs";
const APP_BINARY_NAME: &str = if cfg!(windows) {
"codex-proxy-rs.exe"
} else {
"codex-proxy-rs"
};
const MAX_EXTRACTED_SIZE: u64 = 1024 * 1024 * 1024;
const MAX_ARCHIVE_FILES: usize = 20_000;
@@ -126,14 +130,42 @@ pub(crate) fn extract_release(
fn unsafe_archive_path(path: &Path) -> bool {
path.is_absolute()
|| path.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::Prefix(_) | Component::RootDir
)
|| path.components().any(|component| match component {
Component::ParentDir | Component::Prefix(_) | Component::RootDir => true,
#[cfg(windows)]
Component::Normal(name) => unsafe_windows_name(name),
_ => false,
})
}
#[cfg(windows)]
fn unsafe_windows_name(name: &std::ffi::OsStr) -> bool {
let name = name.to_string_lossy();
// 防止归档路径被 Win32 解释为设备、NTFS 备用数据流或另一个规范化文件名
if name.ends_with(['.', ' '])
|| name
.chars()
.any(|value| value < ' ' || "<>:\"|?*".contains(value))
{
return true;
}
let stem = name
.split('.')
.next()
.unwrap_or_default()
.to_ascii_uppercase();
matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| stem
.strip_prefix("COM")
.or_else(|| stem.strip_prefix("LPT"))
.is_some_and(|suffix| {
matches!(
suffix,
"1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³"
)
})
}
fn web_dist_relative_path(path: &Path) -> Option<PathBuf> {
let components = path
.components()
@@ -16,6 +16,7 @@ mod swap_paths;
pub use config::SystemUpdateConfig;
pub use operations::ProcessSystemOperations;
pub use process::startup_arguments;
pub use release::validate_download_url;
use error::{OperationError, conflict, internal, invalid, upstream};
@@ -41,12 +41,18 @@ pub struct ProcessSystemOperations {
cancellation: CancellationToken,
events: Arc<UpdateEvents>,
config: Arc<SystemUpdateConfig>,
operation_lock: Arc<AsyncMutex<Option<OperationFileLock>>>,
operation_lock: Arc<AsyncMutex<Option<RestartGuard>>>,
release_cache: Arc<ReleaseCache>,
running_files: Arc<Result<ReleaseFiles, OperationError>>,
diagnostics: super::diagnostics::Diagnostics,
}
struct RestartGuard {
// 字段按声明顺序释放,先移除更新锁,再通知替换进程开始启动
_file_lock: OperationFileLock,
_replacement_input: Option<std::process::ChildStdin>,
}
impl ProcessSystemOperations {
#[must_use]
pub fn new(cancellation: CancellationToken, mut config: SystemUpdateConfig) -> Self {
@@ -75,7 +81,7 @@ impl ProcessSystemOperations {
self.diagnostics.bind(diagnostics);
}
fn lock_operation(&self) -> Result<OwnedMutexGuard<Option<OperationFileLock>>, OperationError> {
fn lock_operation(&self) -> Result<OwnedMutexGuard<Option<RestartGuard>>, OperationError> {
let operation = Arc::clone(&self.operation_lock)
.try_lock_owned()
.map_err(|_| conflict("已有系统操作正在执行"))?;
@@ -559,15 +565,17 @@ impl SystemOperations for ProcessSystemOperations {
{
return Err(conflict("重启检查期间安装文件已变化,请重新确认"));
}
let message = if self.config.deployment_mode == "docker" {
"已安排进程内重启"
let (message, replacement_input) = if self.config.deployment_mode == "docker" {
("已安排进程内重启", None)
} else {
spawn_replacement(&self.config)?;
"已安排自重启"
("已安排自重启", spawn_replacement(&self.config)?)
};
let operation_id = operation_id("restart");
// 受理后直到旧服务释放,保留重启状态和跨进程锁;延迟关闭只用于送达响应
*operation = Some(file_lock);
*operation = Some(RestartGuard {
_file_lock: file_lock,
_replacement_input: replacement_input,
});
let cancellation = self.cancellation.clone();
drop(tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(500)).await;
@@ -2,15 +2,37 @@
use std::env;
use std::fs;
use std::process::{Command, Stdio};
use std::io;
use std::process::{ChildStdin, Command, Stdio};
#[cfg(unix)]
use super::config::environment_value;
use super::{OperationError, SystemUpdateConfig, internal};
#[cfg(unix)]
const RESTART_DELAY_ENV: &str = "CPR_RESTART_DELAY_MS";
#[cfg(unix)]
const DEFAULT_RESTART_DELAY_MS: u64 = 1_200;
const RESTART_HANDOFF_ARGUMENT: &str = "--restart-handoff";
pub(crate) fn spawn_replacement(config: &SystemUpdateConfig) -> Result<(), OperationError> {
/// 组合根在创建运行时前接收重启交接,并取得不含内部交接标记的 CLI 参数
pub fn startup_arguments() -> Result<impl Iterator<Item = std::ffi::OsString>, OperationError> {
let mut arguments = env::args_os().skip(1).peekable();
if arguments
.peek()
.is_some_and(|value| value == RESTART_HANDOFF_ARGUMENT)
{
arguments.next();
// 旧服务释放操作锁后关闭管道;旧进程异常退出也会关闭管道,避免固定延迟抢占端口
io::copy(&mut io::stdin().lock(), &mut io::sink())
.map_err(|error| internal("无法等待旧服务释放资源").with_source(error))?;
}
Ok(arguments)
}
pub(crate) fn spawn_replacement(
config: &SystemUpdateConfig,
) -> Result<Option<ChildStdin>, OperationError> {
let executable = config.executable_path()?;
let metadata = fs::metadata(&executable)
.map_err(|error| internal("无法启动替换进程").with_source(error))?;
@@ -25,12 +47,12 @@ pub(crate) fn spawn_replacement(config: &SystemUpdateConfig) -> Result<(), Opera
}
}
let delay_ms = environment_value(RESTART_DELAY_ENV)
.and_then(|value| value.parse::<u64>().ok())
.filter(|value| *value > 0)
.unwrap_or(DEFAULT_RESTART_DELAY_MS);
#[cfg(unix)]
let mut command = {
let delay_ms = environment_value(RESTART_DELAY_ENV)
.and_then(|value| value.parse::<u64>().ok())
.filter(|value| *value > 0)
.unwrap_or(DEFAULT_RESTART_DELAY_MS);
let delay = format!("{}.{:03}", delay_ms / 1_000, delay_ms % 1_000);
let mut command = Command::new("/bin/sh");
command
@@ -39,20 +61,26 @@ pub(crate) fn spawn_replacement(config: &SystemUpdateConfig) -> Result<(), Opera
.arg("codex-proxy-rs-restart")
.arg(delay)
.arg(&executable)
.args(env::args_os().skip(1));
.args(env::args_os().skip(1))
.stdin(Stdio::null());
command
};
#[cfg(not(unix))]
let mut command = {
let mut command = Command::new(&executable);
command
.args(env::args_os().skip(1))
.env(RESTART_DELAY_ENV, delay_ms.to_string());
.arg(RESTART_HANDOFF_ARGUMENT)
// 连续重启时移除当前进程已消费的交接标记,保留原始参数及编码
.args(
env::args_os()
.skip(1)
.skip_while(|value| value == RESTART_HANDOFF_ARGUMENT),
)
.stdin(Stdio::piped());
command
};
command.stdin(Stdio::null());
command
.spawn()
.map(|_| ())
.map(|mut child| child.stdin.take())
.map_err(|error| internal("无法启动替换进程").with_source(error))
}
@@ -149,7 +149,7 @@ impl UpdateTempDir {
impl Drop for UpdateTempDir {
fn drop(&mut self) {
if let Err(error) = fs::remove_dir_all(&self.path)
if let Err(error) = super::swap::remove_dir_all(&self.path)
&& error.kind() != io::ErrorKind::NotFound
{
tracing::warn!(path = %self.path.display(), error = %error, "清理系统更新临时目录失败");
@@ -294,12 +294,40 @@ fn protect_official_plugins(_path: &Path) -> io::Result<()> {
Ok(())
}
fn remove_dir_all(path: &Path) -> io::Result<()> {
pub(crate) fn remove_dir_all(path: &Path) -> io::Result<()> {
#[cfg(unix)]
make_directories_writable(path)?;
#[cfg(windows)]
make_files_writable(path)?;
fs::remove_dir_all(path)
}
#[cfg(windows)]
fn make_files_writable(path: &Path) -> io::Result<()> {
let metadata = fs::symlink_metadata(path)?;
if metadata.file_type().is_symlink() || (!metadata.is_dir() && !metadata.is_file()) {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"release directory contains an unsupported file type",
));
}
if metadata.is_dir() {
for entry in fs::read_dir(path)? {
make_files_writable(&entry?.path())?;
}
} else if metadata.permissions().readonly() {
// tar 的 0444 会成为 Windows 只读属性,清理受管备份前必须移除此属性
let mut permissions = metadata.permissions();
#[expect(
clippy::permissions_set_readonly_false,
reason = "仅 Windows 清理只读属性,不会放宽 Unix 权限"
)]
permissions.set_readonly(false);
fs::set_permissions(path, permissions)?;
}
Ok(())
}
#[cfg(unix)]
fn make_directories_writable(path: &Path) -> io::Result<()> {
use std::os::unix::fs::PermissionsExt as _;
@@ -6,13 +6,25 @@ use std::{
};
fn main() {
let executable = std::env::current_exe().unwrap();
let directory = executable.parent().unwrap();
if directory.join("restart-probe").is_file() {
std::fs::write(directory.join("replacement-waiting"), b"ready").unwrap();
let arguments = gateway_host::system_update::startup_arguments()
.unwrap()
.collect::<Vec<_>>();
assert!(!arguments.iter().any(|value| value == "--restart-handoff"));
assert!(!directory.join("update.lock").exists());
std::fs::write(directory.join("replacement-ran"), b"started").unwrap();
return;
}
let mode = std::fs::read_to_string("mode").unwrap();
if mode == "stderr" {
std::io::stderr().write_all(&[b'x'; 4096]).unwrap();
}
if matches!(
mode.as_str(),
"busy" | "stdin" | "stdout" | "exit" | "allocate"
"busy" | "stdin" | "stdout" | "exit" | "allocate" | "update-probe"
) {
std::io::stdout().write_all(b"R").unwrap();
std::io::stdout().flush().unwrap();
@@ -25,6 +37,11 @@ fn main() {
std::io::stdin().read_exact(&mut [0; 1]).unwrap();
std::fs::write("unblocked", b"stdin").unwrap();
}
"update-probe" => {
std::io::stdin().read_exact(&mut [0; 1]).unwrap();
std::fs::write("unblocked", b"still-running").unwrap();
return;
}
"stdout" => {
// 测试端不消费此正文,超过管道容量后必须由监督器终止,而不是等待写入完成
std::io::stdout()
@@ -18,8 +18,7 @@ async fn manual_image_replacement_should_clear_pending_state_and_invalid_backup(
assert!(complete_update(&service, TARGET_VERSION).await.need_restart);
// 模拟重建镜像:数据卷保留,应用文件和容器内备份被重新替换
fixture.write_executable("manually-installed-binary");
fs::remove_file(fixture.root.path().join("codex-proxy-rs.backup"))
.expect("remove image backup");
fs::remove_file(fixture.backup()).expect("remove image backup");
let mut config = fixture.config(&format!("{}/repos", server.uri()));
config.version = "1.1.0".into();
config.deployment_mode = "docker".into();
@@ -126,11 +125,7 @@ async fn pending_update_should_revoke_rollback_when_backup_content_changes() {
.await;
let service = fixture.service(&server);
assert!(complete_update(&service, TARGET_VERSION).await.need_restart);
fs::write(
fixture.root.path().join("codex-proxy-rs.backup"),
"unrelated-backup",
)
.expect("changed backup");
fs::write(fixture.backup(), "unrelated-backup").expect("changed backup");
let status = service
.update_status()
.await
@@ -40,6 +40,12 @@ use tar::{Builder, EntryType, Header};
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
const BINARY_NAME: &str = if cfg!(windows) {
"codex-proxy-rs.exe"
} else {
"codex-proxy-rs"
};
const OLD_RELEASE_MANIFEST: &str = r#"{"gateway_version":"1.0.0"}"#;
const TARGET_VERSION: &str = "1.9.9";
@@ -253,7 +259,11 @@ impl Fixture {
}
fn executable(&self) -> PathBuf {
self.root.path().join("codex-proxy-rs")
self.root.path().join(BINARY_NAME)
}
fn backup(&self) -> PathBuf {
self.root.path().join(format!("{BINARY_NAME}.backup"))
}
fn web(&self) -> PathBuf {
@@ -393,7 +403,7 @@ impl Fixture {
#[derive(Clone, Copy)]
enum ArchiveKind {
Safe,
UnsafePath,
UnsafePath(&'static str),
MissingOfficialManifest,
NestedOfficialAsset,
OfficialSymlink,
@@ -409,7 +419,7 @@ enum ChecksumKind {
fn release_archive(kind: ArchiveKind) -> Vec<u8> {
let encoder = GzEncoder::new(Vec::new(), Compression::default());
let mut tar = Builder::new(encoder);
append_file(&mut tar, "codex-proxy-rs", b"new-binary", false);
append_file(&mut tar, BINARY_NAME, b"new-binary", false);
append_file(&mut tar, "web/dist/index.html", b"new-web", false);
if !matches!(kind, ArchiveKind::MissingOfficialManifest) {
append_file(
@@ -425,8 +435,8 @@ fn release_archive(kind: ArchiveKind) -> Vec<u8> {
b"new-plugin",
false,
);
if matches!(kind, ArchiveKind::UnsafePath) {
append_file(&mut tar, "safe", b"escape", true);
if let ArchiveKind::UnsafePath(name) = kind {
append_file(&mut tar, name, b"escape", true);
}
if matches!(kind, ArchiveKind::NestedOfficialAsset) {
append_file(
@@ -461,13 +471,19 @@ fn append_symlink(tar: &mut Builder<GzEncoder<Vec<u8>>>, name: &str, target: &st
fn append_file(tar: &mut Builder<GzEncoder<Vec<u8>>>, name: &str, data: &[u8], unsafe_path: bool) {
let mut header = Header::new_gnu();
header.set_entry_type(EntryType::Regular);
header.set_mode(0o755);
header.set_mode(if name.starts_with("plugins/official/") {
0o444
} else {
0o755
});
header.set_size(u64::try_from(data.len()).expect("size"));
header.set_path(name).expect("path");
header
.set_path(if unsafe_path { "safe" } else { name })
.expect("path");
if unsafe_path {
let bytes = header.as_mut_bytes();
bytes[..100].fill(0);
bytes[..9].copy_from_slice(b"../escape");
bytes[..name.len()].copy_from_slice(name.as_bytes());
}
header.set_cksum();
tar.append(&header, data).expect("append");
@@ -2,6 +2,120 @@
use super::*;
#[tokio::test]
async fn update_and_rollback_preserve_a_running_executable() {
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
let server = MockServer::start().await;
let fixture = Fixture::new();
fs::copy(
env!("CARGO_BIN_EXE_gateway-host-process-worker"),
fixture.executable(),
)
.expect("running executable");
let original = fs::read(fixture.executable()).expect("original executable");
fs::write(fixture.root.path().join("mode"), b"update-probe").expect("worker mode");
let mut child = tokio::process::Command::new(fixture.executable())
.current_dir(fixture.root.path())
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.kill_on_drop(true)
.spawn()
.expect("start old executable");
assert_eq!(
tokio::time::timeout(
Duration::from_secs(5),
child.stdout.as_mut().unwrap().read_u8()
)
.await
.expect("worker ready")
.expect("ready byte"),
b'R'
);
fixture
.mount_release(
&server,
TARGET_VERSION,
ArchiveKind::Safe,
ChecksumKind::Valid,
)
.await;
let service = fixture.service(&server);
let status = complete_update(&service, TARGET_VERSION).await;
assert_eq!(
status.operation.status,
SystemOperationStatus::Succeeded,
"{status:?}"
);
assert!(status.need_restart);
assert_eq!(fs::read(fixture.backup()).expect("backup"), original);
assert_eq!(
fs::read(fixture.executable()).expect("installed"),
b"new-binary"
);
assert!(child.try_wait().expect("old process state").is_none());
service
.rollback(Arc::new(AllowingUpdatePreflight))
.await
.expect("rollback running executable");
assert_eq!(
fs::read(fixture.executable()).expect("restored executable"),
original
);
assert!(
!service
.update_status()
.await
.expect("restored status")
.need_restart
);
service
.perform_update(
Some(TARGET_VERSION.to_owned()),
None,
Arc::new(ChangingRevisionPreflight {
confirmations: AtomicUsize::new(0),
}),
)
.await
.expect("second update accepted");
let failed = wait_for_update(&service).await;
assert_eq!(
failed.operation.status,
SystemOperationStatus::Failed,
"{failed:?}"
);
assert_eq!(
fs::read(fixture.executable()).expect("restored after failure"),
original
);
assert_eq!(
fs::read(fixture.web().join("index.html")).expect("restored web"),
b"old-web"
);
child
.stdin
.as_mut()
.unwrap()
.write_u8(b'X')
.await
.expect("release old process");
assert!(
tokio::time::timeout(Duration::from_secs(5), child.wait())
.await
.expect("worker exited")
.expect("exit status")
.success()
);
assert_eq!(
fs::read(fixture.root.path().join("unblocked")).expect("old process result"),
b"still-running"
);
}
#[tokio::test]
async fn restart_should_not_shutdown_when_replacement_spawn_fails() {
let fixture = Fixture::new();
@@ -149,7 +263,12 @@ async fn assert_system_operations_rejected(service: &ProcessSystemOperations, re
async fn restart_should_spawn_replacement_before_shutdown_outside_docker() {
let fixture = Fixture::new();
let replacement_marker = fixture.root.path().join("replacement-ran");
fixture.write_executable("#!/bin/sh\n: > \"${0%/*}/replacement-ran\"\n");
fs::copy(
env!("CARGO_BIN_EXE_gateway-host-process-worker"),
fixture.executable(),
)
.expect("replacement executable");
fs::write(fixture.root.path().join("restart-probe"), b"").expect("restart probe");
let mut config = fixture.config("http://127.0.0.1:1/repos");
config.self_restart_enabled = true;
let shutdown = CancellationToken::new();
@@ -166,6 +285,21 @@ async fn restart_should_spawn_replacement_before_shutdown_outside_docker() {
tokio::time::timeout(Duration::from_secs(2), shutdown.cancelled())
.await
.expect("shutdown requested after spawn");
#[cfg(windows)]
{
tokio::time::timeout(Duration::from_secs(5), async {
while !fixture.root.path().join("replacement-waiting").exists() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("replacement waiting for old service");
assert!(
!replacement_marker.exists(),
"new service must wait for resource release"
);
}
drop(service);
tokio::time::timeout(Duration::from_secs(2), async {
while !replacement_marker.is_file() {
tokio::time::sleep(Duration::from_millis(10)).await;
@@ -189,7 +323,7 @@ async fn restart_should_use_startup_path_after_running_executable_is_renamed() {
let output = std::process::Command::new(child)
.args([
"--exact",
"system_update::restart_should_use_startup_path_after_running_executable_is_renamed",
"system_update::operations::restart_should_use_startup_path_after_running_executable_is_renamed",
])
.env(CHILD_ENV, "1")
.env("CPR_RESTART_DELAY_MS", "1200")
@@ -223,7 +357,7 @@ async fn restart_should_use_startup_path_after_running_executable_is_renamed() {
config.self_restart_enabled = true;
let service = ProcessSystemOperations::new(CancellationToken::new(), config);
let backup = fixture.root.path().join("codex-proxy-rs.backup");
let backup = fixture.backup();
fs::rename(fixture.executable(), &backup).expect("back up running executable");
fixture.write_executable("#!/bin/sh\nprintf new > \"${0%/*}/replacement-version\"\n");
assert_eq!(std::env::current_exe().expect("renamed executable"), backup);
@@ -777,24 +911,42 @@ async fn update_should_reject_release_archive_from_untrusted_host() {
#[tokio::test]
async fn update_should_reject_release_archive_with_unsafe_path() {
let server = MockServer::start().await;
let fixture = Fixture::new();
fixture
.mount_release(
&server,
TARGET_VERSION,
ArchiveKind::UnsafePath,
ChecksumKind::Valid,
)
.await;
for unsafe_path in [
"../escape",
#[cfg(windows)]
"web/dist/index.html:payload",
#[cfg(windows)]
"web/dist/NUL.txt",
#[cfg(windows)]
"web/dist/COM¹",
#[cfg(windows)]
"web/dist/trailing. ",
#[cfg(windows)]
"C:/escape",
] {
let server = MockServer::start().await;
let fixture = Fixture::new();
fixture
.mount_release(
&server,
TARGET_VERSION,
ArchiveKind::UnsafePath(unsafe_path),
ChecksumKind::Valid,
)
.await;
assert_eq!(
complete_update(&fixture.service(&server), TARGET_VERSION)
.await
.operation
.status,
SystemOperationStatus::Failed,
);
assert_eq!(
complete_update(&fixture.service(&server), TARGET_VERSION)
.await
.operation
.status,
SystemOperationStatus::Failed,
);
assert_eq!(
fs::read(fixture.executable()).expect("unchanged binary"),
b"old-binary"
);
}
}
#[tokio::test]
@@ -933,6 +1085,7 @@ async fn update_and_rollback_should_use_the_default_api_asset_directory() {
);
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn update_should_replace_web_assets_across_filesystems() {
let Ok(external) = tempfile::tempdir_in("/dev/shm") else {
@@ -980,7 +1133,7 @@ async fn update_should_restore_web_assets_when_binary_backup_fails() {
ChecksumKind::Valid,
)
.await;
fs::create_dir(fixture.executable().with_extension("backup")).expect("blocking backup dir");
fs::create_dir(fixture.backup()).expect("blocking backup dir");
assert_eq!(
complete_update(&fixture.service(&server), TARGET_VERSION)
@@ -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(),
@@ -327,6 +327,7 @@ pub(crate) fn usage_list_record_from_row(
provider_account_name: get(row, "provider_account_name")?,
provider_account_email: get(row, "provider_account_email")?,
provider_account_notes: get(row, "provider_account_notes")?,
provider_account_upstream_peer: None,
provider_account_plan_type: get(row, "provider_account_plan_type")?,
provider_account_plan_type_display: None,
provider_account_authentication_kind: get(row, "provider_account_authentication_kind")?,
@@ -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
+163 -4
View File
@@ -6,7 +6,7 @@
| 任务 | 入口 |
| --- | --- |
| 安装与启动 | [手动安装](#手动安装) · [启动](#启动) · [公网访问](#公网访问) |
| 安装与启动 | [手动安装](#手动安装) · [Windows 二进制部署](#windows-二进制部署) · [启动](#启动) · [公网访问](#公网访问) |
| 接入客户端 | [客户端配置](#客户端配置) · [时区与客户端环境信息](#时区与客户端环境信息) · [登录与生图排查](#登录与生图排查) |
| 日常运维 | [运行设置](#启动后的运行设置) · [小内存优化](#小内存优化) · [持久化与日志](#持久化与备份) · [请求排查](#请求错误排查) · [密码轮换](#密码语义) |
| 更新与恢复 | [镜像升级](#镜像升级与源码构建) · [在线更新](#管理端在线更新) · [备份与恢复](#备份与恢复) · [停止与重启](#停止与重启) |
@@ -19,7 +19,7 @@
| 位置 | 内容 | 修改方式 |
| --- | --- | --- |
| `deploy/config.yaml` | 监听、数据库/Redis 连接、部署凭据、时区、日志与可选内存分配策略等启动配置 | 从模板创建,已有部署合并修改,不覆盖原文件 |
| `deploy/compose.yaml` | 镜像、容器网络、端口、挂载与资源限制 | 调整 Compose 并重建受影响容器 |
| `deploy/compose.yaml` | 镜像、容器网络、端口、挂载、PostgreSQL 启动参数与资源限制 | 调整 Compose 并重建受影响容器 |
| PostgreSQL | 账号、Key、运行设置、上游身份、插件配置等业务数据 | 管理端或管理 API |
| 浏览器本地 | 主题和界面偏好 | 当前浏览器的主题设置 |
@@ -58,6 +58,21 @@ host:
### 小内存优化
1 GB 内存、网关与 PostgreSQL / Redis 同机、低并发部署可从本节参数开始评估。
这些值是调优起点,不是整机内存上限或容量保证;先记录实际负载下的内存峰值,优先缩小数据库连接池和缓冲,再按需调整 Redis 与应用空闲内存回收
| 调整对象 | 配置入口 | 生效方式 |
| --- | --- | --- |
| 应用空闲内存回收 | `config.yaml` → `services.app-runtime.environment.GLIBC_TUNABLES` | 重建应用容器 |
| 应用 PostgreSQL 连接池 | `config.yaml` → `store.pool.max_connections` | 重启应用;Compose 可按下文重建 |
| PostgreSQL 内存与连接预算 | `compose.yaml` → `services.postgres.command` | 重建 PostgreSQL 容器 |
| Redis 持久化与内存预算 | `config.yaml` → `services.redis-credentials.command` | 重建 Redis 容器 |
以下 YAML 片段合并到已有节点,不要重复创建顶层 `store` / `services`,也不要覆盖凭据、挂载和健康检查。
这些参数没有管理端热更新入口;二进制部署沿用 `store.pool`,PostgreSQL / Redis 参数则配置到各自服务的配置文件或启动命令,应用不会读取 `services` 桥接区来修改外部数据库
#### 应用空闲内存回收
Linux/glibc 部署可选择更积极地归还请求结束后的空闲内存,适合内存紧张且请求后 RSS 长时间偏高的机器。
该选项默认关闭,官方 Linux 镜像支持;更频繁的映射与回收可能增加 CPU 开销,应结合实际负载观察
@@ -71,7 +86,7 @@ services:
```
Compose 从 `services.app-runtime` 取得参数;不开启优化时保留该段并将值设为 `''`。
PostgreSQL 和 Redis 的凭据桥接配置与该段并列,无需在 `compose.yaml` 重复填写参数
PostgreSQL 和 Redis 的凭据桥接配置与该段并列,无需在 `compose.yaml` 重复填写 `GLIBC_TUNABLES`
校验并重建应用容器使配置生效;`docker compose restart` 不会更新容器环境变量:
@@ -94,6 +109,114 @@ GLIBC_TUNABLES=glibc.malloc.mmap_threshold=131072:glibc.malloc.trim_threshold=13
该变量由 glibc 在进程启动时读取,不支持管理端热更新。
它不设置内存上限,不释放仍被请求或连接持有的缓冲,也不处理容器文件缓存;观察时分别比较进程 RSS、cgroup 的 `anon` 与 `file`
#### PostgreSQL 与应用连接池
在 `deploy/config.yaml` 的 `store` 下设置连接池上限,未列出的池参数保留默认值:
```yaml
store:
pool:
max_connections: 5
```
应用默认上限为 20,允许的最小值为 2;降低上限可以限制实际数据库连接数,但也会增加请求、后台写入与管理查询之间的等待。
如果出现连接获取超时,应结合数据库负载调大池容量,不要仅延长等待时间
将 `deploy/compose.yaml` 中 `services.postgres.command` 替换为以下列表,保留 UTC 参数:
```yaml
command:
- postgres
- -c
- timezone=UTC
- -c
- log_timezone=UTC
- -c
- shared_buffers=64MB
- -c
- max_connections=30
- -c
- work_mem=2MB
- -c
- maintenance_work_mem=32MB
- -c
- autovacuum_work_mem=16MB
- -c
- max_parallel_workers_per_gather=0
```
`shared_buffers` 缩小共享缓冲;服务端 `max_connections` 限制整个数据库的连接数,应覆盖所有应用池,并为备份、维护和其他客户端留余量。
它与应用连接池上限是两个配置,调小服务端上限不会主动回收应用池里的空闲连接
`work_mem` 是每个排序或哈希操作的基础预算,哈希还受 `hash_mem_multiplier` 影响,一条 SQL 可以同时使用多份预算。
`maintenance_work_mem` 限制维护操作内存,`autovacuum_work_mem` 按自动清理进程分别计;保持自动清理开启。
关闭并行查询、缩小这些预算可能增加临时文件 I/O,并延长统计、索引创建与维护时间
参数定义见 [PostgreSQL 18 内存与并行查询](https://www.postgresql.org/docs/18/runtime-config-resource.html)和[连接设置](https://www.postgresql.org/docs/18/runtime-config-connection.html)
#### Redis 持久化与内存预算
项目默认开启 AOF,未显式设置 `maxmemory` 和定时 RDB 快照规则。
小内存部署可保留 AOF,关闭定时 RDB 快照以减少快照期间的 fork / 写时复制峰值;代价是少一份独立的 RDB 快照,AOF 重写仍需要预留额外内存
在已有 `deploy/config.yaml` 中替换 `services.redis-credentials.command`,复用同一文件内的密码锚点,保留该服务的 `healthcheck`:
```yaml
services:
redis-credentials:
command:
- redis-server
- --appendonly
- 'yes'
- --save
- ''
- --requirepass
- *redis_password
# 当前用量和负载峰值明显低于 64 MiB 时,再取消以下注释
# - --maxmemory
# - 64mb
# - --maxmemory-policy
# - noeviction
```
`--save` 后的空字符串必须保留,它表示关闭定时快照。持久化取舍见 [Redis 文档](https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/)
设置内存上限前先查看 `INFO memory` 的 `used_memory`、`used_memory_peak` 和 `used_memory_rss`,为业务增长、分配器和持久化峰值留余量。
`maxmemory 64mb` 只是可选起点,未达到上限时不会因此降低 RSS,也不是进程或容器的总内存上限。
达到上限后,`noeviction` 会拒绝需要增加内存的写入,可能影响登录与请求调度,应调大预算或减少负载
Redis 保存租约、冷却和登录会话等[协调状态](../docs/architecture.md#8-状态所有权),不要使用 `allkeys-lru`、`volatile-lru` 等提前淘汰键的策略。
预算与策略定义见 [Redis 内存限制](https://redis.io/docs/latest/develop/reference/eviction/)
#### 使配置生效与检查效果
只调整应用配置时,按[应用空闲内存回收](#应用空闲内存回收)中的命令重建应用容器。
调整 PostgreSQL / Redis 启动参数需要短暂停服,在维护窗口执行;只改其中一个服务时,数据库重建命令只列出该服务:
```bash
docker compose -f deploy/compose.yaml config --quiet
docker compose -f deploy/compose.yaml stop codex-proxy-rs
docker compose -f deploy/compose.yaml up -d --no-build --no-deps --force-recreate --wait postgres redis
docker compose -f deploy/compose.yaml up -d --no-build --no-deps --force-recreate --wait codex-proxy-rs
```
数据库健康检查通过后再执行最后一条命令;`restart` 不会更新容器启动命令或环境变量。
外部数据库需通过其管理方式应用参数,不能用本地 Compose 命令代替
通过交互提示输入对应数据库密码,核对实际生效值与内存占用,避免将密码写入命令行:
```bash
docker compose -f deploy/compose.yaml exec postgres psql -U codex_proxy -d codex_proxy -W \
-c 'SHOW shared_buffers; SHOW max_connections; SHOW work_mem; SHOW maintenance_work_mem; SHOW autovacuum_work_mem; SHOW max_parallel_workers_per_gather;'
docker compose -f deploy/compose.yaml exec redis redis-cli --askpass CONFIG GET appendonly save maxmemory maxmemory-policy
docker compose -f deploy/compose.yaml exec redis redis-cli --askpass INFO memory
docker compose -f deploy/compose.yaml stats --no-stream
```
在相同负载下比较空闲、请求峰值与后台统计 / 维护期间的占用,并检查实际模型请求、管理查询是否正常,有无连接获取超时、Redis OOM 或容器重启。
不要把 PostgreSQL 各进程的 RSS 直接相加,共享页会重复计数;容器文件缓存也应与匿名内存区分。
容器硬内存上限不能代替这些预算,过紧会导致 OOM;需要回退时恢复调整前的参数,按同样流程重建对应容器
## 部署结构
```mermaid
@@ -190,6 +313,42 @@ store:
`redis://u1@redis-host:6379/0`,其中主机名必须能从应用容器访问;只改 `config.yaml` 不会生效。
默认 Compose Redis 服务使用密码认证,不会自动创建 ACL 用户
## Windows 二进制部署
发行包提供 Windows x64(`windows_amd64`)和 ARM64(`windows_arm64`)版本,按运行机器的 CPU 架构选择。
需先准备可访问的 PostgreSQL 和 Redis,创建数据库及用户;使用数据库备份功能时,还需将匹配服务端版本的 `pg_dump` 加入 `PATH`
从 [Release 附件](https://github.com/zyycn/codex-proxy-rs/releases) 下载对应的
`codex-proxy-rs_<version>_windows_<arch>.tar.gz`,解压后保留 `codex-proxy-rs.exe`、`web/dist`、`deploy` 和 `plugins/official` 的目录关系。
在解压后的 `codex-proxy-rs` 目录中打开 PowerShell,首次安装时创建配置:
```powershell
Copy-Item .\deploy\config.example.yaml .\deploy\config.yaml
```
编辑 `deploy/config.yaml`,填写 `store.database`、`store.redis` 的连接地址和独立密码,以及 `admin.default_password`。
密码格式沿用模板要求;将已有的 `api.asset_directory` 改为 `../web/dist`,在 `host` 下合并以下更新与重启配置:
```yaml
host:
system_update:
deployment_mode: binary
self_restart_enabled: true
api:
asset_directory: '../web/dist'
```
从安装目录启动:
```powershell
.\codex-proxy-rs.exe serve
```
启动成功后访问 `http://127.0.0.1:8080`,使用配置中的管理员账号登录;在运行终端按 `Ctrl+C` 停止服务。
官方构建支持管理端[在线更新、重启与回滚](#管理端在线更新),安装目录与 `.runtime` 必须可写。
手动更换版本时先停止进程并备份当前发行文件,再整体替换 EXE、`web/dist` 和 `plugins/official`,
保留 `deploy/config.yaml` 与 `.runtime`,按新版本模板合并配置后重新启动
## 启动
```bash
@@ -783,7 +942,7 @@ Release 必须提供当前 OS/架构的 `codex-proxy-rs_<version>_<os>_<arch>.ta
宿主范围或能力版本不匹配时,可在插件页确认风险后尝试本次启动;清单、RPC、平台或包完整性错误不能绕过。具体操作见[插件故障处理](../docs/plugins.md#故障处理)
部署须保留完整的二进制、Web 资源与官方插件目录;不要手动修改制品元数据或迁移 checksum 绕过校验。
macOS arm64 提供构建产物;部署前需验证目标平台的插件运行与上游调用,不能只以打包成功判断可用
macOS arm64、Windows x64 / ARM64 提供构建产物;插件安装包必须匹配宿主 OS 和架构,部署前需验证目标平台的插件运行与上游调用,不能只以打包成功判断可用
## 备份与恢复
+1
View File
@@ -13,6 +13,7 @@ services:
service: postgres-credentials
image: postgres:18-bookworm@sha256:bbf83594798e144bff365a9c191c5d779f181fb3135902064ab8ff11918020fc
restart: unless-stopped
# 小内存部署可在此调整数据库预算,保留 UTC 参数,详见 README.md#小内存优化
command:
- postgres
- -c
+2
View File
@@ -53,6 +53,7 @@ store:
# 与 PostgreSQL 共用密码格式要求,并满足 Redis 服务端密码策略
password: &redis_password ''
# 可选:PostgreSQL 连接池预算;下列值与当前推荐默认一致。
# 小内存低并发部署的池上限与数据库调优见 deploy/README.md#小内存优化
# pool:
# max_connections: 20
# acquire_timeout_seconds: 5
@@ -104,6 +105,7 @@ services:
POSTGRES_PASSWORD: *postgres_password
redis-credentials:
# 可选的持久化与内存预算见 deploy/README.md#小内存优化,调整时保留密码参数
command:
- redis-server
- --appendonly
+26 -4
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` |
@@ -2494,10 +2511,14 @@ Dashboard 返回原始 `asOf` 及 `asOfDisplay`,作为当前数据的查询锚
管理端请求列表及 Dashboard 最近请求中的 `accountNotes` 来自请求保存的账号备注快照,未保存时为 `null`
请求列表、Dashboard 最近请求及账号维度诊断的 `accountUpstreamPeer` 来自账号当前的互联摘要,
已发现且有观测时间时为 `true`,尚未识别成功时为 `false`;摘要缺失、账号已删除或读取失败时为 `null`。
该字段仅供 CPR 标识展示,不写入历史快照,非账号维度诊断为 `null`
`/api/admin/usage/records` 与 `/api/admin/operations/errors` 的 `clientApiKeyName` 来自请求保存的 Key 名称快照,
未保存时为 `null`,不影响记录返回,不包含密钥原文
查询不关联当前配置,改名、修改订阅或删除配置不改变已有快照。补写的历史 Key 名称与订阅取自补写时仍存在的配置,
上述快照字段不关联当前配置,改名、修改订阅或删除配置不改变已有快照。补写的历史 Key 名称与订阅取自补写时仍存在的配置,
不保证等于请求发生时的值;无法补齐的字段保持未知
运维错误记录的 `errorDetails` 为受控诊断文本,无详情时为 `null`。
@@ -2908,7 +2929,8 @@ GitHub 的 `location` 使用 `kind: "github"`、`repository: "owner/repo"`、`ta
创建与更新的配置字段为 `name`、`artifactSha256`、`enabled`、`configuration` 和必填的 `bindings`,
`secrets` 可选。输入严格拒绝未知字段。`artifactSha256` 必须指向
已接受的制品。`configuration` 是匹配插件 schema 的 JSON 对象;敏感字段必须
放入 `secrets`,空对象清除全部值。省略时同版本编辑保留当前值;跨版本编辑优先保留目标版本快照的密钥,没有快照则保留当前值
放入 `secrets`,按字段更新:字符串设置对应值,`null` 清除对应字段,未出现的字段保留。省略 `secrets` 或传空对象均不修改密钥
同版本编辑以当前值为基础;跨版本编辑优先以目标版本快照的密钥为基础,没有快照则使用当前值。创建以空集合为基础,密钥值不会返回给管理端
创建可传 `creationId`(标准小写 UUID),同一草稿重试复用该 ID,已保存且内容不同则返回 409,不创建副本或覆盖旧配置。
相同内容的重试仍会重新准备并发布,不保证配置 revision 不变。更新可传 `expectedRevision`,与实例列表的 `revision`
+6 -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 的受管上游适配器不继承这两项原生限制
@@ -1235,6 +1239,7 @@ Compose 的 `stop_grace_period` 必须覆盖后台关闭预算并留出进程清
下载阶段不以插件合同不兼容阻止安装。重启前由 Admin 检查已安装发行与启用插件,返回绑定发行摘要和配置版本的确认快照。
Host 在重启操作锁内重新检查,Admin 复核风险确认与配置版本,不改写插件配置;重启后 Runtime 逐实例加载并隔离失败。
重启受理后保持排他状态与跨进程文件锁,直到旧服务释放;延迟触发关闭仅用于送达受理响应。
Windows 替换进程通过交接管道等待旧服务及更新锁释放后才继续启动;旧进程异常退出同样会关闭管道。
更新与回滚在文件交换前后复核同一插件配置版本,失败或取消时恢复二进制、Web 与官方插件目录
Host 在受理时持久化任务并转交后台执行,任务持有操作锁与终态写入责任,不依赖 HTTP 请求的生命周期。
+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` 读取
+2 -1
View File
@@ -48,7 +48,8 @@ flowchart LR
在插件详情中直接启用或停用,设置会保留,无需重新填写。插件没有可设置的参数或认证映射时不显示设置入口
“设置”按当前包的 schema 显示参数,敏感字段单独保存且不回显。首次补齐必填项后“保存并启用”;其他设置修改保持
“设置”按当前包的 schema 显示参数,敏感字段单独保存且不回显。每项显示保存状态,留空保留现有值,填写新值替换该项;
“清除”只删除对应字段,保存前可撤销。首次补齐必填项后“保存并启用”;其他设置修改保持
原有启停状态。请求处理能力随包自动注册,无需在安装时逐项勾选;可在“高级设置”中调整各挂载阶段的启停、顺序和失败处理。
模型请求阶段支持 Key、分组、模型等作用范围;HTTP、WebSocket 消息和服务调用全局生效,具体匹配由插件处理。
客户端 Key、账号分组、Provider 和模型等范围同时满足才生效,同一范围内任一值匹配即可
+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
+1 -1
View File
@@ -266,7 +266,7 @@ export interface ConfigurePluginInstanceRequest {
artifactSha256: string
enabled: boolean
configuration: Record<string, unknown>
secrets?: Record<string, string>
secrets?: Record<string, string | null>
bindings: PluginCapabilityBinding[]
}
@@ -54,6 +54,7 @@ export interface RuntimeSettings {
maxWaitingPerKey: number
maxWaitingPerAccount: number
concurrencyWaitTimeoutSeconds: number
maxRetryAfterSeconds: number
responsesMaxDecompressedBodyBytes: number
rotationStrategy: RotationStrategy
minCodexDesktopVersion: string | null
+2
View File
@@ -94,6 +94,7 @@ export interface UsageListRecord {
accountNotes: string | null
accountPlanType: string | null
accountPlanTypeDisplay: string | null
accountUpstreamPeer: boolean | null
route: string
model: string | null
requestedModel: string | null
@@ -364,6 +365,7 @@ export interface UsageDiagnosticItem {
authenticationKind: string | null
accountPlanType: string | null
accountPlanTypeDisplay: string | null
accountUpstreamPeer: boolean | null
requestCount: number
successCount: number
errorCount: number
@@ -11,6 +11,7 @@ const props = withDefaults(
planType?: string | null
authenticationKind?: string
upstream?: AccountUpstream | null
upstreamPeer?: boolean | null
planTypeDisplay: string
size?: 'xs' | 'sm' | 'md'
}>(),
@@ -61,7 +62,7 @@ const paletteClass = computed(() => {
</script>
<template>
<AccountUpstreamBadge v-if="upstream?.discovered && upstream.observedAt" :size="size" />
<AccountUpstreamBadge v-if="upstreamPeer || (upstream?.discovered && upstream.observedAt)" :size="size" />
<span
v-else-if="!accountUpstreamPending(upstream)"
class="inline-flex min-w-0 max-w-full items-center justify-center whitespace-nowrap leading-none shadow-z-tertiary"
@@ -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>
@@ -128,8 +128,8 @@ defineExpose({ focusField })
<ZForm v-if="showJson">
<ZFormItem :label="t('common.form.inputPlaceholder')" :error="error">
<template #extra>
<ZIconButton v-if="fields !== null" :aria-label="t('common.form.useForm')" size="small" variant="solid" :disabled="disabled || Boolean(error) || hasExtra || incompatibleValue" @click="jsonMode = false">
<List class="size-4" />
<ZIconButton v-if="fields !== null" :aria-label="t('common.form.useForm')" class="size-4!" variant="link" :disabled="disabled || Boolean(error) || hasExtra || incompatibleValue" @click="jsonMode = false">
<List class="size-3.5" />
</ZIconButton>
</template>
<ZTextarea v-model="text" :rows="8" :disabled="disabled" spellcheck="false" autocomplete="off" data-schema-json-control />
@@ -156,8 +156,8 @@ defineExpose({ focusField })
</ZPopover>
</template>
<template v-if="index === 0" #extra>
<ZIconButton :aria-label="t('common.form.editJson')" size="small" variant="solid" :disabled="disabled" @click="jsonMode = true">
<Braces class="size-4" />
<ZIconButton :aria-label="t('common.form.editJson')" class="size-4!" variant="link" :disabled="disabled" @click="jsonMode = true">
<Braces class="size-3.5" />
</ZIconButton>
</template>
<ZSelect
@@ -79,8 +79,9 @@ const { t } = useI18n({ useScope: 'global' })
<template #accountPlanType="{ row }">
<AccountPlanBadge
v-if="row.authenticationKind === 'api_key' || row.accountPlanType"
v-if="row.accountUpstreamPeer || row.authenticationKind === 'api_key' || row.accountPlanType"
:authentication-kind="row.authenticationKind ?? undefined"
:upstream-peer="row.accountUpstreamPeer"
:plan-type="row.accountPlanType"
:plan-type-display="row.accountPlanTypeDisplay || row.accountPlanType || ''"
size="sm"
+8 -12
View File
@@ -51,27 +51,20 @@
},
"configuration": {
"fillInRequiredConfiguration": "Please fill in the required configuration",
"incompatibleSecrets": "The target version does not support some saved secrets, choose to replace or clear them",
"requiredSecretsCannotClear": "Required secrets cannot be cleared: {labels}",
"requiredSecretsMissing": "No saved values for required secrets: {labels}",
"incompatibleSecrets": "Clear the secrets no longer used by this version",
"secretsRequired": "Enter the required secrets",
"checkBasicSettings": "Please check basic settings",
"noParameters": "No plugin parameters required",
"sensitiveConfiguration": "Sensitive configuration",
"secretsHelp": "Saved tokens, keys and other private values are never shown",
"settingsSaved": "Settings saved: {count}",
"emptySecretPlaceholder": "Leave blank to clear this value",
"emptySecretPlaceholder": "Saved, leave blank to keep",
"about": "About {name}",
"enterNewValue": "Enter new value",
"enterNewValue": "Not set, enter a new value",
"pluginParameters": "Parameters",
"advancedSettings": "Requests",
"clientAuthentication": "Client auth",
"applyAndSwitch": "Apply and switch",
"saveAndEnable": "Save and enable",
"saveSettings": "Save",
"keepExistingValues": "Keep existing",
"replaceAllValues": "Replace all",
"clearAllValues": "Clear all",
"checkPluginParameters": "Please check plugin parameters",
"setRequestScopeOrCheckApplyTo": "Please set the request scope, or check \"Apply to all requests\"",
"invalidIdentityMappings": "Check the client authentication identity mappings",
@@ -79,7 +72,6 @@
"pluginSettings": "Plugin settings",
"unchangedVersion": "The current version is unchanged, edit settings before applying",
"pluginSettingsPartition": "Plugin settings partition",
"secretsModeLabel": "Secret storage action",
"customScope": "Custom scope",
"applyGlobally": "Apply globally",
"requestHandling": "Request handling: {value}",
@@ -104,7 +96,11 @@
"deleteHistoricalConfiguration": "Delete historical configuration"
},
"stale": "Plugin settings changed, refresh and try again",
"incompatible": "Adjust incompatible settings and try again"
"incompatible": "Adjust incompatible settings and try again",
"secretPendingClear": "Will be cleared on save",
"clearSecret": "Clear {name}",
"undoClearSecret": "Undo clearing {name}",
"secretNotDeclared": "No longer used, clear this value"
},
"details": {
"version": "Version",
+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": {
+8 -12
View File
@@ -51,27 +51,20 @@
},
"configuration": {
"fillInRequiredConfiguration": "请填写必填配置",
"incompatibleSecrets": "目标版本不支持部分已保存的敏感配置,请选择替换或清除",
"requiredSecretsCannotClear": "必填敏感配置不能清除:{labels}",
"requiredSecretsMissing": "没有可保留的必填敏感配置:{labels}",
"incompatibleSecrets": "请清除当前版本不再使用的敏感配置",
"secretsRequired": "请填写必填敏感配置",
"checkBasicSettings": "请检查基本设置",
"noParameters": "无需填写插件参数",
"sensitiveConfiguration": "敏感配置",
"secretsHelp": "用于令牌、密钥等机密参数,已保存的值不会回显",
"settingsSaved": "已保存 {count} 项",
"emptySecretPlaceholder": "留空将清除此项",
"emptySecretPlaceholder": "已保存,留空保留现有值",
"about": "{name}说明",
"enterNewValue": "输入新值",
"enterNewValue": "未设置,输入新值",
"pluginParameters": "插件参数",
"advancedSettings": "高级设置",
"clientAuthentication": "客户端认证",
"applyAndSwitch": "应用并切换",
"saveAndEnable": "保存并启用",
"saveSettings": "保存设置",
"keepExistingValues": "保留现有值",
"replaceAllValues": "替换全部",
"clearAllValues": "清除全部",
"checkPluginParameters": "请检查插件参数",
"setRequestScopeOrCheckApplyTo": "请设置请求范围,或勾选“应用于所有请求”",
"invalidIdentityMappings": "请检查客户端认证身份映射",
@@ -79,7 +72,6 @@
"pluginSettings": "插件设置",
"unchangedVersion": "当前版本保持不变,修改后再应用",
"pluginSettingsPartition": "插件设置分区",
"secretsModeLabel": "敏感配置保存方式",
"customScope": "自定义范围",
"applyGlobally": "全局生效",
"requestHandling": "请求处理:{value}",
@@ -104,7 +96,11 @@
"deleteHistoricalConfiguration": "删除历史配置"
},
"stale": "插件设置已变更,请刷新后重试",
"incompatible": "请调整不兼容的设置后重试"
"incompatible": "请调整不兼容的设置后重试",
"secretPendingClear": "保存后清除此项",
"clearSecret": "清除{name}",
"undoClearSecret": "撤销清除{name}",
"secretNotDeclared": "当前版本不再使用,请清除"
},
"details": {
"version": "版本",
@@ -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": {
+8 -12
View File
@@ -51,27 +51,20 @@
},
"configuration": {
"fillInRequiredConfiguration": "請填寫必填配置",
"incompatibleSecrets": "目標版本不支持部分已保存的敏感配置,請選擇替換或清除",
"requiredSecretsCannotClear": "必填敏感配置不能清除:{labels}",
"requiredSecretsMissing": "沒有可保留的必填敏感配置:{labels}",
"incompatibleSecrets": "請清除當前版本不再使用的敏感配置",
"secretsRequired": "請填寫必填敏感配置",
"checkBasicSettings": "請檢查基本設置",
"noParameters": "無需填寫插件參數",
"sensitiveConfiguration": "敏感配置",
"secretsHelp": "用於令牌、密鑰等機密參數,已保存的值不會回顯",
"settingsSaved": "已保存 {count} 項",
"emptySecretPlaceholder": "留空將清除此項",
"emptySecretPlaceholder": "已保存,留空保留現有值",
"about": "{name}說明",
"enterNewValue": "輸入新值",
"enterNewValue": "未設置,輸入新值",
"pluginParameters": "插件參數",
"advancedSettings": "高級設置",
"clientAuthentication": "客戶端認證",
"applyAndSwitch": "應用並切換",
"saveAndEnable": "保存並啓用",
"saveSettings": "保存設置",
"keepExistingValues": "保留現有值",
"replaceAllValues": "替換全部",
"clearAllValues": "清除全部",
"checkPluginParameters": "請檢查插件參數",
"setRequestScopeOrCheckApplyTo": "請設置請求範圍,或勾選“應用於所有請求”",
"invalidIdentityMappings": "請檢查客戶端認證身份映射",
@@ -79,7 +72,6 @@
"pluginSettings": "插件設置",
"unchangedVersion": "當前版本保持不變,修改後再應用",
"pluginSettingsPartition": "插件設置分區",
"secretsModeLabel": "敏感配置保存方式",
"customScope": "自定義範圍",
"applyGlobally": "全局生效",
"requestHandling": "請求處理:{value}",
@@ -104,7 +96,11 @@
"deleteHistoricalConfiguration": "刪除歷史配置"
},
"stale": "插件設置已變更,請刷新後重試",
"incompatible": "請調整不兼容的設置後重試"
"incompatible": "請調整不兼容的設置後重試",
"secretPendingClear": "保存後清除此項",
"clearSecret": "清除{name}",
"undoClearSecret": "撤銷清除{name}",
"secretNotDeclared": "當前版本不再使用,請清除"
},
"details": {
"version": "版本",
@@ -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": {
@@ -1,7 +1,8 @@
<script setup lang="ts">
import type { JsonSchema } from '../utils/model'
import { ZEmpty, ZFormItem, ZInput, ZSegmented, ZTag } from '@codex-proxy/ui'
import { ZEmpty, ZFormItem, ZIconButton, ZInput } from '@codex-proxy/ui'
import { RotateCcw, X } from '@lucide/vue'
import { computed, nextTick, shallowRef, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -28,8 +29,7 @@ const emit = defineEmits<{
const { t } = useI18n({ useScope: 'global' })
const configuration = defineModel<Record<string, unknown>>('configuration', { required: true })
const secrets = defineModel<Record<string, string>>('secrets', { required: true })
const secretMode = defineModel<'preserve' | 'replace' | 'clear'>('secretMode', { required: true })
const secrets = defineModel<Record<string, string | null>>('secrets', { required: true })
const configurationText = shallowRef('{}')
const schemaForm = useTemplateRef<{ focusField: (key?: string) => Promise<boolean> }>('schemaForm')
const secretFieldsRoot = useTemplateRef<HTMLElement>('secretFieldsRoot')
@@ -49,30 +49,19 @@ const configurationSchema = computed(() => {
const secretSchemas = computed(() => {
const properties = (props.schema as JsonSchema).properties ?? {}
const required = (props.schema as JsonSchema).required ?? []
return props.secretFields.map(name => ({
return [
...new Set([
...props.secretFields,
...props.existingSecretFields,
]),
].map(name => ({
name,
schema: properties[name] ?? { type: 'string' },
required: required.includes(name),
required: props.secretFields.includes(name) && required.includes(name),
declared: props.secretFields.includes(name),
saved: props.existingSecretFields.includes(name),
}))
})
const secretModes = computed(() => ([
{
label: t('plugins.configuration.keepExistingValues'),
value: 'preserve',
},
{
label: t('plugins.configuration.replaceAllValues'),
value: 'replace',
},
{
label: t('plugins.configuration.clearAllValues'),
value: 'clear',
},
]))
const existingSecretLabels = computed(() => props.existingSecretFields.map(name => ({
name,
label: (props.schema as JsonSchema).properties?.[name]?.title || name,
})))
const requiredConfigurationFields = computed(() => (configurationSchema.value as JsonSchema).required ?? [])
const missingConfigurationFields = computed(() => requiredConfigurationFields.value.filter(name =>
!Object.hasOwn(configuration.value, name),
@@ -84,23 +73,46 @@ const configurationFieldErrors = computed(() => Object.fromEntries(
]),
))
const requiredSecretFields = computed(() => secretSchemas.value.filter(field => field.required))
const missingSecretFields = computed(() => requiredSecretFields.value.filter((field) => {
if (secretMode.value === 'clear')
return true
if (secretMode.value === 'preserve')
return !props.existingSecretFields.includes(field.name)
return !valuePresent(secrets.value[field.name])
}))
const secretModeError = computed(() => {
if (secretMode.value === 'preserve' && props.existingSecretFields.some(name => !props.secretFields.includes(name)))
return t('plugins.configuration.incompatibleSecrets')
if (!missingSecretFields.value.length || secretMode.value === 'replace')
return ''
const labels = missingSecretFields.value.map(field => field.schema.title || field.name).join('、')
return secretMode.value === 'clear'
? t('plugins.configuration.requiredSecretsCannotClear', { labels })
: t('plugins.configuration.requiredSecretsMissing', { labels })
})
const missingSecretFields = computed(() => requiredSecretFields.value.filter(field => !secretPresent(field.name)))
const incompatibleSecretFields = computed(() => secretSchemas.value.filter(field => !field.declared && secretPresent(field.name)))
const secretError = computed(() => incompatibleSecretFields.value.length
? t('plugins.configuration.incompatibleSecrets')
: '')
function secretChange(name: string) {
return Object.hasOwn(secrets.value, name)
? secrets.value[name]
: undefined
}
function secretPresent(name: string) {
const change = secretChange(name)
return change === undefined
? props.existingSecretFields.includes(name)
: valuePresent(change)
}
function secretPlaceholder(name: string) {
if (secretChange(name) === null)
return t('plugins.configuration.secretPendingClear')
if (!props.secretFields.includes(name))
return t('plugins.configuration.secretNotDeclared')
return props.existingSecretFields.includes(name)
? t('plugins.configuration.emptySecretPlaceholder')
: t('plugins.configuration.enterNewValue')
}
function toggleSecretClear(name: string) {
if (secretChange(name) === null) {
const next = { ...secrets.value }
delete next[name]
secrets.value = next
}
else {
secrets.value = { ...secrets.value, [name]: null }
}
}
const hasConfiguration = computed(() => {
const properties = (configurationSchema.value as JsonSchema).properties
return Boolean(properties && Object.keys(properties).length > 0)
@@ -126,10 +138,10 @@ function valuePresent(value: unknown) {
}
const valid = computed(() => !configurationError.value
&& !secretModeError.value
&& !secretError.value
&& !missingConfigurationFields.value.length
&& !missingSecretFields.value.length)
const validationMessage = computed(() => secretModeError.value
const validationMessage = computed(() => secretError.value
|| configurationError.value
|| (missingSecretFields.value.length
? t('plugins.configuration.secretsRequired')
@@ -153,8 +165,6 @@ watch(parsedConfiguration, ({ value }) => {
})
async function focusInvalid() {
if (secretModeError.value)
return 'secretMode' as const
if (configurationError.value) {
await schemaForm.value?.focusField()
return 'configuration' as const
@@ -163,18 +173,15 @@ async function focusInvalid() {
await schemaForm.value?.focusField(missingConfigurationFields.value[0])
return 'configuration' as const
}
if (!missingSecretFields.value.length)
const missingName = missingSecretFields.value[0]?.name ?? incompatibleSecretFields.value[0]?.name
if (!missingName)
return null
if (secretMode.value !== 'replace')
return 'secretMode' as const
await nextTick()
const missingName = missingSecretFields.value[0]?.name
const container = [
...(secretFieldsRoot.value?.querySelectorAll<HTMLElement>('[data-secret-field]') ?? []),
]
.find(element => element.dataset.secretField === missingName)
container?.querySelector<HTMLElement>('input:not([type="hidden"]), textarea, button, [tabindex]:not([tabindex="-1"])')?.focus()
container?.querySelector<HTMLInputElement>('input')?.focus()
return 'secret' as const
}
@@ -200,35 +207,14 @@ defineExpose({ focusInvalid, validationMessage })
:field-errors="configurationFieldErrors"
/>
<section v-if="hasSecrets" class="grid gap-3 rounded-z bg-z-fill-alter p-4" :aria-label="t('plugins.configuration.sensitiveConfiguration')">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<strong class="text-z leading-none text-z-text">{{ t('plugins.configuration.sensitiveConfiguration') }}</strong>
<ZTag v-if="existingSecretFields.length > 0" type="success" size="small">
{{ t('plugins.configuration.settingsSaved', { count: existingSecretFields.length }) }}
</ZTag>
</div>
<p class="m-0 text-z-xs leading-relaxed text-z-text-secondary">
{{ t('plugins.configuration.secretsHelp') }}
<section v-if="hasSecrets" class="grid gap-4 rounded-z bg-z-fill-alter p-4" :aria-label="t('plugins.configuration.sensitiveConfiguration')">
<h3 class="m-0 text-z-lg leading-none font-heavy text-z-text">
{{ t('plugins.configuration.sensitiveConfiguration') }}
</h3>
<p v-if="secretError" role="alert" class="m-0 text-z-xs leading-relaxed text-z-warning-text">
{{ secretError }}
</p>
<ZSegmented
v-if="existingSecretFields.length"
v-model="secretMode"
:options="secretModes"
:aria-label="t('plugins.configuration.secretsModeLabel')"
:disabled="disabled"
class="w-fit max-w-full"
/>
<p v-if="secretModeError" role="alert" class="m-0 text-z-xs leading-relaxed text-z-warning-text">
{{ secretModeError }}
</p>
<ul v-if="secretMode === 'preserve'" class="m-0 flex list-none flex-wrap gap-2 p-0">
<li v-for="field in existingSecretLabels" :key="field.name" class="min-w-0 wrap-anywhere">
<ZTag type="primary">
{{ field.label }}
</ZTag>
</li>
</ul>
<div v-if="secretMode === 'replace'" ref="secretFieldsRoot" class="grid items-end gap-4 sm:grid-cols-2">
<div ref="secretFieldsRoot" class="grid items-start gap-4 sm:grid-cols-2">
<ZFormItem
v-for="field in secretSchemas"
:key="field.name"
@@ -242,15 +228,33 @@ defineExpose({ focusInvalid, validationMessage })
</PluginHelpPopover>
</template>
<ZInput
:model-value="secrets[field.name] ?? ''"
:model-value="secretChange(field.name) ?? ''"
type="password"
autocomplete="new-password"
clearable
:disabled="disabled"
:readonly="!field.declared"
:aria-label="field.schema.title || field.name"
:aria-invalid="field.required && !valuePresent(secrets[field.name])"
:placeholder="existingSecretFields.includes(field.name) ? t('plugins.configuration.emptySecretPlaceholder') : t('plugins.configuration.enterNewValue')"
:aria-invalid="field.required && !secretPresent(field.name)"
:placeholder="secretPlaceholder(field.name)"
@update:model-value="setSecret(field.name, $event)"
/>
>
<template v-if="field.saved && !secretChange(field.name)" #suffix>
<ZIconButton
variant="link"
class="size-4!"
:disabled="disabled"
:aria-label="secretChange(field.name) === null ? t('plugins.configuration.undoClearSecret', { name: field.schema.title || field.name }) : t('plugins.configuration.clearSecret', { name: field.schema.title || field.name })"
@click="toggleSecretClear(field.name)"
>
<RotateCcw v-if="secretChange(field.name) === null" class="size-3.5" />
<X v-else class="size-3.5" />
</ZIconButton>
</template>
</ZInput>
<p v-if="field.required && !secretPresent(field.name)" role="alert" class="mt-1 text-z-xs text-z-warning-text">
{{ t('plugins.configuration.secretsRequired') }}
</p>
</ZFormItem>
</div>
</section>
@@ -2,7 +2,7 @@
import type { ConfigurePluginInstanceRequest, PluginArtifact, PluginInstance, PluginVersionPlan } from '@/api'
import { ZButton, ZDialog, ZSegmented } from '@codex-proxy/ui'
import { Blocks, Save, Settings2, ShieldCheck } from '@lucide/vue'
import { Blocks, Settings2, ShieldCheck } from '@lucide/vue'
import { cloneDeep } from 'es-toolkit'
import { computed, nextTick, ref, shallowRef, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -29,8 +29,7 @@ const section = shallowRef('general')
const configurationValid = shallowRef(true)
const authenticationValid = shallowRef(true)
const bindingsValid = shallowRef(true)
const secretMode = shallowRef<'preserve' | 'replace' | 'clear'>('preserve')
const secretValues = ref<Record<string, string>>({})
const secretValues = ref<Record<string, string | null>>({})
const configuration = ref<Record<string, unknown>>({})
const bindings = ref<ConfigurePluginInstanceRequest['bindings']>([])
const configurationFields = useTemplateRef<{ focusInvalid: () => Promise<unknown>, validationMessage: string }>('configurationFields')
@@ -117,11 +116,8 @@ async function submit() {
configuration: cloneDeep(configuration.value),
bindings: cloneDeep(bindings.value),
}
if (secretMode.value !== 'preserve') {
input.secrets = secretMode.value === 'clear'
? {}
: cloneDeep(secretValues.value)
}
if (Object.keys(secretValues.value).length)
input.secrets = cloneDeep(secretValues.value)
emit('save', input)
}
@@ -131,7 +127,6 @@ watch(open, async (value) => {
secretValues.value = {}
configuration.value = cloneDeep(props.draft?.configuration ?? props.instance?.configuration ?? {})
bindings.value = cloneDeep(props.draft?.bindings ?? props.instance?.bindings ?? [])
secretMode.value = existingSecretFields.value.length ? 'preserve' : 'replace'
configurationValid.value = true
authenticationValid.value = true
bindingsValid.value = true
@@ -158,7 +153,6 @@ function clearSecrets() {
ref="configurationFields"
v-model:configuration="configuration"
v-model:secrets="secretValues"
v-model:secret-mode="secretMode"
:schema="artifact.metadata.configurationSchema"
:secret-fields="artifact.metadata.secretFields"
:existing-secret-fields="existingSecretFields"
@@ -192,9 +186,6 @@ function clearSecrets() {
{{ t('common.actions.cancel') }}
</ZButton>
<ZButton type="primary" :loading="saving" :disabled="!instance || !artifact" @click="submit">
<template #icon>
<Save class="size-4" />
</template>
{{ primaryLabel }}
</ZButton>
</template>

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