Compare commits

..
12 Commits
Author SHA1 Message Date
ZyyandCodex 12c32fcdec chore(release): v3.39.1
Co-authored-by: Codex <noreply@openai.com>
2026-11-03 11:47:32 +08:00
Ke WangandCodex 26de671226 fix(runtime): reload full snapshot on config revision races (#584)
* fix(runtime): reload raced snapshot facts before suspending publication

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

* fix(runtime): retry extension preparation when its revision becomes stale

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

---------

Co-authored-by: Codex <noreply@openai.com>
2026-11-03 11:38:47 +08:00
Ke WangandCodex a92b95c48c fix(proxies): measure latency for the selected successful address family (#585)
Co-authored-by: Codex <noreply@openai.com>
2026-11-03 11:34:40 +08:00
ZyyandCodex e7c24781fb fix(openai): preserve deployment account routing policy
Co-authored-by: Codex <noreply@openai.com>
2026-11-03 11:33:53 +08:00
ZyyandCodex 3b4b547e9c fix(store): release proxy locks before returning delete conflicts
Co-authored-by: Codex <noreply@openai.com>
2026-11-03 11:23:28 +08:00
ZyyandCodex 2016bda031 fix(openai): preserve client headers and send account installation id
Co-authored-by: Codex <noreply@openai.com>
2026-11-03 11:23:16 +08:00
ZyyandCodex 790f3147e0 fix(openai): preserve verified turn state on full requests
Track bounded ownership proofs for upstream turn-state tokens, including failed responses, so same-turn full requests can echo them without trusting unrelated account state.

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

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

Co-authored-by: Codex <noreply@openai.com>
2026-11-02 14:39:44 +08:00
76 changed files with 2076 additions and 456 deletions
+18
View File
@@ -171,6 +171,12 @@ jobs:
toolchain: ${{ needs.release-info.outputs.rust }}
targets: ${{ matrix.target }}
- name: Configure Windows runtime linkage
if: runner.os == 'Windows'
shell: bash
# 发行包静态链接 MSVC 运行库,解压运行时无需另外安装 VC++ Redistributable
run: echo 'RUSTFLAGS=-C target-feature=+crt-static' >> "$GITHUB_ENV"
- name: Cache Cargo
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
@@ -186,6 +192,8 @@ jobs:
shell: bash
run: |
cargo build --release --locked --target "${{ matrix.target }}" --bin codex-proxy-rs
"target/${{ matrix.target }}/release/${{ matrix.binary }}" --help
"target/${{ matrix.target }}/release/${{ matrix.binary }}" --version
mkdir -p "../dist/binaries/${{ matrix.os }}-${{ matrix.arch }}"
cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "../dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/${{ matrix.binary }}"
working-directory: backend
@@ -195,6 +203,16 @@ jobs:
CPR_BUILD_TIME: ${{ needs.release-info.outputs.build_time }}
CPR_BUILD_TYPE: ${{ needs.release-info.outputs.build_type }}
- name: Verify Windows platform behavior
if: runner.os == 'Windows'
shell: bash
working-directory: backend
env:
RUST_MIN_STACK: '16777216'
run: |
cargo test --release --locked --target "${{ matrix.target }}" -p gateway-admin --test main use_case::plugins::official::
cargo test --release --locked --target "${{ matrix.target }}" -p gateway-host --test main system_update::
- name: Upload binary artifact
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
+2
View File
@@ -40,6 +40,8 @@
以下命令适用于 Linux amd64/arm64,需要 Docker Engine、Docker Compose Plugin、curl 和 OpenSSL
Windows x64 / ARM64 使用发行包部署,步骤见 [Windows 二进制部署](deploy/README.md#windows-二进制部署)
已有部署请先看 [升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置
### 一键安装
+1 -2
View File
@@ -1,7 +1,6 @@
//! 网关命令行入口,选择启动模式并创建异步运行时
use std::{
env,
error::Error,
io::{self, Write},
process::ExitCode,
@@ -21,7 +20,7 @@ fn main() -> ExitCode {
fn run() -> Result<ExitCode, Box<dyn Error + Send + Sync>> {
ensure_rustls_provider();
let mut arguments = env::args_os().skip(1);
let mut arguments = gateway_host::system_update::startup_arguments()?;
let command = arguments
.next()
.map(|value| {
@@ -739,6 +739,8 @@ pub struct UsageListRecord {
/// 请求选号时冻结的账号套餐
pub provider_account_plan_type: Option<String>,
pub provider_account_plan_type_display: Option<String>,
/// 查询时的互联识别结果,缺少当前摘要时为未知
pub provider_account_upstream_peer: Option<bool>,
pub provider_account_authentication_kind: Option<String>,
pub upstream_model_id: Option<String>,
pub upstream_transport: Option<String>,
@@ -1439,6 +1441,8 @@ pub struct DiagnosticsItem {
pub name: String,
pub account_authentication_kind: Option<String>,
pub account_plan_type: Option<String>,
/// 仅账号维度补充当前互联识别结果
pub account_upstream_peer: Option<bool>,
pub account_plan_type_display: Option<String>,
pub request_count: u64,
pub success_count: u64,
@@ -165,8 +165,9 @@ pub struct ConfigurePluginInstance {
pub artifact_sha256: String,
pub enabled: bool,
pub configuration: serde_json::Value,
/// 缺省保留旧值;显式空对象清除全部 secret
pub secrets: Option<BTreeMap<String, SecretString>>,
/// 按字段更新,省略的字段保留,null 清除对应字段
#[serde(default)]
pub secrets: BTreeMap<String, Option<SecretString>>,
pub bindings: Vec<PluginCapabilityBinding>,
}
@@ -8,6 +8,7 @@ use std::{
use async_trait::async_trait;
use chrono::{DateTime, Duration, Utc};
use futures::StreamExt as _;
use gateway_core::{account::ProviderAccountId, routing::ProviderKind};
use crate::{
@@ -133,7 +134,8 @@ impl DefaultObservabilityService {
)
.map_err(|error| map_store_error(error, "dashboard"))?;
let runtime_slots = observation.runtime_slots;
self.enrich_list_records(&mut observation.recent_requests);
self.enrich_list_records(&mut observation.recent_requests)
.await;
self.enrich_dashboard_quotas(&mut observation.account_usage)
.await;
for account in &mut observation.account_usage {
@@ -259,7 +261,7 @@ impl ObservabilityService for DefaultObservabilityService {
.list_usage_records(query)
.await
.map_err(|error| map_store_error(error, "usage records"))?;
self.enrich_list_records(&mut page.items);
self.enrich_list_records(&mut page.items).await;
Ok(page)
}
@@ -336,6 +338,12 @@ impl ObservabilityService for DefaultObservabilityService {
.usage_diagnostics(range, filter, dimension, 100)
.await
.map_err(|error| map_store_error(error, "usage diagnostics"))?;
let peers = if dimension == DiagnosticDimension::Account {
self.current_upstream_peers(observation.items.iter().map(|item| item.key.as_str()))
.await
} else {
HashMap::new()
};
let items = observation
.items
.into_iter()
@@ -345,6 +353,7 @@ impl ObservabilityService for DefaultObservabilityService {
rate_or_zero(item.non_completion_count, item.request_count);
let retry_rate = rate_or_zero(item.retried_request_count, item.request_count);
DiagnosticsItem {
account_upstream_peer: peers.get(&item.key).copied(),
key: item.key,
name: item.name,
account_authentication_kind: item.account_authentication_kind,
@@ -912,6 +921,29 @@ fn usd_cost(costs: &[CurrencyCost]) -> Option<DecimalAmount> {
}
impl DefaultObservabilityService {
async fn current_upstream_peers<'a>(
&self,
ids: impl Iterator<Item = &'a str>,
) -> HashMap<String, bool> {
let Some(peer) = self.upstream_info.as_deref() else {
return HashMap::new();
};
let ids = ids
.map(str::to_owned)
.collect::<std::collections::BTreeSet<_>>();
// 同一页重复账号只读取一次摘要,单个账号不存在或读取失败不阻断历史记录
futures::stream::iter(ids)
.map(|id: String| async move {
let account_id = ProviderAccountId::new(id.clone()).ok()?;
let upstream = peer.read_summary(&account_id).await.ok()??.upstream?;
Some((id, upstream.discovered && upstream.observed_at.is_some()))
})
.buffer_unordered(8)
.filter_map(|value| async move { value })
.collect()
.await
}
async fn enrich_dashboard_quotas(&self, accounts: &mut [DashboardAccountUsage]) {
if accounts.is_empty() {
return;
@@ -976,8 +1008,22 @@ impl DefaultObservabilityService {
/// 补充当前套餐展示名称,逐条尽力把可校验的总额升级为完整分解;单条脏数据(非法 Provider kind、
/// 不支持的来源或费用规则失败)只保留该条已存的总额,不影响整页返回
fn enrich_list_records(&self, records: &mut [crate::model::observability::UsageListRecord]) {
async fn enrich_list_records(
&self,
records: &mut [crate::model::observability::UsageListRecord],
) {
let peers = self
.current_upstream_peers(
records
.iter()
.filter_map(|record| record.provider_account_ref.as_deref()),
)
.await;
for record in records {
record.provider_account_upstream_peer = record
.provider_account_ref
.as_ref()
.and_then(|id| peers.get(id).copied());
record.provider_account_plan_type_display =
record.provider_kind.as_deref().and_then(|provider| {
self.providers
@@ -217,7 +217,7 @@ impl PluginsService {
artifact_sha256: metadata.sha256.clone(),
enabled: !configuration_required && compatible,
configuration: configuration.clone(),
secrets: None,
secrets: Default::default(),
bindings: bindings.clone(),
};
match self.configure_instance(None, input, context).await {
@@ -28,17 +28,16 @@ pub(super) fn same_creation_request(
existing: &PluginInstance,
input: &ConfigurePluginInstance,
) -> bool {
let same_secrets = input
let mut requested_secrets = input
.secrets
.as_ref()
.map_or(existing.secrets.is_empty(), |secrets| {
secrets.len() == existing.secrets.len()
&& secrets.iter().all(|(key, value)| {
existing
.secrets
.get(key)
.is_some_and(|current| current.expose_secret() == value.expose_secret())
})
.iter()
.filter_map(|(key, value)| value.as_ref().map(|value| (key, value)));
let same_secrets = requested_secrets.clone().count() == existing.secrets.len()
&& requested_secrets.all(|(key, value)| {
existing
.secrets
.get(key)
.is_some_and(|current| current.expose_secret() == value.expose_secret())
});
existing.name == input.name.trim()
&& existing.artifact_sha256 == input.artifact_sha256
@@ -158,7 +158,6 @@ impl PluginsService {
}
let saved = if let Some(previous) = &existing
&& previous.artifact_sha256 != input.artifact_sha256
&& input.secrets.is_none()
{
self.store
.load_version_configuration(&previous.id, &input.artifact_sha256)
@@ -167,6 +166,21 @@ impl PluginsService {
} else {
None
};
let mut secrets = saved.map(|saved| saved.secrets).unwrap_or_else(|| {
existing
.as_ref()
.map_or_else(Default::default, |instance| instance.secrets.clone())
});
for (name, value) in input.secrets {
match value {
Some(value) => {
secrets.insert(name, value);
}
None => {
secrets.remove(&name);
}
}
}
let instance = PluginInstance {
id: existing.as_ref().map_or_else(
|| {
@@ -182,14 +196,7 @@ impl PluginsService {
enabled: input.enabled,
trusted_process: true,
configuration: input.configuration,
secrets: input
.secrets
.or_else(|| saved.map(|saved| saved.secrets))
.unwrap_or_else(|| {
existing
.as_ref()
.map_or_else(Default::default, |instance| instance.secrets.clone())
}),
secrets,
bindings: input.bindings,
revision: candidate_revision,
};
@@ -366,6 +366,7 @@ fn target_environment_matches(target: &str) -> bool {
match target {
"x86_64-unknown-linux-gnu" | "aarch64-unknown-linux-gnu" => cfg!(target_env = "gnu"),
"aarch64-apple-darwin" => cfg!(target_os = "macos"),
"x86_64-pc-windows-msvc" | "aarch64-pc-windows-msvc" => cfg!(target_env = "msvc"),
_ => false,
}
}
@@ -392,4 +393,18 @@ const SUPPORTED_PLATFORMS: &[Platform] = &[
runtime_os: "macos",
runtime_architecture: "aarch64",
},
Platform {
release_os: "windows",
release_architecture: "amd64",
target: "x86_64-pc-windows-msvc",
runtime_os: "windows",
runtime_architecture: "x86_64",
},
Platform {
release_os: "windows",
release_architecture: "arm64",
target: "aarch64-pc-windows-msvc",
runtime_os: "windows",
runtime_architecture: "aarch64",
},
];
@@ -69,7 +69,7 @@ impl PluginsService {
artifact_sha256: input.artifact_sha256,
enabled: existing.enabled,
configuration: saved.configuration,
secrets: Some(saved.secrets),
secrets: Default::default(),
bindings: saved.bindings,
};
// 沿用同一份快照的 CAS 和状态迁移流程,不能在检查目标后重新读取并覆盖并发配置
@@ -202,7 +202,7 @@ impl PluginsService {
artifact_sha256: input.artifact_sha256,
enabled: existing.enabled,
configuration: configuration.configuration,
secrets: Some(configuration.secrets),
secrets: Default::default(),
bindings: configuration.bindings,
};
// 同一次读取的 CAS 覆盖草稿计算、准备与提交,不能覆盖并发设置
@@ -1479,6 +1479,7 @@ fn total_record(
now: DateTime<Utc>,
) -> UsageListRecord {
UsageListRecord {
provider_account_upstream_peer: None,
error_code: None,
error_message: None,
client_status_code: None,
@@ -0,0 +1,115 @@
//! 实例敏感配置的逐字段更新、版本基准与提交隔离测试
use super::*;
use secrecy::ExposeSecret;
fn secret_values(instance: &PluginInstance) -> serde_json::Value {
serde_json::Value::Object(
instance
.secrets
.iter()
.map(|(name, value)| (name.clone(), json!(value.expose_secret())))
.collect(),
)
}
#[tokio::test]
async fn secret_changes_apply_to_current_or_target_version_configuration() {
for (target, saved_version) in [
(OLD_ARTIFACT, false),
(NEW_ARTIFACT, false),
(NEW_ARTIFACT, true),
] {
let token = if saved_version {
"saved-token"
} else {
"current-token"
};
let untouched = if saved_version {
"saved-value"
} else {
"current-value"
};
for (changes, expected) in [
(json!({}), json!({"token":token,"untouched":untouched})),
(
json!({"adminKey":"new-key"}),
json!({"token":token,"untouched":untouched,"adminKey":"new-key"}),
),
(
json!({"token":"new-token"}),
json!({"token":"new-token","untouched":untouched}),
),
(json!({"token":null}), json!({"untouched":untouched})),
(
json!({"token":null,"adminKey":"new-key","absent":null}),
json!({"untouched":untouched,"adminKey":"new-key"}),
),
] {
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
{
let mut data = fixture.data.lock().unwrap();
data.snapshot.instances[0].secrets = BTreeMap::from([
("token".into(), "current-token".into()),
("untouched".into(), "current-value".into()),
]);
if saved_version {
data.history.get_mut(NEW_ARTIFACT).unwrap().secrets = BTreeMap::from([
("token".into(), "saved-token".into()),
("untouched".into(), "saved-value".into()),
]);
} else {
data.history.clear();
}
}
let before = fixture.snapshot();
let mut request = input();
request.artifact_sha256 = target.into();
request.expected_revision = Some(10);
request.secrets = serde_json::from_value(changes).unwrap();
let result = service(fixture.clone(), Arc::new(Published::default()))
.configure_instance(Some(&before.instances[0].id), request, &context())
.await
.unwrap();
assert_eq!(secret_values(&result.instance), expected);
assert_eq!(secret_values(&fixture.snapshot().instances[0]), expected);
}
}
}
#[tokio::test]
async fn failed_secret_changes_leave_saved_values_unchanged() {
for failure in ["stale", "preparation", "size"] {
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
fixture.data.lock().unwrap().snapshot.instances[0].secrets =
BTreeMap::from([("token".into(), "saved-token".into())]);
let before = fixture.snapshot();
let mut request = input();
request.artifact_sha256 = OLD_ARTIFACT.into();
request.expected_revision = Some(if failure == "stale" { 9 } else { 10 });
request.secrets = BTreeMap::from([
("token".into(), None),
(
"adminKey".into(),
Some(
if failure == "size" {
"x".repeat(8193)
} else {
"new-key".into()
}
.into(),
),
),
]);
fixture.data.lock().unwrap().fail_preparation = failure == "preparation";
let result = service(fixture.clone(), Arc::new(Published::default()))
.configure_instance(Some(&before.instances[0].id), request, &context())
.await;
assert!(result.is_err(), "{failure} must reject the update");
assert_eq!(
secret_values(&fixture.snapshot().instances[0]),
secret_values(&before.instances[0])
);
assert!(fixture.data.lock().unwrap().saves.is_empty());
}
}
@@ -49,6 +49,7 @@ use serde_json::json;
use super::TestPluginPorts;
mod configuration;
mod queries;
const OLD_ARTIFACT: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
@@ -759,7 +760,7 @@ fn input() -> ConfigurePluginInstance {
artifact_sha256: NEW_ARTIFACT.into(),
enabled: true,
configuration: json!({}),
secrets: None,
secrets: Default::default(),
bindings: vec![],
}
}
@@ -942,6 +943,8 @@ async fn configuration_retry_reuses_creation_id_and_rejects_changed_draft() {
// 此用例验证创建重试,使用不需要私有状态迁移的固定版本
value.artifact_sha256 = OLD_ARTIFACT.into();
value.enabled = false;
value.secrets =
serde_json::from_value(json!({"token": "saved-token", "absent": null})).unwrap();
value
};
let created = service
@@ -955,6 +958,12 @@ async fn configuration_retry_reuses_creation_id_and_rejects_changed_draft() {
assert_eq!(created.instance.id, creation_id);
assert_eq!(retried.instance.id, creation_id);
assert_eq!(fixture.snapshot().instances.len(), 2);
use secrecy::ExposeSecret as _;
assert_eq!(
retried.instance.secrets["token"].expose_secret(),
"saved-token"
);
assert!(!retried.instance.secrets.contains_key("absent"));
let before = fixture.snapshot().config_revision;
let mut changed = request();
changed.name = "changed draft".into();
@@ -965,6 +974,17 @@ async fn configuration_retry_reuses_creation_id_and_rejects_changed_draft() {
.unwrap();
assert_eq!(error.kind(), AdminErrorKind::Conflict);
assert_eq!(fixture.snapshot().config_revision, before);
let mut changed = request();
changed
.secrets
.insert("token".into(), Some("different-token".into()));
let error = service
.configure_instance(None, changed, &context())
.await
.err()
.unwrap();
assert_eq!(error.kind(), AdminErrorKind::Conflict);
assert_eq!(fixture.snapshot().config_revision, before);
}
#[tokio::test]
@@ -1435,23 +1455,6 @@ async fn version_switch_preparation_failure_preserves_current_and_saved_settings
);
}
#[tokio::test]
async fn version_edit_preserves_the_target_versions_secrets_when_omitted() {
use secrecy::ExposeSecret as _;
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
let before = fixture.snapshot();
let mut request = input();
request.secrets = None;
let result = service(fixture, Arc::new(Published::default()))
.configure_instance(Some(&before.instances[0].id), request, &context())
.await
.unwrap();
assert_eq!(
result.instance.secrets["token"].expose_secret(),
"old-version-secret"
);
}
#[tokio::test]
async fn rollback_requires_a_saved_configuration_instead_of_reusing_incompatible_current_settings()
{
@@ -446,6 +446,8 @@ fn release_platform() -> (&'static str, &'static str, &'static str) {
("linux", "x86_64") => ("linux", "amd64", "x86_64-unknown-linux-gnu"),
("linux", "aarch64") => ("linux", "arm64", "aarch64-unknown-linux-gnu"),
("macos", "aarch64") => ("darwin", "arm64", "aarch64-apple-darwin"),
("windows", "x86_64") => ("windows", "amd64", "x86_64-pc-windows-msvc"),
("windows", "aarch64") => ("windows", "arm64", "aarch64-pc-windows-msvc"),
platform => panic!("unsupported test platform: {platform:?}"),
}
}
@@ -271,6 +271,7 @@ pub(crate) fn usage_list_record_view(
account_notes: record.provider_account_notes,
account_plan_type: record.provider_account_plan_type,
account_plan_type_display: record.provider_account_plan_type_display,
account_upstream_peer: record.provider_account_upstream_peer,
route: record.endpoint,
model,
requested_model: record.requested_model_id,
@@ -1351,6 +1352,7 @@ pub(crate) fn diagnostics_view(
request_count: item.request_count,
account_plan_type: item.account_plan_type,
account_plan_type_display: item.account_plan_type_display,
account_upstream_peer: item.account_upstream_peer,
success_count: item.success_count,
error_count: item.error_count,
error_rate: item.error_rate,
@@ -99,6 +99,7 @@ pub struct UsageListRecordView {
pub account_notes: Option<String>,
pub account_plan_type: Option<String>,
pub account_plan_type_display: Option<String>,
pub account_upstream_peer: Option<bool>,
pub route: String,
pub model: Option<String>,
pub requested_model: Option<String>,
@@ -849,6 +850,7 @@ pub struct DiagnosticItemView {
pub authentication_kind: Option<String>,
pub account_plan_type: Option<String>,
pub account_plan_type_display: Option<String>,
pub account_upstream_peer: Option<bool>,
pub request_count: u64,
pub success_count: u64,
pub error_count: u64,
@@ -618,8 +618,6 @@ fn passthrough_header_name(name: &str, connection_headers: &[String]) -> bool {
| "chatgpt-project-id"
| "openai-organization"
| "openai-project"
| "x-openai-account-routing-override"
| "x-openai-fedramp"
// 上游指纹必须由运行时画像统一生成,客户端 originator/User-Agent/version
// 不能作为不透明头透传覆盖,避免不同下游客户端暴露不一致的设备指纹
| "originator"
@@ -656,6 +656,23 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
);
}
fn peer_summary(
discovered: bool,
observed: bool,
) -> gateway_admin::model::provider_credentials::ProviderQuota {
gateway_admin::model::provider_credentials::ProviderQuota {
upstream: Some(gateway_admin::model::upstream_info::ProviderUpstreamInfo {
discovered,
observed_at: observed.then(chrono::Utc::now),
stale: true,
connection_revision: "test-connection".to_owned(),
last_error: None,
pool: None,
}),
..Default::default()
}
}
#[tokio::test]
async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
use axum::{
@@ -670,13 +687,17 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
use crate::admin::{AdminTestFixture, AdminTestState};
for (authentication_kind, plan_type, plan_display) in [
(Some("oauth"), Some("pro"), Some("Pro")),
(Some("api_key"), None, None),
(None, None, None),
for (authentication_kind, plan_type, plan_display, peer) in [
(Some("oauth"), Some("pro"), Some("Pro"), None),
(Some("api_key"), None, None, None),
(Some("api_key"), None, None, Some((true, true))),
(Some("api_key"), None, None, Some((true, false))),
(Some("api_key"), None, None, Some((false, true))),
(None, None, None, None),
] {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
*fixture.provider_quota.lock().unwrap() = peer.map(|(d, o)| peer_summary(d, o));
*fixture.diagnostics.lock().expect("diagnostics") = DiagnosticsObservation {
analytics: Default::default(),
total_request_count: 4,
@@ -718,6 +739,10 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
.await
.expect("diagnostics body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("diagnostics JSON");
assert_eq!(
value["data"]["items"][0]["accountUpstreamPeer"],
json!(peer.map(|(d, o)| d && o))
);
assert_eq!(value["data"]["items"][0]["requestShare"], 0.5);
assert_eq!(value["data"]["items"][0]["retryCount"], 3);
assert_eq!(value["data"]["items"][0]["retryRate"], 0.5);
@@ -906,6 +931,7 @@ async fn usage_route_projects_request_facts_and_billing() {
.lock()
.expect("usage records")
.push(UsageListRecord {
provider_account_upstream_peer: None,
error_code: None,
error_message: None,
client_status_code: None,
@@ -1131,6 +1157,39 @@ async fn usage_route_projects_request_facts_and_billing() {
value["data"]["items"][2]["billing"]["totalAmountDisplay"],
"$0.007"
);
// 互联标识随当前摘要变化,重复请求共享同一账号,既有请求快照保持原值
for peer in [
Some((true, true)),
Some((true, false)),
Some((false, true)),
None,
] {
*fixture.provider_quota.lock().unwrap() = peer.map(|(d, o)| peer_summary(d, o));
let response = observability::router::<AdminTestState>()
.with_state(fixture.state())
.oneshot(
Request::builder()
.uri("/api/admin/usage/records?status=all")
.header(header::COOKIE, "cpr_session=valid-session")
.header("x-request-id", "req_peer_badge")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
for item in value["data"]["items"].as_array().unwrap() {
assert_eq!(
item["accountUpstreamPeer"],
json!(peer.map(|(d, o)| d && o))
);
assert_eq!(item["accountName"], "Snapshot Alpha");
assert_eq!(item["accountPlanType"], "supergrok_plus");
}
}
}
#[tokio::test]
@@ -92,6 +92,7 @@ pub(super) async fn fixture() -> AdminTestFixture {
fn usage_record() -> UsageListRecord {
UsageListRecord {
provider_account_upstream_peer: None,
error_code: None,
error_message: None,
client_status_code: None,
@@ -431,6 +431,9 @@ fn decoder_should_preserve_ordinary_request_headers_as_opaque_multivalues() {
values("x-codex-installation-id"),
vec![b"client-installation".to_vec()]
);
for name in ["x-openai-account-routing-override", "x-openai-fedramp"] {
assert_eq!(values(name), vec![b"first".to_vec(), b"second".to_vec()]);
}
for preserved in [
"accept",
"content-type",
@@ -450,8 +453,6 @@ fn decoder_should_preserve_ordinary_request_headers_as_opaque_multivalues() {
"cookie",
"chatgpt-account-id",
"chatgpt-project-id",
"x-openai-account-routing-override",
"x-openai-fedramp",
// 上游指纹由运行时画像统一生成,客户端不得覆盖
"user-agent",
"originator",
@@ -23,7 +23,7 @@ use super::{
RuntimeAccountDirectory, UpstreamModelId,
};
const MAXIMUM_CATALOG_STABILITY_ATTEMPTS: usize = 4;
const MAXIMUM_SNAPSHOT_STABILITY_ATTEMPTS: usize = 4;
type ModelCatalogAccounts = BTreeMap<ProviderKind, BTreeMap<String, BTreeSet<ProviderAccountId>>>;
@@ -328,14 +328,17 @@ impl RuntimeSnapshotCompiler {
ConfigRevision,
crate::routing::extensions::ExtensionSetReference,
)> = None;
for _ in 0..MAXIMUM_CATALOG_STABILITY_ATTEMPTS {
let mut instability = RuntimeSnapshotCompileError::CatalogChanged;
for _ in 0..MAXIMUM_SNAPSHOT_STABILITY_ATTEMPTS {
let facts = self
.store
.load_snapshot_facts()
.await
.map_err(|_| RuntimeSnapshotCompileError::StoreUnavailable)?;
if facts.config_revision != facts.observed_current_revision {
return Err(RuntimeSnapshotCompileError::RevisionChanged);
// 并发提交使本轮事实过期时重读完整快照,不能混用不同版本的权限
instability = RuntimeSnapshotCompileError::RevisionChanged;
continue;
}
let revision = facts.config_revision;
let extensions = match &self.extensions {
@@ -347,7 +350,7 @@ impl RuntimeSnapshotCompiler {
*prepared_revision != revision || !prepared.is_ready()
});
if must_prepare {
let prepared = preparer
let prepared = match preparer
.prepare_with_request_body_limit(
revision,
usize::try_from(
@@ -356,7 +359,21 @@ impl RuntimeSnapshotCompiler {
.unwrap_or(usize::MAX),
)
.await
.map_err(|_| RuntimeSnapshotCompileError::ExtensionsUnavailable)?;
{
Ok(prepared) => prepared,
Err(_) => {
// 扩展读取也可能遇到并发提交;只有版本已变才重读事实
let observed =
self.store.current_config_revision().await.map_err(|_| {
RuntimeSnapshotCompileError::StoreUnavailable
})?;
if observed == revision {
return Err(RuntimeSnapshotCompileError::ExtensionsUnavailable);
}
instability = RuntimeSnapshotCompileError::RevisionChanged;
continue;
}
};
prepared_extensions = Some((revision, prepared));
}
prepared_extensions
@@ -390,7 +407,8 @@ impl RuntimeSnapshotCompiler {
.map_err(|_| RuntimeSnapshotCompileError::StoreUnavailable)?
!= revision
{
return Err(RuntimeSnapshotCompileError::RevisionChanged);
instability = RuntimeSnapshotCompileError::RevisionChanged;
continue;
}
let snapshot = snapshot
.with_provider_catalog_generations(if cached.is_some() && !refresh_catalog {
@@ -401,8 +419,9 @@ impl RuntimeSnapshotCompiler {
.with_extensions(extensions);
return Ok(snapshot);
}
instability = RuntimeSnapshotCompileError::CatalogChanged;
}
Err(RuntimeSnapshotCompileError::CatalogChanged)
Err(instability)
}
}
@@ -730,12 +730,116 @@ impl ProviderCatalogPort for PublishingCatalog {
#[test]
fn compiler_should_reject_revision_changed_during_consistent_read() {
let facts = facts(1, 2);
let compiler = compiler(Arc::new(TestSnapshotStore::new(Ok(facts))));
let store = Arc::new(SequencedSnapshotStore {
facts: vec![facts(1, 2)],
loads: AtomicUsize::new(0),
current_revision: revision(2),
});
let compiler = compiler(store.clone());
let error = block_on(compiler.compile()).expect_err("revision drift must fail closed");
assert_eq!(error, RuntimeSnapshotCompileError::RevisionChanged);
assert_eq!(store.loads.load(Ordering::SeqCst), 4);
}
#[test]
fn compiler_reloads_complete_facts_after_revision_changes_during_read() {
let store = Arc::new(SequencedSnapshotStore {
facts: vec![facts(1, 2), facts(2, 2)],
loads: AtomicUsize::new(0),
current_revision: revision(2),
});
let snapshot = block_on(compiler(store.clone()).compile()).expect("latest consistent facts");
assert_eq!(snapshot.revision(), revision(2));
assert_eq!(store.loads.load(Ordering::SeqCst), 2);
}
#[test]
fn compiler_reloads_revision_changed_after_extension_preparation() {
let drops = Arc::new(AtomicUsize::new(0));
let extensions = Arc::new(CountingExtensionPreparation {
prepares: AtomicUsize::new(0),
drops: Arc::clone(&drops),
});
let compiler = RuntimeSnapshotCompiler::new(
Arc::new(SequencedSnapshotStore {
facts: vec![facts(1, 1), facts(2, 2)],
loads: AtomicUsize::new(0),
current_revision: revision(2),
}),
Arc::new(TestCatalog::Empty),
)
.with_extensions(extensions.clone());
let snapshot = block_on(compiler.compile()).expect("latest prepared snapshot");
assert_eq!(snapshot.revision(), revision(2));
assert_eq!(snapshot.extensions().unwrap().id().as_str(), "prepared-2");
assert_eq!(extensions.prepares.load(Ordering::SeqCst), 2);
assert_eq!(drops.load(Ordering::SeqCst), 1);
drop(snapshot);
assert_eq!(drops.load(Ordering::SeqCst), 2);
}
#[test]
fn compiler_rechecks_revision_when_extension_preparation_rejects_stale_facts() {
struct RacingPreparation {
store: Arc<TestSnapshotStore>,
changes: usize,
calls: AtomicUsize,
}
impl ExtensionPreparationPort for RacingPreparation {
fn prepare(
&self,
_: ConfigRevision,
) -> BoxFuture<'_, Result<ExtensionSetReference, ExtensionPreparationError>> {
Box::pin(async move {
let call = self.calls.fetch_add(1, Ordering::SeqCst);
if self.changes == 0 || call < self.changes {
if self.changes != 0 {
let next = u64::try_from(call).unwrap() + 2;
*self.store.facts.lock().unwrap() = Ok(facts(next, next));
*self.store.current_revision.lock().unwrap() = Ok(revision(next));
}
return Err(ExtensionPreparationError);
}
Ok(ExtensionSetReference::new(
ExtensionSetId::new("ready".to_owned()).unwrap(),
Arc::new(AliasLease(vec![])),
))
})
}
}
for (changes, expected_calls) in [(0, 1), (1, 2), (4, 4)] {
let store = Arc::new(TestSnapshotStore::new(Ok(facts(1, 1))));
let extensions = Arc::new(RacingPreparation {
store: store.clone(),
changes,
calls: AtomicUsize::new(0),
});
let compiler = RuntimeSnapshotCompiler::new(store, Arc::new(TestCatalog::Empty))
.with_extensions(extensions.clone());
let result = block_on(compiler.compile());
match changes {
0 => assert_eq!(
result.unwrap_err(),
RuntimeSnapshotCompileError::ExtensionsUnavailable
),
1 => assert_eq!(result.unwrap().revision(), revision(2)),
_ => assert_eq!(
result.unwrap_err(),
RuntimeSnapshotCompileError::RevisionChanged
),
}
assert_eq!(extensions.calls.load(Ordering::SeqCst), expected_calls);
}
}
#[test]
@@ -326,6 +326,39 @@ fn publisher_should_suspend_but_still_notify_after_committed_refresh_failure() {
);
}
#[test]
fn committed_refresh_reloads_raced_facts_without_losing_revoked_scope() {
block_on(async {
let store = Arc::new(TestSnapshotStore::new(Ok(scoped_facts(10, true))));
let initial = compiler(store.clone())
.compile()
.await
.expect("initial scope");
let handle = RuntimeSnapshotHandle::new(initial);
let frozen = handle.acquire().expect("initial request");
assert!(removed_account_allowed(&frozen));
let publisher = RuntimeSnapshotPublisher::new(
Arc::new(compiler(store.clone())),
handle.clone(),
Arc::new(TestSnapshotSubscriptions::default()),
);
let (release, gate) = oneshot::channel();
*store.next_load.lock().expect("load gate lock") = Some(gate);
let mut committed = publisher.publish_committed(revision(11));
assert!(futures::poll!(committed.as_mut()).is_pending());
*store.facts.lock().expect("facts lock") = Ok(scoped_facts(11, false));
*store.current_revision.lock().expect("revision lock") = Ok(revision(11));
release.send(Ok(facts(10, 11))).expect("release raced read");
committed.await;
let current = handle.acquire().expect("latest snapshot remains available");
assert_eq!(current.revision(), revision(11));
assert!(!removed_account_allowed(&current));
assert!(removed_account_allowed(&frozen));
});
}
#[test]
fn publisher_should_contribute_reconciliation_and_subscription_workers() {
let store = Arc::new(TestSnapshotStore::new(Ok(facts(1, 1))));
+22 -3
View File
@@ -125,6 +125,17 @@ impl HttpProxyProbe {
}
impl HttpProxyProbe {
async fn timed_exit_ip_at(
&self,
proxy: Option<&OutboundProxy>,
endpoint: &str,
) -> (Result<IpAddr, &'static str>, u64) {
let started = Instant::now();
let result = self.exit_ip_at(proxy, endpoint).await;
let latency_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
(result, latency_ms)
}
async fn test_connection(&self, proxy: Option<&OutboundProxy>) -> ProxyTestResult {
let started = Instant::now();
let timeout_limit = Duration::from_secs(15);
@@ -169,15 +180,15 @@ impl HttpProxyProbe {
} => {
let probe_dual = async {
tokio::join!(
self.exit_ip_at(proxy, ipv4_endpoint),
self.exit_ip_at(proxy, ipv6_endpoint),
self.timed_exit_ip_at(proxy, ipv4_endpoint),
self.timed_exit_ip_at(proxy, ipv6_endpoint),
)
};
let result = tokio::time::timeout(timeout_limit, probe_dual).await;
let latency_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
match result {
Ok((res_v4, res_v6)) => {
Ok(((res_v4, latency_v4), (res_v6, latency_v6))) => {
let exit_ipv4: Option<Ipv4Addr> = match res_v4 {
Ok(IpAddr::V4(v4)) => Some(v4),
_ => None,
@@ -186,6 +197,14 @@ impl HttpProxyProbe {
Ok(IpAddr::V6(v6)) => Some(v6),
_ => None,
};
// 延迟对应首选的可用出口,另一地址族失败等待不代表该出口延迟
let latency_ms = if exit_ipv4.is_some() {
latency_v4
} else if exit_ipv6.is_some() {
latency_v6
} else {
latency_ms
};
if exit_ipv4.is_some() && exit_ipv6.is_some() {
ProxyTestResult {
@@ -6,7 +6,11 @@ use std::path::{Component, Path, PathBuf};
use super::{OperationError, internal, invalid};
const APP_BINARY_NAME: &str = "codex-proxy-rs";
const APP_BINARY_NAME: &str = if cfg!(windows) {
"codex-proxy-rs.exe"
} else {
"codex-proxy-rs"
};
const MAX_EXTRACTED_SIZE: u64 = 1024 * 1024 * 1024;
const MAX_ARCHIVE_FILES: usize = 20_000;
@@ -126,14 +130,42 @@ pub(crate) fn extract_release(
fn unsafe_archive_path(path: &Path) -> bool {
path.is_absolute()
|| path.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::Prefix(_) | Component::RootDir
)
|| path.components().any(|component| match component {
Component::ParentDir | Component::Prefix(_) | Component::RootDir => true,
#[cfg(windows)]
Component::Normal(name) => unsafe_windows_name(name),
_ => false,
})
}
#[cfg(windows)]
fn unsafe_windows_name(name: &std::ffi::OsStr) -> bool {
let name = name.to_string_lossy();
// 防止归档路径被 Win32 解释为设备、NTFS 备用数据流或另一个规范化文件名
if name.ends_with(['.', ' '])
|| name
.chars()
.any(|value| value < ' ' || "<>:\"|?*".contains(value))
{
return true;
}
let stem = name
.split('.')
.next()
.unwrap_or_default()
.to_ascii_uppercase();
matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| stem
.strip_prefix("COM")
.or_else(|| stem.strip_prefix("LPT"))
.is_some_and(|suffix| {
matches!(
suffix,
"1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³"
)
})
}
fn web_dist_relative_path(path: &Path) -> Option<PathBuf> {
let components = path
.components()
@@ -16,6 +16,7 @@ mod swap_paths;
pub use config::SystemUpdateConfig;
pub use operations::ProcessSystemOperations;
pub use process::startup_arguments;
pub use release::validate_download_url;
use error::{OperationError, conflict, internal, invalid, upstream};
@@ -41,12 +41,18 @@ pub struct ProcessSystemOperations {
cancellation: CancellationToken,
events: Arc<UpdateEvents>,
config: Arc<SystemUpdateConfig>,
operation_lock: Arc<AsyncMutex<Option<OperationFileLock>>>,
operation_lock: Arc<AsyncMutex<Option<RestartGuard>>>,
release_cache: Arc<ReleaseCache>,
running_files: Arc<Result<ReleaseFiles, OperationError>>,
diagnostics: super::diagnostics::Diagnostics,
}
struct RestartGuard {
// 字段按声明顺序释放,先移除更新锁,再通知替换进程开始启动
_file_lock: OperationFileLock,
_replacement_input: Option<std::process::ChildStdin>,
}
impl ProcessSystemOperations {
#[must_use]
pub fn new(cancellation: CancellationToken, mut config: SystemUpdateConfig) -> Self {
@@ -75,7 +81,7 @@ impl ProcessSystemOperations {
self.diagnostics.bind(diagnostics);
}
fn lock_operation(&self) -> Result<OwnedMutexGuard<Option<OperationFileLock>>, OperationError> {
fn lock_operation(&self) -> Result<OwnedMutexGuard<Option<RestartGuard>>, OperationError> {
let operation = Arc::clone(&self.operation_lock)
.try_lock_owned()
.map_err(|_| conflict("已有系统操作正在执行"))?;
@@ -559,15 +565,17 @@ impl SystemOperations for ProcessSystemOperations {
{
return Err(conflict("重启检查期间安装文件已变化,请重新确认"));
}
let message = if self.config.deployment_mode == "docker" {
"已安排进程内重启"
let (message, replacement_input) = if self.config.deployment_mode == "docker" {
("已安排进程内重启", None)
} else {
spawn_replacement(&self.config)?;
"已安排自重启"
("已安排自重启", spawn_replacement(&self.config)?)
};
let operation_id = operation_id("restart");
// 受理后直到旧服务释放,保留重启状态和跨进程锁;延迟关闭只用于送达响应
*operation = Some(file_lock);
*operation = Some(RestartGuard {
_file_lock: file_lock,
_replacement_input: replacement_input,
});
let cancellation = self.cancellation.clone();
drop(tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(500)).await;
@@ -2,15 +2,37 @@
use std::env;
use std::fs;
use std::process::{Command, Stdio};
use std::io;
use std::process::{ChildStdin, Command, Stdio};
#[cfg(unix)]
use super::config::environment_value;
use super::{OperationError, SystemUpdateConfig, internal};
#[cfg(unix)]
const RESTART_DELAY_ENV: &str = "CPR_RESTART_DELAY_MS";
#[cfg(unix)]
const DEFAULT_RESTART_DELAY_MS: u64 = 1_200;
const RESTART_HANDOFF_ARGUMENT: &str = "--restart-handoff";
pub(crate) fn spawn_replacement(config: &SystemUpdateConfig) -> Result<(), OperationError> {
/// 组合根在创建运行时前接收重启交接,并取得不含内部交接标记的 CLI 参数
pub fn startup_arguments() -> Result<impl Iterator<Item = std::ffi::OsString>, OperationError> {
let mut arguments = env::args_os().skip(1).peekable();
if arguments
.peek()
.is_some_and(|value| value == RESTART_HANDOFF_ARGUMENT)
{
arguments.next();
// 旧服务释放操作锁后关闭管道;旧进程异常退出也会关闭管道,避免固定延迟抢占端口
io::copy(&mut io::stdin().lock(), &mut io::sink())
.map_err(|error| internal("无法等待旧服务释放资源").with_source(error))?;
}
Ok(arguments)
}
pub(crate) fn spawn_replacement(
config: &SystemUpdateConfig,
) -> Result<Option<ChildStdin>, OperationError> {
let executable = config.executable_path()?;
let metadata = fs::metadata(&executable)
.map_err(|error| internal("无法启动替换进程").with_source(error))?;
@@ -25,12 +47,12 @@ pub(crate) fn spawn_replacement(config: &SystemUpdateConfig) -> Result<(), Opera
}
}
let delay_ms = environment_value(RESTART_DELAY_ENV)
.and_then(|value| value.parse::<u64>().ok())
.filter(|value| *value > 0)
.unwrap_or(DEFAULT_RESTART_DELAY_MS);
#[cfg(unix)]
let mut command = {
let delay_ms = environment_value(RESTART_DELAY_ENV)
.and_then(|value| value.parse::<u64>().ok())
.filter(|value| *value > 0)
.unwrap_or(DEFAULT_RESTART_DELAY_MS);
let delay = format!("{}.{:03}", delay_ms / 1_000, delay_ms % 1_000);
let mut command = Command::new("/bin/sh");
command
@@ -39,20 +61,26 @@ pub(crate) fn spawn_replacement(config: &SystemUpdateConfig) -> Result<(), Opera
.arg("codex-proxy-rs-restart")
.arg(delay)
.arg(&executable)
.args(env::args_os().skip(1));
.args(env::args_os().skip(1))
.stdin(Stdio::null());
command
};
#[cfg(not(unix))]
let mut command = {
let mut command = Command::new(&executable);
command
.args(env::args_os().skip(1))
.env(RESTART_DELAY_ENV, delay_ms.to_string());
.arg(RESTART_HANDOFF_ARGUMENT)
// 连续重启时移除当前进程已消费的交接标记,保留原始参数及编码
.args(
env::args_os()
.skip(1)
.skip_while(|value| value == RESTART_HANDOFF_ARGUMENT),
)
.stdin(Stdio::piped());
command
};
command.stdin(Stdio::null());
command
.spawn()
.map(|_| ())
.map(|mut child| child.stdin.take())
.map_err(|error| internal("无法启动替换进程").with_source(error))
}
@@ -149,7 +149,7 @@ impl UpdateTempDir {
impl Drop for UpdateTempDir {
fn drop(&mut self) {
if let Err(error) = fs::remove_dir_all(&self.path)
if let Err(error) = super::swap::remove_dir_all(&self.path)
&& error.kind() != io::ErrorKind::NotFound
{
tracing::warn!(path = %self.path.display(), error = %error, "清理系统更新临时目录失败");
@@ -294,12 +294,40 @@ fn protect_official_plugins(_path: &Path) -> io::Result<()> {
Ok(())
}
fn remove_dir_all(path: &Path) -> io::Result<()> {
pub(crate) fn remove_dir_all(path: &Path) -> io::Result<()> {
#[cfg(unix)]
make_directories_writable(path)?;
#[cfg(windows)]
make_files_writable(path)?;
fs::remove_dir_all(path)
}
#[cfg(windows)]
fn make_files_writable(path: &Path) -> io::Result<()> {
let metadata = fs::symlink_metadata(path)?;
if metadata.file_type().is_symlink() || (!metadata.is_dir() && !metadata.is_file()) {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"release directory contains an unsupported file type",
));
}
if metadata.is_dir() {
for entry in fs::read_dir(path)? {
make_files_writable(&entry?.path())?;
}
} else if metadata.permissions().readonly() {
// tar 的 0444 会成为 Windows 只读属性,清理受管备份前必须移除此属性
let mut permissions = metadata.permissions();
#[expect(
clippy::permissions_set_readonly_false,
reason = "仅 Windows 清理只读属性,不会放宽 Unix 权限"
)]
permissions.set_readonly(false);
fs::set_permissions(path, permissions)?;
}
Ok(())
}
#[cfg(unix)]
fn make_directories_writable(path: &Path) -> io::Result<()> {
use std::os::unix::fs::PermissionsExt as _;
@@ -155,6 +155,39 @@ async fn dual_stack_proxy_probe_reports_single_stack_when_only_one_succeeds() {
assert!(result.message.contains("仅 IPv4"));
}
#[tokio::test]
async fn dual_stack_latency_measures_the_selected_exit_without_the_other_family_wait() {
use std::time::{Duration, Instant};
for working_family in ["v4", "v6"] {
let server = MockServer::start().await;
for (family, ip) in [("v4", "203.0.113.8"), ("v6", "2001:db8::8")] {
let response = if family == working_family {
ResponseTemplate::new(200).set_body_json(json!({"ip": ip}))
} else {
ResponseTemplate::new(503).set_delay(Duration::from_millis(500))
};
Mock::given(path(format!("/{family}")))
.respond_with(response)
.expect(1)
.mount(&server)
.await;
}
let started = Instant::now();
let result = HttpProxyProbe::new_dual(
format!("{}/v4", server.uri()),
format!("{}/v6", server.uri()),
)
.test(None, false)
.await;
assert!(result.success);
assert_eq!(result.exit_ipv4.is_some(), working_family == "v4");
assert_eq!(result.exit_ipv6.is_some(), working_family == "v6");
assert!(started.elapsed() >= Duration::from_millis(500));
assert!(u128::from(result.latency_ms) + 400 < started.elapsed().as_millis());
}
}
fn location_response(ip: &str, timezone: &str) -> serde_json::Value {
json!({"success": true, "ip": ip, "country_code": "JP", "region": "Tokyo", "city": "Tokyo", "timezone": {"id": timezone}})
}
@@ -6,13 +6,25 @@ use std::{
};
fn main() {
let executable = std::env::current_exe().unwrap();
let directory = executable.parent().unwrap();
if directory.join("restart-probe").is_file() {
std::fs::write(directory.join("replacement-waiting"), b"ready").unwrap();
let arguments = gateway_host::system_update::startup_arguments()
.unwrap()
.collect::<Vec<_>>();
assert!(!arguments.iter().any(|value| value == "--restart-handoff"));
assert!(!directory.join("update.lock").exists());
std::fs::write(directory.join("replacement-ran"), b"started").unwrap();
return;
}
let mode = std::fs::read_to_string("mode").unwrap();
if mode == "stderr" {
std::io::stderr().write_all(&[b'x'; 4096]).unwrap();
}
if matches!(
mode.as_str(),
"busy" | "stdin" | "stdout" | "exit" | "allocate"
"busy" | "stdin" | "stdout" | "exit" | "allocate" | "update-probe"
) {
std::io::stdout().write_all(b"R").unwrap();
std::io::stdout().flush().unwrap();
@@ -25,6 +37,11 @@ fn main() {
std::io::stdin().read_exact(&mut [0; 1]).unwrap();
std::fs::write("unblocked", b"stdin").unwrap();
}
"update-probe" => {
std::io::stdin().read_exact(&mut [0; 1]).unwrap();
std::fs::write("unblocked", b"still-running").unwrap();
return;
}
"stdout" => {
// 测试端不消费此正文,超过管道容量后必须由监督器终止,而不是等待写入完成
std::io::stdout()
@@ -18,8 +18,7 @@ async fn manual_image_replacement_should_clear_pending_state_and_invalid_backup(
assert!(complete_update(&service, TARGET_VERSION).await.need_restart);
// 模拟重建镜像:数据卷保留,应用文件和容器内备份被重新替换
fixture.write_executable("manually-installed-binary");
fs::remove_file(fixture.root.path().join("codex-proxy-rs.backup"))
.expect("remove image backup");
fs::remove_file(fixture.backup()).expect("remove image backup");
let mut config = fixture.config(&format!("{}/repos", server.uri()));
config.version = "1.1.0".into();
config.deployment_mode = "docker".into();
@@ -126,11 +125,7 @@ async fn pending_update_should_revoke_rollback_when_backup_content_changes() {
.await;
let service = fixture.service(&server);
assert!(complete_update(&service, TARGET_VERSION).await.need_restart);
fs::write(
fixture.root.path().join("codex-proxy-rs.backup"),
"unrelated-backup",
)
.expect("changed backup");
fs::write(fixture.backup(), "unrelated-backup").expect("changed backup");
let status = service
.update_status()
.await
@@ -40,6 +40,12 @@ use tar::{Builder, EntryType, Header};
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
const BINARY_NAME: &str = if cfg!(windows) {
"codex-proxy-rs.exe"
} else {
"codex-proxy-rs"
};
const OLD_RELEASE_MANIFEST: &str = r#"{"gateway_version":"1.0.0"}"#;
const TARGET_VERSION: &str = "1.9.9";
@@ -253,7 +259,11 @@ impl Fixture {
}
fn executable(&self) -> PathBuf {
self.root.path().join("codex-proxy-rs")
self.root.path().join(BINARY_NAME)
}
fn backup(&self) -> PathBuf {
self.root.path().join(format!("{BINARY_NAME}.backup"))
}
fn web(&self) -> PathBuf {
@@ -393,7 +403,7 @@ impl Fixture {
#[derive(Clone, Copy)]
enum ArchiveKind {
Safe,
UnsafePath,
UnsafePath(&'static str),
MissingOfficialManifest,
NestedOfficialAsset,
OfficialSymlink,
@@ -409,7 +419,7 @@ enum ChecksumKind {
fn release_archive(kind: ArchiveKind) -> Vec<u8> {
let encoder = GzEncoder::new(Vec::new(), Compression::default());
let mut tar = Builder::new(encoder);
append_file(&mut tar, "codex-proxy-rs", b"new-binary", false);
append_file(&mut tar, BINARY_NAME, b"new-binary", false);
append_file(&mut tar, "web/dist/index.html", b"new-web", false);
if !matches!(kind, ArchiveKind::MissingOfficialManifest) {
append_file(
@@ -425,8 +435,8 @@ fn release_archive(kind: ArchiveKind) -> Vec<u8> {
b"new-plugin",
false,
);
if matches!(kind, ArchiveKind::UnsafePath) {
append_file(&mut tar, "safe", b"escape", true);
if let ArchiveKind::UnsafePath(name) = kind {
append_file(&mut tar, name, b"escape", true);
}
if matches!(kind, ArchiveKind::NestedOfficialAsset) {
append_file(
@@ -461,13 +471,19 @@ fn append_symlink(tar: &mut Builder<GzEncoder<Vec<u8>>>, name: &str, target: &st
fn append_file(tar: &mut Builder<GzEncoder<Vec<u8>>>, name: &str, data: &[u8], unsafe_path: bool) {
let mut header = Header::new_gnu();
header.set_entry_type(EntryType::Regular);
header.set_mode(0o755);
header.set_mode(if name.starts_with("plugins/official/") {
0o444
} else {
0o755
});
header.set_size(u64::try_from(data.len()).expect("size"));
header.set_path(name).expect("path");
header
.set_path(if unsafe_path { "safe" } else { name })
.expect("path");
if unsafe_path {
let bytes = header.as_mut_bytes();
bytes[..100].fill(0);
bytes[..9].copy_from_slice(b"../escape");
bytes[..name.len()].copy_from_slice(name.as_bytes());
}
header.set_cksum();
tar.append(&header, data).expect("append");
@@ -2,6 +2,120 @@
use super::*;
#[tokio::test]
async fn update_and_rollback_preserve_a_running_executable() {
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
let server = MockServer::start().await;
let fixture = Fixture::new();
fs::copy(
env!("CARGO_BIN_EXE_gateway-host-process-worker"),
fixture.executable(),
)
.expect("running executable");
let original = fs::read(fixture.executable()).expect("original executable");
fs::write(fixture.root.path().join("mode"), b"update-probe").expect("worker mode");
let mut child = tokio::process::Command::new(fixture.executable())
.current_dir(fixture.root.path())
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.kill_on_drop(true)
.spawn()
.expect("start old executable");
assert_eq!(
tokio::time::timeout(
Duration::from_secs(5),
child.stdout.as_mut().unwrap().read_u8()
)
.await
.expect("worker ready")
.expect("ready byte"),
b'R'
);
fixture
.mount_release(
&server,
TARGET_VERSION,
ArchiveKind::Safe,
ChecksumKind::Valid,
)
.await;
let service = fixture.service(&server);
let status = complete_update(&service, TARGET_VERSION).await;
assert_eq!(
status.operation.status,
SystemOperationStatus::Succeeded,
"{status:?}"
);
assert!(status.need_restart);
assert_eq!(fs::read(fixture.backup()).expect("backup"), original);
assert_eq!(
fs::read(fixture.executable()).expect("installed"),
b"new-binary"
);
assert!(child.try_wait().expect("old process state").is_none());
service
.rollback(Arc::new(AllowingUpdatePreflight))
.await
.expect("rollback running executable");
assert_eq!(
fs::read(fixture.executable()).expect("restored executable"),
original
);
assert!(
!service
.update_status()
.await
.expect("restored status")
.need_restart
);
service
.perform_update(
Some(TARGET_VERSION.to_owned()),
None,
Arc::new(ChangingRevisionPreflight {
confirmations: AtomicUsize::new(0),
}),
)
.await
.expect("second update accepted");
let failed = wait_for_update(&service).await;
assert_eq!(
failed.operation.status,
SystemOperationStatus::Failed,
"{failed:?}"
);
assert_eq!(
fs::read(fixture.executable()).expect("restored after failure"),
original
);
assert_eq!(
fs::read(fixture.web().join("index.html")).expect("restored web"),
b"old-web"
);
child
.stdin
.as_mut()
.unwrap()
.write_u8(b'X')
.await
.expect("release old process");
assert!(
tokio::time::timeout(Duration::from_secs(5), child.wait())
.await
.expect("worker exited")
.expect("exit status")
.success()
);
assert_eq!(
fs::read(fixture.root.path().join("unblocked")).expect("old process result"),
b"still-running"
);
}
#[tokio::test]
async fn restart_should_not_shutdown_when_replacement_spawn_fails() {
let fixture = Fixture::new();
@@ -149,7 +263,12 @@ async fn assert_system_operations_rejected(service: &ProcessSystemOperations, re
async fn restart_should_spawn_replacement_before_shutdown_outside_docker() {
let fixture = Fixture::new();
let replacement_marker = fixture.root.path().join("replacement-ran");
fixture.write_executable("#!/bin/sh\n: > \"${0%/*}/replacement-ran\"\n");
fs::copy(
env!("CARGO_BIN_EXE_gateway-host-process-worker"),
fixture.executable(),
)
.expect("replacement executable");
fs::write(fixture.root.path().join("restart-probe"), b"").expect("restart probe");
let mut config = fixture.config("http://127.0.0.1:1/repos");
config.self_restart_enabled = true;
let shutdown = CancellationToken::new();
@@ -166,6 +285,21 @@ async fn restart_should_spawn_replacement_before_shutdown_outside_docker() {
tokio::time::timeout(Duration::from_secs(2), shutdown.cancelled())
.await
.expect("shutdown requested after spawn");
#[cfg(windows)]
{
tokio::time::timeout(Duration::from_secs(5), async {
while !fixture.root.path().join("replacement-waiting").exists() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("replacement waiting for old service");
assert!(
!replacement_marker.exists(),
"new service must wait for resource release"
);
}
drop(service);
tokio::time::timeout(Duration::from_secs(2), async {
while !replacement_marker.is_file() {
tokio::time::sleep(Duration::from_millis(10)).await;
@@ -189,7 +323,7 @@ async fn restart_should_use_startup_path_after_running_executable_is_renamed() {
let output = std::process::Command::new(child)
.args([
"--exact",
"system_update::restart_should_use_startup_path_after_running_executable_is_renamed",
"system_update::operations::restart_should_use_startup_path_after_running_executable_is_renamed",
])
.env(CHILD_ENV, "1")
.env("CPR_RESTART_DELAY_MS", "1200")
@@ -223,7 +357,7 @@ async fn restart_should_use_startup_path_after_running_executable_is_renamed() {
config.self_restart_enabled = true;
let service = ProcessSystemOperations::new(CancellationToken::new(), config);
let backup = fixture.root.path().join("codex-proxy-rs.backup");
let backup = fixture.backup();
fs::rename(fixture.executable(), &backup).expect("back up running executable");
fixture.write_executable("#!/bin/sh\nprintf new > \"${0%/*}/replacement-version\"\n");
assert_eq!(std::env::current_exe().expect("renamed executable"), backup);
@@ -777,24 +911,42 @@ async fn update_should_reject_release_archive_from_untrusted_host() {
#[tokio::test]
async fn update_should_reject_release_archive_with_unsafe_path() {
let server = MockServer::start().await;
let fixture = Fixture::new();
fixture
.mount_release(
&server,
TARGET_VERSION,
ArchiveKind::UnsafePath,
ChecksumKind::Valid,
)
.await;
for unsafe_path in [
"../escape",
#[cfg(windows)]
"web/dist/index.html:payload",
#[cfg(windows)]
"web/dist/NUL.txt",
#[cfg(windows)]
"web/dist/COM¹",
#[cfg(windows)]
"web/dist/trailing. ",
#[cfg(windows)]
"C:/escape",
] {
let server = MockServer::start().await;
let fixture = Fixture::new();
fixture
.mount_release(
&server,
TARGET_VERSION,
ArchiveKind::UnsafePath(unsafe_path),
ChecksumKind::Valid,
)
.await;
assert_eq!(
complete_update(&fixture.service(&server), TARGET_VERSION)
.await
.operation
.status,
SystemOperationStatus::Failed,
);
assert_eq!(
complete_update(&fixture.service(&server), TARGET_VERSION)
.await
.operation
.status,
SystemOperationStatus::Failed,
);
assert_eq!(
fs::read(fixture.executable()).expect("unchanged binary"),
b"old-binary"
);
}
}
#[tokio::test]
@@ -933,6 +1085,7 @@ async fn update_and_rollback_should_use_the_default_api_asset_directory() {
);
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn update_should_replace_web_assets_across_filesystems() {
let Ok(external) = tempfile::tempdir_in("/dev/shm") else {
@@ -980,7 +1133,7 @@ async fn update_should_restore_web_assets_when_binary_backup_fails() {
ChecksumKind::Valid,
)
.await;
fs::create_dir(fixture.executable().with_extension("backup")).expect("blocking backup dir");
fs::create_dir(fixture.backup()).expect("blocking backup dir");
assert_eq!(
complete_update(&fixture.service(&server), TARGET_VERSION)
@@ -327,6 +327,7 @@ pub(crate) fn usage_list_record_from_row(
provider_account_name: get(row, "provider_account_name")?,
provider_account_email: get(row, "provider_account_email")?,
provider_account_notes: get(row, "provider_account_notes")?,
provider_account_upstream_peer: None,
provider_account_plan_type: get(row, "provider_account_plan_type")?,
provider_account_plan_type_display: None,
provider_account_authentication_kind: get(row, "provider_account_authentication_kind")?,
@@ -857,7 +857,12 @@ impl ProxyStore for PgProxyRepository {
.await
.map_err(|source| store_error(unavailable(source)))?;
if locked.is_none() {
return Err(store_error(conflict(id)));
// 返回版本冲突前释放事务锁,避免误挡紧接着的删除或编辑
let error = match transaction.rollback().await {
Ok(()) => conflict(id),
Err(cleanup) => conflict(id).with_cleanup(cleanup),
};
return Err(store_error(error));
}
let account_ids: Vec<String> = sqlx::query_scalar(
"update provider_accounts set outbound_proxy_id = null, outbound_proxy_url = null, provider_quota_json = null, quota_observed_at = null,
@@ -735,8 +735,8 @@ async fn rejected_import_reservations_release_proxy_lock_before_returning() {
}
#[tokio::test]
async fn stale_proxy_test_releases_lock_before_next_update() {
let Some(database) = TestDatabase::create("stale_proxy_test_lock").await else {
async fn stale_proxy_mutations_release_locks_before_next_update() {
let Some(database) = TestDatabase::create("stale_proxy_mutation_lock").await else {
return;
};
let store = PgProxyRepository::new(database.pool.clone());
@@ -762,52 +762,63 @@ async fn stale_proxy_test_releases_lock_before_next_update() {
.unwrap()
.record;
// 暂停连接归还时的后台清理,确保锁由返回错误前的显式回滚释放
let release_gate = std::sync::Arc::new(tokio::sync::Semaphore::new(0));
let rejected_pool = database
.pool
.options()
.clone()
.max_connections(1)
.after_release({
let release_gate = release_gate.clone();
move |_, _| {
for mutation in ["record_test", "update", "delete"] {
// 暂停连接归还时的后台清理,确保锁由返回错误前的显式回滚释放
let release_gate = std::sync::Arc::new(tokio::sync::Semaphore::new(0));
let rejected_pool = database
.pool
.options()
.clone()
.max_connections(1)
.after_release({
let release_gate = release_gate.clone();
Box::pin(async move {
let _permit = release_gate.acquire().await.unwrap();
Ok(true)
})
}
})
.connect_with((*database.pool.connect_options()).clone())
.await
.unwrap();
let stale_writer = PgProxyRepository::new(rejected_pool.clone());
let error = stale_writer
.record_test(&saved.id, stale_revision, success(), &context)
.await
.unwrap_err();
let update = store
.update(
UpdateProxy {
max_websocket_connections: None,
enabled: None,
id: saved.id.clone(),
revision: saved.revision,
name: saved.name.clone(),
proxy: None,
auto_location: None,
location: None,
test: None,
},
&context,
)
.await;
release_gate.add_permits(1);
rejected_pool.close().await;
move |_, _| {
let release_gate = release_gate.clone();
Box::pin(async move {
let _permit = release_gate.acquire().await.unwrap();
Ok(true)
})
}
})
.connect_with((*database.pool.connect_options()).clone())
.await
.unwrap();
let stale_writer = PgProxyRepository::new(rejected_pool.clone());
let command = |revision| UpdateProxy {
max_websocket_connections: None,
enabled: None,
id: saved.id.clone(),
revision,
name: saved.name.clone(),
proxy: None,
auto_location: None,
location: None,
test: None,
};
let error = match mutation {
"record_test" => stale_writer
.record_test(&saved.id, stale_revision, success(), &context)
.await
.unwrap_err(),
"update" => stale_writer
.update(command(stale_revision), &context)
.await
.unwrap_err(),
"delete" => stale_writer
.delete(&saved.id, stale_revision, &context)
.await
.unwrap_err(),
_ => unreachable!(),
};
let update = store.update(command(saved.revision), &context).await;
release_gate.add_permits(1);
rejected_pool.close().await;
assert_eq!(error.kind(), AdminStoreErrorKind::Conflict);
saved = update
.unwrap_or_else(|error| panic!("过期 {mutation} 不应阻挡后续编辑: {error:?}"))
.record;
}
database.close().await;
assert_eq!(error.kind(), AdminStoreErrorKind::Conflict);
assert!(update.is_ok(), "过期检测结果不应阻挡后续编辑: {update:?}");
}
#[tokio::test]
@@ -397,6 +397,7 @@ pub(super) struct ColdResponse {
pub(super) session_transport_key_hash: Option<String>,
pub(super) session_transport_recovery: CodexSessionTransportRecovery,
pub(super) guardian_routing: guardian::GuardianRouting,
pub(super) turn_state_scope: Option<turn_state::TurnStateScope>,
pub(super) websocket_retry_count: u32,
pub(super) stream_max_retries: u32,
pub(super) session_capture: Option<OpenAiSessionCapture>,
@@ -784,6 +785,7 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
session_transport_key_hash,
session_transport_recovery,
guardian_routing,
turn_state_scope,
websocket_retry_count,
stream_max_retries,
mut session_capture,
@@ -873,6 +875,17 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
},
);
}
if let Some(scope) = &turn_state_scope
&& let Some(response) = failure.error.client_visible_upstream_response()
{
for header in response.headers() {
if header.name().eq_ignore_ascii_case("x-codex-turn-state")
&& let Ok(token) = std::str::from_utf8(header.value().as_ref())
{
scope.remember(token);
}
}
}
if let Some(observation) = failure.observation.take() {
yield ProviderEvent::observation(observation);
}
@@ -897,6 +910,11 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
Err(failure.error)?;
return;
}
if let Some(scope) = &turn_state_scope
&& let Some(token) = response.turn_state.as_deref()
{
scope.remember(token);
}
if let Some(capture) = session_capture.as_mut() {
capture.continuation_scope = Some(if capture.response_store {
OpenAiContinuationScope::Persisted
@@ -1040,6 +1058,7 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
response_metadata_updates.as_ref(),
&mut session_capture,
&mut observation_state,
turn_state_scope.as_ref(),
)
.await;
let observation_event = if rate_limits_changed || metadata_merge.is_some()
@@ -1117,6 +1136,7 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
response_metadata_updates.as_ref(),
&mut session_capture,
&mut observation_state,
turn_state_scope.as_ref(),
)
.await;
let metadata_changed = metadata_merge.unwrap_or(false);
@@ -1317,6 +1337,7 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
response_metadata_updates.as_ref(),
&mut session_capture,
&mut observation_state,
turn_state_scope.as_ref(),
)
.await
.unwrap_or(false);
@@ -1384,8 +1405,12 @@ async fn merge_response_metadata_updates(
updates: Option<&CodexResponseMetadataUpdates>,
session_capture: &mut Option<OpenAiSessionCapture>,
observation_state: &mut OpenAiResponseObservationState,
turn_state_scope: Option<&turn_state::TurnStateScope>,
) -> Option<bool> {
let turn_state = updates?.lock().await.turn_state.take()?;
if let Some(scope) = turn_state_scope {
scope.remember(&turn_state);
}
if let Some(capture) = session_capture.as_mut() {
capture.turn_state = Some(turn_state.clone());
}
@@ -107,6 +107,7 @@ mod failure;
mod guardian;
mod live;
mod observation;
mod turn_state;
mod upstream_adapter;
mod workers;
pub(crate) use live::{CodexLiveGateway, CodexLiveRegistry};
@@ -156,6 +157,7 @@ pub struct CodexProvider {
session_identity: Option<CodexSessionIdentity>,
session_transport_recovery: CodexSessionTransportRecovery,
guardian_routing: guardian::GuardianRouting,
turn_state_ownership: turn_state::TurnStateOwnership,
stream_max_retries: u32,
live_registry: Arc<CodexLiveRegistry>,
live_gateway: Option<Arc<CodexLiveGateway>>,
@@ -361,6 +363,7 @@ impl CodexProvider {
session_identity: None,
session_transport_recovery: CodexSessionTransportRecovery::default(),
guardian_routing: guardian::GuardianRouting::default(),
turn_state_ownership: turn_state::TurnStateOwnership::default(),
stream_max_retries,
live_registry: Arc::new(CodexLiveRegistry::default()),
live_gateway: None,
@@ -963,10 +966,25 @@ impl CodexProvider {
)
.map(|affinity| (response_id.to_owned(), affinity))
});
let turn_state_scope = self
.turn_state_ownership
.scope(&context, &lease, &upstream_request);
// 全量请求的 token 证明不能用于放行 response ID 或其他账号状态
let verified_turn_state = upstream_request
.turn_state
.as_ref()
.filter(|token| {
matches!(account_scope, RequestAccountScope::Unknown)
&& turn_state_scope
.as_ref()
.is_some_and(|scope| scope.owns(token))
})
.cloned();
scope_request_to_account(
&mut upstream_request,
lease.installation_id(),
account_scope,
verified_turn_state.as_deref(),
);
// 父响应的可信关联只恢复此字段,不把首次 Guardian 请求的其他账号状态视为可信
if let Some((response_id, _)) = &guardian_parent
@@ -989,6 +1007,7 @@ impl CodexProvider {
"ownerCrossAccount": state_owner_cross_account,
"accountSwitch": lease.account_switch(),
"accountStateReusable": account_scope.can_reuse_account_state(),
"verifiedTurnStateEcho": verified_turn_state.is_some(),
"before": before,
"after": crate::transport::diagnostics::evidence::request_state(&upstream_request),
}));
@@ -1162,6 +1181,7 @@ impl CodexProvider {
session_transport_key_hash,
session_transport_recovery: self.session_transport_recovery.clone(),
guardian_routing: self.guardian_routing.clone(),
turn_state_scope,
websocket_retry_count,
stream_max_retries: self.stream_max_retries,
session_capture,
@@ -0,0 +1,124 @@
//! 只为上游已返回的 turn state 保存归属证明,不恢复正文或参与账号选择
use std::{
collections::HashMap,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use gateway_core::engine::AttemptContext;
use sha2::{Digest, Sha256};
use crate::{
credential::{CodexCredentialLease, CodexRuntimeAuthentication},
transport::protocol::responses::CodexResponsesRequest,
};
// 仅保存 32 字节摘要与过期时间,4096 项的条目数据约 192 KiB,不随 token 长度增长
const MAX_PROOFS: usize = 4096;
#[derive(Clone, Default)]
pub(super) struct TurnStateOwnership {
proofs: Arc<Mutex<HashMap<[u8; 32], Instant>>>,
}
pub(super) struct TurnStateScope {
ownership: TurnStateOwnership,
identity: Sha256,
retention: Duration,
}
impl TurnStateOwnership {
pub(super) fn scope(
&self,
context: &AttemptContext,
lease: &CodexCredentialLease,
request: &CodexResponsesRequest,
) -> Option<TurnStateScope> {
let turn_id = request
.client_turn_id
.as_deref()
.filter(|id| !id.is_empty())?;
let mut identity = Sha256::new();
for part in [
context.client_api_key_ref().as_str(),
lease.account_id().as_str(),
request.client_session_id.as_deref().unwrap_or_default(),
request.client_thread_id.as_deref().unwrap_or_default(),
turn_id,
] {
identity.update(part.len().to_le_bytes());
identity.update(part.as_bytes());
}
// OAuth 刷新不改变账号归属;API Key 替换则不能继承原认证的路由状态
if matches!(
lease.authentication(),
CodexRuntimeAuthentication::ApiKey(_)
) {
identity.update([1]);
identity.update(lease.account().revision().get().to_le_bytes());
} else {
identity.update([0]);
}
Some(TurnStateScope {
ownership: self.clone(),
identity,
retention: context
.account_selection_policy()
.openai_session_affinity_ttl(),
})
}
}
impl TurnStateScope {
pub(super) fn owns(&self, token: &str) -> bool {
let key = self.key(token);
let mut proofs = self
.ownership
.proofs
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let Some(expires_at) = proofs.get_mut(&key) else {
return false;
};
let now = Instant::now();
if *expires_at <= now {
proofs.remove(&key);
return false;
}
*expires_at = now + self.retention;
true
}
pub(super) fn remember(&self, token: &str) {
if token.is_empty() {
return;
}
let key = self.key(token);
let now = Instant::now();
let mut proofs = self
.ownership
.proofs
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if proofs.len() >= MAX_PROOFS && !proofs.contains_key(&key) {
proofs.retain(|_, expires_at| *expires_at > now);
if proofs.len() >= MAX_PROOFS
&& let Some(oldest) = proofs
.iter()
.min_by_key(|(_, expires_at)| **expires_at)
.map(|(key, _)| *key)
{
proofs.remove(&oldest);
}
}
proofs.insert(key, now + self.retention);
}
fn key(&self, token: &str) -> [u8; 32] {
let mut hash = self.identity.clone();
hash.update(token.len().to_le_bytes());
hash.update(token.as_bytes());
hash.finalize().into()
}
}
@@ -23,16 +23,6 @@ const ID_FIELDS: &[&str] = &[
"parentThreadId",
];
const METADATA_FIELDS: &[&str] = &["turnMetadata", "turn_metadata", "x-codex-turn-metadata"];
const TRACE_FIELDS: &[&str] = &[
"traceparent",
"tracestate",
"sentry-trace",
"baggage",
"ws_request_header_traceparent",
"ws_request_header_tracestate",
"ws_request_header_sentry-trace",
"ws_request_header_baggage",
];
// Live 的 x-session-id 与 voice-session-id 是业务会话引用,必须原样传递
const ID_HEADERS: &[&str] = &[
"session-id",
@@ -102,25 +92,14 @@ fn map_fields(object: &mut Map<String, Value>, installation_id: &str, fields: &[
changed
}
fn scope_object(object: &mut Map<String, Value>, installation_id: Option<&str>) -> usize {
let mut changed = 0;
for key in TRACE_FIELDS {
changed += usize::from(object.remove(*key).is_some());
}
if let Some(installation_id) = installation_id {
changed += map_fields(object, installation_id, ID_FIELDS);
}
changed
}
fn scope_metadata(value: &mut Value, installation_id: Option<&str>) {
fn scope_metadata(value: &mut Value, installation_id: &str) {
match value {
Value::Object(object) => {
scope_object(object, installation_id);
map_fields(object, installation_id, ID_FIELDS);
}
Value::String(raw) => {
if let Ok(Value::Object(mut object)) = serde_json::from_str(raw)
&& scope_object(&mut object, installation_id) > 0
&& map_fields(&mut object, installation_id, ID_FIELDS) > 0
&& let Some(encoded) = serialize_ascii_turn_metadata(&Value::Object(object))
{
*raw = encoded;
@@ -131,29 +110,27 @@ fn scope_metadata(value: &mut Value, installation_id: Option<&str>) {
}
pub(super) fn scope_headers(headers: &mut HeaderMap, installation_id: Option<&str>) {
for name in TRACE_FIELDS {
headers.remove(*name);
}
let Some(installation_id) = installation_id else {
return;
};
for name in ID_HEADERS {
if let Some(installation_id) = installation_id {
let values: Vec<_> = headers
.get_all(*name)
.iter()
.map(|value| {
value
.to_str()
.ok()
.filter(|value| !value.is_empty())
.and_then(|value| {
HeaderValue::from_str(&scoped_id(installation_id, value)).ok()
})
.unwrap_or_else(|| value.clone())
})
.collect();
headers.remove(*name);
for value in values {
headers.append(*name, value);
}
let values: Vec<_> = headers
.get_all(*name)
.iter()
.map(|value| {
value
.to_str()
.ok()
.filter(|value| !value.is_empty())
.and_then(|value| {
HeaderValue::from_str(&scoped_id(installation_id, value)).ok()
})
.unwrap_or_else(|| value.clone())
})
.collect();
headers.remove(*name);
for value in values {
headers.append(*name, value);
}
}
let values: Vec<_> = headers
@@ -183,18 +160,19 @@ pub(super) fn scope_request(
installation_id: Option<&str>,
) {
scope_headers(headers, installation_id);
let Some(installation_id) = installation_id else {
return;
};
if let Some(object) = body.as_object_mut() {
scope_object(object, installation_id);
if let Some(installation_id) = installation_id {
map_fields(object, installation_id, &["prompt_cache_key"]);
}
map_fields(object, installation_id, ID_FIELDS);
map_fields(object, installation_id, &["prompt_cache_key"]);
for key in METADATA_FIELDS {
if let Some(value) = object.get_mut(*key) {
scope_metadata(value, installation_id);
}
}
if let Some(Value::Object(metadata)) = object.get_mut("client_metadata") {
scope_object(metadata, installation_id);
map_fields(metadata, installation_id, ID_FIELDS);
for key in METADATA_FIELDS {
if let Some(value) = metadata.get_mut(*key) {
scope_metadata(value, installation_id);
@@ -202,9 +180,6 @@ pub(super) fn scope_request(
}
}
}
let Some(installation_id) = installation_id else {
return;
};
// 只处理官方自动 Git 上下文的关联字段,不扫描普通用户输入和工具输出
let Ok(targets) = locate_contexts(body, true, || Ok(())) else {
return;
@@ -28,16 +28,15 @@ pub(super) fn is_managed_identity_header(name: &str) -> bool {
| "chatgpt-project-id"
| "openai-organization"
| "openai-project"
// 工作区路由和合规属性属于认证账号,不能继承下游账号的值
| "x-openai-account-routing-override"
| "x-openai-fedramp"
// 安装身份由当前账号写入 client_metadata,不继承下游安装头
// 安装头与 client_metadata 共用当前账号的安装身份
| "x-codex-installation-id"
// 画像和设备证明须属于选定账号,不能由下游覆盖
| "originator"
| "user-agent"
| "version"
// 地域和账号路由统一按部署配置生成
| "x-openai-internal-codex-residency"
| "x-openai-account-routing-override"
| "x-oai-attestation"
| "x-oai-is"
| "x-oai-is-update"
@@ -127,6 +126,11 @@ impl CodexBackendClient {
};
let mut headers = build_codex_model_headers(profile, context.authorization, account_id)?;
insert_optional_header(&mut headers, "cookie", cookie_header)?;
insert_optional_header(
&mut headers,
"x-codex-installation-id",
context.installation_id,
)?;
Ok(headers)
}
@@ -469,19 +469,17 @@ pub(crate) fn scope_request_to_account(
request: &mut CodexResponsesRequest,
installation_id: &str,
account_scope: RequestAccountScope,
verified_turn_state: Option<&str>,
) {
let reset_account_state = !account_scope.can_reuse_account_state();
let client_metadata_turn_state = metadata_string(request, "x-codex-turn-state");
let preserve_turn_state = !reset_account_state
&& (request.turn_state.is_some() || client_metadata_turn_state.is_some());
let turn_state = preserve_turn_state
.then(|| request.turn_state.clone())
.flatten();
let client_metadata_turn_state = if preserve_turn_state {
client_metadata_turn_state
let turn_state = if reset_account_state {
verified_turn_state.map(ToOwned::to_owned)
} else {
None
request.turn_state.clone()
};
let client_metadata_turn_state = client_metadata_turn_state
.filter(|token| !reset_account_state || Some(token.as_str()) == verified_turn_state);
let turn_metadata = request
.turn_metadata
.as_deref()
@@ -45,6 +45,7 @@ const DOWNSTREAM_CLIENT_HEADERS: &[(&str, &str)] = &[
];
const BUSINESS_EXTENSION_HEADERS: &[(&str, &str)] = &[
("x-openai-fedramp", "true"),
("x-grok-turn-idx", "7"),
("X-Grok-Model-Override", "synthetic-model"),
("x-grok-session-id", "synthetic-grok-session"),
@@ -77,6 +78,10 @@ fn request_with_opaque_headers(use_websocket: bool) -> CodexResponsesRequest {
"x-openai-internal-codex-residency",
STANDARD.encode(b"future-region")
],
[
"X-OpenAI-Account-Routing-Override",
STANDARD.encode(b"client-route")
],
["x-codex-turn-state", STANDARD.encode(b"turn-ascii")],
["x-codex-turn-state", STANDARD.encode(b"turn-\x80")],
["bad header name", STANDARD.encode(b"ignored")],
@@ -202,7 +207,10 @@ async fn backend_http_should_preserve_business_headers_without_downstream_transp
});
// 不经 API 解码,直接构造协议上下文,验证 Provider 自身的过滤边界
let request = request_with_opaque_headers(false);
let profile = test_wire_profile();
let mut configured_profile = test_wire_profile().snapshot();
configured_profile.account_routing_override = Some("configured-route".into());
let profile =
provider_openai::transport::profile::CodexWireProfileState::new(configured_profile);
let profile_snapshot = profile.snapshot();
let expected_user_agent = profile_snapshot.user_agent();
let expected_core_version = profile_snapshot.codex_version;
@@ -277,7 +285,18 @@ async fn backend_http_should_preserve_business_headers_without_downstream_transp
] {
assert_eq!(raw_header_values(&raw, name), vec![value.to_vec()]);
}
assert!(raw_header_values(&raw, "traceparent").is_empty());
assert_eq!(
raw_header_values(&raw, "traceparent"),
vec![b"synthetic-trace".to_vec()]
);
assert_eq!(
raw_header_values(&raw, "x-openai-account-routing-override"),
vec![b"configured-route".to_vec()]
);
assert_eq!(
raw_header_values(&raw, "x-codex-installation-id"),
vec![b"lease-installation".to_vec()]
);
let session = raw_header_values(&raw, "session-id");
assert!(uuid::Uuid::parse_str(std::str::from_utf8(&session[0]).unwrap()).is_ok());
// 指纹头由运行时画像生成,不透传客户端值
@@ -295,7 +314,6 @@ async fn backend_http_should_preserve_business_headers_without_downstream_transp
);
for dropped in [
"openai-beta",
"x-codex-installation-id",
"x-openai-actor-authorization",
"x-oai-attestation",
"x-oai-is",
@@ -358,13 +376,15 @@ async fn backend_websocket_should_preserve_business_headers_without_downstream_t
.expect("send opaque terminal event");
});
let request = request_with_opaque_headers(true);
let mut profile = test_wire_profile().snapshot();
profile.account_routing_override = Some("configured-route".into());
let client = CodexBackendClient::new(
reqwest::Client::builder()
.no_proxy()
.build()
.expect("HTTP client"),
format!("http://{address}"),
test_wire_profile(),
provider_openai::transport::profile::CodexWireProfileState::new(profile),
)
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(Duration::from_mins(1))));
@@ -430,7 +450,6 @@ async fn backend_websocket_should_preserve_business_headers_without_downstream_t
assert_eq!(values("version"), vec![b"1.2.3".to_vec()]);
assert!(values("x-openai-internal-codex-residency").is_empty());
for dropped in [
"x-codex-installation-id",
"x-openai-actor-authorization",
"x-oai-attestation",
"x-oai-is",
@@ -462,7 +481,15 @@ async fn backend_websocket_should_preserve_business_headers_without_downstream_t
] {
assert_eq!(values(name), vec![value.to_vec()], "missing {name}");
}
assert!(values("traceparent").is_empty());
assert_eq!(values("traceparent"), vec![b"synthetic-trace".to_vec()]);
assert_eq!(
values("x-openai-account-routing-override"),
vec![b"configured-route".to_vec()]
);
assert_eq!(
values("x-codex-installation-id"),
vec![b"lease-installation".to_vec()]
);
let session = values("session-id");
assert!(uuid::Uuid::parse_str(std::str::from_utf8(&session[0]).unwrap()).is_ok());
assert_eq!(values("x-codex-turn-state"), vec![b"turn-ascii".to_vec()]);
@@ -71,12 +71,15 @@ fn assert_isolated_request(
body: &Value,
owner: Option<&str>,
) {
for name in ["x-openai-account-routing-override", "x-openai-fedramp"] {
assert!(
!headers.contains_key(name),
"leaked {name} with owner {owner:?}"
);
}
assert!(!headers.contains_key("x-openai-account-routing-override"));
assert_eq!(
headers
.get_all("x-openai-fedramp")
.iter()
.map(|value| value.to_str().unwrap())
.collect::<Vec<_>>(),
["true", "false"]
);
assert_eq!(headers["x-codex-guardian"], "true");
assert_eq!(
headers
@@ -112,7 +115,7 @@ fn assert_isolated_request(
}
#[tokio::test]
async fn http_isolates_account_headers_and_only_scopes_guardian_parent_reference() {
async fn http_applies_deployment_routing_and_only_scopes_guardian_parent_reference() {
for owner in [Some(SELECTED_ACCOUNT), Some("acct_scope_old"), None] {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, SELECTED_ACCOUNT).await;
@@ -155,7 +158,7 @@ async fn http_isolates_account_headers_and_only_scopes_guardian_parent_reference
}
#[tokio::test]
async fn websocket_isolates_account_headers_and_only_scopes_guardian_parent_reference() {
async fn websocket_applies_deployment_routing_and_only_scopes_guardian_parent_reference() {
for owner in [Some(SELECTED_ACCOUNT), Some("acct_scope_old"), None] {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, SELECTED_ACCOUNT).await;
@@ -136,15 +136,35 @@ async fn account_correlation_preserves_turns_across_transports_and_account_switc
event.unwrap();
}
let (headers, body) = capture.await;
assert!(!headers.contains_key("traceparent"));
assert!(!headers.contains_key("tracestate"));
assert!(!headers.contains_key("sentry-trace"));
assert!(!headers.contains_key("baggage"));
assert_eq!(
headers["traceparent"],
"00-11111111111111111111111111111111-2222222222222222-01"
);
assert_eq!(headers["tracestate"], "vendor=source");
assert_eq!(headers["sentry-trace"], "source-trace");
assert_eq!(
headers
.get_all("baggage")
.iter()
.map(|value| value.to_str().unwrap())
.collect::<Vec<_>>(),
["sentry-environment=synthetic", "sentry-release=synthetic"]
);
let flat = &body["client_metadata"];
assert!(flat.get("ws_request_header_traceparent").is_none());
assert!(flat.get("ws_request_header_tracestate").is_none());
assert!(flat.get("ws_request_header_sentry-trace").is_none());
assert!(flat.get("ws_request_header_baggage").is_none());
assert_eq!(
flat["ws_request_header_traceparent"],
headers["traceparent"].to_str().unwrap()
);
assert_eq!(flat["ws_request_header_tracestate"], "vendor=source");
assert_eq!(flat["ws_request_header_sentry-trace"], "source-trace");
assert_eq!(
flat["ws_request_header_baggage"],
"sentry-environment=synthetic"
);
assert_eq!(
headers["x-codex-installation-id"].to_str().unwrap(),
flat["x-codex-installation-id"]
);
let header: Value =
serde_json::from_str(headers["x-codex-turn-metadata"].to_str().unwrap()).unwrap();
let full: Value =
@@ -163,8 +183,8 @@ async fn account_correlation_preserves_turns_across_transports_and_account_switc
assert_eq!(flat["x-client-turn-id"], "source-turn");
assert_eq!(flat["turn_id"], "source-turn");
for metadata in [&header, &full] {
assert!(metadata.get("sentry-trace").is_none());
assert!(metadata.get("baggage").is_none());
assert_eq!(metadata["sentry-trace"], "source-trace");
assert_eq!(metadata["baggage"], "sentry-environment=synthetic");
assert_eq!(metadata["turn_id"], "source-turn");
assert_eq!(metadata["parent_turn_id"], "source-turn");
assert_eq!(metadata["root_turn_id"], "source-turn");
@@ -20,6 +20,7 @@ mod search_model_access;
mod selection_retry;
mod session_binding;
mod timing;
mod turn_state;
mod upstream_adapter;
use std::collections::{BTreeMap, BTreeSet};
@@ -6009,7 +6010,7 @@ async fn opaque_provider_options_do_not_change_openai_account_selection() {
}
#[tokio::test]
async fn http_account_scoping_drops_downstream_installation_header() {
async fn http_account_scoping_replaces_downstream_installation_header() {
for owner in ["acct_scope_same", "acct_scope_old"] {
let raw = r#"{"installation_id":"client-installation","future":true}"#;
let request = capture_scoped_http_request(
@@ -6050,7 +6051,10 @@ async fn http_account_scoping_drops_downstream_installation_header() {
assert_ne!(installation_id, "client-installation");
assert!(uuid::Uuid::parse_str(installation_id).is_ok());
assert_eq!(body["installation_id"], installation_id);
assert!(captured_header_values(&request, "x-codex-installation-id").is_empty());
assert_eq!(
captured_header_values(&request, "x-codex-installation-id"),
vec![installation_id.as_bytes().to_vec()]
);
let headers = captured_header_values(&request, "x-codex-turn-metadata");
assert_eq!(headers.len(), 1);
for encoded in [
@@ -6068,7 +6072,7 @@ async fn http_account_scoping_drops_downstream_installation_header() {
}
#[tokio::test]
async fn websocket_account_scoping_drops_downstream_installation_header() {
async fn websocket_account_scoping_replaces_downstream_installation_header() {
for owner in ["acct_scope_same", "acct_scope_old"] {
let raw = r#"{"installation_id":"client-installation","future":true}"#;
let store = Arc::new(MemoryAccountStore::default());
@@ -6147,7 +6151,7 @@ async fn websocket_account_scoping_drops_downstream_installation_header() {
assert_ne!(installation_id, "client-installation");
assert!(uuid::Uuid::parse_str(installation_id).is_ok());
assert_eq!(body["installation_id"], installation_id);
assert!(!header.contains_key("x-codex-installation-id"));
assert_eq!(header["x-codex-installation-id"], installation_id);
for encoded in [
header["x-codex-turn-metadata"]
.to_str()
@@ -6219,7 +6223,10 @@ async fn same_account_scope_preserves_future_protocol_shapes() {
body.pointer("/client_metadata/x-codex-installation-id")
);
assert!(body.pointer("/client_metadata/installation_id").is_none());
assert!(captured_header_values(&request, "x-codex-installation-id").is_empty());
assert_eq!(
request.headers["x-codex-installation-id"].to_str().unwrap(),
body["client_metadata"]["x-codex-installation-id"]
);
}
#[tokio::test]
@@ -85,6 +85,13 @@ async fn independent_endpoints_send_opaque_headers_without_client_identity() {
let entries: Vec<_> = [
("x-future-business", "first"),
("x-future-business", "second"),
("x-codex-installation-id", "client-installation"),
("x-openai-account-routing-override", "client-route"),
("x-openai-fedramp", "true"),
("traceparent", "client-trace"),
("tracestate", "vendor=client"),
("sentry-trace", "sentry-client-trace"),
("baggage", "environment=synthetic"),
("authorization", "Bearer client-secret"),
("chatgpt-account-id", "client-account"),
("x-stainless-runtime", "client-runtime"),
@@ -134,6 +141,21 @@ async fn independent_endpoints_send_opaque_headers_without_client_identity() {
let requests = server.received_requests().await.unwrap();
let sent = requests.last().unwrap();
assert_eq!(sent.url.path(), endpoint);
uuid::Uuid::parse_str(sent.headers["x-codex-installation-id"].to_str().unwrap()).unwrap();
assert!(
!sent
.headers
.contains_key("x-openai-account-routing-override")
);
for (name, expected) in [
("x-openai-fedramp", "true"),
("traceparent", "client-trace"),
("tracestate", "vendor=client"),
("sentry-trace", "sentry-client-trace"),
("baggage", "environment=synthetic"),
] {
assert_eq!(sent.headers[name], expected);
}
if endpoint.ends_with("search") {
assert_scoped_search_body(&sent.body, &raw);
} else {
@@ -291,9 +291,9 @@ async fn privacy_header_change_isolates_websocket_then_reuses_stable_headers() {
let mut sockets = Vec::new();
let mut cache_keys = Vec::new();
let mut session_headers = Vec::new();
for (expected, requests) in [
("Bearer first-synthetic-token", 1),
("Bearer second-synthetic-token", 2),
for (expected, trace, requests) in [
("Bearer first-synthetic-token", "first-trace", 1),
("Bearer second-synthetic-token", "second-trace", 2),
] {
let (stream, _) = timeout(Duration::from_secs(5), listener.accept())
.await
@@ -302,8 +302,11 @@ async fn privacy_header_change_isolates_websocket_then_reuses_stable_headers() {
let mut socket =
crate::transport::accept_codex_test_websocket_with(stream, |request, response| {
assert_eq!(request.headers()["authorization"], expected);
assert!(!request.headers().contains_key("sentry-trace"));
assert!(!request.headers().contains_key("baggage"));
assert_eq!(request.headers()["sentry-trace"], trace);
assert_eq!(
request.headers()["baggage"],
format!("sentry-trace_id={trace}")
);
session_headers.push(request.headers()["session-id"].clone());
response.headers_mut().insert(
"sec-websocket-extensions",
@@ -0,0 +1,415 @@
//! 验证同轮全量请求回传的 token 归属、失败路径与账号状态隔离
use super::*;
const TOKEN: &str = "upstream-turn-state";
fn full_body() -> Value {
json!({
"model": "gpt-5.4", "store": false,
"input": [{"role": "user", "content": "hello"}],
"client_metadata": {
"session_id": "turn-state-session",
"thread_id": "turn-state-thread",
"turn_id": "turn-state-turn"
}
})
}
fn full_request(body: Value, token: Option<&str>, websocket: bool) -> Operation {
let mut protocol_context = Map::from_iter([
("use_websocket".to_owned(), json!(websocket)),
(
"session_id".to_owned(),
body["client_metadata"]["session_id"].clone(),
),
(
"thread_id".to_owned(),
body["client_metadata"]["thread_id"].clone(),
),
(
"turn_metadata".to_owned(),
json!(body["client_metadata"].to_string()),
),
]);
if let Some(token) = token {
protocol_context.insert("turn_state".to_owned(), json!(token));
protocol_context.insert(
"opaque_request_headers".to_owned(),
json!([["x-codex-turn-state", STANDARD.encode(token)]]),
);
}
Operation::Generate(GenerateRequest::from_protocol_payload(
ProtocolPayload::json_object("openai", body.as_object().unwrap().clone())
.unwrap()
.with_context(protocol_context),
))
}
async fn collect_response(
provider: &Arc<CodexProvider>,
operation: Operation,
context: AttemptContext,
) -> Result<bool, gateway_core::error::ProviderError> {
let mut stream = provider
.clone()
.execute(planned_request("openai", operation), context)
.await?;
let mut observed_token = false;
while let Some(event) = stream.next().await {
let event = event?;
if let Some(observation) = event.response_observation() {
observed_token |= observation.client_headers().iter().any(|header| {
header.name() == "x-codex-turn-state" && header.value().as_ref() == TOKEN.as_bytes()
});
}
}
Ok(observed_token)
}
async fn http_fixture() -> (Arc<MemoryAccountStore>, MockServer, Arc<CodexProvider>) {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_session_affinity").await;
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.insert_header("x-codex-turn-state", TOKEN)
.set_body_string(CAPTURE_COMPLETED_SSE),
)
.mount(&server)
.await;
let provider = provider_with_base_url(&store, server.uri());
(store, server, provider)
}
#[tokio::test]
async fn same_turn_full_http_request_preserves_observed_token_without_restoring_other_account_state()
{
let (_store, server, provider) = http_fixture().await;
assert!(
collect_response(
&provider,
full_request(full_body(), None, false),
context("req_turn_first", CancellationToken::new())
)
.await
.unwrap()
);
let mut body = full_body();
body["client_metadata"]["x-codex-turn-state"] = json!(TOKEN);
body["client_metadata"]["response_id"] = json!("unowned-response");
body["client_metadata"]["parent_response_id"] = json!("unowned-parent");
body["client_metadata"]["account_id"] = json!("unowned-account");
collect_response(
&provider,
full_request(body, Some(TOKEN), false),
context("req_turn_echo", CancellationToken::new()),
)
.await
.unwrap();
let requests = server.received_requests().await.unwrap();
assert_eq!(requests.len(), 2);
assert_eq!(requests[1].headers["x-codex-turn-state"], TOKEN);
let body = captured_request_body(&requests[1]);
assert_eq!(body["client_metadata"]["x-codex-turn-state"], TOKEN);
assert!(body.get("previous_response_id").is_none());
for field in ["response_id", "parent_response_id", "account_id"] {
assert!(
body["client_metadata"].get(field).is_none(),
"{field} must remain scoped"
);
}
}
#[tokio::test]
async fn verified_header_echo_does_not_authorize_a_different_metadata_token() {
let (_store, server, provider) = http_fixture().await;
collect_response(
&provider,
full_request(full_body(), None, false),
context("req_turn_first", CancellationToken::new()),
)
.await
.unwrap();
let mut body = full_body();
body["client_metadata"]["x-codex-turn-state"] = json!("unobserved-token");
collect_response(
&provider,
full_request(body, Some(TOKEN), false),
context("req_turn_echo", CancellationToken::new()),
)
.await
.unwrap();
let requests = server.received_requests().await.unwrap();
assert_eq!(requests[1].headers["x-codex-turn-state"], TOKEN);
assert!(
captured_request_body(&requests[1])["client_metadata"]
.get("x-codex-turn-state")
.is_none()
);
}
#[tokio::test]
async fn turn_state_from_failed_http_response_is_owned_before_a_terminal_success() {
let (_store, server, provider) = http_fixture().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.respond_with(ResponseTemplate::new(400)
.insert_header("x-codex-turn-state", TOKEN)
.set_body_json(json!({"error": {"type": "invalid_request_error", "message": "Invalid request."}})))
.up_to_n_times(1)
.with_priority(1)
.mount(&server).await;
assert!(
collect_response(
&provider,
full_request(full_body(), None, false),
context("req_turn_first", CancellationToken::new())
)
.await
.is_err()
);
collect_response(
&provider,
full_request(full_body(), Some(TOKEN), false),
context("req_turn_echo", CancellationToken::new()),
)
.await
.unwrap();
let requests = server.received_requests().await.unwrap();
assert_eq!(requests.len(), 2);
assert_eq!(requests[1].headers["x-codex-turn-state"], TOKEN);
}
#[tokio::test]
async fn turn_state_proof_does_not_authorize_an_unowned_previous_response_id() {
let (_store, server, provider) = http_fixture().await;
collect_response(
&provider,
full_request(full_body(), None, false),
context("req_turn_first", CancellationToken::new()),
)
.await
.unwrap();
let mut body = full_body();
body["previous_response_id"] = json!("unowned-response");
let error = collect_response(
&provider,
full_request(body, Some(TOKEN), false),
context("req_turn_echo", CancellationToken::new()),
)
.await
.unwrap_err();
assert_eq!(
error
.client_visible_upstream_error()
.and_then(|detail| detail.code()),
Some("previous_response_not_found")
);
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}
#[tokio::test]
async fn full_request_turn_state_requires_matching_client_account_thread_turn_and_token() {
for case in [
"new_turn",
"missing_turn",
"new_thread",
"new_session",
"new_key",
"new_account",
"unknown_token",
"conflicting_owner",
"expired",
"new_provider",
] {
let (store, server, provider) = http_fixture().await;
let first_context = if case == "expired" {
AttemptContext::new(
RequestAttemptContext::new(
ModelRequestId::new("req_turn_first").unwrap(),
ClientApiKeyId::new("key_openai_contract").unwrap(),
),
NonZeroU32::new(1).unwrap(),
SystemTime::now() + Duration::from_secs(30),
account_policy().with_openai_session_affinity_ttl(Duration::ZERO),
AccountAttemptContext::new(BTreeSet::new(), None, None)
.with_account_scope(contract_account_scope()),
None,
CancellationToken::new(),
)
} else if case == "new_account" {
diagnostic_context("req_turn_first", "acct_session_affinity")
} else {
context("req_turn_first", CancellationToken::new())
};
collect_response(
&provider,
full_request(full_body(), None, false),
first_context,
)
.await
.unwrap();
let mut body = full_body();
let mut token = TOKEN;
let mut echo_context = context("req_turn_echo", CancellationToken::new());
let provider = if case == "new_provider" {
provider_with_base_url(&store, server.uri())
} else {
provider
};
match case {
"new_turn" => body["client_metadata"]["turn_id"] = json!("another-turn"),
"missing_turn" => {
body["client_metadata"]
.as_object_mut()
.unwrap()
.remove("turn_id");
}
"new_thread" => body["client_metadata"]["thread_id"] = json!("another-thread"),
"new_session" => {
body["client_metadata"]["session_id"] = json!("another-session");
echo_context = diagnostic_context("req_turn_echo", "acct_session_affinity");
}
"unknown_token" => token = "unobserved-token",
"new_account" => {
create_account(&store, "acct_scope_new").await;
echo_context = diagnostic_context("req_turn_echo", "acct_scope_new");
}
"conflicting_owner" => {
echo_context = context_with_state_owner("req_turn_echo", "acct_scope_old")
}
"new_key" => {
echo_context = AttemptContext::new(
RequestAttemptContext::new(
ModelRequestId::new("req_turn_echo").unwrap(),
ClientApiKeyId::new("another-key").unwrap(),
),
NonZeroU32::new(1).unwrap(),
SystemTime::now() + Duration::from_secs(30),
account_policy(),
AccountAttemptContext::diagnostic(
BTreeSet::new(),
ProviderAccountId::new("acct_session_affinity").unwrap(),
None,
),
None,
CancellationToken::new(),
);
}
_ => {}
}
body["client_metadata"]["x-codex-turn-state"] = json!(token);
collect_response(
&provider,
full_request(body, Some(token), false),
echo_context,
)
.await
.unwrap_or_else(|error| panic!("{case}: {error:?}"));
let requests = server.received_requests().await.unwrap();
assert_eq!(requests.len(), 2, "{case}");
assert!(
!requests[1].headers.contains_key("x-codex-turn-state"),
"{case}"
);
assert!(
captured_request_body(&requests[1])["client_metadata"]
.get("x-codex-turn-state")
.is_none(),
"{case}"
);
}
}
#[tokio::test]
async fn websocket_metadata_turn_state_survives_full_replay_after_completion_or_failure() {
for fail_first in [false, true] {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_session_affinity").await;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut websocket = accept_codex_test_websocket(stream).await;
websocket.next().await.unwrap().unwrap();
websocket
.send(Message::Text(
json!({
"type": "response.metadata", "headers": {"x-codex-turn-state": [TOKEN]}
})
.to_string()
.into(),
))
.await
.unwrap();
if fail_first {
websocket.close(None).await.unwrap();
let (stream, _) = listener.accept().await.unwrap();
websocket = accept_codex_test_websocket(stream).await;
} else {
websocket
.send(Message::Text(
CAPTURE_COMPLETED_SSE
.lines()
.find_map(|line| line.strip_prefix("data: "))
.unwrap()
.to_owned()
.into(),
))
.await
.unwrap();
}
let echo: Value = loop {
match websocket.next().await.unwrap().unwrap() {
Message::Text(text) => break serde_json::from_str(&text).unwrap(),
Message::Ping(payload) => websocket.send(Message::Pong(payload)).await.unwrap(),
Message::Close(_) => {
let (stream, _) = listener.accept().await.unwrap();
websocket = accept_codex_test_websocket(stream).await;
}
other => panic!("expected response.create, got {other:?}"),
}
};
websocket
.send(Message::Text(
CAPTURE_COMPLETED_SSE
.lines()
.find_map(|line| line.strip_prefix("data: "))
.unwrap()
.to_owned()
.into(),
))
.await
.unwrap();
echo
});
let provider = provider_with_base_url(&store, base_url);
let first = collect_response(
&provider,
full_request(full_body(), None, true),
context("req_turn_first", CancellationToken::new()),
)
.await;
assert_eq!(first.is_err(), fail_first);
collect_response(
&provider,
full_request(full_body(), Some(TOKEN), true),
context("req_turn_echo", CancellationToken::new()),
)
.await
.unwrap();
let echo = timeout(Duration::from_secs(5), server)
.await
.unwrap()
.unwrap();
assert_eq!(
echo["client_metadata"]["x-codex-turn-state"], TOKEN,
"failed first response: {fail_first}"
);
assert!(echo.get("previous_response_id").is_none());
}
}
@@ -428,6 +428,10 @@ fn live_protocol_headers() -> Vec<gateway_core::operation::ProviderHttpHeader> {
("traceparent", "client-trace"),
("tracestate", "client-state"),
("ws_request_header_traceparent", "client-ws-trace"),
("x-codex-installation-id", "foreign-installation"),
("X-OpenAI-Account-Routing-Override", "first-route"),
("x-openai-account-routing-override", "second-route"),
("x-openai-fedramp", "true"),
("x-codex-turn-state", "foreign-turn-state"),
("x-codex-routing-hint", "foreign-route"),
("openai-alpha", "live-test"),
@@ -452,6 +456,7 @@ fn assert_live_correlation(headers: &reqwest::header::HeaderMap) {
uuid::Uuid::parse_str(session).unwrap();
assert_eq!(headers["thread-id"], session);
assert_eq!(headers["openai-alpha"], "live-test");
assert_eq!(headers["x-openai-fedramp"], "true");
assert_eq!(
headers
.get_all("x-test-business")
@@ -465,9 +470,7 @@ fn assert_live_correlation(headers: &reqwest::header::HeaderMap) {
"x-oai-attestation",
"x-forwarded-for",
"x-stainless-runtime",
"traceparent",
"tracestate",
"ws_request_header_traceparent",
"x-openai-account-routing-override",
"x-codex-turn-state",
"x-codex-routing-hint",
] {
@@ -479,7 +482,14 @@ fn assert_live_correlation(headers: &reqwest::header::HeaderMap) {
assert_eq!(metadata["future"], true);
assert_ne!(metadata["installation_id"], "foreign-installation");
assert!(metadata.get("account_id").is_none());
assert!(metadata.get("traceparent").is_none());
assert_eq!(metadata["traceparent"], "client-trace");
assert_eq!(headers["traceparent"], "client-trace");
assert_eq!(headers["tracestate"], "client-state");
assert_eq!(headers["ws_request_header_traceparent"], "client-ws-trace");
assert_eq!(
headers["x-codex-installation-id"].to_str().unwrap(),
metadata["installation_id"]
);
}
#[tokio::test]
@@ -226,6 +226,7 @@ async fn backend_websocket_should_forward_context_headers_and_preserve_payload_f
("x-codex-parent-thread-id", "parent-456"),
("x-openai-subagent", "future_codex_mode"),
("x-openai-memgen-request", "true"),
("x-codex-installation-id", "install-123"),
("session-id", "cp_derived"),
] {
let value = &headers.iter().find(|(header, _)| header == name).unwrap().1;
@@ -240,7 +241,6 @@ async fn backend_websocket_should_forward_context_headers_and_preserve_payload_f
}
}
for forbidden in [
"x-codex-installation-id",
"x-codex-turn-id",
"x-openai-internal-codex-residency",
"accept",
@@ -365,8 +365,11 @@ async fn backend_http_should_send_codex_context_without_browser_headers() {
] {
assert!(header_names.iter().any(|name| name == required));
}
assert_eq!(
read_header_value(&raw_request, "x-codex-installation-id"),
Some("install-1")
);
for forbidden in [
"x-codex-installation-id",
"x-codex-turn-id",
"x-openai-internal-codex-residency",
"sec-ch-ua",
@@ -143,7 +143,7 @@ fn deployment_routing_survives_artifact_updates_and_profile_selection() {
}
#[tokio::test]
async fn model_transports_apply_configured_routing_and_discard_downstream_values() {
async fn model_transports_apply_deployment_routing_with_or_without_client_headers() {
use base64::{Engine as _, engine::general_purpose::STANDARD};
use gateway_core::operation::{GenerateRequest, ProtocolPayload};
@@ -178,7 +178,9 @@ async fn model_transports_apply_configured_routing_and_discard_downstream_values
[None, Some("NO_CONSTRAINT")],
),
] {
for websocket in [false, true] {
for (websocket, has_downstream_headers) in
[(false, false), (false, true), (true, false), (true, true)]
{
let (base_url, capture): (
String,
futures::future::BoxFuture<'static, reqwest::header::HeaderMap>,
@@ -237,6 +239,7 @@ async fn model_transports_apply_configured_routing_and_discard_downstream_values
);
let downstream_headers = HEADERS
.into_iter()
.filter(|_| has_downstream_headers)
.flat_map(|name| {
["downstream-first", "downstream-second"]
.map(|value| json!([name, STANDARD.encode(value)]))
@@ -270,7 +273,7 @@ async fn model_transports_apply_configured_routing_and_discard_downstream_values
.map(|value| value.to_str().unwrap())
.collect::<Vec<_>>(),
value.into_iter().collect::<Vec<_>>(),
"{document}, websocket={websocket}, {name}",
"{document}, websocket={websocket}, downstream={has_downstream_headers}, {name}",
);
}
}
+166 -6
View File
@@ -6,7 +6,7 @@
| 任务 | 入口 |
| --- | --- |
| 安装与启动 | [手动安装](#手动安装) · [启动](#启动) · [公网访问](#公网访问) |
| 安装与启动 | [手动安装](#手动安装) · [Windows 二进制部署](#windows-二进制部署) · [启动](#启动) · [公网访问](#公网访问) |
| 接入客户端 | [客户端配置](#客户端配置) · [时区与客户端环境信息](#时区与客户端环境信息) · [登录与生图排查](#登录与生图排查) |
| 日常运维 | [运行设置](#启动后的运行设置) · [小内存优化](#小内存优化) · [持久化与日志](#持久化与备份) · [请求排查](#请求错误排查) · [密码轮换](#密码语义) |
| 更新与恢复 | [镜像升级](#镜像升级与源码构建) · [在线更新](#管理端在线更新) · [备份与恢复](#备份与恢复) · [停止与重启](#停止与重启) |
@@ -19,7 +19,7 @@
| 位置 | 内容 | 修改方式 |
| --- | --- | --- |
| `deploy/config.yaml` | 监听、数据库/Redis 连接、部署凭据、时区、日志与可选内存分配策略等启动配置 | 从模板创建,已有部署合并修改,不覆盖原文件 |
| `deploy/compose.yaml` | 镜像、容器网络、端口、挂载与资源限制 | 调整 Compose 并重建受影响容器 |
| `deploy/compose.yaml` | 镜像、容器网络、端口、挂载、PostgreSQL 启动参数与资源限制 | 调整 Compose 并重建受影响容器 |
| PostgreSQL | 账号、Key、运行设置、上游身份、插件配置等业务数据 | 管理端或管理 API |
| 浏览器本地 | 主题和界面偏好 | 当前浏览器的主题设置 |
@@ -58,6 +58,21 @@ host:
### 小内存优化
1 GB 内存、网关与 PostgreSQL / Redis 同机、低并发部署可从本节参数开始评估。
这些值是调优起点,不是整机内存上限或容量保证;先记录实际负载下的内存峰值,优先缩小数据库连接池和缓冲,再按需调整 Redis 与应用空闲内存回收
| 调整对象 | 配置入口 | 生效方式 |
| --- | --- | --- |
| 应用空闲内存回收 | `config.yaml` → `services.app-runtime.environment.GLIBC_TUNABLES` | 重建应用容器 |
| 应用 PostgreSQL 连接池 | `config.yaml` → `store.pool.max_connections` | 重启应用;Compose 可按下文重建 |
| PostgreSQL 内存与连接预算 | `compose.yaml` → `services.postgres.command` | 重建 PostgreSQL 容器 |
| Redis 持久化与内存预算 | `config.yaml` → `services.redis-credentials.command` | 重建 Redis 容器 |
以下 YAML 片段合并到已有节点,不要重复创建顶层 `store` / `services`,也不要覆盖凭据、挂载和健康检查。
这些参数没有管理端热更新入口;二进制部署沿用 `store.pool`,PostgreSQL / Redis 参数则配置到各自服务的配置文件或启动命令,应用不会读取 `services` 桥接区来修改外部数据库
#### 应用空闲内存回收
Linux/glibc 部署可选择更积极地归还请求结束后的空闲内存,适合内存紧张且请求后 RSS 长时间偏高的机器。
该选项默认关闭,官方 Linux 镜像支持;更频繁的映射与回收可能增加 CPU 开销,应结合实际负载观察
@@ -71,7 +86,7 @@ services:
```
Compose 从 `services.app-runtime` 取得参数;不开启优化时保留该段并将值设为 `''`。
PostgreSQL 和 Redis 的凭据桥接配置与该段并列,无需在 `compose.yaml` 重复填写参数
PostgreSQL 和 Redis 的凭据桥接配置与该段并列,无需在 `compose.yaml` 重复填写 `GLIBC_TUNABLES`
校验并重建应用容器使配置生效;`docker compose restart` 不会更新容器环境变量:
@@ -94,6 +109,114 @@ GLIBC_TUNABLES=glibc.malloc.mmap_threshold=131072:glibc.malloc.trim_threshold=13
该变量由 glibc 在进程启动时读取,不支持管理端热更新。
它不设置内存上限,不释放仍被请求或连接持有的缓冲,也不处理容器文件缓存;观察时分别比较进程 RSS、cgroup 的 `anon` 与 `file`
#### PostgreSQL 与应用连接池
在 `deploy/config.yaml` 的 `store` 下设置连接池上限,未列出的池参数保留默认值:
```yaml
store:
pool:
max_connections: 5
```
应用默认上限为 20,允许的最小值为 2;降低上限可以限制实际数据库连接数,但也会增加请求、后台写入与管理查询之间的等待。
如果出现连接获取超时,应结合数据库负载调大池容量,不要仅延长等待时间
将 `deploy/compose.yaml` 中 `services.postgres.command` 替换为以下列表,保留 UTC 参数:
```yaml
command:
- postgres
- -c
- timezone=UTC
- -c
- log_timezone=UTC
- -c
- shared_buffers=64MB
- -c
- max_connections=30
- -c
- work_mem=2MB
- -c
- maintenance_work_mem=32MB
- -c
- autovacuum_work_mem=16MB
- -c
- max_parallel_workers_per_gather=0
```
`shared_buffers` 缩小共享缓冲;服务端 `max_connections` 限制整个数据库的连接数,应覆盖所有应用池,并为备份、维护和其他客户端留余量。
它与应用连接池上限是两个配置,调小服务端上限不会主动回收应用池里的空闲连接
`work_mem` 是每个排序或哈希操作的基础预算,哈希还受 `hash_mem_multiplier` 影响,一条 SQL 可以同时使用多份预算。
`maintenance_work_mem` 限制维护操作内存,`autovacuum_work_mem` 按自动清理进程分别计;保持自动清理开启。
关闭并行查询、缩小这些预算可能增加临时文件 I/O,并延长统计、索引创建与维护时间
参数定义见 [PostgreSQL 18 内存与并行查询](https://www.postgresql.org/docs/18/runtime-config-resource.html)和[连接设置](https://www.postgresql.org/docs/18/runtime-config-connection.html)
#### Redis 持久化与内存预算
项目默认开启 AOF,未显式设置 `maxmemory` 和定时 RDB 快照规则。
小内存部署可保留 AOF,关闭定时 RDB 快照以减少快照期间的 fork / 写时复制峰值;代价是少一份独立的 RDB 快照,AOF 重写仍需要预留额外内存
在已有 `deploy/config.yaml` 中替换 `services.redis-credentials.command`,复用同一文件内的密码锚点,保留该服务的 `healthcheck`:
```yaml
services:
redis-credentials:
command:
- redis-server
- --appendonly
- 'yes'
- --save
- ''
- --requirepass
- *redis_password
# 当前用量和负载峰值明显低于 64 MiB 时,再取消以下注释
# - --maxmemory
# - 64mb
# - --maxmemory-policy
# - noeviction
```
`--save` 后的空字符串必须保留,它表示关闭定时快照。持久化取舍见 [Redis 文档](https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/)
设置内存上限前先查看 `INFO memory` 的 `used_memory`、`used_memory_peak` 和 `used_memory_rss`,为业务增长、分配器和持久化峰值留余量。
`maxmemory 64mb` 只是可选起点,未达到上限时不会因此降低 RSS,也不是进程或容器的总内存上限。
达到上限后,`noeviction` 会拒绝需要增加内存的写入,可能影响登录与请求调度,应调大预算或减少负载
Redis 保存租约、冷却和登录会话等[协调状态](../docs/architecture.md#8-状态所有权),不要使用 `allkeys-lru`、`volatile-lru` 等提前淘汰键的策略。
预算与策略定义见 [Redis 内存限制](https://redis.io/docs/latest/develop/reference/eviction/)
#### 使配置生效与检查效果
只调整应用配置时,按[应用空闲内存回收](#应用空闲内存回收)中的命令重建应用容器。
调整 PostgreSQL / Redis 启动参数需要短暂停服,在维护窗口执行;只改其中一个服务时,数据库重建命令只列出该服务:
```bash
docker compose -f deploy/compose.yaml config --quiet
docker compose -f deploy/compose.yaml stop codex-proxy-rs
docker compose -f deploy/compose.yaml up -d --no-build --no-deps --force-recreate --wait postgres redis
docker compose -f deploy/compose.yaml up -d --no-build --no-deps --force-recreate --wait codex-proxy-rs
```
数据库健康检查通过后再执行最后一条命令;`restart` 不会更新容器启动命令或环境变量。
外部数据库需通过其管理方式应用参数,不能用本地 Compose 命令代替
通过交互提示输入对应数据库密码,核对实际生效值与内存占用,避免将密码写入命令行:
```bash
docker compose -f deploy/compose.yaml exec postgres psql -U codex_proxy -d codex_proxy -W \
-c 'SHOW shared_buffers; SHOW max_connections; SHOW work_mem; SHOW maintenance_work_mem; SHOW autovacuum_work_mem; SHOW max_parallel_workers_per_gather;'
docker compose -f deploy/compose.yaml exec redis redis-cli --askpass CONFIG GET appendonly save maxmemory maxmemory-policy
docker compose -f deploy/compose.yaml exec redis redis-cli --askpass INFO memory
docker compose -f deploy/compose.yaml stats --no-stream
```
在相同负载下比较空闲、请求峰值与后台统计 / 维护期间的占用,并检查实际模型请求、管理查询是否正常,有无连接获取超时、Redis OOM 或容器重启。
不要把 PostgreSQL 各进程的 RSS 直接相加,共享页会重复计数;容器文件缓存也应与匿名内存区分。
容器硬内存上限不能代替这些预算,过紧会导致 OOM;需要回退时恢复调整前的参数,按同样流程重建对应容器
## 部署结构
```mermaid
@@ -190,6 +313,42 @@ store:
`redis://u1@redis-host:6379/0`,其中主机名必须能从应用容器访问;只改 `config.yaml` 不会生效。
默认 Compose Redis 服务使用密码认证,不会自动创建 ACL 用户
## Windows 二进制部署
发行包提供 Windows x64(`windows_amd64`)和 ARM64(`windows_arm64`)版本,按运行机器的 CPU 架构选择。
需先准备可访问的 PostgreSQL 和 Redis,创建数据库及用户;使用数据库备份功能时,还需将匹配服务端版本的 `pg_dump` 加入 `PATH`
从 [Release 附件](https://github.com/zyycn/codex-proxy-rs/releases) 下载对应的
`codex-proxy-rs_<version>_windows_<arch>.tar.gz`,解压后保留 `codex-proxy-rs.exe`、`web/dist`、`deploy` 和 `plugins/official` 的目录关系。
在解压后的 `codex-proxy-rs` 目录中打开 PowerShell,首次安装时创建配置:
```powershell
Copy-Item .\deploy\config.example.yaml .\deploy\config.yaml
```
编辑 `deploy/config.yaml`,填写 `store.database`、`store.redis` 的连接地址和独立密码,以及 `admin.default_password`。
密码格式沿用模板要求;将已有的 `api.asset_directory` 改为 `../web/dist`,在 `host` 下合并以下更新与重启配置:
```yaml
host:
system_update:
deployment_mode: binary
self_restart_enabled: true
api:
asset_directory: '../web/dist'
```
从安装目录启动:
```powershell
.\codex-proxy-rs.exe serve
```
启动成功后访问 `http://127.0.0.1:8080`,使用配置中的管理员账号登录;在运行终端按 `Ctrl+C` 停止服务。
官方构建支持管理端[在线更新、重启与回滚](#管理端在线更新),安装目录与 `.runtime` 必须可写。
手动更换版本时先停止进程并备份当前发行文件,再整体替换 EXE、`web/dist` 和 `plugins/official`,
保留 `deploy/config.yaml` 与 `.runtime`,按新版本模板合并配置后重新启动
## 启动
```bash
@@ -233,8 +392,9 @@ PostgreSQL/Redis 启动密码。日常校验使用 `config --quiet`
位置覆盖只影响 OpenAI 请求中受支持的位置与时区信息,不改变上下文日期、真实出口 IP、epoch 时间戳或系统时区。
`openai.residency` 和 `openai.account_routing_override` 是独立于客户端身份的启动配置,
分别指定 `x-openai-internal-codex-residency` 和 `x-openai-account-routing-override` 请求头。
两项均为可选字符串,填写值原样发送,不校验地域或路由枚举;省略或设为 `null` 时删除对应的下游请求头且不补发。
示例配置均填写 `us`,修改后重启应用生效。身份与位置字段见 [运行设置 API](../docs/api.md#8-运行设置)
两项均为可选字符串,配置值原样发送并覆盖客户端同名头,支持自定义地域或路由值。
省略或设为 `null` 时删除对应请求头。
修改后重启应用生效。身份与位置字段见 [运行设置 API](../docs/api.md#8-运行设置)
后台账号操作使用 Provider 自己的官方画像;发布资料刷新不改写用户选择或 `config.yaml`
### CPR 互联
@@ -783,7 +943,7 @@ Release 必须提供当前 OS/架构的 `codex-proxy-rs_<version>_<os>_<arch>.ta
宿主范围或能力版本不匹配时,可在插件页确认风险后尝试本次启动;清单、RPC、平台或包完整性错误不能绕过。具体操作见[插件故障处理](../docs/plugins.md#故障处理)
部署须保留完整的二进制、Web 资源与官方插件目录;不要手动修改制品元数据或迁移 checksum 绕过校验。
macOS arm64 提供构建产物;部署前需验证目标平台的插件运行与上游调用,不能只以打包成功判断可用
macOS arm64、Windows x64 / ARM64 提供构建产物;插件安装包必须匹配宿主 OS 和架构,部署前需验证目标平台的插件运行与上游调用,不能只以打包成功判断可用
## 备份与恢复
+1
View File
@@ -13,6 +13,7 @@ services:
service: postgres-credentials
image: postgres:18-bookworm@sha256:bbf83594798e144bff365a9c191c5d779f181fb3135902064ab8ff11918020fc
restart: unless-stopped
# 小内存部署可在此调整数据库预算,保留 UTC 参数,详见 README.md#小内存优化
command:
- postgres
- -c
+2
View File
@@ -53,6 +53,7 @@ store:
# 与 PostgreSQL 共用密码格式要求,并满足 Redis 服务端密码策略
password: &redis_password ''
# 可选:PostgreSQL 连接池预算;下列值与当前推荐默认一致。
# 小内存低并发部署的池上限与数据库调优见 deploy/README.md#小内存优化
# pool:
# max_connections: 20
# acquire_timeout_seconds: 5
@@ -104,6 +105,7 @@ services:
POSTGRES_PASSWORD: *postgres_password
redis-credentials:
# 可选的持久化与内存预算见 deploy/README.md#小内存优化,调整时保留密码参数
command:
- redis-server
- --appendonly
+20 -9
View File
@@ -395,10 +395,11 @@ API Key 与 OAuth 共用模拟客户端画像(`User-Agent`、`originator`、`v
包括会话、线程、Lite 和其他业务扩展头。
上游认证只来自选中的账号;API Key 不携带 OAuth Cookie、ChatGPT 账号身份或下游的
`X-OpenAI-Actor-Authorization` 托管认证声明。
下游的 `x-openai-internal-codex-residency`、`x-openai-account-routing-override`、`x-openai-fedramp`
也不透传,地域、工作区路由与合规属性不能从原账号继承。
前两项仅按部署配置 `openai.residency`、`openai.account_routing_override` 的字符串值生成,
省略或 `null` 时不发送,配置说明见 [部署与运维](../deploy/README.md#启动后的运行设置)
`x-codex-installation-id` 使用选中账号已生成并持久化的安装 ID,与正文 `client_metadata` 中的安装身份一致。
`x-openai-internal-codex-residency` 和 `x-openai-account-routing-override` 分别由部署配置
`openai.residency`、`openai.account_routing_override` 控制,配置值原样发送并覆盖客户端同名头,
省略或 `null` 时删除对应请求头。配置说明见 [部署与运维](../deploy/README.md#启动后的运行设置)
`x-openai-fedramp` 按业务头透传,保留原始多值。
上述过滤描述原生转发;已信任插件的显式 header 改写可以覆盖账号与画像生成的默认头,
不按认证或会话字段名称拦截,边界见 [SDK 请求中间件](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#模型请求与-attempt)
@@ -409,8 +410,8 @@ Responses 也不透传 `x-stainless-*`、`Origin`、`Referer`、`sec-ch-ua*` 和
两者同时存在时仍优先使用 `session-id`。正文中的 `client_metadata.session_id`、
`prompt_cache_key` 不受这条请求头规则影响。
`thread-id`、turn metadata 等 Codex 协议字段及未知业务扩展不受下游环境头过滤规则影响;
出站移除下游的 `traceparent`、`tracestate`、`sentry-trace`、`baggage`,
以及 WS metadata 中对应的 `ws_request_header_*` 副本
`traceparent`、`tracestate`、`sentry-trace`、`baggage` 及 WS metadata 中对应的
`ws_request_header_*` 副本保持原值
会话、线程、窗口及父子线程引用在出站时按本次安装身份稳定映射为 UUID;
默认使用账号安装身份,同账号保留引用关系,换账号使用不同标识。HTTP 请求头、正文 metadata、WS 副本及
@@ -425,6 +426,11 @@ Images 的 `x-codex-image-turn-id` 和 Desktop 自动 Git 上下文的 `clientUs
续接 response ID、历史 item/call ID、业务正文和未知扩展不参与映射,
该处理减少显式追踪标识的跨账号复用,不保证会话内容或业务 metadata 无法关联
`x-codex-turn-state` 是上游返回的轮次路由 token,同一轮内应原样回传,开始新轮次时清除。
全量请求没有 `previous_response_id` 时,代理也会保留已确认属于当前 Client Key、账号、会话、线程和轮次的回传值;
未知归属、跨账号或跨轮次的值仍会清除。该证明只用于 turn state,不使其他账号状态字段成为可信续接上下文。
归属证明为进程内有界缓存,实例切换、重启、过期或淘汰后,缺少其他可信会话状态的请求按未知归属处理
#### 正文兼容
OpenAI Provider 的 Responses 上游编码会移除 Codex 不接受的顶层 `temperature`、`max_output_tokens` 和
@@ -911,7 +917,7 @@ CPR 默认路径为 `/v1/cpr/link`,使用同一数据面入口认证,不接
引导、sideband 与 hangup 的业务扩展头默认透传;认证、账号身份、下游环境与传输头遵守
[请求头与身份](#请求头与身份)边界。`X-Session-Id`、`Voice-Session-Id` 是 Live 业务会话标识,原样透传。
`Session-Id`、`Thread-Id` 等已识别追踪标识按选中账号的安装身份稳定映射,同账号各阶段保持一致,
换账号使用不同标识;客户端 trace 被剥离。创建请求可由 attempt 插件改写本次构造身份,后续 sideband / hangup 仍使用钉住账号的身份。
换账号使用不同标识,客户端 trace 原样透传。创建请求可由 attempt 插件改写本次构造身份,后续 sideband / hangup 仍使用钉住账号的身份。
Live 不继承 Responses 的路由提示与续接状态;turn metadata 中的原账号绑定信息清理后保留其他字段。
真实 call ID 保留用于钉住账号访问通话。HTTP 业务头保留原始多值字节,sideband 握手沿用 WebSocket 可编码的头值
@@ -2511,10 +2517,14 @@ Dashboard 返回原始 `asOf` 及 `asOfDisplay`,作为当前数据的查询锚
管理端请求列表及 Dashboard 最近请求中的 `accountNotes` 来自请求保存的账号备注快照,未保存时为 `null`
请求列表、Dashboard 最近请求及账号维度诊断的 `accountUpstreamPeer` 来自账号当前的互联摘要,
已发现且有观测时间时为 `true`,尚未识别成功时为 `false`;摘要缺失、账号已删除或读取失败时为 `null`。
该字段仅供 CPR 标识展示,不写入历史快照,非账号维度诊断为 `null`
`/api/admin/usage/records` 与 `/api/admin/operations/errors` 的 `clientApiKeyName` 来自请求保存的 Key 名称快照,
未保存时为 `null`,不影响记录返回,不包含密钥原文
查询不关联当前配置,改名、修改订阅或删除配置不改变已有快照。补写的历史 Key 名称与订阅取自补写时仍存在的配置,
上述快照字段不关联当前配置,改名、修改订阅或删除配置不改变已有快照。补写的历史 Key 名称与订阅取自补写时仍存在的配置,
不保证等于请求发生时的值;无法补齐的字段保持未知
运维错误记录的 `errorDetails` 为受控诊断文本,无详情时为 `null`。
@@ -2925,7 +2935,8 @@ GitHub 的 `location` 使用 `kind: "github"`、`repository: "owner/repo"`、`ta
创建与更新的配置字段为 `name`、`artifactSha256`、`enabled`、`configuration` 和必填的 `bindings`,
`secrets` 可选。输入严格拒绝未知字段。`artifactSha256` 必须指向
已接受的制品。`configuration` 是匹配插件 schema 的 JSON 对象;敏感字段必须
放入 `secrets`,空对象清除全部值。省略时同版本编辑保留当前值;跨版本编辑优先保留目标版本快照的密钥,没有快照则保留当前值
放入 `secrets`,按字段更新:字符串设置对应值,`null` 清除对应字段,未出现的字段保留。省略 `secrets` 或传空对象均不修改密钥
同版本编辑以当前值为基础;跨版本编辑优先以目标版本快照的密钥为基础,没有快照则使用当前值。创建以空集合为基础,密钥值不会返回给管理端
创建可传 `creationId`(标准小写 UUID),同一草稿重试复用该 ID,已保存且内容不同则返回 409,不创建副本或覆盖旧配置。
相同内容的重试仍会重新准备并发布,不保证配置 revision 不变。更新可传 `expectedRevision`,与实例列表的 `revision`
+9 -1
View File
@@ -503,6 +503,11 @@ OpenAI Provider 按 Client Key、所选 OAuth 账号和父响应 ID 关联 WS
缺失或失效时按普通上游路由执行。transport 负责 Cookie 有效期和同 origin、同端点路径约束,
连接池按快照身份隔离审查连接;复用父连接不会重新计算 Cookie 有效期,Cookie 到期不强制关闭已建立的审查连接
OpenAI Provider 在交付响应头或 WS metadata 前记录 turn state 的归属证明,包括失败响应。
证明按 Client Key、账号、客户端会话、线程和轮次隔离,API Key 额外绑定凭据版本;只保存摘要与过期时间,
不保存 token 原值或正文。进程内最多 4096 项,保留时长复用请求的会话亲和策略,已验证回传与上游观测续期;
满额时清理过期项并淘汰最早过期项。证明缺失时保留既有未知归属处理,不参与选号或授权其他账号状态复用
OpenAI 的 OAuth 与 API Key 共用现有账号和事务。API Key 的 Base URL、密钥和传输策略属于 Provider 凭据 JSON,
随 credential revision 更新;普通详情只投影非敏感连接设置。API Key 客户端目录按账号、凭据版本和客户端版本隔离,
标准 `data` 模型列表通过通用画像输出,完整 Codex `models` 目录保留原生对象。通用账号层按 Provider 提交的
@@ -515,7 +520,7 @@ OpenAI 模型目录用于发现与原生 Responses 账号资格筛选,OAuth
- OpenAI 是透明边界。正文兼容、身份归一和显式请求策略之外的业务字段保留原始语义;
业务响应按原始字节转发,客户端错误兼容与原生续写额度恢复按明确例外处理,见 [OpenAI 透传](api.md#openai-透传)。
`transport/correlation` 在出站副本上按本次安装身份统一映射关联标识并剥离客户端 trace;
`transport/correlation` 在出站副本上按本次安装身份统一映射关联标识,客户端 trace 原样保留。
原生安装身份取自账号;插件的身份生成、持久化及关联字段改写通过既有中间件链路自行完成。
内部选号、亲和、连接池与本地诊断继续读取原始身份;具体字段边界见 [API 协议](api.md#请求头与身份)
canonical facts 从同一数据旁路提取,用于路由、恢复判断、观测和计费。
@@ -584,6 +589,8 @@ client,OIDC 的 JWKS 缓存与单飞归属对应出口状态。自动刷新提
代理独立保存和测试,通过 `outboundProxyId` 绑定账号;账号保留解析后的 URL 供 Provider 使用。
出口测试结果属于诊断信息,不作为选择、授权或导入绑定的准入条件。
双栈出口并发检测,延迟对应首选可用出口的 HTTP 探测耗时(IPv4 优先),不包含另一地址族失败的等待;
检测仍等待两个地址族完成或超时,以保留双栈可用性结果。两者均失败时,延迟为整体检测耗时。
代理启停由代理记录独立持有;关闭时保留绑定、连接配置和位置规则,账号执行投影改为直连,
不使用该代理的位置覆盖,重新启用后恢复原出口。
连接配置改变时在同一事务内同步关联账号并清除旧测试结果。
@@ -1239,6 +1246,7 @@ Compose 的 `stop_grace_period` 必须覆盖后台关闭预算并留出进程清
下载阶段不以插件合同不兼容阻止安装。重启前由 Admin 检查已安装发行与启用插件,返回绑定发行摘要和配置版本的确认快照。
Host 在重启操作锁内重新检查,Admin 复核风险确认与配置版本,不改写插件配置;重启后 Runtime 逐实例加载并隔离失败。
重启受理后保持排他状态与跨进程文件锁,直到旧服务释放;延迟触发关闭仅用于送达受理响应。
Windows 替换进程通过交接管道等待旧服务及更新锁释放后才继续启动;旧进程异常退出同样会关闭管道。
更新与回滚在文件交换前后复核同一插件配置版本,失败或取消时恢复二进制、Web 与官方插件目录
Host 在受理时持久化任务并转交后台执行,任务持有操作锁与终态写入责任,不依赖 HTTP 请求的生命周期。
+2 -1
View File
@@ -48,7 +48,8 @@ flowchart LR
在插件详情中直接启用或停用,设置会保留,无需重新填写。插件没有可设置的参数或认证映射时不显示设置入口
“设置”按当前包的 schema 显示参数,敏感字段单独保存且不回显。首次补齐必填项后“保存并启用”;其他设置修改保持
“设置”按当前包的 schema 显示参数,敏感字段单独保存且不回显。每项显示保存状态,留空保留现有值,填写新值替换该项;
“清除”只删除对应字段,保存前可撤销。首次补齐必填项后“保存并启用”;其他设置修改保持
原有启停状态。请求处理能力随包自动注册,无需在安装时逐项勾选;可在“高级设置”中调整各挂载阶段的启停、顺序和失败处理。
模型请求阶段支持 Key、分组、模型等作用范围;HTTP、WebSocket 消息和服务调用全局生效,具体匹配由插件处理。
客户端 Key、账号分组、Provider 和模型等范围同时满足才生效,同一范围内任一值匹配即可
+1 -1
View File
@@ -266,7 +266,7 @@ export interface ConfigurePluginInstanceRequest {
artifactSha256: string
enabled: boolean
configuration: Record<string, unknown>
secrets?: Record<string, string>
secrets?: Record<string, string | null>
bindings: PluginCapabilityBinding[]
}
+2
View File
@@ -94,6 +94,7 @@ export interface UsageListRecord {
accountNotes: string | null
accountPlanType: string | null
accountPlanTypeDisplay: string | null
accountUpstreamPeer: boolean | null
route: string
model: string | null
requestedModel: string | null
@@ -364,6 +365,7 @@ export interface UsageDiagnosticItem {
authenticationKind: string | null
accountPlanType: string | null
accountPlanTypeDisplay: string | null
accountUpstreamPeer: boolean | null
requestCount: number
successCount: number
errorCount: number
@@ -11,6 +11,7 @@ const props = withDefaults(
planType?: string | null
authenticationKind?: string
upstream?: AccountUpstream | null
upstreamPeer?: boolean | null
planTypeDisplay: string
size?: 'xs' | 'sm' | 'md'
}>(),
@@ -61,7 +62,7 @@ const paletteClass = computed(() => {
</script>
<template>
<AccountUpstreamBadge v-if="upstream?.discovered && upstream.observedAt" :size="size" />
<AccountUpstreamBadge v-if="upstreamPeer || (upstream?.discovered && upstream.observedAt)" :size="size" />
<span
v-else-if="!accountUpstreamPending(upstream)"
class="inline-flex min-w-0 max-w-full items-center justify-center whitespace-nowrap leading-none shadow-z-tertiary"
@@ -128,8 +128,8 @@ defineExpose({ focusField })
<ZForm v-if="showJson">
<ZFormItem :label="t('common.form.inputPlaceholder')" :error="error">
<template #extra>
<ZIconButton v-if="fields !== null" :aria-label="t('common.form.useForm')" size="small" variant="solid" :disabled="disabled || Boolean(error) || hasExtra || incompatibleValue" @click="jsonMode = false">
<List class="size-4" />
<ZIconButton v-if="fields !== null" :aria-label="t('common.form.useForm')" class="size-4!" variant="link" :disabled="disabled || Boolean(error) || hasExtra || incompatibleValue" @click="jsonMode = false">
<List class="size-3.5" />
</ZIconButton>
</template>
<ZTextarea v-model="text" :rows="8" :disabled="disabled" spellcheck="false" autocomplete="off" data-schema-json-control />
@@ -156,8 +156,8 @@ defineExpose({ focusField })
</ZPopover>
</template>
<template v-if="index === 0" #extra>
<ZIconButton :aria-label="t('common.form.editJson')" size="small" variant="solid" :disabled="disabled" @click="jsonMode = true">
<Braces class="size-4" />
<ZIconButton :aria-label="t('common.form.editJson')" class="size-4!" variant="link" :disabled="disabled" @click="jsonMode = true">
<Braces class="size-3.5" />
</ZIconButton>
</template>
<ZSelect
@@ -79,8 +79,9 @@ const { t } = useI18n({ useScope: 'global' })
<template #accountPlanType="{ row }">
<AccountPlanBadge
v-if="row.authenticationKind === 'api_key' || row.accountPlanType"
v-if="row.accountUpstreamPeer || row.authenticationKind === 'api_key' || row.accountPlanType"
:authentication-kind="row.authenticationKind ?? undefined"
:upstream-peer="row.accountUpstreamPeer"
:plan-type="row.accountPlanType"
:plan-type-display="row.accountPlanTypeDisplay || row.accountPlanType || ''"
size="sm"
+8 -12
View File
@@ -51,27 +51,20 @@
},
"configuration": {
"fillInRequiredConfiguration": "Please fill in the required configuration",
"incompatibleSecrets": "The target version does not support some saved secrets, choose to replace or clear them",
"requiredSecretsCannotClear": "Required secrets cannot be cleared: {labels}",
"requiredSecretsMissing": "No saved values for required secrets: {labels}",
"incompatibleSecrets": "Clear the secrets no longer used by this version",
"secretsRequired": "Enter the required secrets",
"checkBasicSettings": "Please check basic settings",
"noParameters": "No plugin parameters required",
"sensitiveConfiguration": "Sensitive configuration",
"secretsHelp": "Saved tokens, keys and other private values are never shown",
"settingsSaved": "Settings saved: {count}",
"emptySecretPlaceholder": "Leave blank to clear this value",
"emptySecretPlaceholder": "Saved, leave blank to keep",
"about": "About {name}",
"enterNewValue": "Enter new value",
"enterNewValue": "Not set, enter a new value",
"pluginParameters": "Parameters",
"advancedSettings": "Requests",
"clientAuthentication": "Client auth",
"applyAndSwitch": "Apply and switch",
"saveAndEnable": "Save and enable",
"saveSettings": "Save",
"keepExistingValues": "Keep existing",
"replaceAllValues": "Replace all",
"clearAllValues": "Clear all",
"checkPluginParameters": "Please check plugin parameters",
"setRequestScopeOrCheckApplyTo": "Please set the request scope, or check \"Apply to all requests\"",
"invalidIdentityMappings": "Check the client authentication identity mappings",
@@ -79,7 +72,6 @@
"pluginSettings": "Plugin settings",
"unchangedVersion": "The current version is unchanged, edit settings before applying",
"pluginSettingsPartition": "Plugin settings partition",
"secretsModeLabel": "Secret storage action",
"customScope": "Custom scope",
"applyGlobally": "Apply globally",
"requestHandling": "Request handling: {value}",
@@ -104,7 +96,11 @@
"deleteHistoricalConfiguration": "Delete historical configuration"
},
"stale": "Plugin settings changed, refresh and try again",
"incompatible": "Adjust incompatible settings and try again"
"incompatible": "Adjust incompatible settings and try again",
"secretPendingClear": "Will be cleared on save",
"clearSecret": "Clear {name}",
"undoClearSecret": "Undo clearing {name}",
"secretNotDeclared": "No longer used, clear this value"
},
"details": {
"version": "Version",
+8 -12
View File
@@ -51,27 +51,20 @@
},
"configuration": {
"fillInRequiredConfiguration": "请填写必填配置",
"incompatibleSecrets": "目标版本不支持部分已保存的敏感配置,请选择替换或清除",
"requiredSecretsCannotClear": "必填敏感配置不能清除:{labels}",
"requiredSecretsMissing": "没有可保留的必填敏感配置:{labels}",
"incompatibleSecrets": "请清除当前版本不再使用的敏感配置",
"secretsRequired": "请填写必填敏感配置",
"checkBasicSettings": "请检查基本设置",
"noParameters": "无需填写插件参数",
"sensitiveConfiguration": "敏感配置",
"secretsHelp": "用于令牌、密钥等机密参数,已保存的值不会回显",
"settingsSaved": "已保存 {count} 项",
"emptySecretPlaceholder": "留空将清除此项",
"emptySecretPlaceholder": "已保存,留空保留现有值",
"about": "{name}说明",
"enterNewValue": "输入新值",
"enterNewValue": "未设置,输入新值",
"pluginParameters": "插件参数",
"advancedSettings": "高级设置",
"clientAuthentication": "客户端认证",
"applyAndSwitch": "应用并切换",
"saveAndEnable": "保存并启用",
"saveSettings": "保存设置",
"keepExistingValues": "保留现有值",
"replaceAllValues": "替换全部",
"clearAllValues": "清除全部",
"checkPluginParameters": "请检查插件参数",
"setRequestScopeOrCheckApplyTo": "请设置请求范围,或勾选“应用于所有请求”",
"invalidIdentityMappings": "请检查客户端认证身份映射",
@@ -79,7 +72,6 @@
"pluginSettings": "插件设置",
"unchangedVersion": "当前版本保持不变,修改后再应用",
"pluginSettingsPartition": "插件设置分区",
"secretsModeLabel": "敏感配置保存方式",
"customScope": "自定义范围",
"applyGlobally": "全局生效",
"requestHandling": "请求处理:{value}",
@@ -104,7 +96,11 @@
"deleteHistoricalConfiguration": "删除历史配置"
},
"stale": "插件设置已变更,请刷新后重试",
"incompatible": "请调整不兼容的设置后重试"
"incompatible": "请调整不兼容的设置后重试",
"secretPendingClear": "保存后清除此项",
"clearSecret": "清除{name}",
"undoClearSecret": "撤销清除{name}",
"secretNotDeclared": "当前版本不再使用,请清除"
},
"details": {
"version": "版本",
+8 -12
View File
@@ -51,27 +51,20 @@
},
"configuration": {
"fillInRequiredConfiguration": "請填寫必填配置",
"incompatibleSecrets": "目標版本不支持部分已保存的敏感配置,請選擇替換或清除",
"requiredSecretsCannotClear": "必填敏感配置不能清除:{labels}",
"requiredSecretsMissing": "沒有可保留的必填敏感配置:{labels}",
"incompatibleSecrets": "請清除當前版本不再使用的敏感配置",
"secretsRequired": "請填寫必填敏感配置",
"checkBasicSettings": "請檢查基本設置",
"noParameters": "無需填寫插件參數",
"sensitiveConfiguration": "敏感配置",
"secretsHelp": "用於令牌、密鑰等機密參數,已保存的值不會回顯",
"settingsSaved": "已保存 {count} 項",
"emptySecretPlaceholder": "留空將清除此項",
"emptySecretPlaceholder": "已保存,留空保留現有值",
"about": "{name}說明",
"enterNewValue": "輸入新值",
"enterNewValue": "未設置,輸入新值",
"pluginParameters": "插件參數",
"advancedSettings": "高級設置",
"clientAuthentication": "客戶端認證",
"applyAndSwitch": "應用並切換",
"saveAndEnable": "保存並啓用",
"saveSettings": "保存設置",
"keepExistingValues": "保留現有值",
"replaceAllValues": "替換全部",
"clearAllValues": "清除全部",
"checkPluginParameters": "請檢查插件參數",
"setRequestScopeOrCheckApplyTo": "請設置請求範圍,或勾選“應用於所有請求”",
"invalidIdentityMappings": "請檢查客戶端認證身份映射",
@@ -79,7 +72,6 @@
"pluginSettings": "插件設置",
"unchangedVersion": "當前版本保持不變,修改後再應用",
"pluginSettingsPartition": "插件設置分區",
"secretsModeLabel": "敏感配置保存方式",
"customScope": "自定義範圍",
"applyGlobally": "全局生效",
"requestHandling": "請求處理:{value}",
@@ -104,7 +96,11 @@
"deleteHistoricalConfiguration": "刪除歷史配置"
},
"stale": "插件設置已變更,請刷新後重試",
"incompatible": "請調整不兼容的設置後重試"
"incompatible": "請調整不兼容的設置後重試",
"secretPendingClear": "保存後清除此項",
"clearSecret": "清除{name}",
"undoClearSecret": "撤銷清除{name}",
"secretNotDeclared": "當前版本不再使用,請清除"
},
"details": {
"version": "版本",
@@ -1,7 +1,8 @@
<script setup lang="ts">
import type { JsonSchema } from '../utils/model'
import { ZEmpty, ZFormItem, ZInput, ZSegmented, ZTag } from '@codex-proxy/ui'
import { ZEmpty, ZFormItem, ZIconButton, ZInput } from '@codex-proxy/ui'
import { RotateCcw, X } from '@lucide/vue'
import { computed, nextTick, shallowRef, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -28,8 +29,7 @@ const emit = defineEmits<{
const { t } = useI18n({ useScope: 'global' })
const configuration = defineModel<Record<string, unknown>>('configuration', { required: true })
const secrets = defineModel<Record<string, string>>('secrets', { required: true })
const secretMode = defineModel<'preserve' | 'replace' | 'clear'>('secretMode', { required: true })
const secrets = defineModel<Record<string, string | null>>('secrets', { required: true })
const configurationText = shallowRef('{}')
const schemaForm = useTemplateRef<{ focusField: (key?: string) => Promise<boolean> }>('schemaForm')
const secretFieldsRoot = useTemplateRef<HTMLElement>('secretFieldsRoot')
@@ -49,30 +49,19 @@ const configurationSchema = computed(() => {
const secretSchemas = computed(() => {
const properties = (props.schema as JsonSchema).properties ?? {}
const required = (props.schema as JsonSchema).required ?? []
return props.secretFields.map(name => ({
return [
...new Set([
...props.secretFields,
...props.existingSecretFields,
]),
].map(name => ({
name,
schema: properties[name] ?? { type: 'string' },
required: required.includes(name),
required: props.secretFields.includes(name) && required.includes(name),
declared: props.secretFields.includes(name),
saved: props.existingSecretFields.includes(name),
}))
})
const secretModes = computed(() => ([
{
label: t('plugins.configuration.keepExistingValues'),
value: 'preserve',
},
{
label: t('plugins.configuration.replaceAllValues'),
value: 'replace',
},
{
label: t('plugins.configuration.clearAllValues'),
value: 'clear',
},
]))
const existingSecretLabels = computed(() => props.existingSecretFields.map(name => ({
name,
label: (props.schema as JsonSchema).properties?.[name]?.title || name,
})))
const requiredConfigurationFields = computed(() => (configurationSchema.value as JsonSchema).required ?? [])
const missingConfigurationFields = computed(() => requiredConfigurationFields.value.filter(name =>
!Object.hasOwn(configuration.value, name),
@@ -84,23 +73,46 @@ const configurationFieldErrors = computed(() => Object.fromEntries(
]),
))
const requiredSecretFields = computed(() => secretSchemas.value.filter(field => field.required))
const missingSecretFields = computed(() => requiredSecretFields.value.filter((field) => {
if (secretMode.value === 'clear')
return true
if (secretMode.value === 'preserve')
return !props.existingSecretFields.includes(field.name)
return !valuePresent(secrets.value[field.name])
}))
const secretModeError = computed(() => {
if (secretMode.value === 'preserve' && props.existingSecretFields.some(name => !props.secretFields.includes(name)))
return t('plugins.configuration.incompatibleSecrets')
if (!missingSecretFields.value.length || secretMode.value === 'replace')
return ''
const labels = missingSecretFields.value.map(field => field.schema.title || field.name).join('、')
return secretMode.value === 'clear'
? t('plugins.configuration.requiredSecretsCannotClear', { labels })
: t('plugins.configuration.requiredSecretsMissing', { labels })
})
const missingSecretFields = computed(() => requiredSecretFields.value.filter(field => !secretPresent(field.name)))
const incompatibleSecretFields = computed(() => secretSchemas.value.filter(field => !field.declared && secretPresent(field.name)))
const secretError = computed(() => incompatibleSecretFields.value.length
? t('plugins.configuration.incompatibleSecrets')
: '')
function secretChange(name: string) {
return Object.hasOwn(secrets.value, name)
? secrets.value[name]
: undefined
}
function secretPresent(name: string) {
const change = secretChange(name)
return change === undefined
? props.existingSecretFields.includes(name)
: valuePresent(change)
}
function secretPlaceholder(name: string) {
if (secretChange(name) === null)
return t('plugins.configuration.secretPendingClear')
if (!props.secretFields.includes(name))
return t('plugins.configuration.secretNotDeclared')
return props.existingSecretFields.includes(name)
? t('plugins.configuration.emptySecretPlaceholder')
: t('plugins.configuration.enterNewValue')
}
function toggleSecretClear(name: string) {
if (secretChange(name) === null) {
const next = { ...secrets.value }
delete next[name]
secrets.value = next
}
else {
secrets.value = { ...secrets.value, [name]: null }
}
}
const hasConfiguration = computed(() => {
const properties = (configurationSchema.value as JsonSchema).properties
return Boolean(properties && Object.keys(properties).length > 0)
@@ -126,10 +138,10 @@ function valuePresent(value: unknown) {
}
const valid = computed(() => !configurationError.value
&& !secretModeError.value
&& !secretError.value
&& !missingConfigurationFields.value.length
&& !missingSecretFields.value.length)
const validationMessage = computed(() => secretModeError.value
const validationMessage = computed(() => secretError.value
|| configurationError.value
|| (missingSecretFields.value.length
? t('plugins.configuration.secretsRequired')
@@ -153,8 +165,6 @@ watch(parsedConfiguration, ({ value }) => {
})
async function focusInvalid() {
if (secretModeError.value)
return 'secretMode' as const
if (configurationError.value) {
await schemaForm.value?.focusField()
return 'configuration' as const
@@ -163,18 +173,15 @@ async function focusInvalid() {
await schemaForm.value?.focusField(missingConfigurationFields.value[0])
return 'configuration' as const
}
if (!missingSecretFields.value.length)
const missingName = missingSecretFields.value[0]?.name ?? incompatibleSecretFields.value[0]?.name
if (!missingName)
return null
if (secretMode.value !== 'replace')
return 'secretMode' as const
await nextTick()
const missingName = missingSecretFields.value[0]?.name
const container = [
...(secretFieldsRoot.value?.querySelectorAll<HTMLElement>('[data-secret-field]') ?? []),
]
.find(element => element.dataset.secretField === missingName)
container?.querySelector<HTMLElement>('input:not([type="hidden"]), textarea, button, [tabindex]:not([tabindex="-1"])')?.focus()
container?.querySelector<HTMLInputElement>('input')?.focus()
return 'secret' as const
}
@@ -200,35 +207,14 @@ defineExpose({ focusInvalid, validationMessage })
:field-errors="configurationFieldErrors"
/>
<section v-if="hasSecrets" class="grid gap-3 rounded-z bg-z-fill-alter p-4" :aria-label="t('plugins.configuration.sensitiveConfiguration')">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<strong class="text-z leading-none text-z-text">{{ t('plugins.configuration.sensitiveConfiguration') }}</strong>
<ZTag v-if="existingSecretFields.length > 0" type="success" size="small">
{{ t('plugins.configuration.settingsSaved', { count: existingSecretFields.length }) }}
</ZTag>
</div>
<p class="m-0 text-z-xs leading-relaxed text-z-text-secondary">
{{ t('plugins.configuration.secretsHelp') }}
<section v-if="hasSecrets" class="grid gap-4 rounded-z bg-z-fill-alter p-4" :aria-label="t('plugins.configuration.sensitiveConfiguration')">
<h3 class="m-0 text-z-lg leading-none font-heavy text-z-text">
{{ t('plugins.configuration.sensitiveConfiguration') }}
</h3>
<p v-if="secretError" role="alert" class="m-0 text-z-xs leading-relaxed text-z-warning-text">
{{ secretError }}
</p>
<ZSegmented
v-if="existingSecretFields.length"
v-model="secretMode"
:options="secretModes"
:aria-label="t('plugins.configuration.secretsModeLabel')"
:disabled="disabled"
class="w-fit max-w-full"
/>
<p v-if="secretModeError" role="alert" class="m-0 text-z-xs leading-relaxed text-z-warning-text">
{{ secretModeError }}
</p>
<ul v-if="secretMode === 'preserve'" class="m-0 flex list-none flex-wrap gap-2 p-0">
<li v-for="field in existingSecretLabels" :key="field.name" class="min-w-0 wrap-anywhere">
<ZTag type="primary">
{{ field.label }}
</ZTag>
</li>
</ul>
<div v-if="secretMode === 'replace'" ref="secretFieldsRoot" class="grid items-end gap-4 sm:grid-cols-2">
<div ref="secretFieldsRoot" class="grid items-start gap-4 sm:grid-cols-2">
<ZFormItem
v-for="field in secretSchemas"
:key="field.name"
@@ -242,15 +228,33 @@ defineExpose({ focusInvalid, validationMessage })
</PluginHelpPopover>
</template>
<ZInput
:model-value="secrets[field.name] ?? ''"
:model-value="secretChange(field.name) ?? ''"
type="password"
autocomplete="new-password"
clearable
:disabled="disabled"
:readonly="!field.declared"
:aria-label="field.schema.title || field.name"
:aria-invalid="field.required && !valuePresent(secrets[field.name])"
:placeholder="existingSecretFields.includes(field.name) ? t('plugins.configuration.emptySecretPlaceholder') : t('plugins.configuration.enterNewValue')"
:aria-invalid="field.required && !secretPresent(field.name)"
:placeholder="secretPlaceholder(field.name)"
@update:model-value="setSecret(field.name, $event)"
/>
>
<template v-if="field.saved && !secretChange(field.name)" #suffix>
<ZIconButton
variant="link"
class="size-4!"
:disabled="disabled"
:aria-label="secretChange(field.name) === null ? t('plugins.configuration.undoClearSecret', { name: field.schema.title || field.name }) : t('plugins.configuration.clearSecret', { name: field.schema.title || field.name })"
@click="toggleSecretClear(field.name)"
>
<RotateCcw v-if="secretChange(field.name) === null" class="size-3.5" />
<X v-else class="size-3.5" />
</ZIconButton>
</template>
</ZInput>
<p v-if="field.required && !secretPresent(field.name)" role="alert" class="mt-1 text-z-xs text-z-warning-text">
{{ t('plugins.configuration.secretsRequired') }}
</p>
</ZFormItem>
</div>
</section>
@@ -2,7 +2,7 @@
import type { ConfigurePluginInstanceRequest, PluginArtifact, PluginInstance, PluginVersionPlan } from '@/api'
import { ZButton, ZDialog, ZSegmented } from '@codex-proxy/ui'
import { Blocks, Save, Settings2, ShieldCheck } from '@lucide/vue'
import { Blocks, Settings2, ShieldCheck } from '@lucide/vue'
import { cloneDeep } from 'es-toolkit'
import { computed, nextTick, ref, shallowRef, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -29,8 +29,7 @@ const section = shallowRef('general')
const configurationValid = shallowRef(true)
const authenticationValid = shallowRef(true)
const bindingsValid = shallowRef(true)
const secretMode = shallowRef<'preserve' | 'replace' | 'clear'>('preserve')
const secretValues = ref<Record<string, string>>({})
const secretValues = ref<Record<string, string | null>>({})
const configuration = ref<Record<string, unknown>>({})
const bindings = ref<ConfigurePluginInstanceRequest['bindings']>([])
const configurationFields = useTemplateRef<{ focusInvalid: () => Promise<unknown>, validationMessage: string }>('configurationFields')
@@ -117,11 +116,8 @@ async function submit() {
configuration: cloneDeep(configuration.value),
bindings: cloneDeep(bindings.value),
}
if (secretMode.value !== 'preserve') {
input.secrets = secretMode.value === 'clear'
? {}
: cloneDeep(secretValues.value)
}
if (Object.keys(secretValues.value).length)
input.secrets = cloneDeep(secretValues.value)
emit('save', input)
}
@@ -131,7 +127,6 @@ watch(open, async (value) => {
secretValues.value = {}
configuration.value = cloneDeep(props.draft?.configuration ?? props.instance?.configuration ?? {})
bindings.value = cloneDeep(props.draft?.bindings ?? props.instance?.bindings ?? [])
secretMode.value = existingSecretFields.value.length ? 'preserve' : 'replace'
configurationValid.value = true
authenticationValid.value = true
bindingsValid.value = true
@@ -158,7 +153,6 @@ function clearSecrets() {
ref="configurationFields"
v-model:configuration="configuration"
v-model:secrets="secretValues"
v-model:secret-mode="secretMode"
:schema="artifact.metadata.configurationSchema"
:secret-fields="artifact.metadata.secretFields"
:existing-secret-fields="existingSecretFields"
@@ -192,9 +186,6 @@ function clearSecrets() {
{{ t('common.actions.cancel') }}
</ZButton>
<ZButton type="primary" :loading="saving" :disabled="!instance || !artifact" @click="submit">
<template #icon>
<Save class="size-4" />
</template>
{{ primaryLabel }}
</ZButton>
</template>
@@ -119,6 +119,7 @@ function previewUsageRecord(options: PreviewUsageRecordOptions): UsageListRecord
accountNotes: null,
accountPlanType: null,
accountPlanTypeDisplay: null,
accountUpstreamPeer: null,
route: stream ? '/v1/responses' : '/v1/chat/completions',
model: options.model,
requestedModel: options.model,
@@ -200,8 +200,9 @@ function diagnosticNameDisplay(name: string) {
<template #accountPlanType="{ row }">
<AccountPlanBadge
v-if="row.authenticationKind === 'api_key' || row.accountPlanType"
v-if="row.accountUpstreamPeer || row.authenticationKind === 'api_key' || row.accountPlanType"
:authentication-kind="row.authenticationKind ?? undefined"
:upstream-peer="row.accountUpstreamPeer"
:plan-type="row.accountPlanType"
:plan-type-display="row.accountPlanTypeDisplay || row.accountPlanType || ''"
size="sm"
+10 -13
View File
@@ -1,19 +1,16 @@
# v3.38.2
# v3.39.1
## 请求稳定性
## OpenAI 兼容性
- 修复上游返回长 `Retry-After` 时在原账号空等的问题;新增“上游重试等待上限”,默认 240 秒,超限后按原有重试规则尝试换号,无可用账号时返回上游错误并保留原始等待提示
- 在“系统设置 → 运行设置 → 并发与重试”中调整等待上限,统一作用于瞬态重试、WebSocket 传输重试和转 HTTP 回退;该值限制单次建议等待,不是请求总时长
- 修复 Codex OAuth 复用未初始化搜索的 WebSocket 连接后首次启用搜索被拒绝的问题:发送前重建不兼容连接,保留搜索参数与完整输入;依赖原连接的增量请求由客户端按既有机制完整重放
- 收紧账号模型冷却的识别条件,按结构化 `model_not_found` 错误码及状态判断,避免普通参数错误或仅在提示中提及模型不支持时误触发冷却与换号
- 修复代理编辑因版本冲突失败后,紧接着导入账号可能误报代理被占用的问题
- 修复同轮全量请求丢失 `x-codex-turn-state` 的问题:HTTP 与 WebSocket 请求可回传已确认归属的上游轮次 token,包括失败响应中收到的值;继续隔离不同 Client Key、账号、会话、线程和轮次,不放宽其他续接状态的校验
- 保留客户端的 `traceparent`、`tracestate`、`sentry-trace`、`baggage` 及对应 WebSocket metadata,`x-openai-fedramp` 按业务头原样透传并保留多值
- 出站 `x-codex-installation-id` 使用所选账号的持久安装身份,与正文中的安装身份一致;地域与账号路由头继续由部署配置控制,配置值覆盖客户端同名头,省略或 `null` 时不发送
## 互联与界面
## 运行稳定性
- 修复 CPR 互联账号池套餐分布遗漏:账号缺少明确套餐时,从已保存的额度快照补全,无需同时开启额度公开;未知套餐仍保持未知
- 修正账号列表 CPR 徽标的图文对齐,更新前后端依赖及构建基础镜像
- 配置提交发生版本竞争时,在既有有界重试预算内重新读取完整快照并准备扩展,减少创建或撤销 Key 等并发操作造成的短暂不可用;持续竞争或真实存储、扩展故障仍按原有规则暂停快照
- 修复删除代理返回版本冲突后事务锁未及时释放的问题,避免阻塞紧接着的代理编辑或删除
## 升级事项
## 代理检测
- 新增数据库迁移 `0037_retry_after_wait_limit.sql`,服务启动时自动执行,为已有设置填入默认 240 秒
- 等待上限设为 `0` 时不接受正数 `Retry-After`;未提供有效等待建议时仍采用原有本地退避,换号继续遵守次数、续接绑定及安全重放条件
- 双栈代理的延迟按首选可用出口的 HTTP 探测耗时显示,IPv4 优先,其次 IPv6,不再把另一地址族失败的等待计入成功出口延迟;检测仍等待两个地址族完成或超时,以保留完整可用性结果
+14
View File
@@ -23,3 +23,17 @@ platforms:
target: aarch64-apple-darwin
binary: codex-proxy-rs
archive: tar.gz
- os: windows
arch: amd64
runner: windows-2025
target: x86_64-pc-windows-msvc
binary: codex-proxy-rs.exe
archive: tar.gz
- os: windows
arch: arm64
runner: windows-11-arm
target: aarch64-pc-windows-msvc
binary: codex-proxy-rs.exe
archive: tar.gz
+1 -1
View File
@@ -1 +1 @@
version: "3.38.2"
version: "3.39.1"