Compare commits

..
18 Commits
Author SHA1 Message Date
ZyyandCodex c622f08574 chore: bump version to 3.44.0
Co-authored-by: Codex <noreply@openai.com>
2026-11-09 12:17:06 +08:00
ZyyandCodex 5741683ea1 chore(deps): upgrade UI to v0.9.1
Co-authored-by: Codex <noreply@openai.com>
2026-11-09 12:14:33 +08:00
Zyy 16b3671733 fix(accounts): show proxy failures in connection tests 2026-11-09 12:05:11 +08:00
dependabot[bot] a708301cb4 chore(deps): bump the minor-and-patch group in /frontend with 6 updates (#648)
Bumps the minor-and-patch group in /frontend with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@lucide/vue](https://github.com/lucide-icons/lucide/tree/HEAD/packages/vue) | `1.67.0` | `1.70.0` |
| [es-toolkit](https://github.com/toss/es-toolkit) | `1.53.1` | `1.53.2` |
| [vue](https://github.com/vuejs/core) | `3.5.44` | `3.5.45` |
| [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.4` | `4.3.5` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.4` | `4.3.5` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.3.5` | `8.3.6` |


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

Updates `es-toolkit` from 1.53.1 to 1.53.2
- [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.1...v1.53.2)

Updates `vue` from 3.5.44 to 3.5.45
- [Release notes](https://github.com/vuejs/core/releases)
- [Changelog](https://github.com/vuejs/core/blob/main/CHANGELOG.md)
- [Commits](https://github.com/vuejs/core/compare/v3.5.44...v3.5.45)

Updates `@tailwindcss/vite` from 4.3.4 to 4.3.5
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.5/packages/@tailwindcss-vite)

Updates `tailwindcss` from 4.3.4 to 4.3.5
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.5/packages/tailwindcss)

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

---
updated-dependencies:
- dependency-name: "@lucide/vue"
  dependency-version: 1.70.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: es-toolkit
  dependency-version: 1.53.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vue
  dependency-version: 3.5.45
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.3.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: tailwindcss
  dependency-version: 4.3.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vite
  dependency-version: 8.3.6
  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-09 11:22:27 +08:00
dependabot[bot] 65fd34991b chore(deps): bump node from bc0a2af to add5c17 in /deploy (#650)
Bumps node from `bc0a2af` to `add5c17`.

---
updated-dependencies:
- dependency-name: node
  dependency-version: 26.13.0-bookworm-slim
  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-09 11:21:59 +08:00
dependabot[bot] 03186b5411 chore(deps): bump debian from 99c0b25 to 734a72b in /deploy (#649)
Bumps debian from `99c0b25` to `734a72b`.

---
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-09 11:21:47 +08:00
dependabot[bot] 47ce36f4bf chore(deps): bump yaml-rust2 from 0.11.0 to 0.13.0 in /backend (#647)
Bumps [yaml-rust2](https://github.com/Ethiraric/yaml-rust2) from 0.11.0 to 0.13.0.
- [Release notes](https://github.com/Ethiraric/yaml-rust2/releases)
- [Changelog](https://github.com/Ethiraric/yaml-rust2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Ethiraric/yaml-rust2/compare/v0.11.0...v0.13.0)

---
updated-dependencies:
- dependency-name: yaml-rust2
  dependency-version: 0.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-09 11:21:33 +08:00
dependabot[bot] 462cef5dd5 chore(deps): bump the minor-and-patch group in /backend with 2 updates (#646)
Bumps the minor-and-patch group in /backend with 2 updates: [zip](https://github.com/zip-rs/zip2) and [async-compression](https://github.com/Nullus157/async-compression).


Updates `zip` from 9.2.2 to 9.4.0
- [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.2.2...v9.4.0)

Updates `async-compression` from 0.4.51 to 0.4.52
- [Release notes](https://github.com/Nullus157/async-compression/releases)
- [Commits](https://github.com/Nullus157/async-compression/compare/async-compression-v0.4.51...async-compression-v0.4.52)

---
updated-dependencies:
- dependency-name: zip
  dependency-version: 9.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: async-compression
  dependency-version: 0.4.52
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-09 11:21:16 +08:00
dependabot[bot] 5647dc8dcb chore(deps): bump the minor-and-patch group with 5 updates (#645)
Bumps the minor-and-patch group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [taiki-e/install-action](https://github.com/taiki-e/install-action) | `2.89.1` | `2.90.2` |
| [docker/login-action](https://github.com/docker/login-action) | `4.7.0` | `4.8.0` |
| [sigstore/cosign-installer](https://github.com/sigstore/cosign-installer) | `4.1.2` | `4.1.3` |
| [softprops/action-gh-release](https://github.com/softprops/action-gh-release) | `3.0.3` | `3.0.4` |
| [pnpm/action-setup](https://github.com/pnpm/action-setup) | `6.1.0` | `6.1.1` |


Updates `taiki-e/install-action` from 2.89.1 to 2.90.2
- [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/682ce6c3c696a4cd5168a05f98e51aa02b877941...3a3daafd3b7f23b8f65d9f5ba2db203fb59b79e2)

Updates `docker/login-action` from 4.7.0 to 4.8.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/81c6f93d4bc991e510b342a00b419b3bb4efc5c6...8aa5831fb8d48d8f76594c7ddde61ae58019f550)

Updates `sigstore/cosign-installer` from 4.1.2 to 4.1.3
- [Release notes](https://github.com/sigstore/cosign-installer/releases)
- [Commits](https://github.com/sigstore/cosign-installer/compare/6f9f17788090df1f26f669e9d70d6ae9567deba6...756fe2d284c7292c4e9b62c8a28f1e5629c59532)

Updates `softprops/action-gh-release` from 3.0.3 to 3.0.4
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/efb35369e0ad2afab669f228072c1b0d510eae64...8a0cd651e5b2365bd640854a3fa5dfc70e4875f3)

Updates `pnpm/action-setup` from 6.1.0 to 6.1.1
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/ea17c68df8912ef543352723c149a84f56e3d413...73e46992e05b19abd31f2f3c20e0e7370d762115)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.90.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: docker/login-action
  dependency-version: 4.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: sigstore/cosign-installer
  dependency-version: 4.1.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: pnpm/action-setup
  dependency-version: 6.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-09 11:20:09 +08:00
ZyyandCodex 0d44492309 fix(openai): scope active quota window reassignment to image endpoints
Co-authored-by: Codex <noreply@openai.com>
2026-11-09 11:16:15 +08:00
ZyyandZyy 31f471b051 feat(plugin): support HTTP requests by proxy ID (#644)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
2026-11-09 03:29:52 +08:00
github-actions[bot] e7ef31d882 chore(release): update release channels to 3.43.1 2026-11-08 10:55:22 +00:00
ZyyandCodex 197e9e517a chore: bump version to 3.43.1
Co-authored-by: Codex <noreply@openai.com>
2026-11-08 18:12:22 +08:00
Zyy e770c94a9e fix(openai): preserve Codex quota after image generation 2026-11-08 18:04:45 +08:00
ce620f1b53 feat(release): add Intel macOS downloads (#641)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-08 17:30:22 +08:00
ZyyandCodex 290aecb7a4 fix(admin): keep page scrolling on the compositor
Co-authored-by: Codex <noreply@openai.com>
2026-11-08 14:05:10 +08:00
ZyyandCodex 1a2a4a56ae fix(openai): update account identity on OAuth reauthorization
Co-authored-by: Codex <noreply@openai.com>
2026-11-08 03:55:30 +08:00
github-actions[bot] 6dd0da1fbd chore(release): update release channels to 3.43.0 2026-11-07 19:38:40 +00:00
78 changed files with 1893 additions and 733 deletions
+1 -1
View File
@@ -125,7 +125,7 @@ jobs:
run: cargo clippy --all-targets --all-features --locked -- -D warnings
- name: Setup nextest
uses: taiki-e/install-action@682ce6c3c696a4cd5168a05f98e51aa02b877941 # v2.89.1
uses: taiki-e/install-action@3a3daafd3b7f23b8f65d9f5ba2db203fb59b79e2 # v2.90.2
with:
tool: cargo-nextest@0.9.143
fallback: none
+5 -5
View File
@@ -115,7 +115,7 @@ jobs:
cache-bin: false
save-if: ${{ github.event_name != 'pull_request' }}
- name: Setup nextest
uses: taiki-e/install-action@682ce6c3c696a4cd5168a05f98e51aa02b877941 # v2.89.1
uses: taiki-e/install-action@3a3daafd3b7f23b8f65d9f5ba2db203fb59b79e2 # v2.90.2
with:
tool: cargo-nextest@0.9.143
fallback: none
@@ -340,7 +340,7 @@ jobs:
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Login to GHCR
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -434,10 +434,10 @@ jobs:
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Install Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
uses: sigstore/cosign-installer@756fe2d284c7292c4e9b62c8a28f1e5629c59532 # v4.1.3
- name: Login to GHCR
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -539,7 +539,7 @@ jobs:
- name: Upload GitHub Release assets
id: release
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
uses: softprops/action-gh-release@8a0cd651e5b2365bd640854a3fa5dfc70e4875f3 # v3.0.4
with:
tag_name: ${{ needs.release-info.outputs.tag }}
generate_release_notes: true
+2 -2
View File
@@ -34,7 +34,7 @@ jobs:
ref: ${{ inputs.ref || github.event.pull_request.head.sha || github.sha }}
- name: Install cargo-audit
uses: taiki-e/install-action@682ce6c3c696a4cd5168a05f98e51aa02b877941 # v2.89.1
uses: taiki-e/install-action@3a3daafd3b7f23b8f65d9f5ba2db203fb59b79e2 # v2.90.2
with:
tool: cargo-audit@0.22.2
fallback: none
@@ -53,7 +53,7 @@ jobs:
ref: ${{ inputs.ref || github.event.pull_request.head.sha || github.sha }}
- name: Setup pnpm
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1
with:
package_json_file: frontend/package.json
+2 -1
View File
@@ -40,7 +40,8 @@
以下命令适用于 Linux amd64/arm64,需要 Docker Engine、Docker Compose Plugin、curl 和 OpenSSL
Windows x64 / ARM64 使用发行包部署,步骤见 [Windows 二进制部署](deploy/README.md#windows-二进制部署)
macOS 从[发行页](https://github.com/zyycn/codex-proxy-rs/releases)下载对应的归档:Intel 机型选 `darwin_amd64`,Apple Silicon 机型选 `darwin_arm64`。
Windows x64 / ARM64 的安装步骤见 [Windows 二进制部署](deploy/README.md#windows-二进制部署)
已有部署请先看 [升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置
+32 -12
View File
@@ -147,9 +147,9 @@ dependencies = [
[[package]]
name = "async-compression"
version = "0.4.51"
version = "0.4.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d5755640120b24961f8231e05892e4a4f7e0d04e8aeca161508c9c397946970"
checksum = "6ad47a740fb906fffdd891e8b6d5fde2511600f7bfaebd68939fa08a14999459"
dependencies = [
"compression-codecs",
"compression-core",
@@ -942,9 +942,9 @@ dependencies = [
[[package]]
name = "compression-codecs"
version = "0.4.46"
version = "0.4.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cae36683101b2899f48bd31f8db9839437356188e3b1ff23096e73dab09ba8fe"
checksum = "84f840cff84d10085260e460023934d0af699e71212b09b2a41452e8bd9cda43"
dependencies = [
"compression-core",
"flate2",
@@ -966,7 +966,7 @@ dependencies = [
"pathdiff",
"serde_core",
"winnow 1.0.3",
"yaml-rust2",
"yaml-rust2 0.11.1",
]
[[package]]
@@ -1834,7 +1834,7 @@ dependencies = [
"tracing-appender",
"tracing-subscriber",
"wiremock",
"yaml-rust2",
"yaml-rust2 0.13.0",
]
[[package]]
@@ -2065,6 +2065,15 @@ dependencies = [
"hashbrown 0.16.1",
]
[[package]]
name = "hashlink"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"
dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "headers"
version = "0.4.2"
@@ -4245,7 +4254,7 @@ dependencies = [
"futures-io",
"futures-util",
"hashbrown 0.16.1",
"hashlink",
"hashlink 0.11.1",
"indexmap",
"log",
"memchr",
@@ -5519,13 +5528,24 @@ dependencies = [
[[package]]
name = "yaml-rust2"
version = "0.11.0"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "631a50d867fafb7093e709d75aaee9e0e0d5deb934021fcea25ac2fe09edc51e"
checksum = "b36710ce3a279cfce8465dbab826f161675a262950b922cb2c3663852dfe9eb0"
dependencies = [
"arraydeque",
"encoding_rs",
"hashlink",
"hashlink 0.11.1",
]
[[package]]
name = "yaml-rust2"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57e5b818a27a4cd30884ea380857a5e56f7ec3ba24a3990a3cc0b95af3238e18"
dependencies = [
"arraydeque",
"encoding_rs",
"hashlink 0.12.2",
]
[[package]]
@@ -5647,9 +5667,9 @@ dependencies = [
[[package]]
name = "zip"
version = "9.2.2"
version = "9.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e408c6cc4699dd017d53dc080cd51c2cea2b5354af57a1479bd265ed42911487"
checksum = "3cec733c9820347a592f6375c3c0089b479ba0a0b1cbf3dd1958b02959b7c033"
dependencies = [
"crc32fast",
"flate2",
@@ -7,6 +7,7 @@ use gateway_admin::{
ports::{
plugin_accounts::PluginAccountAccess, plugin_client_keys::PluginClientKeyAccess,
plugin_resources::PluginResourceAccess, provider::ProviderAdminRegistry,
proxy::PluginProxyAccess,
},
};
use gateway_core::runtime::{RuntimeSnapshotHandle, SnapshotControl};
@@ -35,6 +36,7 @@ pub(super) struct ManagementPorts {
pub(super) accounts: Arc<dyn PluginAccountAccess>,
keys: Arc<dyn PluginClientKeyAccess>,
resources: Arc<dyn PluginResourceAccess>,
proxies: Arc<dyn PluginProxyAccess>,
}
impl ManagementPorts {
@@ -46,6 +48,7 @@ impl ManagementPorts {
) -> Self {
let ports = store.admin_ports();
Self {
proxies: gateway_admin::initialize_plugin_proxies(ports.proxies()),
accounts: gateway_admin::initialize_plugin_accounts(
providers.clone(),
ports.accounts(),
@@ -66,6 +69,7 @@ impl ManagementPorts {
pub(super) fn bind(&self, runtime: &PluginRuntime) -> Result<(), AdminError> {
runtime.bind_account_ports(&self.accounts)?;
runtime.bind_proxy_ports(&self.proxies)?;
runtime.bind_client_key_ports(&self.keys)?;
runtime.bind_resource_ports(&self.resources)
}
@@ -264,6 +264,14 @@ pub fn initialize_plugin_accounts(
))
}
/// 为 Runtime 创建代理目录与当前出口解析的窄端口
#[must_use]
pub fn initialize_plugin_proxies(
store: Arc<dyn ports::proxy::ProxyStore>,
) -> Arc<dyn ports::proxy::PluginProxyAccess> {
Arc::new(use_case::proxies::DefaultPluginProxyAccess { store })
}
/// 为 Runtime 创建非秘密 Client Key 目录与预算重置的窄端口
#[must_use]
pub fn initialize_plugin_client_keys(
+2 -2
View File
@@ -42,8 +42,8 @@ pub use use_case::{
pub use bootstrap::{
initialize, initialize_accounts, initialize_plugin_accounts, initialize_plugin_client_keys,
initialize_plugin_resources, initialize_settings, initialize_usage_data,
initialize_with_plugin_accounts,
initialize_plugin_proxies, initialize_plugin_resources, initialize_settings,
initialize_usage_data, initialize_with_plugin_accounts,
};
use ports::{
@@ -293,6 +293,7 @@ pub enum AccountConnectionTestEvent {
Failed {
source: AccountProbeErrorSource,
gateway_error_code: GatewayErrorKind,
proxy_hint: Option<gateway_core::error::ProxyConnectionHint>,
send_state: Option<UpstreamSendState>,
message: String,
provider_error_code: Option<String>,
@@ -5,13 +5,20 @@ use gateway_core::account::{OutboundProxy, ProviderAccountId};
use super::store::AdminStoreResult;
use crate::model::{
MutationContext, Revision,
AdminError, MutationContext, Revision,
proxies::{
ImportProxyBinding, NewProxy, ProxyAccountListQuery, ProxyAccountPage, ProxyListQuery,
ProxyMutation, ProxyPage, ProxyRecord, ProxyTestResult, UpdateProxy,
},
};
/// 插件受管网络读取代理的窄端口,连接配置仅供宿主发送请求
#[async_trait]
pub trait PluginProxyAccess: Send + Sync {
async fn list(&self, query: ProxyListQuery) -> Result<ProxyPage, AdminError>;
async fn resolve(&self, id: &str) -> Result<OutboundProxy, AdminError>;
}
#[async_trait]
pub trait ProxyStore: Send + Sync {
/// 在凭据交换到提交期间保护选定代理的连接配置和测试结果
@@ -1214,6 +1214,7 @@ impl AccountsService for DefaultAccountsService {
vec![AccountConnectionTestEvent::Failed {
source: error.source(),
gateway_error_code: error.kind(),
proxy_hint: error.proxy_hint(),
send_state: error.send_state(),
message,
provider_error_code: error.client_error_code().map(ToOwned::to_owned),
@@ -365,7 +365,7 @@ fn current_platform() -> Option<Platform> {
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-apple-darwin" | "aarch64-apple-darwin" => cfg!(target_os = "macos"),
"x86_64-pc-windows-msvc" | "aarch64-pc-windows-msvc" => cfg!(target_env = "msvc"),
_ => false,
}
@@ -386,6 +386,13 @@ const SUPPORTED_PLATFORMS: &[Platform] = &[
runtime_os: "linux",
runtime_architecture: "aarch64",
},
Platform {
release_os: "darwin",
release_architecture: "amd64",
target: "x86_64-apple-darwin",
runtime_os: "macos",
runtime_architecture: "x86_64",
},
Platform {
release_os: "darwin",
release_architecture: "arm64",
@@ -23,11 +23,51 @@ use crate::{
},
ports::{
provider::ProviderAdminRegistry,
proxy::{ProxyProbe, ProxyStore},
proxy::{PluginProxyAccess, ProxyProbe, ProxyStore},
store::AdminStoreErrorKind,
},
};
pub(crate) struct DefaultPluginProxyAccess {
pub(crate) store: Arc<dyn ProxyStore>,
}
#[async_trait]
impl PluginProxyAccess for DefaultPluginProxyAccess {
async fn list(&self, query: ProxyListQuery) -> Result<ProxyPage, AdminError> {
list_proxies(self.store.as_ref(), query).await
}
async fn resolve(&self, id: &str) -> Result<OutboundProxy, AdminError> {
if id.is_empty() || id.len() > 128 || id.chars().any(char::is_control) {
return Err(AdminError::invalid("代理 ID 不合法"));
}
let record = self
.store
.get(id)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
// 显式选择代理时必须有可用配置,不能沿用账号停用代理后直连的语义
if !record.enabled {
return Err(AdminError::invalid("代理已禁用,请启用后再选择"));
}
Ok(record.proxy)
}
}
async fn list_proxies(
store: &dyn ProxyStore,
query: ProxyListQuery,
) -> Result<ProxyPage, AdminError> {
if query.page == 0 || query.search.len() > 256 || query.search.chars().any(char::is_control) {
return Err(AdminError::invalid("代理查询参数不合法"));
}
store
.list(query)
.await
.map_err(|error| map_store_error(error, "proxy"))
}
#[async_trait]
pub trait ProxiesService: Send + Sync {
async fn create_batch(
@@ -268,14 +308,7 @@ impl ProxiesService for DefaultProxiesService {
}
async fn list(&self, query: ProxyListQuery) -> Result<ProxyPage, AdminError> {
if query.page == 0 || query.search.len() > 256 || query.search.chars().any(char::is_control)
{
return Err(AdminError::invalid("代理查询参数不合法"));
}
self.store
.list(query)
.await
.map_err(|error| map_store_error(error, "proxy"))
list_proxies(self.store.as_ref(), query).await
}
async fn create(
@@ -423,31 +423,37 @@ fn system_context() -> MutationContext {
}
fn plugin_entry(id: &str, marker: u8) -> serde_json::Value {
let (release_os, release_architecture, target) = release_platform();
// 清单列出所有平台,夹具只提供本机归档,以验证导入时不会读取其他平台的包
let artifacts = [
("linux", "amd64", "x86_64-unknown-linux-gnu"),
("linux", "arm64", "aarch64-unknown-linux-gnu"),
("darwin", "amd64", "x86_64-apple-darwin"),
("darwin", "arm64", "aarch64-apple-darwin"),
("windows", "amd64", "x86_64-pc-windows-msvc"),
("windows", "arm64", "aarch64-pc-windows-msvc"),
]
.map(|(release_os, release_architecture, target)| {
let os = if release_os == "darwin" {
"macos"
} else {
release_os
};
let (architecture, _) = target.split_once('-').unwrap();
serde_json::json!({
"release_os": release_os,
"release_architecture": release_architecture,
"target": target,
"os": os,
"architecture": architecture,
"archive": format!("{id}-1.0.0-{os}-{architecture}.tar.gz"),
"sha256": format!("{marker:01x}").repeat(64),
})
});
serde_json::json!({
"id": id,
"version": "1.0.0",
"publisher": "test",
"stability": "experimental",
"artifacts": [{
"release_os": release_os,
"release_architecture": release_architecture,
"target": target,
"os": std::env::consts::OS,
"architecture": std::env::consts::ARCH,
"archive": format!("{id}-1.0.0-{}-{}.tar.gz", std::env::consts::OS, std::env::consts::ARCH),
"sha256": format!("{marker:01x}").repeat(64),
}]
"artifacts": artifacts,
})
}
fn release_platform() -> (&'static str, &'static str, &'static str) {
match (std::env::consts::OS, std::env::consts::ARCH) {
("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:?}"),
}
}
@@ -56,8 +56,13 @@ impl ProxyStore for TestProxies {
guard: Box::new(ImportGuard(events.clone())),
})
}
async fn list(&self, _: ProxyListQuery) -> AdminStoreResult<ProxyPage> {
Err(super::unavailable("proxy"))
async fn list(&self, query: ProxyListQuery) -> AdminStoreResult<ProxyPage> {
Ok(ProxyPage {
items: self.records.clone(),
total: self.records.len() as u64,
page: query.page,
page_size: query.page_size.get(),
})
}
async fn list_accounts(
&self,
@@ -114,6 +119,79 @@ impl ProxyStore for TestProxies {
}
}
#[tokio::test]
async fn plugin_proxy_resolution_requires_enabled_configuration_without_account_or_probe() {
use gateway_admin::model::{AdminErrorKind, PageSize};
use std::sync::Arc;
let now = chrono::Utc::now();
let records: Vec<_> = [true, false]
.into_iter()
.map(|enabled| ProxyRecord {
id: format!("proxy_{enabled}"),
name: "Login".into(),
proxy: OutboundProxy::parse("http://user:secret@127.0.0.1:8080").unwrap(),
revision: Revision::new(1).unwrap(),
enabled,
auto_location: false,
location: None,
detected_location: None,
account_count: 0,
last_test_at: Some(now),
last_test: Some(ProxyTestResult {
location: Default::default(),
success: false,
latency_ms: 1,
exit_ip: None,
exit_ipv4: None,
exit_ipv6: None,
message: "fixture failure".into(),
}),
created_at: now,
updated_at: now,
})
.collect();
let access = gateway_admin::initialize_plugin_proxies(Arc::new(TestProxies {
records: records.clone(),
..Default::default()
}));
assert_eq!(
access.resolve("proxy_true").await.unwrap(),
records[0].proxy
);
assert_eq!(
access.resolve("proxy_false").await.unwrap_err().kind(),
AdminErrorKind::Invalid
);
assert!(access.resolve("missing").await.is_err());
for id in ["", "bad\n", &"x".repeat(129)] {
assert_eq!(
access.resolve(id).await.unwrap_err().kind(),
AdminErrorKind::Invalid
);
}
let query = || ProxyListQuery {
page: 1,
page_size: PageSize::new(10).unwrap(),
search: String::new(),
};
let page = access.list(query()).await.unwrap();
assert_eq!(page.items.len(), 2);
assert!(!page.items[1].enabled);
for invalid in [
ProxyListQuery { page: 0, ..query() },
ProxyListQuery {
search: "bad\n".into(),
..query()
},
] {
assert_eq!(
access.list(invalid).await.unwrap_err().kind(),
AdminErrorKind::Invalid
);
}
}
#[async_trait]
impl ProxyProbe for TestProxies {
async fn test(&self, _: Option<&OutboundProxy>, _: bool) -> ProxyTestResult {
@@ -1129,6 +1129,7 @@ impl From<(DomainConnectionTestEvent, crate::time::TimePresenter)> for AccountCo
DomainConnectionTestEvent::Failed {
source,
gateway_error_code,
proxy_hint,
send_state,
message,
provider_error_code,
@@ -1140,6 +1141,7 @@ impl From<(DomainConnectionTestEvent, crate::time::TimePresenter)> for AccountCo
"type": "error",
"source": source.as_str(),
"gatewayErrorCode": gateway_error_code.as_str(),
"proxyHint": proxy_hint.map(gateway_core::error::ProxyConnectionHint::as_str),
"sendState": send_state.map(gateway_core::upstream::UpstreamSendState::as_str),
"error": message,
"providerErrorCode": provider_error_code,
@@ -944,6 +944,7 @@ mod actions {
DomainConnectionTestEvent::Failed {
source: AccountProbeErrorSource::Upstream,
gateway_error_code: GatewayErrorKind::RateLimited,
proxy_hint: None,
send_state: Some(UpstreamSendState::Sent),
message: "upstream unavailable".to_owned(),
provider_error_code: Some("usage_exhausted".to_owned()),
@@ -1005,6 +1006,7 @@ mod actions {
"type": "error",
"source": "upstream",
"gatewayErrorCode": "rate_limited",
"proxyHint": null,
"sendState": "sent",
"error": "upstream unavailable",
"providerErrorCode": "usage_exhausted",
@@ -1463,7 +1463,18 @@ impl DefaultExecutionService {
}
_ => (AccountProbeErrorSource::Gateway, None, None),
};
let mut failure = AccountProbeError::new(
gateway_error_from_engine(error),
source,
send_state,
upstream_response,
);
if let EngineError::Provider(provider_error) = error {
if source == AccountProbeErrorSource::Provider {
failure.proxy_hint = provider_error
.diagnostic()
.and_then(|diagnostic| diagnostic.proxy_hint());
}
let latency = started_at.elapsed().unwrap_or_default();
let latency_ms = u64::try_from(latency.as_millis()).unwrap_or(u64::MAX);
tracing::warn!(
@@ -1499,12 +1510,7 @@ impl DefaultExecutionService {
);
}
}
AccountProbeError::new(
gateway_error_from_engine(error),
source,
send_state,
upstream_response,
)
failure
}
}
@@ -5,7 +5,9 @@ use std::fmt;
use bytes::Bytes;
use futures::future::BoxFuture;
use crate::error::{ClientVisibleUpstreamResponse, GatewayError, GatewayErrorKind};
use crate::error::{
ClientVisibleUpstreamResponse, GatewayError, GatewayErrorKind, ProxyConnectionHint,
};
use crate::identity::ProviderKind;
use crate::routing::UpstreamModelId;
use crate::{
@@ -126,6 +128,7 @@ impl AccountProbeErrorSource {
#[derive(Debug)]
pub struct AccountProbeError {
gateway: GatewayError,
pub(super) proxy_hint: Option<ProxyConnectionHint>,
source: AccountProbeErrorSource,
send_state: Option<UpstreamSendState>,
upstream_response: Option<Box<AccountProbeUpstreamResponse>>,
@@ -141,6 +144,7 @@ impl AccountProbeError {
) -> Self {
Self {
gateway,
proxy_hint: None,
source,
send_state,
upstream_response: upstream_response.map(Box::new),
@@ -162,6 +166,11 @@ impl AccountProbeError {
self.send_state
}
#[must_use]
pub const fn proxy_hint(&self) -> Option<ProxyConnectionHint> {
self.proxy_hint
}
#[must_use]
pub fn client_message(&self) -> &str {
self.gateway.client_message()
@@ -220,12 +220,34 @@ impl fmt::Debug for ClientVisibleUpstreamError {
}
}
/// 本次建连的代理诊断,仅用于管理端展示,不参与错误处置
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProxyConnectionHint {
InvalidConfiguration,
Unreachable,
TunnelFailed,
CheckProxy,
}
impl ProxyConnectionHint {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::InvalidConfiguration => "invalid_configuration",
Self::Unreachable => "unreachable",
Self::TunnelFailed => "tunnel_failed",
Self::CheckProxy => "check_proxy",
}
}
}
/// Provider 已分类为可安全持久化和展示的诊断摘要
///
/// Adapter 只能放入不含凭据、cookie、原始请求正文和 opaque response ID 的有界摘要;
/// 原始上游错误返回由 [`RawUpstreamError`] 单独承载
#[derive(Clone, PartialEq, Eq)]
pub struct ProviderDiagnostic {
proxy_hint: Option<ProxyConnectionHint>,
message: String,
stage: Option<&'static str>,
code: Option<&'static str>,
@@ -236,11 +258,23 @@ impl ProviderDiagnostic {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
proxy_hint: None,
stage: None,
code: None,
}
}
#[must_use]
pub const fn with_proxy_hint(mut self, hint: ProxyConnectionHint) -> Self {
self.proxy_hint = Some(hint);
self
}
#[must_use]
pub const fn proxy_hint(&self) -> Option<ProxyConnectionHint> {
self.proxy_hint
}
/// 附着本地定义的阶段与原因码;独立于发送状态和重试策略
#[must_use]
pub const fn with_classification(mut self, stage: &'static str, code: &'static str) -> Self {
@@ -3232,6 +3232,10 @@ fn provider_local_probe_failure_should_remain_distinct_from_upstream() {
assert_eq!(error.source(), AccountProbeErrorSource::Provider);
assert_eq!(error.send_state(), Some(UpstreamSendState::NotSent));
assert!(error.upstream_response().is_none());
assert_eq!(
error.proxy_hint(),
Some(gateway_core::error::ProxyConnectionHint::InvalidConfiguration)
);
}
#[test]
@@ -3673,10 +3677,17 @@ impl Provider for LocalFailingProvider {
_: ProviderRequest,
_: AttemptContext,
) -> Result<ProviderStream, ProviderError> {
Err(ProviderError::new(
ProviderErrorKind::Unsupported,
UpstreamSendState::NotSent,
))
Err(
ProviderError::new(ProviderErrorKind::Unsupported, UpstreamSendState::NotSent)
.with_diagnostic(
gateway_core::error::ProviderDiagnostic::new(
"Unsupported account proxy configuration",
)
.with_proxy_hint(
gateway_core::error::ProxyConnectionHint::InvalidConfiguration,
),
),
)
}
}
+1 -1
View File
@@ -46,7 +46,7 @@ tokio-tungstenite = { version = "0.29.0", default-features = false, features = [
tracing = "0.1.44"
tracing-appender = "0.2.5"
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
yaml-rust2 = "0.11.0"
yaml-rust2 = "0.13.0"
[dev-dependencies]
filetime = "0.2.27"
@@ -71,6 +71,7 @@ impl CallbackHandler for PluginCallbacks {
let log = self.log.clone();
let private_state = self.private_state.clone();
let accounts = self.accounts.clone();
let proxies = self.proxies.clone();
let resources = self.resources.clone();
let data = self.data.clone();
let keys = self.keys.clone();
@@ -233,6 +234,12 @@ impl CallbackHandler for PluginCallbacks {
maximum_payload,
request_limits: &request_limits,
};
if method == host::PROXIES_LIST {
return proxies.list(params, &payload).await;
}
if method == host::PROXY_HTTP {
return http.proxy(&proxies, params, payload).await;
}
if method == host::ACCOUNT_HTTP {
return http.account(&accounts, params, payload).await;
}
@@ -55,6 +55,30 @@ impl HttpCallbacks<'_> {
.await
}
pub(super) async fn proxy(
&self,
proxies: &super::PluginProxyPortSlot,
params: serde_json::Value,
payload: Vec<u8>,
) -> Result<RpcReply, PluginFault> {
let request: wire::ProxyHttpRequest = decode(params)?;
let maximum_body_bytes = self.body_limit(&payload)?;
let proxy = proxies.resolve(&request.proxy_id).await?;
self.send(
HttpRequest {
method: request.request.method,
url: request.request.url,
headers: request.request.headers,
body: payload,
maximum_body_bytes,
},
true,
None,
Some(&proxy),
)
.await
}
pub(super) async fn send(
&self,
request: HttpRequest,
@@ -20,6 +20,7 @@ mod model;
pub(crate) mod outbound;
mod ports;
pub(crate) mod private_state;
mod proxies;
mod replay;
pub(crate) mod request_limits;
mod resources;
@@ -40,6 +41,7 @@ pub(crate) use middleware::{
MiddlewareInvocation,
};
pub(crate) use model::PluginModelPortSlot;
pub(crate) use proxies::PluginProxyPortSlot;
pub(crate) use resources::PluginResourcePorts;
pub(crate) use scope::{CallbackScope, InvocationContext};
@@ -11,7 +11,7 @@ use gateway_host::outbound::{HttpClient, NetworkPolicy};
use super::{
CallResources, CallbackScope, MiddlewareCallback, PluginAccountPortSlot,
PluginAffinityPortSlot, PluginClientIdentityPortSlot, PluginClientKeyPortSlot,
PluginModelPortSlot, PluginResourcePorts,
PluginModelPortSlot, PluginProxyPortSlot, PluginResourcePorts,
};
use super::{
accounts, affinity, client, data, keys, log, model, private_state, request_limits, resources,
@@ -23,6 +23,7 @@ pub(crate) struct PluginCallbackPorts {
pub(crate) services: Arc<services::ServicePorts>,
pub(crate) http: Arc<HttpClient>,
pub(crate) accounts: Arc<PluginAccountPortSlot>,
pub(crate) proxies: Arc<PluginProxyPortSlot>,
pub(crate) keys: Arc<PluginClientKeyPortSlot>,
pub(crate) clients: Arc<PluginClientIdentityPortSlot>,
pub(crate) models: Arc<PluginModelPortSlot>,
@@ -36,6 +37,7 @@ pub(crate) struct PluginCallbacks {
pub(super) services: Arc<services::ServicePorts>,
pub(super) service_actor: gateway_admin::model::usage_data::UsageActor,
pub(super) accounts: Arc<accounts::PluginAccounts>,
pub(super) proxies: Arc<PluginProxyPortSlot>,
pub(super) resources: Arc<resources::PluginResources>,
pub(super) data: Arc<data::PluginData>,
pub(super) keys: Arc<keys::PluginClientKeys>,
@@ -79,6 +81,7 @@ impl PluginCallbacks {
ports.keys.clone(),
)),
accounts: Arc::new(accounts::PluginAccounts::new(ports.accounts)),
proxies: ports.proxies,
keys: Arc::new(keys::PluginClientKeys::new(instance, ports.keys)),
affinity: Arc::new(affinity::PluginAffinity::new(ports.affinity)),
log: Arc::new(
@@ -0,0 +1,80 @@
//! 代理目录的安全投影与受管 HTTP 出口解析,配置事实由 Admin 持有
use std::sync::{Arc, OnceLock, Weak};
use gateway_admin::{
model::{AdminError, PageSize, proxies::ProxyListQuery},
ports::proxy::PluginProxyAccess,
};
use gateway_core::account::OutboundProxy;
use gateway_plugin_sdk::{PluginFault, call::host as wire};
use super::{
admin::{encode, map_admin_error},
invalid,
};
use crate::RpcReply;
pub(crate) struct PluginProxyPortSlot {
access: OnceLock<Weak<dyn PluginProxyAccess>>,
}
impl PluginProxyPortSlot {
pub(crate) const fn new() -> Self {
Self {
access: OnceLock::new(),
}
}
pub(crate) fn bind(&self, access: &Arc<dyn PluginProxyAccess>) -> Result<(), AdminError> {
self.access
.set(Arc::downgrade(access))
.map_err(|_| AdminError::conflict("插件代理端口已经绑定"))
}
fn access(&self) -> Result<Arc<dyn PluginProxyAccess>, PluginFault> {
self.access
.get()
.and_then(Weak::upgrade)
.ok_or_else(|| map_admin_error(AdminError::unavailable("插件代理服务暂不可用")))
}
pub(super) async fn resolve(&self, id: &str) -> Result<OutboundProxy, PluginFault> {
self.access()?.resolve(id).await.map_err(map_admin_error)
}
pub(super) async fn list(
&self,
params: serde_json::Value,
payload: &[u8],
) -> Result<RpcReply, PluginFault> {
if params != serde_json::json!({}) {
return Err(invalid());
}
let request: wire::ProxyListRequest =
serde_json::from_slice(payload).map_err(|_| invalid())?;
let page = self
.access()?
.list(ProxyListQuery {
page: request.page,
page_size: PageSize::new(request.page_size).map_err(|_| invalid())?,
search: request.search,
})
.await
.map_err(map_admin_error)?;
encode(&wire::ProxyListResult {
items: page
.items
.into_iter()
.map(|proxy| wire::ProxySummary {
id: proxy.id,
name: proxy.name,
enabled: proxy.enabled,
})
.collect(),
total: page.total,
page: page.page,
page_size: page.page_size,
})
}
}
@@ -195,6 +195,7 @@ impl PluginRuntime {
usage: self.usage_ports.clone(),
http: self.http.clone(),
accounts: self.account_ports.clone(),
proxies: self.proxy_ports.clone(),
keys: self.client_key_ports.clone(),
clients: self.client_identity_port.clone(),
models: self.model_ports.clone(),
@@ -9,7 +9,7 @@ use crate::{
PackageLimits, RpcLimits, RpcSession,
callback::{
PluginAccountPortSlot, PluginAffinityPortSlot, PluginClientIdentityPortSlot,
PluginClientKeyPortSlot, PluginModelPortSlot,
PluginClientKeyPortSlot, PluginModelPortSlot, PluginProxyPortSlot,
},
};
use gateway_admin::{
@@ -69,6 +69,7 @@ pub struct PluginRuntime {
pub(super) configuration_schemas: Arc<super::configuration::ConfigurationSchemas>,
pub(super) log_slots: Arc<Semaphore>,
pub(super) account_ports: Arc<PluginAccountPortSlot>,
pub(super) proxy_ports: Arc<PluginProxyPortSlot>,
pub(super) client_key_ports: Arc<PluginClientKeyPortSlot>,
pub(super) resource_ports: Arc<crate::callback::PluginResourcePorts>,
pub(super) client_identity_port: Arc<PluginClientIdentityPortSlot>,
@@ -115,6 +116,7 @@ impl PluginRuntime {
request_limits: Arc::default(),
service_ports: Arc::new(crate::callback::services::ServicePorts::default()),
account_ports: Arc::new(PluginAccountPortSlot::new()),
proxy_ports: Arc::new(PluginProxyPortSlot::new()),
client_key_ports: Arc::new(PluginClientKeyPortSlot::new()),
resource_ports: Arc::new(crate::callback::PluginResourcePorts::new()),
client_identity_port: Arc::new(PluginClientIdentityPortSlot::new()),
@@ -172,6 +174,14 @@ impl PluginRuntime {
self.account_ports.bind(access)
}
/// 代理读取由 Admin 提供并保活,Runtime 不依赖账号绑定
pub fn bind_proxy_ports(
&self,
access: &Arc<dyn gateway_admin::ports::proxy::PluginProxyAccess>,
) -> Result<(), AdminError> {
self.proxy_ports.bind(access)
}
/// Key 目录与预算端口由 Admin 组合并保活,完整字段由对应公开合同返回
pub fn bind_client_key_ports(
&self,
@@ -363,7 +363,11 @@ async fn quota_refresh_is_available_without_permission_declarations() {
}
}
async fn management_queries(accounts: Arc<dyn PluginAccountAccess>, queries: Value) -> Value {
async fn management_queries(
accounts: Option<Arc<dyn PluginAccountAccess>>,
proxies: Option<Arc<dyn gateway_admin::ports::proxy::PluginProxyAccess>>,
queries: Value,
) -> Value {
let (cache, store, runtime) =
super::setup_with_contributions(Contributions::from([crate::support::contribution(
Capability::Management,
@@ -372,7 +376,12 @@ async fn management_queries(accounts: Arc<dyn PluginAccountAccess>, queries: Val
vec![],
)]))
.await;
runtime.bind_account_ports(&accounts).unwrap();
if let Some(accounts) = &accounts {
runtime.bind_account_ports(accounts).unwrap();
}
if let Some(proxies) = &proxies {
runtime.bind_proxy_ports(proxies).unwrap();
}
store.snapshot.lock().unwrap().instances[0].configuration = json!({
"management_registration":{"routes":[{"method":"GET","path":"facts","request_content_types":[],"response_content_types":["application/json"]}]},
"data_queries":queries,
@@ -416,95 +425,260 @@ async fn request_head(stream: &mut tokio::net::TcpStream) -> String {
String::from_utf8(bytes).unwrap()
}
struct ProxyFacts {
resolutions: std::sync::Mutex<
std::collections::VecDeque<Result<gateway_core::account::OutboundProxy, AdminError>>,
>,
page: Option<gateway_admin::model::proxies::ProxyPage>,
}
#[async_trait]
impl gateway_admin::ports::proxy::PluginProxyAccess for ProxyFacts {
async fn resolve(&self, id: &str) -> Result<gateway_core::account::OutboundProxy, AdminError> {
assert_eq!(id, "proxy_login");
self.resolutions
.lock()
.unwrap()
.pop_front()
.expect("每次发送读取当前配置")
}
async fn list(
&self,
query: gateway_admin::model::proxies::ProxyListQuery,
) -> Result<gateway_admin::model::proxies::ProxyPage, AdminError> {
assert_eq!(query.page, 2);
assert_eq!(query.page_size.get(), 1);
assert_eq!(query.search, "Login");
Ok(self.page.clone().unwrap())
}
}
#[tokio::test]
async fn account_http_uses_the_selected_proxy_and_rejects_unavailable_account_versions() {
async fn selected_http_uses_account_or_proxy_without_fallback_or_redirects() {
use gateway_core::account::OutboundProxy;
use tokio::io::AsyncWriteExt as _;
for scenario in [
"plain",
"explicit-auth",
"rotated",
"disabled",
"proxy-failed",
"missing",
for (selector, scenarios) in [
(
"account",
&[
"plain",
"explicit-auth",
"rotated",
"disabled",
"proxy-failed",
"missing",
][..],
),
(
"proxy",
&[
"plain",
"explicit-auth",
"disabled",
"proxy-failed",
"missing",
"redirect",
][..],
),
] {
let target = wiremock::MockServer::start().await;
for &scenario in scenarios {
let target = wiremock::MockServer::start().await;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy = OutboundProxy::parse(&format!("http://{}", listener.local_addr().unwrap()))
.unwrap();
let successful = matches!(scenario, "plain" | "explicit-auth" | "redirect");
let target_url = target.uri();
let proxy_task = if successful {
Some(tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let request = request_head(&mut socket).await;
let response = if scenario == "redirect" {
format!(
"HTTP/1.1 302 Found\r\nLocation: {target_url}/redirect\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
)
} else {
"HTTP/1.1 201 Created\r\nSet-Cookie: step=one\r\nSet-Cookie: session=two\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_owned()
};
socket.write_all(response.as_bytes()).await.unwrap();
request
}))
} else {
drop(listener);
None
};
let headers = if scenario == "explicit-auth" {
vec![("authorization", b"Bearer plugin-fixture".to_vec())]
} else {
vec![]
};
let request =
json!({"method":"GET", "url":format!("{}/login", target.uri()), "headers":headers});
let results = if selector == "account" {
let mut selected = account();
selected.outbound_proxy = Some(proxy);
selected.enabled = scenario != "disabled";
if scenario == "rotated" {
selected.credential_revision = Revision::new(2).unwrap();
}
let accounts = Arc::new(Facts {
runtime_account: Some(selected),
..Default::default()
});
management_queries(Some(accounts), None, json!([{
"method":"host.http.account", "params":{
"account_id":if scenario == "missing" {"acct_missing"} else {"acct_facts"},
"credential_revision":1, "request":request,
},
}])).await
} else {
let resolution = match scenario {
"missing" => Err(AdminError::not_found("代理不存在")),
"disabled" => Err(AdminError::invalid("代理已禁用")),
_ => Ok(proxy),
};
let proxies = Arc::new(ProxyFacts {
resolutions: std::sync::Mutex::new([resolution].into()),
page: None,
});
management_queries(None, Some(proxies), json!([{
"method":"host.http.proxy", "params":{"proxy_id":"proxy_login", "request":request},
}])).await
};
if let Some(task) = proxy_task {
assert_eq!(
results[0]["status"],
if scenario == "redirect" { 302 } else { 201 }
);
let request = tokio::time::timeout(std::time::Duration::from_secs(3), task)
.await
.unwrap()
.unwrap()
.to_ascii_lowercase();
assert!(
request.starts_with("get http://127.0.0.1:")
&& request.contains("/login http/1.1")
);
assert_eq!(
request
.lines()
.any(|line| line.starts_with("authorization:")),
scenario == "explicit-auth"
);
if scenario == "explicit-auth" {
assert!(request.contains("authorization: bearer plugin-fixture\r\n"));
}
if scenario != "redirect" {
assert_eq!(
results[0]["headers"]
.as_array()
.unwrap()
.iter()
.filter(|header| header[0] == "set-cookie")
.count(),
2
);
}
} else {
assert_eq!(
results[0]["error"],
match scenario {
"missing" => "rejected",
"rotated" => "conflict",
"disabled" if selector == "account" => "permission_denied",
"disabled" => "invalid_input",
_ => "upstream",
},
"{selector}/{scenario}: {results}"
);
}
assert!(
target.received_requests().await.unwrap().is_empty(),
"{selector}/{scenario} must not connect directly"
);
}
}
}
#[tokio::test]
async fn proxy_http_resolves_each_send_and_directory_only_exposes_selection_facts() {
use gateway_admin::model::proxies::{ProxyPage, ProxyRecord};
use gateway_core::account::OutboundProxy;
use tokio::io::AsyncWriteExt as _;
let target = wiremock::MockServer::start().await;
let mut resolutions = std::collections::VecDeque::new();
let mut tasks = Vec::new();
for status in [201, 202] {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy = gateway_core::account::OutboundProxy::parse(&format!(
resolutions.push_back(Ok(OutboundProxy::parse(&format!(
"http://{}",
listener.local_addr().unwrap()
))
.unwrap();
let successful = matches!(scenario, "plain" | "explicit-auth");
let proxy_task = if successful {
Some(tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let request = request_head(&mut socket).await;
assert!(request.starts_with("GET http://127.0.0.1:"));
socket
.write_all(
b"HTTP/1.1 201 Created\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
.unwrap()));
tasks.push(tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
request_head(&mut socket).await;
socket
.write_all(
format!(
"HTTP/1.1 {status} OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
)
.await
.unwrap();
request
}))
} else {
drop(listener);
None
};
let mut selected = account();
selected.outbound_proxy = Some(proxy);
selected.enabled = scenario != "disabled";
if scenario == "rotated" {
selected.credential_revision = Revision::new(2).unwrap();
}
let accounts: Arc<dyn PluginAccountAccess> = Arc::new(Facts {
runtime_account: Some(selected),
..Default::default()
});
let headers = if scenario == "explicit-auth" {
vec![("authorization", b"Bearer plugin-fixture".to_vec())]
} else {
vec![]
};
let results = management_queries(accounts, json!([{
"method":gateway_plugin_sdk::call::host::ACCOUNT_HTTP,
"params":{
"account_id":if scenario == "missing" {"acct_missing"} else {"acct_facts"},
"credential_revision":1,
"request":{"method":"GET","url":format!("{}/facts", target.uri()),"headers":headers},
},
}])).await;
if let Some(task) = proxy_task {
assert_eq!(results[0]["status"], 201, "{scenario}: {results}");
let request = tokio::time::timeout(std::time::Duration::from_secs(3), task)
.as_bytes(),
)
.await
.unwrap()
.unwrap()
.to_ascii_lowercase();
assert!(
request.starts_with("get http://127.0.0.1:") && request.contains("/facts http/1.1"),
"{request}"
);
assert_eq!(
request.contains("authorization:"),
scenario == "explicit-auth"
);
if scenario == "explicit-auth" {
assert!(request.contains("authorization: bearer plugin-fixture\r\n"));
}
} else {
let expected = match scenario {
"rotated" => "conflict",
"disabled" => "permission_denied",
"missing" => "rejected",
_ => "upstream",
};
assert_eq!(results[0]["error"], expected, "{scenario}: {results}");
}
assert!(
target.received_requests().await.unwrap().is_empty(),
"{scenario} must not connect directly"
);
.unwrap();
}));
}
let now = chrono::Utc::now();
let proxies = Arc::new(ProxyFacts {
resolutions: std::sync::Mutex::new(resolutions),
page: Some(ProxyPage {
total: 2,
page: 2,
page_size: 1,
items: vec![ProxyRecord {
id: "proxy_login".into(),
name: "Login".into(),
enabled: false,
proxy: OutboundProxy::parse("http://user:secret@private.example:8080").unwrap(),
revision: Revision::new(2).unwrap(),
auto_location: false,
detected_location: None,
location: None,
account_count: 0,
last_test_at: None,
last_test: None,
created_at: now,
updated_at: now,
}],
}),
});
let request = json!({"method":"host.http.proxy", "params":{
"proxy_id":"proxy_login", "request":{"method":"GET", "url":target.uri(), "headers":[]},
}});
let results = management_queries(
None,
Some(proxies.clone()),
json!([
request, request,
{"method":"host.proxies.list", "query":{"page":2,"page_size":1,"search":"Login"}},
]),
)
.await;
assert_eq!(results[0]["status"], 201);
assert_eq!(results[1]["status"], 202);
assert_eq!(
results[2],
json!({"items":[{"id":"proxy_login","name":"Login","enabled":false}],"total":2,"page":2,"page_size":1})
);
assert!(proxies.resolutions.lock().unwrap().is_empty());
assert!(target.received_requests().await.unwrap().is_empty());
for task in tasks {
tokio::time::timeout(std::time::Duration::from_secs(3), task)
.await
.unwrap()
.unwrap();
}
}
@@ -263,12 +263,23 @@ HTTP 和 request 中间件中的模型回调沿用有效请求设置;attempt
请求 headers 与正文完全由插件提供;宿主不注入账号认证、不申请推理租约,代理不可用时直接失败,不回退直连。
这项能力与上游适配器的账号推理 HTTP 分开,适用于插件自行构造的账号网络请求
`HostClient::proxy_http(ProxyHttpRequest { proxy_id, request }, body)` 经 `host.http.proxy` 使用指定代理的当前配置,
返回同一响应头与 `HostHttpBody`,不要求账号存在或绑定代理,也不校验账号凭据版本。
代理不存在、禁用或连接失败时返回错误,不回退直连;未测试或历史测试失败不阻止实际请求。
每次发送重新读取代理,已发出的请求沿该次解析的出口完成,不锁定整个插件业务流程的代理配置。
目标请求的 headers 与正文由插件提供,宿主不注入账号认证;代理连接所需认证由宿主处理
`HostClient::list_proxies(ProxyListRequest { page, page_size, search })` 经 `host.proxies.list` 查询代理目录。
`page` 从 1 开始,`page_size` 为 1–200,`search` 可省略,默认空字符串,最多 256 字节且不能包含控制字符。
返回 `{ items: [{ id, name, enabled }], total, page, page_size }`,包含禁用代理,不返回连接地址、凭据或账号信息。
查询和结果使用二进制 JSON 载荷,控制参数及结果均为 `{}`;目录展示不代替发送时的状态校验
请求和响应 `headers` 使用 `(String, Vec<u8>)`,保留重复头与非 UTF-8 值;响应另有 `http_version`。
读取至 EOF 后通过 `body.trailers()` 取得完整尾部头,读取中只包含已收到的部分。
`collect` 只返回正文,需要尾部头时使用 `read`;底层非流式回复携带 `trailers`,流式回复在 `stream_read` 中交付尾部头
`host.http.do/do_stream` 和流读取/关闭方法提供受管 HTTP。正文使用独立二进制载荷,Host 统一执行代理、超时和背压,不按插件身份限制目标地址段。DNS 由宿主解析,连接保留目标 Host 与 TLS 域名校验;重定向不会自动跟随。
受管 HTTP(包括 `host.http.account` 和 `host.upstream.http.do[_stream]`)的请求正文按有效设置
受管 HTTP(包括 `host.http.account`、`host.http.proxy` 和 `host.upstream.http.do[_stream]`)的请求正文按有效设置
`responses_max_decompressed_body_bytes` 检查。HTTP/request 中间件使用有效请求设置;
attempt、outbound、路由、调度、重试和上游适配器使用执行冻结预算,其余调用读取当前发布配置。
超限返回带 HTTP 413 的 `invalid_input` 和 `not_sent`,不向目标发送请求。
@@ -87,6 +87,39 @@ pub struct HttpRequest {
}
pub const ACCOUNT_HTTP: &str = "host.http.account";
pub const PROXY_HTTP: &str = "host.http.proxy";
pub const PROXIES_LIST: &str = "host.proxies.list";
/// 按当前代理配置发送请求,不依赖账号或注入账号认证
#[derive(Clone, Serialize, Deserialize)]
pub struct ProxyHttpRequest {
pub proxy_id: String,
pub request: HttpRequest,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProxyListRequest {
pub page: u32,
pub page_size: u16,
#[serde(default)]
pub search: String,
}
/// 插件可选择的代理摘要,不携带连接地址或凭据
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProxySummary {
pub id: String,
pub name: String,
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProxyListResult {
pub items: Vec<ProxySummary>,
pub total: u64,
pub page: u32,
pub page_size: u16,
}
/// 显式选择账号代理和凭据版本;认证头仍由插件提供,不取得推理租约
#[derive(Clone, Serialize, Deserialize)]
@@ -29,6 +29,29 @@ pub struct HostHttpBody {
}
impl HostClient {
/// 分页查询代理摘要,不读取连接地址或凭据
///
/// # Errors
/// 查询参数无效或宿主读取失败时返回错误
pub async fn list_proxies(
&self,
query: wire::ProxyListRequest,
) -> Result<wire::ProxyListResult, PluginFault> {
super::payload_call(self, wire::PROXIES_LIST, query).await
}
/// 使用指定代理的当前配置发送请求;代理不存在、禁用或连接失败时不回退直连
///
/// # Errors
/// 代理不可用、期限或网络请求失败时返回错误
pub async fn proxy_http(
&self,
request: wire::ProxyHttpRequest,
body: Vec<u8>,
) -> Result<HostHttpResponse, PluginFault> {
self.open_http(wire::PROXY_HTTP, request, body, false).await
}
/// 使用受管 HTTP 发送请求;账号上游使用 [`Self::upstream_http`]
///
/// # Errors
@@ -5,7 +5,7 @@ use gateway_plugin_sdk::client::{CallFuture, CallReply, PluginCall, PluginHandle
use gateway_plugin_sdk::{ErrorCode, Frame, Message};
use gateway_plugin_sdk::{
call::{
host::{AccountHttpRequest, HttpRequest},
host::{AccountHttpRequest, HttpRequest, ProxyHttpRequest, ProxyListRequest},
upstream_adapter::{UpstreamHttpRequest, UpstreamWebSocketRequest, WebSocketMessageKind},
},
client::UpstreamWebSocketUpgrade,
@@ -17,6 +17,21 @@ struct NetworkHandler;
impl PluginHandler for NetworkHandler {
fn call(&self, call: PluginCall) -> CallFuture<'_> {
Box::pin(async move {
if call.method == "proxies" {
let page = call
.host
.list_proxies(ProxyListRequest {
page: 2,
page_size: 10,
search: "login".into(),
})
.await?;
assert_eq!(page.page, 2);
assert_eq!(page.total, 11);
assert_eq!(page.items[0].id, "proxy_one");
assert!(!page.items[0].enabled);
return Ok(CallReply::unary(json!({}), vec![]));
}
if call.method.starts_with("websocket") {
let upgrade = call
.host
@@ -106,6 +121,20 @@ impl PluginHandler for NetworkHandler {
call.payload,
)
.await?
} else if call.method == "proxy" {
call.host
.proxy_http(
ProxyHttpRequest {
proxy_id: "proxy_one".into(),
request: HttpRequest {
method: "POST".into(),
url: "https://example.test/login".into(),
headers: vec![],
},
},
call.payload,
)
.await?
} else {
call.host
.upstream_http(
@@ -172,7 +201,7 @@ async fn reply_callback(
#[tokio::test]
async fn http_response_objects_share_pull_semantics_and_retire_eof_handles() {
for method in ["http", "account", "upstream"] {
for method in ["http", "account", "proxy", "upstream"] {
let (mut host, task) = start_session(NetworkHandler).await;
send_call(
&mut host,
@@ -189,6 +218,8 @@ async fn http_response_objects_share_pull_semantics_and_retire_eof_handles() {
};
let open_method = if method == "account" {
gateway_plugin_sdk::call::host::ACCOUNT_HTTP.to_owned()
} else if method == "proxy" {
gateway_plugin_sdk::call::host::PROXY_HTTP.to_owned()
} else {
format!("{prefix}.do_stream")
};
@@ -201,9 +232,13 @@ async fn http_response_objects_share_pull_semantics_and_retire_eof_handles() {
.await;
assert_eq!(frame.payload, b"request");
if method == "account" {
assert!(matches!(frame.message, Message::Callback { params, .. }
assert!(matches!(frame.message, Message::Callback { ref params, .. }
if params["account_id"] == "acct_one" && params["credential_revision"] == 7));
}
if method == "proxy" {
assert!(matches!(frame.message, Message::Callback { ref params, .. }
if params["proxy_id"] == "proxy_one" && params["request"]["url"] == "https://example.test/login"));
}
let frame = reply_callback(
&mut host,
&format!("{prefix}.stream_read"),
@@ -228,6 +263,33 @@ async fn http_response_objects_share_pull_semantics_and_retire_eof_handles() {
}
}
#[tokio::test]
async fn proxy_directory_uses_typed_binary_payloads() {
let (mut host, task) = start_session(NetworkHandler).await;
send_call(&mut host, 1, "proxies", json!({}), vec![]).await;
let frame = reply_callback(
&mut host,
"host.proxies.list",
json!({}),
serde_json::to_vec(&json!({
"items":[{"id":"proxy_one","name":"login","enabled":false}],
"total":11,"page":2,"page_size":10,
}))
.unwrap(),
)
.await;
assert!(matches!(frame.message, Message::Callback { params, .. } if params == json!({})));
assert_eq!(
serde_json::from_slice::<Value>(&frame.payload).unwrap(),
json!({"page":2,"page_size":10,"search":"login"})
);
assert!(matches!(
receive(&mut host).await.message,
Message::Result { id: 1, .. }
));
shutdown(&mut host, task).await;
}
#[tokio::test]
async fn bounded_collection_closes_the_host_stream_before_reporting_overflow() {
let (mut host, task) = start_session(NetworkHandler).await;
@@ -361,6 +361,20 @@ pub fn parse_rate_limit_headers(headers: &[(String, String)]) -> Option<ParsedRa
})
}
/// Images 端点有时仅用 active-limit 标明通用窗口属于哪个图片计量项
pub fn parse_image_rate_limit_headers(headers: &[(String, String)]) -> Option<ParsedRateLimits> {
let mut parsed = parse_rate_limit_headers(headers)?;
if let Some(active) = &parsed.active_limit
&& active != "codex"
&& !parsed.limits.contains_key(active)
&& let Some(mut details) = parsed.limits.remove("codex")
{
details.limit_id.clone_from(active);
parsed.limits.insert(active.clone(), details);
}
Some(parsed)
}
/// 判断响应头是否属于可被动同步的限流领域
pub fn is_rate_limit_header_name(name: &str) -> bool {
let normalized = name.to_ascii_lowercase();
@@ -2,8 +2,9 @@
use gateway_protocol::openai::events::{
RateLimitKeySource, RateLimitWindow, TokenUsage, billable_usage_is_complete, extract_sse_usage,
extract_usage, is_codex_quota_header_name, is_rate_limit_header_name, parse_rate_limit_headers,
parse_rate_limits_event, rate_limits_to_header_pairs, retry_after_seconds_from_body,
extract_usage, is_codex_quota_header_name, is_rate_limit_header_name,
parse_image_rate_limit_headers, parse_rate_limit_headers, parse_rate_limits_event,
rate_limits_to_header_pairs, retry_after_seconds_from_body,
};
use serde_json::json;
@@ -540,6 +541,7 @@ fn parse_rate_limit_headers_should_extract_primary_and_secondary_windows() {
#[test]
fn parse_rate_limit_headers_should_preserve_dynamic_limit_and_account_metadata() {
let headers = vec![
("x-codex-primary-used-percent".to_owned(), "95".to_owned()),
(
"x-codex-other-primary-used-percent".to_owned(),
"63.5".to_owned(),
@@ -556,6 +558,13 @@ fn parse_rate_limit_headers_should_preserve_dynamic_limit_and_account_metadata()
];
let parsed = parse_rate_limit_headers(&headers).expect("rate-limit headers");
assert_eq!(
parsed.limits["codex"]
.primary
.expect("core window")
.used_percent,
95.0
);
let details = parsed.limits.get("codex_other").expect("dynamic limit");
let credits = parsed.credits.expect("credits");
@@ -579,6 +588,40 @@ fn parse_rate_limit_headers_should_preserve_dynamic_limit_and_account_metadata()
);
}
#[test]
fn image_headers_assign_generic_windows_to_the_active_limit_only_without_its_own_headers() {
let headers = [
("x-codex-active-limit", "imagegen_premium"),
("x-codex-primary-used-percent", "0"),
("x-codex-primary-window-minutes", "1440"),
("x-codex-primary-reset-at", "1900000000"),
("x-codex-secondary-used-percent", "0"),
("x-codex-secondary-window-minutes", "0"),
("x-codex-secondary-reset-at", ""),
]
.map(|(name, value)| (name.to_owned(), value.to_owned()));
let parsed = parse_image_rate_limit_headers(&headers).expect("image rate-limit headers");
let image = parsed.limits.get("imagegen_premium").expect("image limit");
assert_eq!(image.limit_id, "imagegen_premium");
assert_eq!(image.key_source, RateLimitKeySource::LimitId);
assert_eq!(
image.primary,
Some(RateLimitWindow {
used_percent: 0.0,
window_minutes: Some(1440),
reset_at: Some(1_900_000_000),
})
);
assert!(!parsed.limits.contains_key("codex"));
let mut headers = rate_limits_to_header_pairs(&parsed);
headers.push(("x-codex-primary-used-percent".to_owned(), "89".to_owned()));
let separate =
parse_image_rate_limit_headers(&headers).expect("separate image and core limits");
assert_eq!(separate.limits["imagegen_premium"], *image);
assert_eq!(separate.limits["codex"].primary.unwrap().used_percent, 89.0);
}
#[test]
fn parse_rate_limit_headers_should_preserve_a_code_review_window() {
let headers = vec![
@@ -138,10 +138,13 @@ async fn commit_authorization_in_transaction(
context,
gateway_admin::model::audit::MutationAuditOperation::ProviderAccountReauthorize,
)?;
let result =
repository::rotate_provider_account_admin_in_transaction(transaction, prepared)
.await
.map_err(|error| admin_store_error(ENTITY, error))?;
let result = repository::rotate_provider_account_admin_in_transaction(
transaction,
prepared,
true,
)
.await
.map_err(|error| admin_store_error(ENTITY, error))?;
admin_adapter::rotation_result(result, account_id.clone())?
}
_ => return Err(invalid_receipt()),
@@ -520,7 +520,8 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
postgres_unavailable("begin provider account admin rotation", source)
})?;
let account_id = command.credential.account_id.clone();
let result = rotate_provider_account_admin_in_transaction(&mut transaction, command).await;
let result =
rotate_provider_account_admin_in_transaction(&mut transaction, command, false).await;
let rotation =
finish_admin_transaction(transaction, result, "provider account admin rotation")
.await?;
@@ -849,6 +850,7 @@ pub(crate) async fn rotate_provider_account_in_transaction(
profile: &UpdateProviderAccount,
replacement_identity: Option<&ProviderAccountIdentity>,
update: &ProviderCredentialUpdate,
preserve_name: bool,
) -> StoreResult<Revision> {
let replace_identity = replacement_identity.is_some();
let upstream_user_id = replacement_identity.map(ProviderAccountIdentity::upstream_user_id);
@@ -857,11 +859,15 @@ pub(crate) async fn rotate_provider_account_in_transaction(
// 事务时间可能早于应用写入的额度观测,保留既有时间下界,避免轮换破坏时间约束
let next = sqlx::query_scalar::<_, i64>(
"update provider_accounts
set name = case when $14 then name else $4 end,
set name = case when $14 or $16 then name else $4 end,
email = case when $14 then email else $5 end,
plan_type = case when $14 then plan_type else $6 end,
provider_quota_json = case when $14 then provider_quota_json else null end,
quota_observed_at = case when $14 then quota_observed_at else null end,
quota_access_state = case when $11 then 'unknown' else quota_access_state end,
quota_evidence = case when $11 then null else quota_evidence end,
quota_access_observed_at = case when $11 then null else quota_access_observed_at end,
quota_reset_at = case when $11 then null else quota_reset_at end,
provider_credentials_json = $7,
credential_revision = credential_revision + 1,
has_refresh_token = $8,
@@ -900,6 +906,7 @@ pub(crate) async fn rotate_provider_account_in_transaction(
.bind(upstream_account_id)
.bind(update.preserve_profile)
.bind(update.preserve_credential_state)
.bind(preserve_name)
.fetch_optional(&mut **transaction)
.await
.map_err(|error| {
@@ -1166,6 +1173,7 @@ pub(super) async fn import_provider_accounts_in_transaction(
pub(super) async fn rotate_provider_account_admin_in_transaction(
transaction: &mut Transaction<'_, Postgres>,
command: RotateProviderAccount,
preserve_name: bool,
) -> StoreResult<ProviderAccountAdminRotation> {
command.scope.validate()?;
require_nonempty(ENTITY, "account_id", &command.profile.id)?;
@@ -1194,6 +1202,7 @@ pub(super) async fn rotate_provider_account_admin_in_transaction(
&command.profile,
command.replacement_identity.as_ref(),
&command.credential,
preserve_name,
)
.await?;
// 凭据 CAS、普通设置和审计共享事务,任何设置失败都回滚凭据更新
@@ -310,11 +310,14 @@ async fn reauthorization_replay_keeps_the_committed_revision_and_later_profile_e
account_id: id.clone(),
provider_kind: ProviderKind::new("openai").unwrap(),
expected_credential_revision: gateway_admin::model::Revision::new(1).unwrap(),
replacement_identity: None,
replacement_identity: Some(ProviderAccountIdentity::new(
"new-user".into(),
Some("new-workspace".into()),
)),
name: "remote profile".into(),
email: None,
plan_type: None,
preserve_profile: true,
email: Some("new@example.com".into()),
plan_type: Some("plus".into()),
preserve_profile: false,
preserve_credential_state: false,
provider_material: ProviderDocument::new(OpaqueProviderData::new(
json!({"access_token":"rotated-test-only"})
@@ -347,15 +350,17 @@ async fn reauthorization_replay_keeps_the_committed_revision_and_later_profile_e
.commit_authorization(command.clone(), &context)
.await
.unwrap();
let account = store
.load_account(id.as_str(), Default::default())
.await
.unwrap()
.unwrap()
.account;
assert_eq!(account.name, "renamed team");
assert_eq!(account.email.as_deref(), Some("new@example.com"));
assert_eq!(
store
.load_account(id.as_str(), Default::default())
.await
.unwrap()
.unwrap()
.account
.name,
"renamed team"
account.upstream_account_id.as_deref(),
Some("new-workspace")
);
store
.update_account(
@@ -3007,7 +3007,7 @@ async fn provider_account_import_and_reauthorization_preserve_existing_membershi
}
#[tokio::test]
async fn verified_credential_rotation_preserves_quota_exhaustion() {
async fn quota_exhaustion_survives_rotation_only_for_the_same_upstream_identity() {
let Some(database) = TestDatabase::create("provider_account_rotation_quota").await else {
return;
};
@@ -3043,7 +3043,7 @@ async fn verified_credential_rotation_preserves_quota_exhaustion() {
repository
.rotate_provider_account(RotateProviderAccount {
settings: None,
scope,
scope: scope.clone(),
profile: profile("acct_rotation_quota", "reauthorized account"),
replacement_identity: None,
credential: credential_update("acct_rotation_quota", 1, "reauthorized-secret"),
@@ -3064,6 +3064,28 @@ async fn verified_credential_rotation_preserves_quota_exhaustion() {
.expect("load exhausted account after credential rotation");
assert_eq!(quota_access_state, "exhausted".to_owned());
repository
.rotate_provider_account(RotateProviderAccount {
settings: None,
scope,
profile: profile("acct_rotation_quota", "reauthorized account"),
replacement_identity: Some(ProviderAccountIdentity::new(
"new-user".into(),
Some("new-workspace".into()),
)),
credential: credential_update("acct_rotation_quota", 2, "new-account-secret"),
audit: audit("audit_quota_rebind", "reauthorize", "acct_rotation_quota"),
})
.await
.unwrap();
let account = repository
.get_account(&ProviderAccountId::new("acct_rotation_quota").unwrap())
.await
.unwrap()
.unwrap();
assert_eq!(account.upstream_account_id(), Some("new-workspace"));
assert_eq!(account.quota(), QuotaState::unknown());
database.close().await;
}
@@ -661,7 +661,7 @@ impl ProviderAdmin for OpenAiAdminProvider {
let access_token_expires_at =
parse_access_token_expiration(secret.access_token.expose_secret());
let prepared = CodexCredentialAdmin
.prepare_refreshed_oauth_rotation(current, secret, access_token_expires_at, None)
.prepare_oauth_rotation(current, secret, None, access_token_expires_at, None)
.map_err(map_credential_admin_error)?;
prepared_rotation(prepared, command.account.provider_kind)
}
@@ -31,9 +31,8 @@ use super::token_client::{
OpenAiTokenClient, PersonalAccessTokenError, RefreshFailure, TokenRefresher,
};
use super::types::{
CODEX_AUTHENTICATION_KIND_OAUTH, CodexAccountProfile, CodexCredentialData,
CodexCredentialPrincipal, CodexOAuthMetadata, CodexOAuthSecret, ResponsesTransport,
parse_access_token_expiration, parse_chatgpt_jwt_claims,
CODEX_AUTHENTICATION_KIND_OAUTH, CodexAccountProfile, CodexCredentialData, CodexOAuthMetadata,
CodexOAuthSecret, ResponsesTransport, parse_access_token_expiration, parse_chatgpt_jwt_claims,
};
const PROVIDER_NAME: &str = "openai";
@@ -272,26 +271,6 @@ struct CodexCprOAuthExportAccount {
access_token_expires_at: Option<String>,
}
/// App 已从 Store 读取的当前账号、revision 与明文 Provider JSON
pub struct RotateManagedCodexCredential {
pub current: LoadedCredential,
pub secret: CodexOAuthSecret,
pub verified_account: CodexAccountProfile,
pub next_refresh_at: Option<DateTime<Utc>>,
}
impl std::fmt::Debug for RotateManagedCodexCredential {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("RotateManagedCodexCredential")
.field("current", &self.current)
.field("secret", &"<redacted>")
.field("verified_account", &self.verified_account)
.field("next_refresh_at", &self.next_refresh_at)
.finish()
}
}
/// Provider 验证后的 rotation;App 只做 Core -> Store command 的机械映射
pub struct PreparedCodexCredentialRotation {
pub profile: ProviderAccountUpdate,
@@ -725,21 +704,12 @@ impl CodexCredentialAdmin {
})
}
pub fn prepare_rotation(
&self,
input: RotateManagedCodexCredential,
) -> Result<PreparedCodexCredentialRotation, CodexCredentialAdminError> {
self.prepare_oauth_rotation(input, false)
}
/// 构建一次成功 RT exchange 的 CAS 写入,保留已存账号身份资料
///
/// Refresh endpoint 已经完成本次 token 交换的授权;这里不对新 access
/// token 重新执行身份验证
pub(crate) fn prepare_refreshed_oauth_rotation(
/// 重新授权提供本次登录的身份;普通令牌续期只轮换凭据
pub(crate) fn prepare_oauth_rotation(
&self,
current: LoadedCredential,
secret: CodexOAuthSecret,
metadata: Option<CodexOAuthMetadata>,
access_token_expires_at: Option<DateTime<Utc>>,
next_refresh_at: Option<DateTime<Utc>>,
) -> Result<PreparedCodexCredentialRotation, CodexCredentialAdminError> {
@@ -753,6 +723,32 @@ impl CodexCredentialAdmin {
let oauth = data
.oauth_mut()
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
let mut profile = ProviderAccountUpdate {
account_id: current.account.id().clone(),
name: current.account.name().to_owned(),
email: current.account.email().map(str::to_owned),
plan_type: current.account.plan_type().map(str::to_owned),
};
let reauthorization = metadata.is_some();
let mut replacement_identity = None;
if let Some(metadata) = metadata {
let user_id = metadata
.chatgpt_user_id
.filter(|id| !id.trim().is_empty())
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
if current.account.upstream_user_id() != Some(user_id.as_str())
|| current.account.upstream_account_id() != metadata.chatgpt_account_id.as_deref()
{
replacement_identity = Some(ProviderAccountIdentity::new(
user_id,
metadata.chatgpt_account_id,
));
oauth.cookies.clear();
oauth.principal = None;
}
profile.email = metadata.email;
profile.plan_type = metadata.chatgpt_plan_type;
}
oauth.access_token = secret.access_token.expose_secret().to_owned();
oauth.refresh_token = secret
.refresh_token
@@ -764,12 +760,6 @@ impl CodexCredentialAdmin {
.map(|value| value.expose_secret().to_owned());
let credential = CodexCredentialCodec::encode_complete(data)
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
let profile = ProviderAccountUpdate {
account_id: current.account.id().clone(),
name: current.account.name().to_owned(),
email: current.account.email().map(str::to_owned),
plan_type: current.account.plan_type().map(str::to_owned),
};
let credential = CredentialCasUpdate::new(
current.account.id().clone(),
current.account.revision(),
@@ -779,76 +769,14 @@ impl CodexCredentialAdmin {
optional_time(access_token_expires_at),
optional_time(next_refresh_at),
)
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?
.preserving_profile();
Ok(PreparedCodexCredentialRotation {
profile,
credential,
replacement_identity: None,
refresh_guards: None,
})
}
fn prepare_oauth_rotation(
&self,
input: RotateManagedCodexCredential,
replace_identity: bool,
) -> Result<PreparedCodexCredentialRotation, CodexCredentialAdminError> {
let access_token_expires_at = input.verified_account.access_token_expires_at;
let mut data = CodexCredentialCodec::decode_complete(&input.current.credential)
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
let oauth = data
.oauth_mut()
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
if input.current.account.provider().as_str() != PROVIDER_NAME
|| input.current.account.authentication_kind() != CODEX_AUTHENTICATION_KIND_OAUTH
{
return Err(CodexCredentialAdminError::InvalidCredential);
}
let replacement_identity = replace_identity.then(|| {
ProviderAccountIdentity::new(
input.verified_account.chatgpt_user_id.clone(),
Some(input.verified_account.chatgpt_account_id.clone()),
)
});
if replace_identity {
oauth.principal = Some(CodexCredentialPrincipal {
oauth_subject: input.verified_account.oauth_subject.clone(),
poid: input.verified_account.poid.clone(),
});
}
oauth.access_token = input.secret.access_token.expose_secret().to_owned();
oauth.refresh_token = input
.secret
.refresh_token
.as_ref()
.map(|value| value.expose_secret().to_owned());
oauth.id_token = input
.secret
.id_token
.as_ref()
.map(|value| value.expose_secret().to_owned());
let credential = CodexCredentialCodec::encode_complete(data)
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
let profile = ProviderAccountUpdate {
account_id: input.current.account.id().clone(),
name: input.current.account.name().to_owned(),
email: input.verified_account.email,
plan_type: input.verified_account.plan_type,
};
let credential = CredentialCasUpdate::new(
input.current.account.id().clone(),
input.current.account.revision(),
profile.clone(),
credential,
input.secret.refresh_token.is_some(),
optional_time(access_token_expires_at),
optional_time(input.next_refresh_at),
)
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
Ok(PreparedCodexCredentialRotation {
profile,
credential,
credential: if reauthorization {
credential
} else {
credential.preserving_profile()
},
replacement_identity,
refresh_guards: None,
})
@@ -1002,9 +930,10 @@ impl CodexCredentialAdminService {
// 正常预刷新由 worker 读取当时的 runtime policy 动态判断;这里仅清除
// 之前瞬态失败留下的 retry-not-before
let next_refresh_at = None;
let mut prepared = CodexCredentialAdmin.prepare_refreshed_oauth_rotation(
let mut prepared = CodexCredentialAdmin.prepare_oauth_rotation(
current,
secret,
None,
access_token_expires_at,
next_refresh_at,
)?;
@@ -1095,20 +1024,7 @@ impl CodexCredentialAdminService {
.as_ref()
.and_then(|token| parse_chatgpt_jwt_claims(token.expose_secret()).ok())
.unwrap_or_default();
let access_metadata = parse_chatgpt_jwt_claims(secret.access_token.expose_secret())
.unwrap_or_default();
CodexOAuthMetadata {
email: id_metadata.email.or(access_metadata.email),
chatgpt_plan_type: id_metadata
.chatgpt_plan_type
.or(access_metadata.chatgpt_plan_type),
chatgpt_user_id: id_metadata
.chatgpt_user_id
.or(access_metadata.chatgpt_user_id),
chatgpt_account_id: id_metadata
.chatgpt_account_id
.or(access_metadata.chatgpt_account_id),
}
id_metadata.with_access_token_fallback(&secret.access_token)
};
let prepared =
CodexCredentialAdmin.prepare_unresolved_oauth(UnresolvedCodexOAuthCredential {
@@ -36,7 +36,7 @@ pub use admin::{
CodexCprExportDocument, CodexCredentialAdmin, CodexCredentialAdminError,
CodexCredentialAdminService, ExportManagedCodexCredential, ImportCodexOAuthCredential,
PreparedCodexAccountImport, PreparedCodexCredentialRotation,
PreparedCodexCredentialRotationGuard, RotateManagedCodexCredential,
PreparedCodexCredentialRotationGuard,
};
pub use catalog::{
CodexCatalogScope, CodexCredentialCatalogError, CodexCredentialCatalogService,
@@ -72,6 +72,7 @@ pub use selector::{
SelectCodexCredential,
};
pub(crate) use selector::{CodexCyberPolicyScope, SelectCodexProviderEndpointCredential};
pub(crate) use types::oauth_identity_key;
pub use types::{
CODEX_AUTHENTICATION_KIND_OAUTH, CodexAccountProfile, CodexCookie, CodexCookieCaptureOutcome,
CodexCredentialData, CodexCredentialPrincipal, CodexOAuthCredentialData, CodexOAuthSecret,
@@ -516,16 +516,10 @@ impl CodexOAuthAdminService {
} else {
None
};
let fallback_refresh_token = current
.as_ref()
.map(current_oauth_refresh_token)
.transpose()?
.flatten();
let (mut secret, id_token) = self
.exchange_pending(
&pending,
callback_url,
fallback_refresh_token,
current
.as_ref()
.map_or(pending.mutation.outbound_proxy(), |current| {
@@ -537,14 +531,16 @@ impl CodexOAuthAdminService {
let access_token_expires_at =
parse_access_token_expiration(secret.access_token.expose_secret());
let metadata = parse_chatgpt_jwt_claims(id_token.expose_secret())
.map_err(|_| CodexOAuthAdminError::TokenRejected)?;
.map_err(|_| CodexOAuthAdminError::TokenRejected)?
.with_access_token_fallback(&secret.access_token);
secret.id_token = Some(id_token);
let credential = if let Some(current) = current {
CompletedCodexOAuthCredential::Reauthorize(Box::new(
self.credentials
.prepare_refreshed_oauth_rotation(
.prepare_oauth_rotation(
current,
secret,
Some(metadata),
access_token_expires_at,
None,
)
@@ -680,7 +676,6 @@ impl CodexOAuthAdminService {
&self,
pending: &CodexPendingAuthorization,
callback_url: &str,
fallback_refresh_token: Option<SecretString>,
proxy: Option<&gateway_core::account::OutboundProxy>,
) -> Result<(super::types::CodexOAuthSecret, SecretString), CodexOAuthAdminError> {
let (code, callback_state) = callback_parts(callback_url)?;
@@ -701,10 +696,7 @@ impl CodexOAuthAdminService {
)
.await
.map_err(CodexOAuthAdminError::Exchange)?;
let mut secret = tokens.secret;
if secret.refresh_token.is_none() {
secret.refresh_token = fallback_refresh_token;
}
let secret = tokens.secret;
record_oauth_recovery(
CodexOAuthRecoveryOperation::AuthorizationCode,
pending
@@ -720,17 +712,6 @@ impl CodexOAuthAdminService {
}
}
fn current_oauth_refresh_token(
current: &LoadedCredential,
) -> Result<Option<SecretString>, CodexOAuthAdminError> {
let runtime = CodexCredentialCodec::decode(&current.credential)
.map_err(|_| CodexOAuthAdminError::Credential)?;
Ok(runtime
.authentication
.oauth()
.and_then(|oauth| oauth.refresh_token.clone()))
}
fn current_installation_id(current: &LoadedCredential) -> Result<String, CodexOAuthAdminError> {
let runtime = CodexCredentialCodec::decode(&current.credential)
.map_err(|_| CodexOAuthAdminError::Credential)?;
@@ -110,7 +110,7 @@ pub struct CodexCredentialProfileService {
profile: CodexWireProfileState,
http: Client,
base_url: String,
avatar_sources: Mutex<HashMap<ProviderAccountId, CachedProfileAvatarSource>>,
avatar_sources: Mutex<HashMap<String, CachedProfileAvatarSource>>,
}
impl CodexCredentialProfileService {
@@ -183,23 +183,36 @@ impl CodexCredentialProfileService {
) -> Result<CodexProfileStatistics, CodexProfileStatisticsError> {
let (authorization, upstream_account_id, account) =
self.account_authentication(account_id).await?;
self.fetch_profile_statistics(&authorization, upstream_account_id.as_deref(), &account)
.await
}
async fn fetch_profile_statistics(
&self,
authorization: &SecretString,
upstream_account_id: Option<&str>,
account: &gateway_core::account::ProviderAccount,
) -> Result<CodexProfileStatistics, CodexProfileStatisticsError> {
let request_id = format!("profile_statistics_{}", Uuid::now_v7().simple());
let statistics = CodexBackendClient::new(
self.http.clone(),
self.base_url.clone(),
self.profile.clone(),
)
.for_account(&account)
.for_account(account)
.map_err(map_client_error)?
.fetch_profile_statistics(CodexRequestContext::auxiliary(
authorization.expose_secret(),
upstream_account_id.as_deref(),
upstream_account_id,
&request_id,
None,
))
.await
.map_err(map_client_error)?;
self.remember_avatar_source(account_id, statistics.image_url.as_deref());
self.remember_avatar_source(
super::oauth_identity_key(account),
statistics.image_url.as_deref(),
);
Ok(statistics)
}
@@ -253,16 +266,16 @@ impl CodexCredentialProfileService {
&self,
account_id: &ProviderAccountId,
) -> Result<CodexProfileAvatar, CodexProfileAvatarError> {
let source = match self.cached_avatar_source(account_id) {
let (authorization, upstream_account_id, account) =
self.account_authentication(account_id).await?;
let source = match self.cached_avatar_source(&super::oauth_identity_key(&account)) {
Some(source) => source,
None => self
.profile_statistics(account_id)
.fetch_profile_statistics(&authorization, upstream_account_id.as_deref(), &account)
.await?
.image_url
.ok_or(CodexProfileAvatarError::Missing)?,
};
let (authorization, upstream_account_id, account) =
self.account_authentication(account_id).await?;
let http = if account.outbound_proxy().is_some() {
crate::transport::client::build_account_http_client(
account.id().as_str(),
@@ -288,17 +301,17 @@ impl CodexCredentialProfileService {
.map_err(map_avatar_fetch_error)
}
fn cached_avatar_source(&self, account_id: &ProviderAccountId) -> Option<String> {
fn cached_avatar_source(&self, identity: &str) -> Option<String> {
let now = Instant::now();
let mut sources = self
.avatar_sources
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
sources.retain(|_, source| source.expires_at > now);
sources.get(account_id).map(|source| source.source.clone())
sources.get(identity).map(|source| source.source.clone())
}
fn remember_avatar_source(&self, account_id: &ProviderAccountId, source: Option<&str>) {
fn remember_avatar_source(&self, identity: String, source: Option<&str>) {
let now = Instant::now();
let mut sources = self
.avatar_sources
@@ -308,7 +321,7 @@ impl CodexCredentialProfileService {
match source {
Some(source) => {
sources.insert(
account_id.clone(),
identity,
CachedProfileAvatarSource {
source: source.to_owned(),
expires_at: now + PROFILE_AVATAR_SOURCE_TTL,
@@ -316,7 +329,7 @@ impl CodexCredentialProfileService {
);
}
None => {
sources.remove(account_id);
sources.remove(&identity);
}
}
}
@@ -4,10 +4,26 @@ use std::fmt;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{DateTime, Utc};
use secrecy::SecretString;
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use url::Url;
/// 复用上游会话与账号资料时绑定本地记录和真实 OAuth 身份,正常续期不改变归属
pub(crate) fn oauth_identity_key(account: &gateway_core::account::ProviderAccount) -> String {
use sha2::{Digest, Sha256};
let mut hash = Sha256::new();
for part in [
account.id().as_str(),
account.upstream_user_id().unwrap_or_default(),
account.upstream_account_id().unwrap_or_default(),
] {
hash.update((part.len() as u64).to_le_bytes());
hash.update(part.as_bytes());
}
hex::encode(hash.finalize())
}
/// OAuth AT/RT/ID Token;`Debug` 永不输出明文
#[derive(Clone)]
pub struct CodexOAuthSecret {
@@ -53,6 +69,18 @@ pub(crate) struct CodexOAuthMetadata {
pub(crate) chatgpt_account_id: Option<String>,
}
impl CodexOAuthMetadata {
pub(crate) fn with_access_token_fallback(self, access_token: &SecretString) -> Self {
let fallback = parse_chatgpt_jwt_claims(access_token.expose_secret()).unwrap_or_default();
Self {
email: self.email.or(fallback.email),
chatgpt_plan_type: self.chatgpt_plan_type.or(fallback.chatgpt_plan_type),
chatgpt_user_id: self.chatgpt_user_id.or(fallback.chatgpt_user_id),
chatgpt_account_id: self.chatgpt_account_id.or(fallback.chatgpt_account_id),
}
}
}
impl fmt::Debug for CodexOAuthMetadata {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
@@ -351,7 +351,7 @@ impl CodexProvider {
installation_id,
client: client
.for_account(lease.account())
.map_err(|error| map_client_error(error, UpstreamSendState::NotSent, false).error)?
.map_err(|error| map_client_error(error, UpstreamSendState::NotSent, None).error)?
.with_responses_api_base_url(lease.authentication().responses_api_base_url())
.with_middleware_headers(middleware_headers)
.with_outbound_middleware(outbound_middleware)
@@ -429,6 +429,8 @@ pub(super) struct OpenAiSessionState {
pub(super) account_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) credential_revision: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) oauth_identity: Option<String>,
pub(super) conversation_id: Option<String>,
#[serde(default)]
pub(super) turn_state: Option<String>,
@@ -450,6 +452,7 @@ pub(super) enum OpenAiContinuationScope {
pub(super) struct OpenAiSessionCapture {
pub(super) account_id: String,
pub(super) credential_revision: Option<u64>,
pub(super) oauth_identity: Option<String>,
pub(super) conversation_id: Option<String>,
pub(super) turn_state: Option<String>,
pub(super) client_turn_id: Option<String>,
@@ -498,6 +501,7 @@ fn encode_openai_session_capture(
encode_openai_session_state(OpenAiSessionState {
account_id: capture.account_id.clone(),
credential_revision: capture.credential_revision,
oauth_identity: capture.oauth_identity.clone(),
conversation_id: capture.conversation_id.clone(),
turn_state: capture.turn_state.clone(),
client_turn_id: capture.client_turn_id.clone(),
@@ -584,9 +588,12 @@ pub(super) async fn create_response_attempt(
pub(super) fn map_handshake_attempt_error(
error: CodexHandshakeAttemptError,
account: &ProviderAccount,
) -> MappedProviderFailure {
match error {
CodexHandshakeAttemptError::Client(error) => map_handshake_error(error),
CodexHandshakeAttemptError::Client(error) => {
map_client_error(error, UpstreamSendState::Ambiguous, Some(account))
}
CodexHandshakeAttemptError::Cancelled => MappedProviderFailure::plain(provider_error(
ProviderErrorKind::Cancelled,
UpstreamSendState::Ambiguous,
@@ -637,6 +644,7 @@ pub(super) async fn create_json_attempt(
pub(super) fn cold_json_response_stream(request: ColdJsonResponse) -> EventStream {
Box::pin(async_stream::try_stream! {
let allows_account_state_mutation = request.lease.allows_account_state_mutation();
let image_endpoint = matches!(request.endpoint_path, CODEX_IMAGE_GENERATIONS_PATH | CODEX_IMAGE_EDITS_PATH);
let failure_context = OpenAiFailureContext {
client: &request.client,
selector: &request.selector,
@@ -644,6 +652,7 @@ pub(super) fn cold_json_response_stream(request: ColdJsonResponse) -> EventStrea
response_origin: &request.response_origin,
cyber_policy_scope: None,
allows_account_state_mutation,
image_endpoint,
upstream_model: None,
};
let active_account = request.lease.account().clone();
@@ -677,7 +686,7 @@ pub(super) fn cold_json_response_stream(request: ColdJsonResponse) -> EventStrea
error,
);
}
let response = match response.map_err(map_handshake_attempt_error) {
let response = match response.map_err(|error| map_handshake_attempt_error(error, &active_account)) {
Ok(response) => response,
Err(mut failure) => {
if let Some(observation) = failure.observation.take() {
@@ -709,6 +718,7 @@ pub(super) fn cold_json_response_stream(request: ColdJsonResponse) -> EventStrea
&request.quota,
&active_account,
&response.rate_limit_headers,
image_endpoint,
)
.await;
if !response.set_cookie_headers.is_empty()
@@ -732,7 +742,7 @@ pub(super) fn cold_json_response_stream(request: ColdJsonResponse) -> EventStrea
let response_meta =
ResponseMeta::for_provider_endpoint(request.context.request_id().as_str());
yield ProviderEvent::canonical(GatewayEvent::Started(response_meta.clone()));
if matches!(request.endpoint_path, CODEX_IMAGE_GENERATIONS_PATH | CODEX_IMAGE_EDITS_PATH)
if image_endpoint
&& let Some((usage, cost)) = image_response_metering(&request.body, &response.body, request.context.pricing())
{
yield ProviderEvent::canonical(GatewayEvent::Usage(usage));
@@ -808,6 +818,7 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
let allows_account_state_mutation = lease.allows_account_state_mutation();
let failure_context = OpenAiFailureContext {
client: &client,
image_endpoint: false,
selector: &selector,
quota: &quota,
response_origin: &response_origin,
@@ -866,7 +877,7 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
error,
);
}
let response = response.map_err(map_handshake_attempt_error);
let response = response.map_err(|error| map_handshake_attempt_error(error, &active_account));
let response = match response {
Ok(response) => response,
Err(mut failure) => {
@@ -1164,7 +1175,7 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
if let Some(affinity) = &routing_affinity {
for fact in events.iter().flat_map(ProviderEvent::canonical_facts) {
if let GatewayEvent::Started(meta) = fact {
guardian_routing.remember(context.client_api_key_ref(), active_account.id(), meta.response_id(), affinity);
guardian_routing.remember(context.client_api_key_ref(), &active_account, meta.response_id(), affinity);
}
}
}
@@ -2,7 +2,7 @@
use super::*;
use crate::transport::protocol::responses::is_replaceable_response_prelude;
use gateway_core::error::TransientRetryPolicy;
use gateway_core::error::{ProxyConnectionHint, TransientRetryPolicy};
/// OpenAI 失败对 Smart 账号分数的结构化 reason 闭集
///
@@ -161,6 +161,7 @@ pub(super) struct OpenAiFailureContext<'a> {
pub(super) response_origin: &'a Url,
pub(super) cyber_policy_scope: Option<&'a CodexCyberPolicyScope>,
pub(super) allows_account_state_mutation: bool,
pub(super) image_endpoint: bool,
pub(super) upstream_model: Option<&'a UpstreamModelId>,
}
@@ -310,7 +311,13 @@ pub(super) async fn apply_failure(
if !context.allows_account_state_mutation {
return;
}
synchronize_passive_quota_headers(context.quota, account, &failure.rate_limit_headers).await;
synchronize_passive_quota_headers(
context.quota,
account,
&failure.rate_limit_headers,
context.image_endpoint,
)
.await;
if failure.model_unsupported
&& let Some(model) = context.upstream_model
&& let Err(error) = context
@@ -430,10 +437,6 @@ pub(super) fn schedule_authoritative_quota_refresh_after_failure(
}));
}
pub(super) fn map_handshake_error(error: CodexClientError) -> MappedProviderFailure {
map_client_error(error, UpstreamSendState::Ambiguous, true)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum WebSocketFailurePolicy {
Budgeted,
@@ -711,7 +714,7 @@ fn continuation_replay_error_detail() -> ClientVisibleUpstreamError {
pub(super) fn map_stream_error(error: CodexClientError) -> MappedProviderFailure {
let allows_pre_delivery_retry = stream_transport_allows_pre_delivery_retry(&error);
let websocket_failure = error.transport() == Some(CodexBackendTransport::WebSocket);
let mut failure = map_client_error(error, UpstreamSendState::Sent, false);
let mut failure = map_client_error(error, UpstreamSendState::Sent, None);
failure.websocket_transport_retryable = allows_pre_delivery_retry && websocket_failure;
if allows_pre_delivery_retry && !websocket_failure {
failure.error = failure.error.with_pre_delivery_retry();
@@ -761,7 +764,7 @@ pub(super) fn map_canonical_error(
pub(super) fn map_client_error(
error: CodexClientError,
uncertain_state: UpstreamSendState,
observe_transport: bool,
handshake_account: Option<&ProviderAccount>,
) -> MappedProviderFailure {
if let CodexClientError::Outbound(error) = error {
return MappedProviderFailure::plain(*error);
@@ -773,7 +776,10 @@ pub(super) fn map_client_error(
.with_classification("admission", "local_connection_capacity"),
)
} else {
client_diagnostic(&error)
client_diagnostic(
&error,
handshake_account.is_some_and(|account| account.outbound_proxy().is_some()),
)
};
let raw_upstream_error = match &error {
CodexClientError::WebSocket(error) => websocket_raw_error(error),
@@ -808,9 +814,7 @@ pub(super) fn map_client_error(
}),
_ => None,
};
let observation = observe_transport
.then(|| codex_error_observation(&error))
.flatten();
let observation = handshake_account.and_then(|_| codex_error_observation(&error));
if let Some(failure) = error.upstream_failure() {
return map_upstream_failure(failure, observation, ReplayBoundary::BeforeSemanticOutput);
}
@@ -1007,7 +1011,10 @@ pub(super) fn map_client_error(
}
/// 在消费 transport 错误前提取可持久化事实,不能使用可能携带 URL/凭据的 Display
pub(super) fn client_diagnostic(error: &CodexClientError) -> Option<ProviderDiagnostic> {
pub(super) fn client_diagnostic(
error: &CodexClientError,
uses_account_proxy: bool,
) -> Option<ProviderDiagnostic> {
let (stage, code, message) = match error {
CodexClientError::ConnectionBudgetExhausted => (
"connect",
@@ -1040,13 +1047,16 @@ pub(super) fn client_diagnostic(error: &CodexClientError) -> Option<ProviderDiag
} else {
("exchange", "http_transport_failed")
};
return Some(
ProviderDiagnostic::new(format!(
"OpenAI HTTP failure: stage={stage}, cause={code}"
))
.with_classification(stage, code)
.with_io_cause(error),
);
let diagnostic = ProviderDiagnostic::new(format!(
"OpenAI HTTP failure: stage={stage}, cause={code}"
))
.with_classification(stage, code)
.with_io_cause(error);
return Some(if uses_account_proxy && error.is_connect() {
diagnostic.with_proxy_hint(ProxyConnectionHint::CheckProxy)
} else {
diagnostic
});
}
CodexClientError::StreamIdleTimeout { timeout } => (
"receive",
@@ -1114,10 +1124,11 @@ pub(super) fn profile_diagnostic(
fn custom_ca_diagnostic(error: &crate::transport::tls::CustomCaError) -> ProviderDiagnostic {
use crate::transport::tls::CustomCaError;
let (code, message) = match error {
CustomCaError::ProxyConfiguration => (
"account_proxy_invalid",
"OpenAI account proxy configuration is invalid".to_owned(),
),
CustomCaError::ProxyConfiguration => {
return ProviderDiagnostic::new("OpenAI account proxy configuration is invalid")
.with_classification("prepare", "account_proxy_invalid")
.with_proxy_hint(ProxyConnectionHint::InvalidConfiguration);
}
CustomCaError::ReadCaFile { source_env, .. } => (
"custom_ca_read_failed",
format!("OpenAI CA certificate file selected by {source_env} could not be read"),
@@ -1155,7 +1166,7 @@ fn custom_ca_diagnostic(error: &crate::transport::tls::CustomCaError) -> Provide
fn websocket_diagnostic(error: &CodexWebSocketExchangeError) -> ProviderDiagnostic {
let (stage, code) = match error.diagnostic_cause() {
CodexWebSocketExchangeError::Connect(_) => (
CodexWebSocketExchangeError::Connect { .. } => (
"connect",
error
.transport_failure_reason()
@@ -1191,7 +1202,18 @@ fn websocket_diagnostic(error: &CodexWebSocketExchangeError) -> ProviderDiagnost
.unwrap_or("websocket_transport_failed"),
),
};
websocket_diagnostic_message(error).with_classification(stage, code)
let diagnostic = websocket_diagnostic_message(error).with_classification(stage, code);
match error.classified() {
CodexWebSocketExchangeError::Connect {
proxy_hint: Some(hint),
..
}
| CodexWebSocketExchangeError::ConnectTimeout {
proxy_hint: Some(hint),
..
} => diagnostic.with_proxy_hint(*hint),
_ => diagnostic,
}
}
fn websocket_diagnostic_message(error: &CodexWebSocketExchangeError) -> ProviderDiagnostic {
@@ -1212,7 +1234,7 @@ fn websocket_diagnostic_message(error: &CodexWebSocketExchangeError) -> Provider
if let Some(reason) = error.transport_failure_reason() {
if matches!(
error.diagnostic_cause(),
CodexWebSocketExchangeError::Connect(_)
CodexWebSocketExchangeError::Connect { .. }
) {
return ProviderDiagnostic::new(format!(
"OpenAI WebSocket connection failed before payload send ({reason})"
@@ -1235,10 +1257,10 @@ fn websocket_diagnostic_message(error: &CodexWebSocketExchangeError) -> Provider
| CodexWebSocketExchangeError::PostSendAmbiguous { .. } => {
"OpenAI WebSocket transport failed after payload send; result is ambiguous".to_owned()
}
CodexWebSocketExchangeError::Connect(_) => {
CodexWebSocketExchangeError::Connect { .. } => {
"OpenAI WebSocket connection failed before payload send".to_owned()
}
CodexWebSocketExchangeError::ConnectTimeout { timeout } => {
CodexWebSocketExchangeError::ConnectTimeout { timeout, .. } => {
format!("OpenAI WebSocket connection timed out after {timeout:?}")
}
CodexWebSocketExchangeError::OriginCircuitOpen => {
@@ -1615,7 +1637,7 @@ pub(super) fn account_failure(
pub(super) fn websocket_send_state(error: &CodexWebSocketExchangeError) -> UpstreamSendState {
match error.classified() {
CodexWebSocketExchangeError::InvalidRequest(_)
| CodexWebSocketExchangeError::Connect(_)
| CodexWebSocketExchangeError::Connect { .. }
| CodexWebSocketExchangeError::ConnectTimeout { .. }
| CodexWebSocketExchangeError::OriginCircuitOpen
| CodexWebSocketExchangeError::OriginHalfOpenBusy
@@ -1659,7 +1681,7 @@ pub(super) fn websocket_error_kind(error: &CodexWebSocketExchangeError) -> Provi
CodexWebSocketExchangeError::ConnectionLimitReached(_) => ProviderErrorKind::RateLimited,
CodexWebSocketExchangeError::Transport(_)
| CodexWebSocketExchangeError::SendNotStarted
| CodexWebSocketExchangeError::Connect(_)
| CodexWebSocketExchangeError::Connect { .. }
| CodexWebSocketExchangeError::StreamEndedBeforeTerminal { .. }
| CodexWebSocketExchangeError::ReusedConnectionDiedBeforeFirstEvent { .. } => {
ProviderErrorKind::Transport
@@ -6,7 +6,7 @@ use std::{
time::{Duration, Instant},
};
use gateway_core::{account::ProviderAccountId, policy::ClientApiKeyId};
use gateway_core::{account::ProviderAccount, policy::ClientApiKeyId};
use sha2::{Digest, Sha256};
use crate::transport::routing_affinity::ChatGptRoutingAffinity;
@@ -29,7 +29,7 @@ impl GuardianRouting {
pub(super) fn get(
&self,
client_key: &ClientApiKeyId,
account: &ProviderAccountId,
account: &ProviderAccount,
response_id: &str,
) -> Option<ChatGptRoutingAffinity> {
let key = parent_key(client_key, account, response_id);
@@ -48,7 +48,7 @@ impl GuardianRouting {
pub(super) fn remember(
&self,
client_key: &ClientApiKeyId,
account: &ProviderAccountId,
account: &ProviderAccount,
response_id: &str,
affinity: &ChatGptRoutingAffinity,
) {
@@ -83,11 +83,12 @@ impl GuardianRouting {
fn parent_key(
client_key: &ClientApiKeyId,
account: &ProviderAccountId,
account: &ProviderAccount,
response_id: &str,
) -> [u8; 32] {
let mut hash = Sha256::new();
for part in [client_key.as_str(), account.as_str(), response_id] {
let identity = crate::credential::oauth_identity_key(account);
for part in [client_key.as_str(), &identity, response_id] {
hash.update(part.len().to_le_bytes());
hash.update(part.as_bytes());
}
@@ -367,7 +367,7 @@ impl CodexProvider {
client: self
.client_for_request(&context)?
.for_account(lease.account())
.map_err(|error| map_client_error(error, UpstreamSendState::NotSent, false).error)?
.map_err(|error| map_client_error(error, UpstreamSendState::NotSent, None).error)?
.with_responses_api_base_url(lease.authentication().responses_api_base_url())
.with_middleware_headers(middleware_headers)
.with_outbound_middleware(outbound_middleware),
@@ -418,6 +418,7 @@ fn cold_live_call_stream(request: ColdLiveCall) -> EventStream {
let allows_account_state_mutation = request.lease.allows_account_state_mutation();
let failure_context = OpenAiFailureContext {
client: &client,
image_endpoint: false,
selector: &request.selector,
quota: &request.quota,
response_origin: &request.response_origin,
@@ -478,7 +479,7 @@ fn cold_live_call_stream(request: ColdLiveCall) -> EventStream {
error,
);
}
let response = match attempt.map_err(map_handshake_attempt_error) {
let response = match attempt.map_err(|error| map_handshake_attempt_error(error, &active_account)) {
Ok(response) => response,
Err(mut failure) => {
if let Some(observation) = failure.observation.take() {
@@ -655,7 +656,7 @@ fn map_live_http_error(error: CodexClientError) -> LiveGatewayError {
fn local_live_error(error: CodexClientError, message: &'static str) -> LiveGatewayError {
// 本地传输异常可能包含 URL 或代理上下文;客户端提示与现有安全诊断各自保留
if let Some(diagnostic) = super::failure::client_diagnostic(&error) {
if let Some(diagnostic) = super::failure::client_diagnostic(&error, false) {
tracing::warn!(
stage = diagnostic.stage(),
code = diagnostic.code(),
@@ -888,15 +888,24 @@ impl CodexProvider {
}
let provider_kind = ProviderKind::new(PROVIDER_NAME)
.map_err(|_| provider_error(ProviderErrorKind::Protocol, UpstreamSendState::NotSent))?;
let oauth_identity = matches!(
lease.authentication(),
crate::credential::CodexRuntimeAuthentication::OAuth(_)
)
.then(|| crate::credential::oauth_identity_key(lease.account()));
let native_owner = context.continuation().and_then(ContinuationBinding::pinned);
let state_owner_cross_account = native_owner
.is_some_and(|owner| !owner.matches(&provider_kind, lease.account_id()))
|| context
.account_state_owner()
.is_some_and(|owner| !owner.matches(&provider_kind, lease.account_id()))
|| previous_session
.as_ref()
.is_some_and(|state| state.account_id != lease.account_id().as_str());
|| previous_session.as_ref().is_some_and(|state| {
state.account_id != lease.account_id().as_str()
|| state
.oauth_identity
.as_ref()
.is_some_and(|identity| Some(identity) != oauth_identity.as_ref())
});
let has_explicit_state_owner = context.account_state_owner().is_some()
|| previous_session.is_some()
|| native_owner.is_some();
@@ -970,11 +979,7 @@ impl CodexProvider {
.flatten()
.and_then(|response_id| {
self.guardian_routing
.get(
context.client_api_key_ref(),
lease.account_id(),
response_id,
)
.get(context.client_api_key_ref(), lease.account(), response_id)
.map(|affinity| (response_id.to_owned(), affinity))
});
let client = self.client_for_request(&context)?;
@@ -1133,6 +1138,7 @@ impl CodexProvider {
let session_capture =
(!continuation_requested || previous_session.is_some()).then(|| OpenAiSessionCapture {
account_id: lease.account_id().as_str().to_owned(),
oauth_identity,
credential_revision: matches!(
lease.authentication(),
crate::credential::CodexRuntimeAuthentication::ApiKey(_)
@@ -1164,7 +1170,7 @@ impl CodexProvider {
installation_id,
client: client
.for_account(lease.account())
.map_err(|error| map_client_error(error, UpstreamSendState::NotSent, false).error)?
.map_err(|error| map_client_error(error, UpstreamSendState::NotSent, None).error)?
.with_responses_api_base_url(lease.authentication().responses_api_base_url())
.with_routing_affinity(guardian_parent.map(|(_, affinity)| affinity))
.with_connection_budget(context.connection_budget().clone())
@@ -553,11 +553,16 @@ pub(super) async fn synchronize_passive_quota_headers(
quota: &CodexCredentialQuotaService,
account: &ProviderAccount,
headers: &[(String, String)],
image_endpoint: bool,
) {
let Some(rate_limits) = parse_rate_limit_headers(headers) else {
return;
let parsed = if image_endpoint {
gateway_protocol::openai::events::parse_image_rate_limit_headers(headers)
} else {
parse_rate_limit_headers(headers)
};
synchronize_passive_quota(quota, account, std::slice::from_ref(&rate_limits)).await;
if let Some(rate_limits) = parsed {
synchronize_passive_quota(quota, account, std::slice::from_ref(&rate_limits)).await;
}
}
pub(super) const fn actual_transport_name(transport: CodexBackendTransport) -> &'static str {
@@ -885,6 +885,10 @@ impl CodexBackendClient {
client
.egress_key
.push_str(&format!(":revision:{}", account.revision().get()));
} else {
client
.egress_key
.push_str(&crate::credential::oauth_identity_key(account));
}
client.websocket_origin_key = format!(
"{}:{}",
@@ -440,7 +440,7 @@ fn start_pooled_websocket_connect(
fn opening_failure_reason(error: &CodexWebSocketExchangeError) -> &'static str {
match error.classified() {
CodexWebSocketExchangeError::InvalidRequest(_) => "request_build_failure",
CodexWebSocketExchangeError::Connect(error)
CodexWebSocketExchangeError::Connect { source: error, .. }
| CodexWebSocketExchangeError::Transport(error) => transport_metric_reason(error),
CodexWebSocketExchangeError::ConnectTimeout { .. } => "connect_timeout",
CodexWebSocketExchangeError::Upstream(_) => "upgrade_rejected",
@@ -2,6 +2,7 @@
use std::{fmt, time::Duration};
use gateway_core::error::ProxyConnectionHint;
use gateway_protocol::openai::sse::SseError;
use thiserror::Error;
use uuid::Uuid;
@@ -27,11 +28,16 @@ pub enum CodexWebSocketExchangeError {
#[error("websocket transport error: {0}")]
Transport(#[from] tungstenite::Error),
/// DNS、TCP、TLS 或 opening handshake 在发送 payload 前失败
#[error("websocket connect failed before payload send: {0}")]
Connect(#[source] tungstenite::Error),
#[error("websocket connect failed before payload send: {source}")]
Connect {
#[source]
source: tungstenite::Error,
proxy_hint: Option<ProxyConnectionHint>,
},
/// DNS、TCP、TLS 或 WebSocket upgrade 未在限定时间内完成
#[error("websocket connect timed out after {timeout:?}")]
ConnectTimeout {
proxy_hint: Option<ProxyConnectionHint>,
/// 建连超时时长
timeout: Duration,
},
@@ -257,7 +263,7 @@ impl CodexWebSocketExchangeError {
/// 与已有指标粗分类分开:缺少关闭握手本身不能证明收到 TCP RST
pub(crate) fn transport_failure_reason(&self) -> Option<&'static str> {
match self.diagnostic_cause() {
Self::Transport(error) | Self::Connect(error) => Some(match error {
Self::Transport(error) | Self::Connect { source: error, .. } => Some(match error {
tungstenite::Error::Protocol(
tungstenite::error::ProtocolError::ResetWithoutClosingHandshake,
) => "reset_without_closing_handshake",
@@ -3,6 +3,7 @@
use std::time::Duration;
use bytes::Bytes;
use gateway_core::error::ProxyConnectionHint;
use gateway_protocol::openai::events;
use tokio::{io::BufWriter, time::timeout};
use tokio_tungstenite::{
@@ -205,10 +206,9 @@ async fn connect_websocket(
) -> Result<(RawWsStream, WsResponse<Option<Vec<u8>>>), CodexWebSocketExchangeError> {
let request = websocket_handshake_request(connection)?;
let connector = tls::maybe_build_rustls_client_config_with_custom_ca()
.map_err(|error| {
CodexWebSocketExchangeError::Connect(tungstenite::Error::Io(std::io::Error::other(
error,
)))
.map_err(|error| CodexWebSocketExchangeError::Connect {
source: tungstenite::Error::Io(std::io::Error::other(error)),
proxy_hint: None,
})?
.map(Connector::Rustls);
let remaining = connection
@@ -216,15 +216,16 @@ async fn connect_websocket(
.as_ref()
.map(|budget| budget.begin())
.transpose()
.map_err(|error| {
CodexWebSocketExchangeError::Connect(tungstenite::Error::Io(std::io::Error::other(
error,
)))
.map_err(|error| CodexWebSocketExchangeError::Connect {
source: tungstenite::Error::Io(std::io::Error::other(error)),
proxy_hint: None,
})?
.flatten();
let connect_timeout = remaining.map_or(WEBSOCKET_CONNECT_TIMEOUT, |remaining| {
remaining.min(WEBSOCKET_CONNECT_TIMEOUT)
});
// 超时会取消拨号 future,当前阶段留给外层生成准确的代理诊断
let mut proxy_hint = None;
let result = timeout(connect_timeout, async {
let _permit = if fast_path {
crate::transport::connection::try_acquire()
@@ -248,7 +249,7 @@ async fn connect_websocket(
.await?;
return Ok((Box::new(websocket) as RawWsStream, response));
}
let stream = dial_account(connection).await?;
let stream = dial_account(connection, &mut proxy_hint).await?;
match stream {
MaybeTlsStream::Plain(tcp) => {
let (websocket, response) = client_async_tls_with_config(
@@ -275,18 +276,23 @@ async fn connect_websocket(
.await
.map_err(|_| CodexWebSocketExchangeError::ConnectTimeout {
timeout: connect_timeout,
proxy_hint,
})?;
match result {
Ok((websocket, response)) => Ok((websocket, response)),
Err(tungstenite::Error::Http(response)) => {
Err(websocket_opening_error(response.as_ref(), connection))
}
Err(error) => Err(CodexWebSocketExchangeError::Connect(error)),
Err(error) => Err(CodexWebSocketExchangeError::Connect {
source: error,
proxy_hint,
}),
}
}
async fn dial_account(
connection: &CodexWebSocketConnection,
proxy_hint: &mut Option<ProxyConnectionHint>,
) -> Result<MaybeTlsStream<tokio::net::TcpStream>, tungstenite::Error> {
use tokio::net::lookup_host;
use tungstenite::proxy::ProxyConfig;
@@ -302,6 +308,7 @@ async fn dial_account(
let Some(proxy) = connection.outbound_proxy.as_ref() else {
return Ok(MaybeTlsStream::Plain(connect_tcp(host, port).await?));
};
*proxy_hint = Some(ProxyConnectionHint::InvalidConfiguration);
let mut proxy_url = url::Url::parse(proxy.expose_url()).map_err(|_| invalid())?;
let tls_proxy = proxy_url.scheme() == "https";
let proxy_port = proxy_url.port_or_known_default().ok_or_else(invalid)?;
@@ -316,10 +323,13 @@ async fn dial_account(
Some(url::Host::Ipv6(ip)) => ip.to_string(),
_ => config.host.clone(),
};
*proxy_hint = Some(ProxyConnectionHint::Unreachable);
let tcp = connect_tcp(&proxy_host, config.port).await?;
let stream = if tls_proxy {
*proxy_hint = None;
let tls = tls::account_proxy_tls_config().map_err(|_| invalid())?;
let name = rustls_pki_types::ServerName::try_from(proxy_host).map_err(|_| invalid())?;
*proxy_hint = Some(ProxyConnectionHint::Unreachable);
MaybeTlsStream::Rustls(
tokio_rustls::TlsConnector::from(tls)
.connect(name, tcp)
@@ -329,6 +339,7 @@ async fn dial_account(
MaybeTlsStream::Plain(tcp)
};
// 固定的 tungstenite 分支对两种 SOCKS 协议均把域名交给代理解析
*proxy_hint = None;
let target = if proxy_url.scheme() == "socks5" {
lookup_host((dns_host.as_str(), port))
.await?
@@ -344,9 +355,12 @@ async fn dial_account(
// 依赖分两次写入 SOCKS 方法协商,再显式 flush;部分代理会提前拒绝半包
// 仅在代理握手期间合并写入,不缓冲读取,也不改变后续 TLS/WS 的发送方式
// 这只是兼容措施,不能保证网络层不再拆分 TCP 数据
tokio_tungstenite::proxy::connect_via_proxy(BufWriter::new(stream), &config, &target, port)
.await
.map(BufWriter::into_inner)
*proxy_hint = Some(ProxyConnectionHint::TunnelFailed);
let stream =
tokio_tungstenite::proxy::connect_via_proxy(BufWriter::new(stream), &config, &target, port)
.await?;
*proxy_hint = Some(ProxyConnectionHint::CheckProxy);
Ok(stream.into_inner())
}
async fn connect_tcp(host: &str, port: u16) -> Result<tokio::net::TcpStream, tungstenite::Error> {
@@ -476,6 +476,89 @@ async fn repeated_reauthorization_preserves_surface_id_with_a_cleared_name() {
assert_eq!(surface_ids[0], surface_ids[1]);
}
#[tokio::test]
async fn reauthorization_binds_the_selected_slot_to_the_new_oauth_account() {
use secrecy::ExposeSecret;
let store = Arc::new(MemoryAccountStore::default());
let account_id = ProviderAccountId::new("acct_reauth").unwrap();
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: account_id.to_string(),
name: "local label".into(),
secret: secret("original-access"),
verified_account: account_profile("workspace-a"),
next_refresh_at: None,
enabled: true,
})
.await;
let pending = Arc::new(PendingStore::default());
let service = CodexOAuthAdminService::new(
pending.clone(),
Arc::new(Exchanger {
id_token: id_token(serde_json::json!({
"email": "b@example.com",
"https://api.openai.com/auth": {
"chatgpt_user_id": "user-b",
"chatgpt_account_id": "workspace-b",
"chatgpt_plan_type": "plus"
}
})),
}),
store,
CodexCredentialAdmin,
profile(),
);
let started = service
.start_authorization(StartCodexOAuthAuthorization {
mutation: reauthorization_mutation(account_id.clone(), "reauth-request"),
})
.await
.unwrap();
let state = pending
.pending
.lock()
.unwrap()
.as_ref()
.unwrap()
.state
.clone();
let completed = service
.complete_authorization(CompleteCodexOAuthAuthorization {
owner_ref: "test-owner".into(),
flow_id: started.flow_id,
callback_url: SecretString::from(format!(
"http://127.0.0.1:1455/auth/callback?code=browser&state={}",
state.expose_secret()
)),
})
.await
.unwrap();
let CompletedCodexOAuthCredential::Reauthorize(rotation) = completed.credential else {
panic!("expected reauthorization")
};
let identity = rotation.replacement_identity.unwrap();
assert_eq!(identity.upstream_user_id(), "user-b");
assert_eq!(identity.upstream_account_id(), Some("workspace-b"));
assert_eq!(rotation.profile.account_id, account_id);
assert_eq!(rotation.profile.name, "local label");
assert_eq!(rotation.profile.email.as_deref(), Some("b@example.com"));
assert_eq!(rotation.profile.plan_type.as_deref(), Some("plus"));
let credential = rotation.credential.into_parts();
assert!(!credential.preserve_profile);
let runtime = CodexCredentialCodec::decode(&credential.credential).unwrap();
assert!(runtime.principal.is_none());
assert_eq!(
runtime
.authentication
.oauth()
.unwrap()
.access_token
.expose_secret(),
"access-token-from-upstream"
);
}
#[tokio::test]
async fn first_exchange_preserves_optional_local_name_and_upstream_email() {
for name in ["", "My account"] {
@@ -276,7 +276,8 @@ async fn passive_quota_updates_keep_core_and_model_specific_buckets_independent(
assert_eq!(spark.used_percent(), Some(1.0));
let core_headers = vec![
("x-codex-active-limit".to_owned(), "codex".to_owned()),
// 文本响应的活动标记不是独立额度头,通用周窗口仍应更新原来的账号额度
("x-codex-active-limit".to_owned(), "premium".to_owned()),
("x-codex-primary-used-percent".to_owned(), "82".to_owned()),
(
"x-codex-primary-window-minutes".to_owned(),
@@ -2888,6 +2888,102 @@ async fn image_endpoints_bypass_only_the_text_catalog_and_preserve_the_current_c
server.verify().await;
}
#[tokio::test]
async fn image_quota_headers_keep_the_codex_weekly_window_and_access_state() {
let store = Arc::new(MemoryAccountStore::default());
let account_id = "acct_provider_contract";
create_account(&store, account_id).await;
let account = store.account(account_id).expect("account");
let server = MockServer::start().await;
let (provider, quota) = provider_and_quota_with_affinity_and_base_url_and_leases(
&store,
Arc::new(MemorySessionAffinity::default()),
server.uri(),
Arc::new(TestLeaseCoordinator::default()),
0,
);
let core_headers = [
("x-codex-active-limit", "codex"),
("x-codex-primary-used-percent", "95"),
("x-codex-primary-window-minutes", "10080"),
("x-codex-primary-reset-at", "1900604800"),
("x-codex-allowed", "true"),
]
.map(|(name, value)| (name.to_owned(), value.to_owned()));
quota
.synchronize_passive_headers(&account, &core_headers)
.await
.expect("initial weekly quota");
let initial = quota.read_account(account.id()).await.unwrap().unwrap();
let response = br#"{"data":[{"b64_json":"AAEC"}]}"#;
// 生图响应仅用 active-limit 标明归属,窗口仍使用通用 x-codex 头
Mock::given(method("POST"))
.and(path("/codex/images/generations"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("x-codex-active-limit", "imagegen_premium")
.insert_header("x-codex-primary-used-percent", "0")
.insert_header("x-codex-primary-window-minutes", "1440")
.insert_header("x-codex-primary-reset-at", "1900000000")
.insert_header("x-codex-secondary-used-percent", "0")
.insert_header("x-codex-secondary-window-minutes", "0")
.insert_header("x-codex-secondary-reset-at", "")
.set_body_raw(response.as_slice(), "application/json"),
)
.expect(1)
.mount(&server)
.await;
let payload = RawJsonPayload::new(
"openai",
Bytes::from_static(br#"{"model":"gpt-image-2.6-flare","prompt":"a tree"}"#),
)
.unwrap();
let operation = Operation::GenerateImage(ImageRequest::from_raw_json(
ImageRequestKind::Generation,
payload,
));
let mut stream = provider
.execute(
planned_provider_endpoint_request("openai", operation),
context("req_image_quota", CancellationToken::new()),
)
.await
.expect("image stream");
let mut delivered = None;
while let Some(event) = stream.next().await {
let event = event.expect("image event");
if let Some(body) = event.wire_event().and_then(|wire| wire.raw_json_body()) {
delivered = Some(body.clone());
}
}
assert_eq!(delivered.as_deref(), Some(response.as_slice()));
let snapshot = quota.read_account(account.id()).await.unwrap().unwrap();
let core = snapshot
.windows()
.iter()
.find(|window| window.is_account_wide())
.unwrap();
let image = snapshot
.windows()
.iter()
.find(|window| window.source() == "imagegen_premium")
.unwrap();
assert_eq!(core, &initial.windows()[0]);
assert_eq!(image.window_seconds(), Some(86_400));
assert_eq!(image.used_percent(), Some(0.0));
assert!(!image.is_account_wide());
assert_eq!(store.account(account_id).unwrap().quota(), initial.quota());
quota
.synchronize_passive_headers(&account, &core_headers)
.await
.expect("next Codex quota observation");
let refreshed = quota.read_account(account.id()).await.unwrap().unwrap();
assert_eq!(refreshed.windows(), snapshot.windows());
server.verify().await;
}
#[tokio::test]
async fn image_endpoints_should_report_usage_before_delivering_the_unchanged_body() {
let response = br#"{ "created":1778832973,"data":[{"b64_json":"AAEC"}],"quality":"medium","size":"1024x1536","usage":{"input_tokens":1474,"input_tokens_details":{"image_tokens":1457,"text_tokens":17,"cached_tokens":100},"output_tokens":1372,"output_tokens_details":{"image_tokens":1372,"text_tokens":0},"total_tokens":2846},"future":9007199254740993 }"#;
@@ -4166,12 +4262,44 @@ async fn websocket_fast_path_miss_uses_http_and_keeps_background_preconnect() {
#[tokio::test]
async fn connection_failure_preserves_io_cause_without_claiming_payload_was_sent() {
for websocket in [false, true] {
use gateway_core::error::ProxyConnectionHint;
for (websocket, proxy_hint) in [
(false, None),
(true, None),
(false, Some(ProxyConnectionHint::CheckProxy)),
(true, Some(ProxyConnectionHint::Unreachable)),
(true, Some(ProxyConnectionHint::TunnelFailed)),
] {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_abrupt_disconnect").await;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
drop(listener);
let rejection = if proxy_hint == Some(ProxyConnectionHint::TunnelFailed) {
Some(tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
assert!(
capture_http_request(&mut socket)
.await
.starts_with(b"CONNECT ")
);
socket
.write_all(
b"HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\n\r\n",
)
.await
.unwrap();
}))
} else {
drop(listener);
None
};
if proxy_hint.is_some() {
store.set_egress(
"acct_abrupt_disconnect",
Some(gateway_core::account::OutboundProxy::parse(&base_url).unwrap()),
None,
);
}
let operation = if websocket {
Operation::Generate(generate_with_persisted_session_context(
"acct_abrupt_disconnect",
@@ -4197,7 +4325,13 @@ async fn connection_failure_preserves_io_cause_without_claiming_payload_was_sent
}
};
let diagnostic = error.diagnostic().expect("connection diagnosis");
assert_eq!(diagnostic.code(), Some("connection_refused"));
assert_eq!(diagnostic.proxy_hint(), proxy_hint);
if let Some(rejection) = rejection {
rejection.await.unwrap();
}
if proxy_hint.is_none() {
assert_eq!(diagnostic.code(), Some("connection_refused"));
}
assert_eq!(diagnostic.stage(), Some("connect"));
assert_eq!(error.send_state(), UpstreamSendState::NotSent);
assert!(!diagnostic.as_str().contains("after payload send"));
@@ -10095,7 +10229,7 @@ async fn successful_response_with_full_subscription_blocks_subsequent_requests()
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.insert_header("x-codex-active-limit", "codex")
.insert_header("x-codex-active-limit", "premium")
.insert_header("x-codex-primary-used-percent", "100")
.insert_header("x-codex-primary-window-minutes", "300")
.insert_header("x-codex-primary-reset-at", "1900000000")
@@ -289,6 +289,21 @@ async fn exact_continuation_reuses_owning_socket_after_connection_profile_update
profile.clone(),
)
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(Duration::from_mins(1))));
let account = |user: &str| {
use gateway_core::account::{CredentialRevision, ProviderAccount, ProviderAccountId};
ProviderAccount::new(
ProviderAccountId::new("acct_shared_slot").unwrap(),
gateway_core::routing::ProviderKind::new("openai").unwrap(),
String::new(),
Some(user.to_owned()),
"oauth".to_owned(),
CredentialRevision::new(1).unwrap(),
None,
)
.with_profile(None, Some("chatgpt-account".into()), None)
};
let replacement = backend.for_account(&account("user-b")).unwrap();
let backend = backend.for_account(&account("user-a")).unwrap();
let first_request = pooled_websocket_request("wire-profile-continuation");
let first = backend
.create_response(
@@ -309,6 +324,16 @@ async fn exact_continuation_reuses_owning_socket_after_connection_profile_update
let mut continuation = first_request;
continuation.set_previous_response_id(Some("resp_connection_local".to_owned()));
continuation.previous_response_scope = Some(PreviousResponseScope::ConnectionLocal);
assert!(
replacement
.create_response(
&continuation,
request_context("req_reauthorized", Some("chatgpt-account"))
)
.await
.is_err(),
"同一工作区的另一个用户不能续用旧账号的连接"
);
let changed_installation = backend
.create_response(
&continuation,
@@ -424,14 +424,19 @@ impl GrokInferenceTransport for ReqwestGrokInferenceTransport {
}
let dns_observer = Arc::new(RequestDnsObserver::default());
let headers_started_at = Instant::now();
let response = REQUEST_DNS_OBSERVER
.scope(Arc::clone(&dns_observer), builder.send())
.await
.map_err(|error| {
classify_inference_reqwest_error(error).with_transport_metrics(
inference_transport_metrics(client_cache_status, None, &dns_observer),
)
})?;
let response =
REQUEST_DNS_OBSERVER
.scope(Arc::clone(&dns_observer), builder.send())
.await
.map_err(|error| {
classify_inference_reqwest_error(
error,
request.binding().outbound_proxy().is_some(),
)
.with_transport_metrics(
inference_transport_metrics(client_cache_status, None, &dns_observer),
)
})?;
let transport_metrics = inference_transport_metrics(
client_cache_status,
Some(elapsed_millis(headers_started_at.elapsed())),
@@ -1001,7 +1006,10 @@ fn inference_http_diagnostic(error: &reqwest::Error) -> ProviderDiagnostic {
.with_io_cause(error)
}
fn classify_inference_reqwest_error(error: reqwest::Error) -> GrokInferenceTransportError {
fn classify_inference_reqwest_error(
error: reqwest::Error,
uses_account_proxy: bool,
) -> GrokInferenceTransportError {
let (kind, send_state) = if error.is_builder() {
(
GrokInferenceTransportErrorKind::Protocol,
@@ -1023,8 +1031,12 @@ fn classify_inference_reqwest_error(error: reqwest::Error) -> GrokInferenceTrans
UpstreamSendState::Ambiguous,
)
};
let failure = GrokInferenceTransportError::new(kind, send_state)
.with_diagnostic(inference_http_diagnostic(&error));
let mut diagnostic = inference_http_diagnostic(&error);
if uses_account_proxy && error.is_connect() {
diagnostic =
diagnostic.with_proxy_hint(gateway_core::error::ProxyConnectionHint::CheckProxy);
}
let failure = GrokInferenceTransportError::new(kind, send_state).with_diagnostic(diagnostic);
with_inference_reqwest_source(failure, error)
}
@@ -184,11 +184,15 @@ async fn inference_and_oauth_connect_through_each_accounts_authenticated_proxy()
.unwrap()
.with_outbound_proxy(Some(proxy)),
);
assert!(
tokio::time::timeout(Duration::from_secs(5), inference.execute(request))
.await
.unwrap()
.is_err()
let error = tokio::time::timeout(Duration::from_secs(5), inference.execute(request))
.await
.unwrap()
.expect_err("proxy rejects CONNECT");
assert_eq!(
error
.diagnostic()
.and_then(|diagnostic| diagnostic.proxy_hint()),
Some(gateway_core::error::ProxyConnectionHint::CheckProxy)
);
let connect = tokio::time::timeout(Duration::from_secs(5), captured)
.await
@@ -581,6 +585,7 @@ async fn inference_connect_failure_keeps_safe_diagnosis_without_endpoint_or_payl
.await
.expect_err("closed listener");
let diagnostic = error.diagnostic().expect("transport cause");
assert_eq!(diagnostic.proxy_hint(), None);
assert_eq!(diagnostic.stage(), Some("connect"));
assert_eq!(diagnostic.code(), Some("connection_refused"));
assert!(!diagnostic.as_str().contains("127.0.0.1"));
+2 -2
View File
@@ -4,7 +4,7 @@
# 版本严格一致。升级数据库版本时只需同步修改这里与 deploy/compose.yaml。
ARG POSTGRES_IMAGE=postgres:18-bookworm@sha256:bbf83594798e144bff365a9c191c5d779f181fb3135902064ab8ff11918020fc
FROM node:26.13.0-bookworm-slim@sha256:bc0a2afd3b74ce12222fc8bab99271e66257f021078f5f4a5d687a77d280c1d9 AS frontend-builder
FROM node:26.13.0-bookworm-slim@sha256:add5c17c14752c54d2f3d519ec46a3815684d41c33f198bcb09fb998f7dcbf68 AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./
@@ -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:99c0b25c46d0214d5a2091092e7908907dcaecd6a962cde0ac700acee222b6f3 AS runtime-base
FROM debian:bookworm-slim@sha256:734a72b3d6142a71993b4bc580ca01d8ca687f77a2c422d96a961d3376b179bc AS runtime-base
ENV TZ=UTC
+1 -1
View File
@@ -959,7 +959,7 @@ Release 必须提供当前 OS/架构的 `codex-proxy-rs_<version>_<os>_<arch>.ta
宿主范围或能力版本不匹配时,可在插件页确认风险后尝试本次启动;清单、RPC、平台或包完整性错误不能绕过。具体操作见[插件故障处理](../docs/plugins.md#故障处理)
部署须保留完整的二进制、Web 资源与官方插件目录;不要手动修改制品元数据或迁移 checksum 绕过校验。
macOS arm64、Windows x64 / ARM64 提供构建产物;插件安装包必须匹配宿主 OS 和架构,部署前需验证目标平台的插件运行与上游调用,不能只以打包成功判断可用
插件安装包必须匹配宿主 OS 和架构,部署前需验证目标平台的插件运行与上游调用,不能只以打包成功判断可用
## 备份与恢复
+10 -3
View File
@@ -1384,6 +1384,7 @@ OAuth 等待回调期间不持有保护;提交仍拒绝已删除或连接配
"source": "upstream",
"gatewayErrorCode": "rate_limited",
"sendState": "sent",
"proxyHint": null,
"error": "upstream unavailable",
"providerErrorCode": "usage_exhausted",
"providerErrorType": "invalid_request_error",
@@ -1399,6 +1400,9 @@ OAuth 等待回调期间不持有保护;提交仍拒绝已删除或连接配
- `source` 为 `gateway`、`provider` 或 `upstream`:分别表示尚未进入 Provider、Provider 本地且未发送、
已发送/可能已发送或已经捕获到上游事实
- `gatewayErrorCode` 是 `GatewayErrorKind` 的稳定机器值,管理端据此生成中文摘要
- `proxyHint` 仅在 Provider 发送前失败且有代理诊断时返回:`invalid_configuration` 表示代理配置无效,
`unreachable` 表示无法连接代理端点,`tunnel_failed` 表示代理隧道协商失败。
`check_proxy` 表示本次使用代理且建连失败,不能据此判定故障在代理;无提示时为 `null`
- `sendState` 为 `not_sent`、`sent`、`ambiguous`,非 Provider 错误为 `null`
- `error`、`providerErrorCode`、`providerErrorType`、`upstreamStatus`、`upstreamContentType` 和
`upstreamBody` 是实际捕获的原始诊断字段;缺失时为 `null`,不会由本地猜测或翻译
@@ -1598,8 +1602,10 @@ OpenAI 授权码交换与令牌刷新请求的连接超时为 30 秒,单次上
失效后需取得新 PAT 再导入
- 首次 OAuth 保留回调 `state`、PKCE 与官方 token exchange,并持久化 `idToken`、`accessToken`、
`refreshToken`。刷新响应中的三个 token 字段均按官方语义独立轮换:返回新值时替换,省略时分别保留
现值。重新授权也保留这些回调保护,但只轮换目标账号的 token。回调地址只承载 `code`/`state`,
不以 host/path 形式作为拒绝条件
现值。回调地址只承载 `code`/`state`,不以 host/path 形式作为拒绝条件
- OpenAI 重新授权允许登录另一个上游账号。保留本地账号 ID、名称和调度设置,按新授权更新令牌、
上游用户与工作区 ID、邮箱和套餐。换号时清除旧账号的额度快照和耗尽状态,新额度由后台重新查询。
无法识别新用户或新身份已绑定其他记录时,原账号保持不变
- OAuth 账号文件导入和 OAuth complete(包括重新授权)在 credential 提交后后台尝试一次额度观测,不等待
观测完成才返回成功。观测失败只记录告警,不回滚已提交的账号;手工或后台 RT 刷新只更新 token,
不隐式等同于手工额度刷新,也不更新既有账号资料或 OAuth principal。xAI 导入与 OAuth complete
@@ -3108,7 +3114,8 @@ GitHub 的 `location` 使用 `kind: "github"`、`repository: "owner/repo"`、`ta
回调保持类型、期限、取消、实例 revision、资源归属和事务一致性检查。模型调用可显式选择 Key,按所选 Key 的准入、预算、用量与计费合同执行;省略 Key 时继承现有模型父请求身份
受管 HTTP 不限制插件目标地址段。宿主解析 DNS,连接保留原始 Host 与 TLS 域名校验;代理沿用账号配置,失败不自动回退直连,重定向不自动跟随
受管 HTTP 不限制插件目标地址段。宿主解析 DNS,连接保留原始 Host 与 TLS 域名校验;出口可继承调用范围、指定账号或按代理 ID 选择,连接失败不自动回退直连,重定向不自动跟随。
显式代理选择与目录查询见 [SDK 网络回调](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#网络)
#### 能力绑定与范围
+8
View File
@@ -243,6 +243,8 @@ flowchart LR
流、取消确认与消费方共同持有同一在途租约,执行名额只覆盖首结果前的处理;调用等待与回调有效期共用各端的预算状态,请求总期限独立生效。
显式 Key 的身份快照随调用保留,每个模型执行拥有独立子调用图与结算;省略 Key 时继承现有模型父请求。页面 Responses 桥复核精确实例目标和 revision
`frontend_authentication` 使用 principal 到 Key 的映射;公开登录回调的一次性票据用于关联流程,不限制插件回调能力。接口合同见 [SDK 回调](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#宿主资源回调)
- **显式代理独立于账号**:Admin 通过窄读取端口提供当前代理配置与目录,Runtime 只投影选择信息并复用 Host 的 HTTP 发送和调用内流资源。
按代理 ID 发送时要求代理存在且启用,失败不回退直连;不关联账号凭据版本,也不持有跨请求配置锁。业务流程中的出口选择由插件维护
- **观察合同统一**:`observer` 通过一个类型化 RPC 入口接收完成与实际上游 WebSocket 事件,发布时按事件类型编译独立作用域。完成事件直接携带终态、用量、费用和耗时,不按能力声明裁剪字段;异步投递的并发、队列和载荷有界,观察失败不改变业务结果
- **维护只作用于已发布实例**:`maintenance` 由 Host 监督的 Runtime worker 调用,启用、恢复、配置发布和周期补偿共用对账入口。
发布通知只用于唤醒,事实始终来自当前快照与数据库;按发布层复用的实例身份保留在途任务,无关实例变更不重启维护。同实例串行,通知合并;自身停用、替换或恢复时取消旧任务,等取消确认或进程退出后启动替代任务。
@@ -505,6 +507,7 @@ OpenAI Provider 按 Client Key、所选 OAuth 账号和父响应 ID 关联 WS
缺失或失效时按普通上游路由执行。transport 负责 Cookie 有效期和同 origin、同端点路径约束,
连接池按快照身份隔离审查连接;复用父连接不会重新计算 Cookie 有效期,Cookie 到期不强制关闭已建立的审查连接
OpenAI OAuth 的连接、续接状态、Guardian 路由 Cookie 和头像缓存按本地账号、上游用户与工作区隔离,同号令牌续期不改变归属。
OpenAI Provider 统一处理 Responses 的账号归属与 turn state 隔离,HTTP 与 WS 消费同一出站请求。
全量请求缺少本地归属记录时保留客户端回传的路由 token,明确换号或可信续接状态不属于同一轮时清除;
turn state 不参与选号,也不授权其他账号状态或 response ID 的复用
@@ -1028,6 +1031,11 @@ credential 与 quota 是两组独立事实:credential refresh 不等于 quota
OpenAI Provider 将其中明确的套餐变更与额度原子提交,共用凭据版本和观察时间保护;
空值及 `unknown` 不覆盖套餐,同族泛化值保留具体子类型。Token 刷新保留提交时的账号资料
OpenAI 响应头的额度窗口按头名前缀归属,普通 Responses 的 `x-codex-primary-*` 和
`x-codex-secondary-*` 更新账号通用额度。
仅在图片生成和编辑端点,若 `x-codex-active-limit` 指向具名计量项且没有该项的独立额度头,
通用窗口才归入该计量项。Provider 按计量项独立保存,具名额度不改变账号级套餐访问结论
OpenAI Provider 统一解释点数可用性,主动查询、耗尽恢复与失败回读复用同一规则。
Store 在账号事务内保存点数放行依据与快照;关闭自动使用时撤销该依据,迟到观测不能绕过开关。
放行条件与默认值见[账号额度 API](api.md#查询与账号字段)。
+2 -2
View File
@@ -160,7 +160,7 @@ interface ThemeCustomization {
完整主题预设组合品牌配色、推荐材质、全局 Seed、组件尺寸和可编辑 Token。选择时替换草稿配置,保留当前明暗模式;保存后应用到管理端
表面材质可单独选择:`default` 使用简洁表面,`soft` 使用柔和光影。切换材质保留草稿的配色和组件配置,显式尺寸优先于材质默认值
表面材质可单独选择:`default` 使用简洁表面,`soft` 使用柔和光影。材质只影响表面高光、渐变与阴影,不改变配色、圆角、字号或组件尺寸
持久化键为 `codex-proxy-rs-theme`,`themePreset` 缺省为 `default`。Store 不保存完整色板或 CSS Variables,损坏的模式、预设、颜色与自定义值会在
初始化时规范化并回退到默认配置
@@ -291,7 +291,7 @@ Purple Strong Container 或 Purple Solid;同一组颜色仍由运行时色板
| ID | 名称 | 推荐品牌色 | 表面 |
| --- | --- | --- | --- |
| `default` | 默认 | 星津夜航 | 简洁表面与轻量阴影 |
| `soft` | 柔光 | 星津夜航 | 圆润边缘、表面高光与分层阴影,配色跟随品牌 |
| `soft` | 柔光 | 星津夜航 | 表面高光与分层阴影,配色跟随品牌 |
### 主题预设
+7 -7
View File
@@ -17,23 +17,23 @@
},
"dependencies": {
"@boxicons/vue": "1.0.1",
"@codex-proxy/ui": "github:zyycn/codex-proxy-ui#v0.9.0",
"@codex-proxy/ui": "github:zyycn/codex-proxy-ui#v0.9.1",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@lucide/vue": "^1.67.0",
"@lucide/vue": "^1.70.0",
"@vueuse/core": "^15.0.0",
"@vueuse/integrations": "^15.0.0",
"axios": "^1.21.0",
"circle-flags": "2.8.3",
"css-tree": "^3.2.1",
"echarts": "^6.1.0",
"es-toolkit": "^1.53.1",
"es-toolkit": "^1.53.2",
"gsap": "^3.15.1",
"pinia": "^4.1.0",
"pinia-plugin-persistedstate": "^4.7.1",
"semver": "^7.8.6",
"sortablejs": "^1.15.7",
"vue": "^3.5.44",
"vue": "^3.5.45",
"vue-i18n": "11.4.18",
"vue-router": "^5.4.0"
},
@@ -41,7 +41,7 @@
"@antfu/eslint-config": "^10.0.0",
"@intlify/eslint-plugin-vue-i18n": "4.5.1",
"@intlify/unplugin-vue-i18n": "11.2.8",
"@tailwindcss/vite": "^4.3.4",
"@tailwindcss/vite": "^4.3.5",
"@types/css-tree": "^3.2.0",
"@types/node": "^26.6.6",
"@types/semver": "^7.8.0",
@@ -51,9 +51,9 @@
"eslint": "^10.14.0",
"eslint-plugin-format": "^2.0.1",
"eslint-plugin-vuejs-accessibility": "^2.6.3",
"tailwindcss": "^4.3.4",
"tailwindcss": "^4.3.5",
"typescript": "^6.0.3",
"vite": "^8.3.5",
"vite": "^8.3.6",
"vue-tsc": "3.3.14"
}
}
+337 -256
View File
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -69,7 +69,7 @@
"completedAt": "Completed",
"events": {
"title": "Event timeline",
"request": "Request sent",
"request": "Preparing test request",
"response": "Receiving response",
"completed": "Response complete"
},
@@ -86,6 +86,11 @@
"capacityUnavailable": "This account has no available capacity",
"infrastructureUnavailable": "The provider's local infrastructure is temporarily unavailable",
"rateLimited": "Upstream rate limit exceeded",
"notSent": "The request could not be sent upstream",
"proxyInvalid": "Invalid account proxy configuration",
"proxyUnreachable": "Could not connect to the account proxy — test it in Proxies",
"proxyTunnelFailed": "Could not establish a tunnel through the account proxy — check its settings",
"checkProxy": "The request could not be sent upstream — test the account proxy in Proxies",
"upstreamUnavailable": "Upstream service temporarily unavailable",
"timeout": "Upstream request timed out",
"canceled": "Test canceled",
@@ -100,7 +105,7 @@
},
"stages": {
"gateway": "Gateway validation",
"provider": "Provider setup",
"provider": "Failed before sending",
"upstream": "Upstream response"
},
"failed": "Test failed",
@@ -108,7 +113,6 @@
"runningDescription": "Sending a request to the selected model and receiving its streaming response",
"successTitle": "Connection successful",
"completedDescription": "Request complete, view the model, response time, and event timeline below",
"failedDescription": "Request incomplete, view the failure source and raw diagnostics below",
"readyTitle": "Ready to test",
"readyDescription": "Select a model and choose Start test to send a real request",
"starting": "Starting a connection test for {value}",
@@ -69,7 +69,7 @@
"completedAt": "完成时间",
"events": {
"title": "事件轨迹",
"request": "发起请求",
"request": "准备测试请求",
"response": "接收响应内容",
"completed": "响应完成"
},
@@ -86,6 +86,11 @@
"capacityUnavailable": "指定账号当前没有可用容量",
"infrastructureUnavailable": "Provider 本地基础设施暂不可用",
"rateLimited": "上游请求过于频繁",
"notSent": "请求未能发往上游",
"proxyInvalid": "账号代理配置无效",
"proxyUnreachable": "无法连接账号代理,请在「代理管理」中测试该代理",
"proxyTunnelFailed": "未能通过账号代理建立隧道,请检查代理设置",
"checkProxy": "请求未能发往上游,可在「代理管理」中测试账号代理",
"upstreamUnavailable": "上游服务暂不可用",
"timeout": "上游请求超时",
"canceled": "测试请求已取消",
@@ -100,7 +105,7 @@
},
"stages": {
"gateway": "网关校验",
"provider": "Provider 本地准备",
"provider": "请求发送前失败",
"upstream": "上游响应"
},
"failed": "测试失败",
@@ -108,7 +113,6 @@
"runningDescription": "正在向所选模型发送请求并接收流式响应",
"successTitle": "连接正常",
"completedDescription": "请求已完成,可在下方查看模型、耗时和事件轨迹",
"failedDescription": "请求未完成,请在下方查看失败来源与原始诊断",
"readyTitle": "准备测试",
"readyDescription": "选择模型后,点击“开始测试”发送真实请求",
"starting": "开始测试 {value}",
@@ -69,7 +69,7 @@
"completedAt": "完成時間",
"events": {
"title": "事件軌跡",
"request": "發起請求",
"request": "準備測試請求",
"response": "接收響應內容",
"completed": "響應完成"
},
@@ -86,6 +86,11 @@
"capacityUnavailable": "指定賬號當前沒有可用容量",
"infrastructureUnavailable": "Provider 本地基礎設施暫不可用",
"rateLimited": "上游請求過於頻繁",
"notSent": "請求未能送往上游",
"proxyInvalid": "帳號代理設定無效",
"proxyUnreachable": "無法連線至帳號代理,請在「代理管理」中測試該代理",
"proxyTunnelFailed": "無法透過帳號代理建立通道,請檢查代理設定",
"checkProxy": "請求未能送往上游,可在「代理管理」中測試帳號代理",
"upstreamUnavailable": "上游服務暫不可用",
"timeout": "上游請求超時",
"canceled": "測試請求已取消",
@@ -100,7 +105,7 @@
},
"stages": {
"gateway": "網關校驗",
"provider": "Provider 本地準備",
"provider": "請求送出前失敗",
"upstream": "上游響應"
},
"failed": "測試失敗",
@@ -108,7 +113,6 @@
"runningDescription": "正在向所選模型發送請求並接收流式響應",
"successTitle": "連接正常",
"completedDescription": "請求已完成,可在下方查看模型、耗時和事件軌跡",
"failedDescription": "請求未完成,請在下方查看失敗來源與原始診斷",
"readyTitle": "準備測試",
"readyDescription": "選擇模型後,點擊“開始測試”發送真實請求",
"starting": "開始測試 {value}",
+2 -1
View File
@@ -76,7 +76,8 @@ watch(
/>
<FloatingSidebarToggle v-show="!mobileSidebarOpen" @open="openMobileSidebar" />
<main class="relative isolate h-dvh min-w-0 flex-1 overflow-hidden">
<ZScrollbar ref="pageScrollbarRef">
<!-- 让主滚动区自身绘制不透明底色,浏览器可在合成线程滚动 -->
<ZScrollbar ref="pageScrollbarRef" class="[&>.z-scrollbar-hidden]:bg-z-bg-layout">
<div class="flex min-h-full min-w-0 flex-col p-4 min-[961px]:p-6">
<RouterView v-slot="{ Component }">
<component :is="Component" class="min-h-0 flex-1" />
@@ -31,6 +31,7 @@ interface ConnectionTestLog {
text: string
tone: ConnectionTestLogTone
detail: string
failureMessage?: string
}
interface ConnectionTestRequestPayload {
@@ -71,6 +72,7 @@ interface ConnectionTestFailureEvent {
type: 'error'
source?: ConnectionTestFailureSource
gatewayErrorCode?: string
proxyHint?: string | null
sendState?: ConnectionTestSendState | null
error?: string
providerErrorCode?: string | null
@@ -132,6 +134,16 @@ function parseConnectionTestEvent(raw: string): ConnectionTestEvent | null {
}
function connectionTestFailureText(event: ConnectionTestFailureEvent) {
if (event.source === 'provider' && event.sendState === 'not_sent') {
switch (event.proxyHint) {
case 'invalid_configuration': return t('accounts.connectionTest.errors.proxyInvalid')
case 'unreachable': return t('accounts.connectionTest.errors.proxyUnreachable')
case 'tunnel_failed': return t('accounts.connectionTest.errors.proxyTunnelFailed')
case 'check_proxy': return t('accounts.connectionTest.errors.checkProxy')
}
if (event.gatewayErrorCode === 'upstream_unavailable')
return t('accounts.connectionTest.errors.notSent')
}
return event.gatewayErrorCode
? CONNECTION_TEST_FAILURE_TEXT.value[event.gatewayErrorCode] || t('accounts.connectionTest.errors.unknown')
: t('accounts.connectionTest.errors.failed')
@@ -147,6 +159,7 @@ function connectionTestFailureDiagnostics(event: ConnectionTestFailureEvent) {
return {
error: event.error ?? null,
gatewayErrorCode: event.gatewayErrorCode ?? null,
proxyHint: event.proxyHint ?? null,
sendState: event.sendState ?? null,
upstreamStatus: event.upstreamStatus ?? null,
providerErrorCode: event.providerErrorCode ?? null,
@@ -215,7 +228,7 @@ export function useAccountConnectionTest(options: { reload: () => Promise<unknow
if (connectionTestStatus.value === 'error') {
return {
label: t('accounts.connectionTest.failed'),
description: t('accounts.connectionTest.failedDescription'),
description: connectionTestLogs.value.at(-1)?.failureMessage,
icon: XCircle,
badge: 'bg-z-error-container text-z-error-on-container',
iconClass: 'text-z-error',
@@ -350,8 +363,10 @@ export function useAccountConnectionTest(options: { reload: () => Promise<unknow
detail?: unknown,
event?: ConnectionTestTimestamp,
) {
connectionTestError.value = message
setConnectionTestLog(key, `${label}:${message}`, 'danger', detail, event)
connectionTestLogs.value.push({
...connectionTestLogItem(key, label, 'danger', detail, event),
failureMessage: message,
})
finishConnectionTest('error', event)
}
+9 -5
View File
@@ -1,14 +1,18 @@
# v3.43.0
# v3.44.0
## 新增功能
- 管理端主题:可选星津夜航、水墨江汀、粉墙回廊、朱阙金瓦四套场景预设,一次应用配色、控件尺寸和圆角,也可单独切换默认或柔光材质
- 插件 SDK:支持查询已保存的代理,并按代理 ID 指定 HTTP 请求出口
## 优化改进
- 管理端:系统概览和用量页移除与正文重复的悬浮提示,保留截断文本的全文和错误详情
- 账号连接测试:区分请求发送前失败和上游响应,显示可识别的代理配置、连接及隧道错误
## 问题修复
- 账号调度:网关正常关闭时释放本实例持有的账号并发占位,避免重启后仍需等待旧租约到期
- 账号管理:修复桌面端列表高度计算不准、内容可容纳时仍出现整页滚动条的问题
- OpenAI 额度:常规模型请求的通用额度头归入 Codex 额度,仅在生图端点按活动计量项归类
- 管理端主题:切换默认或柔光材质只改变表面高光、渐变与阴影,圆角和组件尺寸可独立设置
## 升级须知
- 柔光材质采用通用默认圆角,未自定义时控件为 8px、卡片为 18px、侧栏为直角
+7
View File
@@ -17,6 +17,13 @@ platforms:
docker: true
dockerPlatform: linux/arm64
- os: darwin
arch: amd64
runner: macos-15-intel
target: x86_64-apple-darwin
binary: codex-proxy-rs
archive: tar.gz
- os: darwin
arch: arm64
runner: macos-15
+5 -5
View File
@@ -1,7 +1,7 @@
version: 3.43.0
version: 3.44.0
published:
'3':
stable: 3.42.1
rc: 3.42.1
beta: 3.42.1
alpha: 3.42.1
stable: 3.43.1
rc: 3.43.1
beta: 3.43.1
alpha: 3.43.1