mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-13 08:06:41 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96e8443ede | ||
|
|
42badf64ca | ||
|
|
35afe1123f | ||
|
|
2ac4265832 | ||
|
|
d22ec29824 |
@@ -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:
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,7 +6,11 @@ use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use super::{OperationError, internal, invalid};
|
||||
|
||||
const APP_BINARY_NAME: &str = "codex-proxy-rs";
|
||||
const APP_BINARY_NAME: &str = if cfg!(windows) {
|
||||
"codex-proxy-rs.exe"
|
||||
} else {
|
||||
"codex-proxy-rs"
|
||||
};
|
||||
const MAX_EXTRACTED_SIZE: u64 = 1024 * 1024 * 1024;
|
||||
const MAX_ARCHIVE_FILES: usize = 20_000;
|
||||
|
||||
@@ -126,14 +130,42 @@ pub(crate) fn extract_release(
|
||||
|
||||
fn unsafe_archive_path(path: &Path) -> bool {
|
||||
path.is_absolute()
|
||||
|| path.components().any(|component| {
|
||||
matches!(
|
||||
component,
|
||||
Component::ParentDir | Component::Prefix(_) | Component::RootDir
|
||||
)
|
||||
|| path.components().any(|component| match component {
|
||||
Component::ParentDir | Component::Prefix(_) | Component::RootDir => true,
|
||||
#[cfg(windows)]
|
||||
Component::Normal(name) => unsafe_windows_name(name),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn unsafe_windows_name(name: &std::ffi::OsStr) -> bool {
|
||||
let name = name.to_string_lossy();
|
||||
// 防止归档路径被 Win32 解释为设备、NTFS 备用数据流或另一个规范化文件名
|
||||
if name.ends_with(['.', ' '])
|
||||
|| name
|
||||
.chars()
|
||||
.any(|value| value < ' ' || "<>:\"|?*".contains(value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let stem = name
|
||||
.split('.')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_ascii_uppercase();
|
||||
matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|
||||
|| stem
|
||||
.strip_prefix("COM")
|
||||
.or_else(|| stem.strip_prefix("LPT"))
|
||||
.is_some_and(|suffix| {
|
||||
matches!(
|
||||
suffix,
|
||||
"1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn web_dist_relative_path(path: &Path) -> Option<PathBuf> {
|
||||
let components = path
|
||||
.components()
|
||||
|
||||
@@ -16,6 +16,7 @@ mod swap_paths;
|
||||
|
||||
pub use config::SystemUpdateConfig;
|
||||
pub use operations::ProcessSystemOperations;
|
||||
pub use process::startup_arguments;
|
||||
pub use release::validate_download_url;
|
||||
|
||||
use error::{OperationError, conflict, internal, invalid, upstream};
|
||||
|
||||
@@ -41,12 +41,18 @@ pub struct ProcessSystemOperations {
|
||||
cancellation: CancellationToken,
|
||||
events: Arc<UpdateEvents>,
|
||||
config: Arc<SystemUpdateConfig>,
|
||||
operation_lock: Arc<AsyncMutex<Option<OperationFileLock>>>,
|
||||
operation_lock: Arc<AsyncMutex<Option<RestartGuard>>>,
|
||||
release_cache: Arc<ReleaseCache>,
|
||||
running_files: Arc<Result<ReleaseFiles, OperationError>>,
|
||||
diagnostics: super::diagnostics::Diagnostics,
|
||||
}
|
||||
|
||||
struct RestartGuard {
|
||||
// 字段按声明顺序释放,先移除更新锁,再通知替换进程开始启动
|
||||
_file_lock: OperationFileLock,
|
||||
_replacement_input: Option<std::process::ChildStdin>,
|
||||
}
|
||||
|
||||
impl ProcessSystemOperations {
|
||||
#[must_use]
|
||||
pub fn new(cancellation: CancellationToken, mut config: SystemUpdateConfig) -> Self {
|
||||
@@ -75,7 +81,7 @@ impl ProcessSystemOperations {
|
||||
self.diagnostics.bind(diagnostics);
|
||||
}
|
||||
|
||||
fn lock_operation(&self) -> Result<OwnedMutexGuard<Option<OperationFileLock>>, OperationError> {
|
||||
fn lock_operation(&self) -> Result<OwnedMutexGuard<Option<RestartGuard>>, OperationError> {
|
||||
let operation = Arc::clone(&self.operation_lock)
|
||||
.try_lock_owned()
|
||||
.map_err(|_| conflict("已有系统操作正在执行"))?;
|
||||
@@ -559,15 +565,17 @@ impl SystemOperations for ProcessSystemOperations {
|
||||
{
|
||||
return Err(conflict("重启检查期间安装文件已变化,请重新确认"));
|
||||
}
|
||||
let message = if self.config.deployment_mode == "docker" {
|
||||
"已安排进程内重启"
|
||||
let (message, replacement_input) = if self.config.deployment_mode == "docker" {
|
||||
("已安排进程内重启", None)
|
||||
} else {
|
||||
spawn_replacement(&self.config)?;
|
||||
"已安排自重启"
|
||||
("已安排自重启", spawn_replacement(&self.config)?)
|
||||
};
|
||||
let operation_id = operation_id("restart");
|
||||
// 受理后直到旧服务释放,保留重启状态和跨进程锁;延迟关闭只用于送达响应
|
||||
*operation = Some(file_lock);
|
||||
*operation = Some(RestartGuard {
|
||||
_file_lock: file_lock,
|
||||
_replacement_input: replacement_input,
|
||||
});
|
||||
let cancellation = self.cancellation.clone();
|
||||
drop(tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
@@ -2,15 +2,37 @@
|
||||
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::io;
|
||||
use std::process::{ChildStdin, Command, Stdio};
|
||||
|
||||
#[cfg(unix)]
|
||||
use super::config::environment_value;
|
||||
use super::{OperationError, SystemUpdateConfig, internal};
|
||||
|
||||
#[cfg(unix)]
|
||||
const RESTART_DELAY_ENV: &str = "CPR_RESTART_DELAY_MS";
|
||||
#[cfg(unix)]
|
||||
const DEFAULT_RESTART_DELAY_MS: u64 = 1_200;
|
||||
const RESTART_HANDOFF_ARGUMENT: &str = "--restart-handoff";
|
||||
|
||||
pub(crate) fn spawn_replacement(config: &SystemUpdateConfig) -> Result<(), OperationError> {
|
||||
/// 组合根在创建运行时前接收重启交接,并取得不含内部交接标记的 CLI 参数
|
||||
pub fn startup_arguments() -> Result<impl Iterator<Item = std::ffi::OsString>, OperationError> {
|
||||
let mut arguments = env::args_os().skip(1).peekable();
|
||||
if arguments
|
||||
.peek()
|
||||
.is_some_and(|value| value == RESTART_HANDOFF_ARGUMENT)
|
||||
{
|
||||
arguments.next();
|
||||
// 旧服务释放操作锁后关闭管道;旧进程异常退出也会关闭管道,避免固定延迟抢占端口
|
||||
io::copy(&mut io::stdin().lock(), &mut io::sink())
|
||||
.map_err(|error| internal("无法等待旧服务释放资源").with_source(error))?;
|
||||
}
|
||||
Ok(arguments)
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_replacement(
|
||||
config: &SystemUpdateConfig,
|
||||
) -> Result<Option<ChildStdin>, OperationError> {
|
||||
let executable = config.executable_path()?;
|
||||
let metadata = fs::metadata(&executable)
|
||||
.map_err(|error| internal("无法启动替换进程").with_source(error))?;
|
||||
@@ -25,12 +47,12 @@ pub(crate) fn spawn_replacement(config: &SystemUpdateConfig) -> Result<(), Opera
|
||||
}
|
||||
}
|
||||
|
||||
let delay_ms = environment_value(RESTART_DELAY_ENV)
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(DEFAULT_RESTART_DELAY_MS);
|
||||
#[cfg(unix)]
|
||||
let mut command = {
|
||||
let delay_ms = environment_value(RESTART_DELAY_ENV)
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(DEFAULT_RESTART_DELAY_MS);
|
||||
let delay = format!("{}.{:03}", delay_ms / 1_000, delay_ms % 1_000);
|
||||
let mut command = Command::new("/bin/sh");
|
||||
command
|
||||
@@ -39,20 +61,26 @@ pub(crate) fn spawn_replacement(config: &SystemUpdateConfig) -> Result<(), Opera
|
||||
.arg("codex-proxy-rs-restart")
|
||||
.arg(delay)
|
||||
.arg(&executable)
|
||||
.args(env::args_os().skip(1));
|
||||
.args(env::args_os().skip(1))
|
||||
.stdin(Stdio::null());
|
||||
command
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let mut command = {
|
||||
let mut command = Command::new(&executable);
|
||||
command
|
||||
.args(env::args_os().skip(1))
|
||||
.env(RESTART_DELAY_ENV, delay_ms.to_string());
|
||||
.arg(RESTART_HANDOFF_ARGUMENT)
|
||||
// 连续重启时移除当前进程已消费的交接标记,保留原始参数及编码
|
||||
.args(
|
||||
env::args_os()
|
||||
.skip(1)
|
||||
.skip_while(|value| value == RESTART_HANDOFF_ARGUMENT),
|
||||
)
|
||||
.stdin(Stdio::piped());
|
||||
command
|
||||
};
|
||||
command.stdin(Stdio::null());
|
||||
command
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map(|mut child| child.stdin.take())
|
||||
.map_err(|error| internal("无法启动替换进程").with_source(error))
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ impl UpdateTempDir {
|
||||
|
||||
impl Drop for UpdateTempDir {
|
||||
fn drop(&mut self) {
|
||||
if let Err(error) = fs::remove_dir_all(&self.path)
|
||||
if let Err(error) = super::swap::remove_dir_all(&self.path)
|
||||
&& error.kind() != io::ErrorKind::NotFound
|
||||
{
|
||||
tracing::warn!(path = %self.path.display(), error = %error, "清理系统更新临时目录失败");
|
||||
|
||||
@@ -294,12 +294,40 @@ fn protect_official_plugins(_path: &Path) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_dir_all(path: &Path) -> io::Result<()> {
|
||||
pub(crate) fn remove_dir_all(path: &Path) -> io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
make_directories_writable(path)?;
|
||||
#[cfg(windows)]
|
||||
make_files_writable(path)?;
|
||||
fs::remove_dir_all(path)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn make_files_writable(path: &Path) -> io::Result<()> {
|
||||
let metadata = fs::symlink_metadata(path)?;
|
||||
if metadata.file_type().is_symlink() || (!metadata.is_dir() && !metadata.is_file()) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"release directory contains an unsupported file type",
|
||||
));
|
||||
}
|
||||
if metadata.is_dir() {
|
||||
for entry in fs::read_dir(path)? {
|
||||
make_files_writable(&entry?.path())?;
|
||||
}
|
||||
} else if metadata.permissions().readonly() {
|
||||
// tar 的 0444 会成为 Windows 只读属性,清理受管备份前必须移除此属性
|
||||
let mut permissions = metadata.permissions();
|
||||
#[expect(
|
||||
clippy::permissions_set_readonly_false,
|
||||
reason = "仅 Windows 清理只读属性,不会放宽 Unix 权限"
|
||||
)]
|
||||
permissions.set_readonly(false);
|
||||
fs::set_permissions(path, permissions)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn make_directories_writable(path: &Path) -> io::Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
@@ -6,13 +6,25 @@ use std::{
|
||||
};
|
||||
|
||||
fn main() {
|
||||
let executable = std::env::current_exe().unwrap();
|
||||
let directory = executable.parent().unwrap();
|
||||
if directory.join("restart-probe").is_file() {
|
||||
std::fs::write(directory.join("replacement-waiting"), b"ready").unwrap();
|
||||
let arguments = gateway_host::system_update::startup_arguments()
|
||||
.unwrap()
|
||||
.collect::<Vec<_>>();
|
||||
assert!(!arguments.iter().any(|value| value == "--restart-handoff"));
|
||||
assert!(!directory.join("update.lock").exists());
|
||||
std::fs::write(directory.join("replacement-ran"), b"started").unwrap();
|
||||
return;
|
||||
}
|
||||
let mode = std::fs::read_to_string("mode").unwrap();
|
||||
if mode == "stderr" {
|
||||
std::io::stderr().write_all(&[b'x'; 4096]).unwrap();
|
||||
}
|
||||
if matches!(
|
||||
mode.as_str(),
|
||||
"busy" | "stdin" | "stdout" | "exit" | "allocate"
|
||||
"busy" | "stdin" | "stdout" | "exit" | "allocate" | "update-probe"
|
||||
) {
|
||||
std::io::stdout().write_all(b"R").unwrap();
|
||||
std::io::stdout().flush().unwrap();
|
||||
@@ -25,6 +37,11 @@ fn main() {
|
||||
std::io::stdin().read_exact(&mut [0; 1]).unwrap();
|
||||
std::fs::write("unblocked", b"stdin").unwrap();
|
||||
}
|
||||
"update-probe" => {
|
||||
std::io::stdin().read_exact(&mut [0; 1]).unwrap();
|
||||
std::fs::write("unblocked", b"still-running").unwrap();
|
||||
return;
|
||||
}
|
||||
"stdout" => {
|
||||
// 测试端不消费此正文,超过管道容量后必须由监督器终止,而不是等待写入完成
|
||||
std::io::stdout()
|
||||
|
||||
@@ -18,8 +18,7 @@ async fn manual_image_replacement_should_clear_pending_state_and_invalid_backup(
|
||||
assert!(complete_update(&service, TARGET_VERSION).await.need_restart);
|
||||
// 模拟重建镜像:数据卷保留,应用文件和容器内备份被重新替换
|
||||
fixture.write_executable("manually-installed-binary");
|
||||
fs::remove_file(fixture.root.path().join("codex-proxy-rs.backup"))
|
||||
.expect("remove image backup");
|
||||
fs::remove_file(fixture.backup()).expect("remove image backup");
|
||||
let mut config = fixture.config(&format!("{}/repos", server.uri()));
|
||||
config.version = "1.1.0".into();
|
||||
config.deployment_mode = "docker".into();
|
||||
@@ -126,11 +125,7 @@ async fn pending_update_should_revoke_rollback_when_backup_content_changes() {
|
||||
.await;
|
||||
let service = fixture.service(&server);
|
||||
assert!(complete_update(&service, TARGET_VERSION).await.need_restart);
|
||||
fs::write(
|
||||
fixture.root.path().join("codex-proxy-rs.backup"),
|
||||
"unrelated-backup",
|
||||
)
|
||||
.expect("changed backup");
|
||||
fs::write(fixture.backup(), "unrelated-backup").expect("changed backup");
|
||||
let status = service
|
||||
.update_status()
|
||||
.await
|
||||
|
||||
@@ -40,6 +40,12 @@ use tar::{Builder, EntryType, Header};
|
||||
use wiremock::matchers::{method, path, query_param};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
const BINARY_NAME: &str = if cfg!(windows) {
|
||||
"codex-proxy-rs.exe"
|
||||
} else {
|
||||
"codex-proxy-rs"
|
||||
};
|
||||
|
||||
const OLD_RELEASE_MANIFEST: &str = r#"{"gateway_version":"1.0.0"}"#;
|
||||
|
||||
const TARGET_VERSION: &str = "1.9.9";
|
||||
@@ -253,7 +259,11 @@ impl Fixture {
|
||||
}
|
||||
|
||||
fn executable(&self) -> PathBuf {
|
||||
self.root.path().join("codex-proxy-rs")
|
||||
self.root.path().join(BINARY_NAME)
|
||||
}
|
||||
|
||||
fn backup(&self) -> PathBuf {
|
||||
self.root.path().join(format!("{BINARY_NAME}.backup"))
|
||||
}
|
||||
|
||||
fn web(&self) -> PathBuf {
|
||||
@@ -393,7 +403,7 @@ impl Fixture {
|
||||
#[derive(Clone, Copy)]
|
||||
enum ArchiveKind {
|
||||
Safe,
|
||||
UnsafePath,
|
||||
UnsafePath(&'static str),
|
||||
MissingOfficialManifest,
|
||||
NestedOfficialAsset,
|
||||
OfficialSymlink,
|
||||
@@ -409,7 +419,7 @@ enum ChecksumKind {
|
||||
fn release_archive(kind: ArchiveKind) -> Vec<u8> {
|
||||
let encoder = GzEncoder::new(Vec::new(), Compression::default());
|
||||
let mut tar = Builder::new(encoder);
|
||||
append_file(&mut tar, "codex-proxy-rs", b"new-binary", false);
|
||||
append_file(&mut tar, BINARY_NAME, b"new-binary", false);
|
||||
append_file(&mut tar, "web/dist/index.html", b"new-web", false);
|
||||
if !matches!(kind, ArchiveKind::MissingOfficialManifest) {
|
||||
append_file(
|
||||
@@ -425,8 +435,8 @@ fn release_archive(kind: ArchiveKind) -> Vec<u8> {
|
||||
b"new-plugin",
|
||||
false,
|
||||
);
|
||||
if matches!(kind, ArchiveKind::UnsafePath) {
|
||||
append_file(&mut tar, "safe", b"escape", true);
|
||||
if let ArchiveKind::UnsafePath(name) = kind {
|
||||
append_file(&mut tar, name, b"escape", true);
|
||||
}
|
||||
if matches!(kind, ArchiveKind::NestedOfficialAsset) {
|
||||
append_file(
|
||||
@@ -461,13 +471,19 @@ fn append_symlink(tar: &mut Builder<GzEncoder<Vec<u8>>>, name: &str, target: &st
|
||||
fn append_file(tar: &mut Builder<GzEncoder<Vec<u8>>>, name: &str, data: &[u8], unsafe_path: bool) {
|
||||
let mut header = Header::new_gnu();
|
||||
header.set_entry_type(EntryType::Regular);
|
||||
header.set_mode(0o755);
|
||||
header.set_mode(if name.starts_with("plugins/official/") {
|
||||
0o444
|
||||
} else {
|
||||
0o755
|
||||
});
|
||||
header.set_size(u64::try_from(data.len()).expect("size"));
|
||||
header.set_path(name).expect("path");
|
||||
header
|
||||
.set_path(if unsafe_path { "safe" } else { name })
|
||||
.expect("path");
|
||||
if unsafe_path {
|
||||
let bytes = header.as_mut_bytes();
|
||||
bytes[..100].fill(0);
|
||||
bytes[..9].copy_from_slice(b"../escape");
|
||||
bytes[..name.len()].copy_from_slice(name.as_bytes());
|
||||
}
|
||||
header.set_cksum();
|
||||
tar.append(&header, data).expect("append");
|
||||
|
||||
@@ -2,6 +2,120 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_and_rollback_preserve_a_running_executable() {
|
||||
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
|
||||
|
||||
let server = MockServer::start().await;
|
||||
let fixture = Fixture::new();
|
||||
fs::copy(
|
||||
env!("CARGO_BIN_EXE_gateway-host-process-worker"),
|
||||
fixture.executable(),
|
||||
)
|
||||
.expect("running executable");
|
||||
let original = fs::read(fixture.executable()).expect("original executable");
|
||||
fs::write(fixture.root.path().join("mode"), b"update-probe").expect("worker mode");
|
||||
let mut child = tokio::process::Command::new(fixture.executable())
|
||||
.current_dir(fixture.root.path())
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.expect("start old executable");
|
||||
assert_eq!(
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
child.stdout.as_mut().unwrap().read_u8()
|
||||
)
|
||||
.await
|
||||
.expect("worker ready")
|
||||
.expect("ready byte"),
|
||||
b'R'
|
||||
);
|
||||
fixture
|
||||
.mount_release(
|
||||
&server,
|
||||
TARGET_VERSION,
|
||||
ArchiveKind::Safe,
|
||||
ChecksumKind::Valid,
|
||||
)
|
||||
.await;
|
||||
let service = fixture.service(&server);
|
||||
let status = complete_update(&service, TARGET_VERSION).await;
|
||||
assert_eq!(
|
||||
status.operation.status,
|
||||
SystemOperationStatus::Succeeded,
|
||||
"{status:?}"
|
||||
);
|
||||
assert!(status.need_restart);
|
||||
assert_eq!(fs::read(fixture.backup()).expect("backup"), original);
|
||||
assert_eq!(
|
||||
fs::read(fixture.executable()).expect("installed"),
|
||||
b"new-binary"
|
||||
);
|
||||
assert!(child.try_wait().expect("old process state").is_none());
|
||||
|
||||
service
|
||||
.rollback(Arc::new(AllowingUpdatePreflight))
|
||||
.await
|
||||
.expect("rollback running executable");
|
||||
assert_eq!(
|
||||
fs::read(fixture.executable()).expect("restored executable"),
|
||||
original
|
||||
);
|
||||
assert!(
|
||||
!service
|
||||
.update_status()
|
||||
.await
|
||||
.expect("restored status")
|
||||
.need_restart
|
||||
);
|
||||
|
||||
service
|
||||
.perform_update(
|
||||
Some(TARGET_VERSION.to_owned()),
|
||||
None,
|
||||
Arc::new(ChangingRevisionPreflight {
|
||||
confirmations: AtomicUsize::new(0),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("second update accepted");
|
||||
let failed = wait_for_update(&service).await;
|
||||
assert_eq!(
|
||||
failed.operation.status,
|
||||
SystemOperationStatus::Failed,
|
||||
"{failed:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(fixture.executable()).expect("restored after failure"),
|
||||
original
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(fixture.web().join("index.html")).expect("restored web"),
|
||||
b"old-web"
|
||||
);
|
||||
|
||||
child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.write_u8(b'X')
|
||||
.await
|
||||
.expect("release old process");
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_secs(5), child.wait())
|
||||
.await
|
||||
.expect("worker exited")
|
||||
.expect("exit status")
|
||||
.success()
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(fixture.root.path().join("unblocked")).expect("old process result"),
|
||||
b"still-running"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restart_should_not_shutdown_when_replacement_spawn_fails() {
|
||||
let fixture = Fixture::new();
|
||||
@@ -149,7 +263,12 @@ async fn assert_system_operations_rejected(service: &ProcessSystemOperations, re
|
||||
async fn restart_should_spawn_replacement_before_shutdown_outside_docker() {
|
||||
let fixture = Fixture::new();
|
||||
let replacement_marker = fixture.root.path().join("replacement-ran");
|
||||
fixture.write_executable("#!/bin/sh\n: > \"${0%/*}/replacement-ran\"\n");
|
||||
fs::copy(
|
||||
env!("CARGO_BIN_EXE_gateway-host-process-worker"),
|
||||
fixture.executable(),
|
||||
)
|
||||
.expect("replacement executable");
|
||||
fs::write(fixture.root.path().join("restart-probe"), b"").expect("restart probe");
|
||||
let mut config = fixture.config("http://127.0.0.1:1/repos");
|
||||
config.self_restart_enabled = true;
|
||||
let shutdown = CancellationToken::new();
|
||||
@@ -166,6 +285,21 @@ async fn restart_should_spawn_replacement_before_shutdown_outside_docker() {
|
||||
tokio::time::timeout(Duration::from_secs(2), shutdown.cancelled())
|
||||
.await
|
||||
.expect("shutdown requested after spawn");
|
||||
#[cfg(windows)]
|
||||
{
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while !fixture.root.path().join("replacement-waiting").exists() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("replacement waiting for old service");
|
||||
assert!(
|
||||
!replacement_marker.exists(),
|
||||
"new service must wait for resource release"
|
||||
);
|
||||
}
|
||||
drop(service);
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
while !replacement_marker.is_file() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
@@ -189,7 +323,7 @@ async fn restart_should_use_startup_path_after_running_executable_is_renamed() {
|
||||
let output = std::process::Command::new(child)
|
||||
.args([
|
||||
"--exact",
|
||||
"system_update::restart_should_use_startup_path_after_running_executable_is_renamed",
|
||||
"system_update::operations::restart_should_use_startup_path_after_running_executable_is_renamed",
|
||||
])
|
||||
.env(CHILD_ENV, "1")
|
||||
.env("CPR_RESTART_DELAY_MS", "1200")
|
||||
@@ -223,7 +357,7 @@ async fn restart_should_use_startup_path_after_running_executable_is_renamed() {
|
||||
config.self_restart_enabled = true;
|
||||
let service = ProcessSystemOperations::new(CancellationToken::new(), config);
|
||||
|
||||
let backup = fixture.root.path().join("codex-proxy-rs.backup");
|
||||
let backup = fixture.backup();
|
||||
fs::rename(fixture.executable(), &backup).expect("back up running executable");
|
||||
fixture.write_executable("#!/bin/sh\nprintf new > \"${0%/*}/replacement-version\"\n");
|
||||
assert_eq!(std::env::current_exe().expect("renamed executable"), backup);
|
||||
@@ -777,24 +911,42 @@ async fn update_should_reject_release_archive_from_untrusted_host() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_should_reject_release_archive_with_unsafe_path() {
|
||||
let server = MockServer::start().await;
|
||||
let fixture = Fixture::new();
|
||||
fixture
|
||||
.mount_release(
|
||||
&server,
|
||||
TARGET_VERSION,
|
||||
ArchiveKind::UnsafePath,
|
||||
ChecksumKind::Valid,
|
||||
)
|
||||
.await;
|
||||
for unsafe_path in [
|
||||
"../escape",
|
||||
#[cfg(windows)]
|
||||
"web/dist/index.html:payload",
|
||||
#[cfg(windows)]
|
||||
"web/dist/NUL.txt",
|
||||
#[cfg(windows)]
|
||||
"web/dist/COM¹",
|
||||
#[cfg(windows)]
|
||||
"web/dist/trailing. ",
|
||||
#[cfg(windows)]
|
||||
"C:/escape",
|
||||
] {
|
||||
let server = MockServer::start().await;
|
||||
let fixture = Fixture::new();
|
||||
fixture
|
||||
.mount_release(
|
||||
&server,
|
||||
TARGET_VERSION,
|
||||
ArchiveKind::UnsafePath(unsafe_path),
|
||||
ChecksumKind::Valid,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
complete_update(&fixture.service(&server), TARGET_VERSION)
|
||||
.await
|
||||
.operation
|
||||
.status,
|
||||
SystemOperationStatus::Failed,
|
||||
);
|
||||
assert_eq!(
|
||||
complete_update(&fixture.service(&server), TARGET_VERSION)
|
||||
.await
|
||||
.operation
|
||||
.status,
|
||||
SystemOperationStatus::Failed,
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(fixture.executable()).expect("unchanged binary"),
|
||||
b"old-binary"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -933,6 +1085,7 @@ async fn update_and_rollback_should_use_the_default_api_asset_directory() {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::test]
|
||||
async fn update_should_replace_web_assets_across_filesystems() {
|
||||
let Ok(external) = tempfile::tempdir_in("/dev/shm") else {
|
||||
@@ -980,7 +1133,7 @@ async fn update_should_restore_web_assets_when_binary_backup_fails() {
|
||||
ChecksumKind::Valid,
|
||||
)
|
||||
.await;
|
||||
fs::create_dir(fixture.executable().with_extension("backup")).expect("blocking backup dir");
|
||||
fs::create_dir(fixture.backup()).expect("blocking backup dir");
|
||||
|
||||
assert_eq!(
|
||||
complete_update(&fixture.service(&server), TARGET_VERSION)
|
||||
|
||||
@@ -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")?,
|
||||
|
||||
+163
-4
@@ -6,7 +6,7 @@
|
||||
|
||||
| 任务 | 入口 |
|
||||
| --- | --- |
|
||||
| 安装与启动 | [手动安装](#手动安装) · [启动](#启动) · [公网访问](#公网访问) |
|
||||
| 安装与启动 | [手动安装](#手动安装) · [Windows 二进制部署](#windows-二进制部署) · [启动](#启动) · [公网访问](#公网访问) |
|
||||
| 接入客户端 | [客户端配置](#客户端配置) · [时区与客户端环境信息](#时区与客户端环境信息) · [登录与生图排查](#登录与生图排查) |
|
||||
| 日常运维 | [运行设置](#启动后的运行设置) · [小内存优化](#小内存优化) · [持久化与日志](#持久化与备份) · [请求排查](#请求错误排查) · [密码轮换](#密码语义) |
|
||||
| 更新与恢复 | [镜像升级](#镜像升级与源码构建) · [在线更新](#管理端在线更新) · [备份与恢复](#备份与恢复) · [停止与重启](#停止与重启) |
|
||||
@@ -19,7 +19,7 @@
|
||||
| 位置 | 内容 | 修改方式 |
|
||||
| --- | --- | --- |
|
||||
| `deploy/config.yaml` | 监听、数据库/Redis 连接、部署凭据、时区、日志与可选内存分配策略等启动配置 | 从模板创建,已有部署合并修改,不覆盖原文件 |
|
||||
| `deploy/compose.yaml` | 镜像、容器网络、端口、挂载与资源限制 | 调整 Compose 并重建受影响容器 |
|
||||
| `deploy/compose.yaml` | 镜像、容器网络、端口、挂载、PostgreSQL 启动参数与资源限制 | 调整 Compose 并重建受影响容器 |
|
||||
| PostgreSQL | 账号、Key、运行设置、上游身份、插件配置等业务数据 | 管理端或管理 API |
|
||||
| 浏览器本地 | 主题和界面偏好 | 当前浏览器的主题设置 |
|
||||
|
||||
@@ -58,6 +58,21 @@ host:
|
||||
|
||||
### 小内存优化
|
||||
|
||||
1 GB 内存、网关与 PostgreSQL / Redis 同机、低并发部署可从本节参数开始评估。
|
||||
这些值是调优起点,不是整机内存上限或容量保证;先记录实际负载下的内存峰值,优先缩小数据库连接池和缓冲,再按需调整 Redis 与应用空闲内存回收
|
||||
|
||||
| 调整对象 | 配置入口 | 生效方式 |
|
||||
| --- | --- | --- |
|
||||
| 应用空闲内存回收 | `config.yaml` → `services.app-runtime.environment.GLIBC_TUNABLES` | 重建应用容器 |
|
||||
| 应用 PostgreSQL 连接池 | `config.yaml` → `store.pool.max_connections` | 重启应用;Compose 可按下文重建 |
|
||||
| PostgreSQL 内存与连接预算 | `compose.yaml` → `services.postgres.command` | 重建 PostgreSQL 容器 |
|
||||
| Redis 持久化与内存预算 | `config.yaml` → `services.redis-credentials.command` | 重建 Redis 容器 |
|
||||
|
||||
以下 YAML 片段合并到已有节点,不要重复创建顶层 `store` / `services`,也不要覆盖凭据、挂载和健康检查。
|
||||
这些参数没有管理端热更新入口;二进制部署沿用 `store.pool`,PostgreSQL / Redis 参数则配置到各自服务的配置文件或启动命令,应用不会读取 `services` 桥接区来修改外部数据库
|
||||
|
||||
#### 应用空闲内存回收
|
||||
|
||||
Linux/glibc 部署可选择更积极地归还请求结束后的空闲内存,适合内存紧张且请求后 RSS 长时间偏高的机器。
|
||||
该选项默认关闭,官方 Linux 镜像支持;更频繁的映射与回收可能增加 CPU 开销,应结合实际负载观察
|
||||
|
||||
@@ -71,7 +86,7 @@ services:
|
||||
```
|
||||
|
||||
Compose 从 `services.app-runtime` 取得参数;不开启优化时保留该段并将值设为 `''`。
|
||||
PostgreSQL 和 Redis 的凭据桥接配置与该段并列,无需在 `compose.yaml` 重复填写参数
|
||||
PostgreSQL 和 Redis 的凭据桥接配置与该段并列,无需在 `compose.yaml` 重复填写 `GLIBC_TUNABLES`
|
||||
|
||||
校验并重建应用容器使配置生效;`docker compose restart` 不会更新容器环境变量:
|
||||
|
||||
@@ -94,6 +109,114 @@ GLIBC_TUNABLES=glibc.malloc.mmap_threshold=131072:glibc.malloc.trim_threshold=13
|
||||
该变量由 glibc 在进程启动时读取,不支持管理端热更新。
|
||||
它不设置内存上限,不释放仍被请求或连接持有的缓冲,也不处理容器文件缓存;观察时分别比较进程 RSS、cgroup 的 `anon` 与 `file`
|
||||
|
||||
#### PostgreSQL 与应用连接池
|
||||
|
||||
在 `deploy/config.yaml` 的 `store` 下设置连接池上限,未列出的池参数保留默认值:
|
||||
|
||||
```yaml
|
||||
store:
|
||||
pool:
|
||||
max_connections: 5
|
||||
```
|
||||
|
||||
应用默认上限为 20,允许的最小值为 2;降低上限可以限制实际数据库连接数,但也会增加请求、后台写入与管理查询之间的等待。
|
||||
如果出现连接获取超时,应结合数据库负载调大池容量,不要仅延长等待时间
|
||||
|
||||
将 `deploy/compose.yaml` 中 `services.postgres.command` 替换为以下列表,保留 UTC 参数:
|
||||
|
||||
```yaml
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
- timezone=UTC
|
||||
- -c
|
||||
- log_timezone=UTC
|
||||
- -c
|
||||
- shared_buffers=64MB
|
||||
- -c
|
||||
- max_connections=30
|
||||
- -c
|
||||
- work_mem=2MB
|
||||
- -c
|
||||
- maintenance_work_mem=32MB
|
||||
- -c
|
||||
- autovacuum_work_mem=16MB
|
||||
- -c
|
||||
- max_parallel_workers_per_gather=0
|
||||
```
|
||||
|
||||
`shared_buffers` 缩小共享缓冲;服务端 `max_connections` 限制整个数据库的连接数,应覆盖所有应用池,并为备份、维护和其他客户端留余量。
|
||||
它与应用连接池上限是两个配置,调小服务端上限不会主动回收应用池里的空闲连接
|
||||
|
||||
`work_mem` 是每个排序或哈希操作的基础预算,哈希还受 `hash_mem_multiplier` 影响,一条 SQL 可以同时使用多份预算。
|
||||
`maintenance_work_mem` 限制维护操作内存,`autovacuum_work_mem` 按自动清理进程分别计;保持自动清理开启。
|
||||
关闭并行查询、缩小这些预算可能增加临时文件 I/O,并延长统计、索引创建与维护时间
|
||||
|
||||
参数定义见 [PostgreSQL 18 内存与并行查询](https://www.postgresql.org/docs/18/runtime-config-resource.html)和[连接设置](https://www.postgresql.org/docs/18/runtime-config-connection.html)
|
||||
|
||||
#### Redis 持久化与内存预算
|
||||
|
||||
项目默认开启 AOF,未显式设置 `maxmemory` 和定时 RDB 快照规则。
|
||||
小内存部署可保留 AOF,关闭定时 RDB 快照以减少快照期间的 fork / 写时复制峰值;代价是少一份独立的 RDB 快照,AOF 重写仍需要预留额外内存
|
||||
|
||||
在已有 `deploy/config.yaml` 中替换 `services.redis-credentials.command`,复用同一文件内的密码锚点,保留该服务的 `healthcheck`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
redis-credentials:
|
||||
command:
|
||||
- redis-server
|
||||
- --appendonly
|
||||
- 'yes'
|
||||
- --save
|
||||
- ''
|
||||
- --requirepass
|
||||
- *redis_password
|
||||
# 当前用量和负载峰值明显低于 64 MiB 时,再取消以下注释
|
||||
# - --maxmemory
|
||||
# - 64mb
|
||||
# - --maxmemory-policy
|
||||
# - noeviction
|
||||
```
|
||||
|
||||
`--save` 后的空字符串必须保留,它表示关闭定时快照。持久化取舍见 [Redis 文档](https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/)
|
||||
|
||||
设置内存上限前先查看 `INFO memory` 的 `used_memory`、`used_memory_peak` 和 `used_memory_rss`,为业务增长、分配器和持久化峰值留余量。
|
||||
`maxmemory 64mb` 只是可选起点,未达到上限时不会因此降低 RSS,也不是进程或容器的总内存上限。
|
||||
达到上限后,`noeviction` 会拒绝需要增加内存的写入,可能影响登录与请求调度,应调大预算或减少负载
|
||||
|
||||
Redis 保存租约、冷却和登录会话等[协调状态](../docs/architecture.md#8-状态所有权),不要使用 `allkeys-lru`、`volatile-lru` 等提前淘汰键的策略。
|
||||
预算与策略定义见 [Redis 内存限制](https://redis.io/docs/latest/develop/reference/eviction/)
|
||||
|
||||
#### 使配置生效与检查效果
|
||||
|
||||
只调整应用配置时,按[应用空闲内存回收](#应用空闲内存回收)中的命令重建应用容器。
|
||||
调整 PostgreSQL / Redis 启动参数需要短暂停服,在维护窗口执行;只改其中一个服务时,数据库重建命令只列出该服务:
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/compose.yaml config --quiet
|
||||
docker compose -f deploy/compose.yaml stop codex-proxy-rs
|
||||
docker compose -f deploy/compose.yaml up -d --no-build --no-deps --force-recreate --wait postgres redis
|
||||
docker compose -f deploy/compose.yaml up -d --no-build --no-deps --force-recreate --wait codex-proxy-rs
|
||||
```
|
||||
|
||||
数据库健康检查通过后再执行最后一条命令;`restart` 不会更新容器启动命令或环境变量。
|
||||
外部数据库需通过其管理方式应用参数,不能用本地 Compose 命令代替
|
||||
|
||||
通过交互提示输入对应数据库密码,核对实际生效值与内存占用,避免将密码写入命令行:
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/compose.yaml exec postgres psql -U codex_proxy -d codex_proxy -W \
|
||||
-c 'SHOW shared_buffers; SHOW max_connections; SHOW work_mem; SHOW maintenance_work_mem; SHOW autovacuum_work_mem; SHOW max_parallel_workers_per_gather;'
|
||||
docker compose -f deploy/compose.yaml exec redis redis-cli --askpass CONFIG GET appendonly save maxmemory maxmemory-policy
|
||||
docker compose -f deploy/compose.yaml exec redis redis-cli --askpass INFO memory
|
||||
docker compose -f deploy/compose.yaml stats --no-stream
|
||||
```
|
||||
|
||||
在相同负载下比较空闲、请求峰值与后台统计 / 维护期间的占用,并检查实际模型请求、管理查询是否正常,有无连接获取超时、Redis OOM 或容器重启。
|
||||
不要把 PostgreSQL 各进程的 RSS 直接相加,共享页会重复计数;容器文件缓存也应与匿名内存区分。
|
||||
容器硬内存上限不能代替这些预算,过紧会导致 OOM;需要回退时恢复调整前的参数,按同样流程重建对应容器
|
||||
|
||||
## 部署结构
|
||||
|
||||
```mermaid
|
||||
@@ -190,6 +313,42 @@ store:
|
||||
`redis://u1@redis-host:6379/0`,其中主机名必须能从应用容器访问;只改 `config.yaml` 不会生效。
|
||||
默认 Compose Redis 服务使用密码认证,不会自动创建 ACL 用户
|
||||
|
||||
## Windows 二进制部署
|
||||
|
||||
发行包提供 Windows x64(`windows_amd64`)和 ARM64(`windows_arm64`)版本,按运行机器的 CPU 架构选择。
|
||||
需先准备可访问的 PostgreSQL 和 Redis,创建数据库及用户;使用数据库备份功能时,还需将匹配服务端版本的 `pg_dump` 加入 `PATH`
|
||||
|
||||
从 [Release 附件](https://github.com/zyycn/codex-proxy-rs/releases) 下载对应的
|
||||
`codex-proxy-rs_<version>_windows_<arch>.tar.gz`,解压后保留 `codex-proxy-rs.exe`、`web/dist`、`deploy` 和 `plugins/official` 的目录关系。
|
||||
在解压后的 `codex-proxy-rs` 目录中打开 PowerShell,首次安装时创建配置:
|
||||
|
||||
```powershell
|
||||
Copy-Item .\deploy\config.example.yaml .\deploy\config.yaml
|
||||
```
|
||||
|
||||
编辑 `deploy/config.yaml`,填写 `store.database`、`store.redis` 的连接地址和独立密码,以及 `admin.default_password`。
|
||||
密码格式沿用模板要求;将已有的 `api.asset_directory` 改为 `../web/dist`,在 `host` 下合并以下更新与重启配置:
|
||||
|
||||
```yaml
|
||||
host:
|
||||
system_update:
|
||||
deployment_mode: binary
|
||||
self_restart_enabled: true
|
||||
api:
|
||||
asset_directory: '../web/dist'
|
||||
```
|
||||
|
||||
从安装目录启动:
|
||||
|
||||
```powershell
|
||||
.\codex-proxy-rs.exe serve
|
||||
```
|
||||
|
||||
启动成功后访问 `http://127.0.0.1:8080`,使用配置中的管理员账号登录;在运行终端按 `Ctrl+C` 停止服务。
|
||||
官方构建支持管理端[在线更新、重启与回滚](#管理端在线更新),安装目录与 `.runtime` 必须可写。
|
||||
手动更换版本时先停止进程并备份当前发行文件,再整体替换 EXE、`web/dist` 和 `plugins/official`,
|
||||
保留 `deploy/config.yaml` 与 `.runtime`,按新版本模板合并配置后重新启动
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
@@ -783,7 +942,7 @@ Release 必须提供当前 OS/架构的 `codex-proxy-rs_<version>_<os>_<arch>.ta
|
||||
宿主范围或能力版本不匹配时,可在插件页确认风险后尝试本次启动;清单、RPC、平台或包完整性错误不能绕过。具体操作见[插件故障处理](../docs/plugins.md#故障处理)
|
||||
|
||||
部署须保留完整的二进制、Web 资源与官方插件目录;不要手动修改制品元数据或迁移 checksum 绕过校验。
|
||||
macOS arm64 提供构建产物;部署前需验证目标平台的插件运行与上游调用,不能只以打包成功判断可用
|
||||
macOS arm64、Windows x64 / ARM64 提供构建产物;插件安装包必须匹配宿主 OS 和架构,部署前需验证目标平台的插件运行与上游调用,不能只以打包成功判断可用
|
||||
|
||||
## 备份与恢复
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ services:
|
||||
service: postgres-credentials
|
||||
image: postgres:18-bookworm@sha256:bbf83594798e144bff365a9c191c5d779f181fb3135902064ab8ff11918020fc
|
||||
restart: unless-stopped
|
||||
# 小内存部署可在此调整数据库预算,保留 UTC 参数,详见 README.md#小内存优化
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
|
||||
@@ -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
|
||||
|
||||
+7
-2
@@ -2511,10 +2511,14 @@ Dashboard 返回原始 `asOf` 及 `asOfDisplay`,作为当前数据的查询锚
|
||||
|
||||
管理端请求列表及 Dashboard 最近请求中的 `accountNotes` 来自请求保存的账号备注快照,未保存时为 `null`
|
||||
|
||||
请求列表、Dashboard 最近请求及账号维度诊断的 `accountUpstreamPeer` 来自账号当前的互联摘要,
|
||||
已发现且有观测时间时为 `true`,尚未识别成功时为 `false`;摘要缺失、账号已删除或读取失败时为 `null`。
|
||||
该字段仅供 CPR 标识展示,不写入历史快照,非账号维度诊断为 `null`
|
||||
|
||||
`/api/admin/usage/records` 与 `/api/admin/operations/errors` 的 `clientApiKeyName` 来自请求保存的 Key 名称快照,
|
||||
未保存时为 `null`,不影响记录返回,不包含密钥原文
|
||||
|
||||
查询不关联当前配置,改名、修改订阅或删除配置不改变已有快照。补写的历史 Key 名称与订阅取自补写时仍存在的配置,
|
||||
上述快照字段不关联当前配置,改名、修改订阅或删除配置不改变已有快照。补写的历史 Key 名称与订阅取自补写时仍存在的配置,
|
||||
不保证等于请求发生时的值;无法补齐的字段保持未知
|
||||
|
||||
运维错误记录的 `errorDetails` 为受控诊断文本,无详情时为 `null`。
|
||||
@@ -2925,7 +2929,8 @@ GitHub 的 `location` 使用 `kind: "github"`、`repository: "owner/repo"`、`ta
|
||||
创建与更新的配置字段为 `name`、`artifactSha256`、`enabled`、`configuration` 和必填的 `bindings`,
|
||||
`secrets` 可选。输入严格拒绝未知字段。`artifactSha256` 必须指向
|
||||
已接受的制品。`configuration` 是匹配插件 schema 的 JSON 对象;敏感字段必须
|
||||
放入 `secrets`,空对象清除全部值。省略时同版本编辑保留当前值;跨版本编辑优先保留目标版本快照的密钥,没有快照则保留当前值
|
||||
放入 `secrets`,按字段更新:字符串设置对应值,`null` 清除对应字段,未出现的字段保留。省略 `secrets` 或传空对象均不修改密钥
|
||||
同版本编辑以当前值为基础;跨版本编辑优先以目标版本快照的密钥为基础,没有快照则使用当前值。创建以空集合为基础,密钥值不会返回给管理端
|
||||
|
||||
创建可传 `creationId`(标准小写 UUID),同一草稿重试复用该 ID,已保存且内容不同则返回 409,不创建副本或覆盖旧配置。
|
||||
相同内容的重试仍会重新准备并发布,不保证配置 revision 不变。更新可传 `expectedRevision`,与实例列表的 `revision`
|
||||
|
||||
@@ -1239,6 +1239,7 @@ Compose 的 `stop_grace_period` 必须覆盖后台关闭预算并留出进程清
|
||||
下载阶段不以插件合同不兼容阻止安装。重启前由 Admin 检查已安装发行与启用插件,返回绑定发行摘要和配置版本的确认快照。
|
||||
Host 在重启操作锁内重新检查,Admin 复核风险确认与配置版本,不改写插件配置;重启后 Runtime 逐实例加载并隔离失败。
|
||||
重启受理后保持排他状态与跨进程文件锁,直到旧服务释放;延迟触发关闭仅用于送达受理响应。
|
||||
Windows 替换进程通过交接管道等待旧服务及更新锁释放后才继续启动;旧进程异常退出同样会关闭管道。
|
||||
更新与回滚在文件交换前后复核同一插件配置版本,失败或取消时恢复二进制、Web 与官方插件目录
|
||||
|
||||
Host 在受理时持久化任务并转交后台执行,任务持有操作锁与终态写入责任,不依赖 HTTP 请求的生命周期。
|
||||
|
||||
+2
-1
@@ -48,7 +48,8 @@ flowchart LR
|
||||
|
||||
在插件详情中直接启用或停用,设置会保留,无需重新填写。插件没有可设置的参数或认证映射时不显示设置入口
|
||||
|
||||
“设置”按当前包的 schema 显示参数,敏感字段单独保存且不回显。首次补齐必填项后“保存并启用”;其他设置修改保持
|
||||
“设置”按当前包的 schema 显示参数,敏感字段单独保存且不回显。每项显示保存状态,留空保留现有值,填写新值替换该项;
|
||||
“清除”只删除对应字段,保存前可撤销。首次补齐必填项后“保存并启用”;其他设置修改保持
|
||||
原有启停状态。请求处理能力随包自动注册,无需在安装时逐项勾选;可在“高级设置”中调整各挂载阶段的启停、顺序和失败处理。
|
||||
模型请求阶段支持 Key、分组、模型等作用范围;HTTP、WebSocket 消息和服务调用全局生效,具体匹配由插件处理。
|
||||
客户端 Key、账号分组、Provider 和模型等范围同时满足才生效,同一范围内任一值匹配即可
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "版本",
|
||||
|
||||
@@ -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"
|
||||
|
||||
+16
-12
@@ -1,19 +1,23 @@
|
||||
# v3.38.2
|
||||
# v3.39.0
|
||||
|
||||
## 请求稳定性
|
||||
## Windows 支持
|
||||
|
||||
- 修复上游返回长 `Retry-After` 时在原账号空等的问题;新增“上游重试等待上限”,默认 240 秒,超限后按原有重试规则尝试换号,无可用账号时返回上游错误并保留原始等待提示
|
||||
- 在“系统设置 → 运行设置 → 并发与重试”中调整等待上限,统一作用于瞬态重试、WebSocket 传输重试和转 HTTP 回退;该值限制单次建议等待,不是请求总时长
|
||||
- 修复 Codex OAuth 复用未初始化搜索的 WebSocket 连接后首次启用搜索被拒绝的问题:发送前重建不兼容连接,保留搜索参数与完整输入;依赖原连接的增量请求由客户端按既有机制完整重放
|
||||
- 收紧账号模型冷却的识别条件,按结构化 `model_not_found` 错误码及状态判断,避免普通参数错误或仅在提示中提及模型不支持时误触发冷却与换号
|
||||
- 修复代理编辑因版本冲突失败后,紧接着导入账号可能误报代理被占用的问题
|
||||
- 新增 Windows x64 与 ARM64 发行包,静态链接 MSVC 运行库,解压后无需额外安装 VC++ Redistributable
|
||||
- 支持 Windows 管理端在线更新、重启与回滚,处理运行中程序替换、重启交接及只读发行文件清理,并校验 Windows 特有的不安全归档路径
|
||||
- 补充 Windows 二进制部署说明;运行前需准备 PostgreSQL 和 Redis,使用数据库备份时需提供匹配服务端版本的 `pg_dump`
|
||||
|
||||
## 互联与界面
|
||||
## 插件设置
|
||||
|
||||
- 修复 CPR 互联账号池套餐分布遗漏:账号缺少明确套餐时,从已保存的额度快照补全,无需同时开启额度公开;未知套餐仍保持未知
|
||||
- 修正账号列表 CPR 徽标的图文对齐,更新前后端依赖及构建基础镜像
|
||||
- 敏感配置支持逐字段修改,补填一项不会清除其他已保存密钥;留空保留现有值,单项清除可在保存前撤销
|
||||
- 设置弹窗直接展示各敏感字段的保存状态,支持清理目标版本不再使用的字段,并调整表单与 JSON 切换按钮的尺寸
|
||||
|
||||
## 互联与部署
|
||||
|
||||
- 请求记录、Dashboard 最近请求和账号维度诊断补充 CPR 互联徽标,按账号当前互联摘要展示,不改写请求历史快照
|
||||
- 补充小内存部署的 PostgreSQL 预算、应用连接池和 Redis 持久化调优说明,包含生效、验证与回退步骤;默认配置保持不变
|
||||
|
||||
## 升级事项
|
||||
|
||||
- 新增数据库迁移 `0037_retry_after_wait_limit.sql`,服务启动时自动执行,为已有设置填入默认 240 秒
|
||||
- 等待上限设为 `0` 时不接受正数 `Retry-After`;未提供有效等待建议时仍采用原有本地退避,换号继续遵守次数、续接绑定及安全重放条件
|
||||
- 插件管理 API 的 `secrets` 改为逐字段更新:字符串设置对应值,`null` 清除对应字段,未出现的字段保留;省略 `secrets` 或传 `{}` 均不修改密钥。依赖整份替换或空对象清空语义的调用方需调整
|
||||
- 跨插件版本编辑优先使用目标版本保存的密钥快照,没有快照时沿用当前值,再应用本次字段变更
|
||||
- Windows 首次部署请下载对应架构的完整发行包;插件安装包须匹配宿主系统与架构,部署前需验证实际插件运行与上游调用
|
||||
|
||||
@@ -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 @@
|
||||
version: "3.38.2"
|
||||
version: "3.39.0"
|
||||
|
||||
Reference in New Issue
Block a user