From 419c603e79dcbed10230a866f2dd3ef8ba318935 Mon Sep 17 00:00:00 2001 From: n33 <1741170479@qq.com> Date: Tue, 13 Oct 2026 21:56:52 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(providers):=20=E5=B0=86=E6=9C=AC?= =?UTF-8?q?=E6=9C=BA=E9=85=8D=E7=BD=AE=E4=BF=9D=E5=AD=98=E4=B8=BA=E5=8F=AF?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E4=BE=9B=E5=BA=94=E5=95=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src-tauri/src/commands.rs | 25 +- apps/codex-plus-manager/src/App.tsx | 4 +- .../src/local-config-provider.test.ts | 19 ++ crates/codex-plus-core/src/provider_import.rs | 118 ++++++- crates/codex-plus-core/src/relay_config.rs | 3 + crates/codex-plus-core/src/settings.rs | 4 + .../tests/local_config_provider.rs | 294 ++++++++++++++++++ docs/local-config-provider.md | 16 + 8 files changed, 480 insertions(+), 3 deletions(-) create mode 100644 apps/codex-plus-manager/src/local-config-provider.test.ts create mode 100644 crates/codex-plus-core/tests/local_config_provider.rs create mode 100644 docs/local-config-provider.md diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index adf4d7797..fec057c47 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -3136,7 +3136,30 @@ pub async fn native_browser_status() -> NativeBrowserDiagnostics { #[tauri::command] pub fn load_settings() -> CommandResult { - settings_payload("设置已加载。", "设置读取失败") + let Ok(_guard) = relay_switch_mutex().lock() else { + return failed( + "供应商切换锁已损坏,请重启管理器后再试。", + fallback_settings_payload(), + ); + }; + let store = SettingsStore::default(); + let home = codex_plus_core::relay_config::default_codex_home_dir(); + match codex_plus_core::provider_import::initialize_local_config_provider(&store, &home) { + Ok(settings) => ok( + "设置已加载。", + SettingsPayload { + settings, + settings_path: codex_plus_core::paths::default_settings_path() + .to_string_lossy() + .to_string(), + user_scripts: user_script_inventory(), + }, + ), + Err(error) => failed( + &format!("读取本机默认供应商失败:{error}"), + fallback_settings_payload(), + ), + } } #[tauri::command] diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index 10834322d..68513ead5 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -316,6 +316,7 @@ type BackendSettings = WhaleBalanceSettings & { providerSyncManualProviders: string[]; providerSyncLastSelectedProvider: string; relayProfilesEnabled: boolean; + localConfigProviderImported: boolean; enhancementsEnabled: boolean; codexAppStandaloneQuotaUnlock: boolean; codexAppPluginMarketplaceUnlock: boolean; @@ -1101,6 +1102,7 @@ const defaultSettings: BackendSettings = { providerSyncManualProviders: [], providerSyncLastSelectedProvider: "", relayProfilesEnabled: true, + localConfigProviderImported: false, enhancementsEnabled: true, codexAppStandaloneQuotaUnlock: false, codexAppPluginMarketplaceUnlock: true, @@ -14130,7 +14132,7 @@ function relaySettingsWithDraft( } function relayProfileUsesLiveFiles(profile: RelayProfile): boolean { - return profile.relayMode !== "official" || profile.officialMixApiKey; + return profile.relayMode !== "official" || profile.officialMixApiKey || !!profile.configContents.trim(); } function authJsonHasOpenAiApiKey(contents: string): boolean { diff --git a/apps/codex-plus-manager/src/local-config-provider.test.ts b/apps/codex-plus-manager/src/local-config-provider.test.ts new file mode 100644 index 000000000..689dbf8d6 --- /dev/null +++ b/apps/codex-plus-manager/src/local-config-provider.test.ts @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import ts from "typescript"; + +test("官方本机配置保留可编辑快照并接入现有文件编辑路径", async () => { + const source = await readFile(new URL("./App.tsx", import.meta.url), "utf8"); + const file = ts.createSourceFile("App.tsx", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const fn = file.statements.find((node) => ts.isFunctionDeclaration(node) + && node.name?.text === "relayProfileUsesLiveFiles"); + assert.ok(fn); + const compiled = ts.transpileModule(fn.getText(file), { + compilerOptions: { target: ts.ScriptTarget.ES2022 }, + }).outputText; + const usesFiles = new Function(`${compiled}; return relayProfileUsesLiveFiles;`)(); + assert.equal(usesFiles({ relayMode: "official", officialMixApiKey: false, configContents: 'model = "gpt-5.5"' }), true); + assert.equal(usesFiles({ relayMode: "official", officialMixApiKey: false, configContents: "" }), false); + assert.equal(usesFiles({ relayMode: "pureApi", officialMixApiKey: false, configContents: "" }), true); +}); diff --git a/crates/codex-plus-core/src/provider_import.rs b/crates/codex-plus-core/src/provider_import.rs index a1560bf84..0a885ada3 100644 --- a/crates/codex-plus-core/src/provider_import.rs +++ b/crates/codex-plus-core/src/provider_import.rs @@ -1,4 +1,4 @@ -use crate::settings::{RelayMode, RelayProfile, RelayProtocol, SettingsStore}; +use crate::settings::{BackendSettings, RelayMode, RelayProfile, RelayProtocol, SettingsStore}; use anyhow::Context; use std::path::Path; @@ -26,6 +26,122 @@ pub struct ProviderImportResult { pub profile_name: String, } +/// 将本机配置保存为普通供应商;后续读取不覆盖用户对该供应商的修改。 +pub fn initialize_local_config_provider( + store: &SettingsStore, + home: &Path, +) -> anyhow::Result { + let mut settings = store.load()?; + if settings.local_config_provider_imported { + return Ok(settings); + } + if !settings + .relay_profiles + .iter() + .any(|profile| matches!(profile.name.as_str(), "系统默认" | "系统默认配置")) + { + let mut profile = RelayProfile { + use_common_config: false, + ..RelayProfile::default() + }; + let read_local = |name: &str| -> anyhow::Result { + match std::fs::read_to_string(home.join(name)) { + Ok(contents) => Ok(contents), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(String::new()), + Err(error) => Err(error).with_context(|| format!("无法读取本机 {name}")), + } + }; + profile.config_contents = read_local("config.toml")?; + profile.auth_contents = read_local("auth.json")?; + let config: toml::Value = profile + .config_contents + .parse() + .context("本机 config.toml 格式无效")?; + let auth: serde_json::Value = if profile.auth_contents.trim().is_empty() { + serde_json::json!({}) + } else { + serde_json::from_str(&profile.auth_contents).context("本机 auth.json 格式无效")? + }; + let provider_id = config + .get("model_provider") + .and_then(toml::Value::as_str) + .unwrap_or("openai"); + let provider = config + .get("model_providers") + .and_then(|providers| providers.get(provider_id)); + let wire_api = provider + .and_then(|value| value.get("wire_api")) + .and_then(toml::Value::as_str); + if matches!(wire_api, Some("chat" | "chat_completions")) + || config + .get("codex_plus_chat_base_url") + .and_then(toml::Value::as_str) + .is_some_and(|value| !value.trim().is_empty()) + { + profile.protocol = RelayProtocol::ChatCompletions; + } + profile.base_url = crate::relay_config::relay_profile_base_url(&profile); + profile.upstream_base_url = profile.base_url.clone(); + profile.relay_mode = RelayMode::PureApi; + profile.api_key = crate::relay_config::relay_profile_api_key(&profile); + let api_auth = auth + .get("OPENAI_API_KEY") + .and_then(serde_json::Value::as_str) + .is_some_and(|key| !key.trim().is_empty()); + profile.no_auth = !api_auth + && profile.api_key.is_empty() + && !profile.base_url.is_empty() + && provider + .and_then(|value| value.get("requires_openai_auth")) + .and_then(toml::Value::as_bool) + == Some(false); + if !api_auth && profile.api_key.is_empty() && !profile.no_auth { + profile.relay_mode = RelayMode::Official; + profile.official_mix_api_key = !profile.base_url.is_empty(); + } + + // 本机文件指向工具的协议代理时,复用当前供应商保存的真实上游和凭证。 + // 直接把代理地址当上游保存,会在再次切换后形成回环。 + if settings.relay_profiles_enabled + && crate::relay_config::responses_proxy_configured_in_home(home) + { + profile = settings.active_relay_profile(); + profile.use_common_config = false; + crate::relay_config::backfill_relay_profile_from_home_with_common( + home, + &mut profile, + &mut String::new(), + )?; + } else { + crate::relay_config::backfill_relay_profile_from_home_with_common_and_policy( + home, + &mut profile, + &mut String::new(), + crate::relay_config::RelayBackfillPolicy::AdoptLiveIdentity, + )?; + } + profile.id = format!("relay-{}", uuid::Uuid::new_v4()); + profile.name = "系统默认".to_string(); + profile.model_list = profile.model.clone(); + + if settings.relay_profiles == crate::settings::default_relay_profiles() { + let legacy = settings.active_relay_profile(); + if legacy == RelayProfile::default() { + settings.relay_profiles.clear(); + } else { + settings.relay_profiles[0] = legacy; + } + } + if settings.relay_profiles.is_empty() { + settings.active_relay_id = profile.id.clone(); + } + settings.relay_profiles.insert(0, profile); + } + settings.local_config_provider_imported = true; + store.save(&settings)?; + Ok(settings) +} + pub fn import_provider_from_url(url: &str) -> anyhow::Result { let request = request_from_url(url)?; import_provider(request) diff --git a/crates/codex-plus-core/src/relay_config.rs b/crates/codex-plus-core/src/relay_config.rs index dbf0f2cfa..a4d1443f2 100644 --- a/crates/codex-plus-core/src/relay_config.rs +++ b/crates/codex-plus-core/src/relay_config.rs @@ -621,6 +621,9 @@ fn model_list_head(profile: &RelayProfile) -> String { /// 后续的目标任务对齐,模型漂移重现。值与当前目标任务模型相等时视为工具 /// 写入是安全的:apply 每次都会按最新目标模型重新注入,不需要 backfill 保存。 fn live_model_is_tool_written(home: &Path, profile: &RelayProfile, model: &str) -> bool { + if profile.relay_mode == RelayMode::Official && !profile.official_mix_api_key { + return false; + } let model = model.trim(); if model.is_empty() { return false; diff --git a/crates/codex-plus-core/src/settings.rs b/crates/codex-plus-core/src/settings.rs index a964d7ebe..ff023e699 100644 --- a/crates/codex-plus-core/src/settings.rs +++ b/crates/codex-plus-core/src/settings.rs @@ -715,6 +715,9 @@ pub struct BackendSettings { pub relay_api_key: String, #[serde(rename = "relayProfiles", default = "default_relay_profiles")] pub relay_profiles: Vec, + /// 本机配置只自动导入一次,删除或重命名后不再补回。 + #[serde(rename = "localConfigProviderImported", default)] + pub local_config_provider_imported: bool, #[serde(rename = "relayCommonConfigContents", default)] pub relay_common_config_contents: String, #[serde(rename = "relayContextConfigContents", default)] @@ -812,6 +815,7 @@ impl Default for BackendSettings { relay_base_url: default_relay_base_url(), relay_api_key: String::new(), relay_profiles: default_relay_profiles(), + local_config_provider_imported: false, relay_common_config_contents: String::new(), relay_context_config_contents: String::new(), active_relay_id: default_active_relay_id(), diff --git a/crates/codex-plus-core/tests/local_config_provider.rs b/crates/codex-plus-core/tests/local_config_provider.rs new file mode 100644 index 000000000..919d8bf03 --- /dev/null +++ b/crates/codex-plus-core/tests/local_config_provider.rs @@ -0,0 +1,294 @@ +use codex_plus_core::provider_import::initialize_local_config_provider; +use codex_plus_core::settings::{ + BackendSettings, RelayMode, RelayProfile, RelayProtocol, SettingsStore, +}; + +fn api_profile(id: &str) -> RelayProfile { + RelayProfile { + id: id.to_string(), + name: id.to_string(), + relay_mode: RelayMode::PureApi, + base_url: format!("https://{id}.example/v1"), + api_key: format!("test-{id}"), + config_contents: format!( + "model = \"{id}-model\"\nmodel_provider = \"{id}\"\n[model_providers.{id}]\nname = \"{id}\"\nbase_url = \"https://{id}.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n" + ), + auth_contents: format!(r#"{{"OPENAI_API_KEY":"test-{id}"}}"#), + use_common_config: false, + ..RelayProfile::default() + } +} + +#[test] +fn imports_local_files_as_an_ordinary_selectable_provider() { + let temp = tempfile::tempdir().unwrap(); + let store = SettingsStore::new(temp.path().join("settings.json")); + store + .save(&BackendSettings { + relay_profiles_enabled: false, + ..BackendSettings::default() + }) + .unwrap(); + let local = api_profile("local"); + std::fs::write(temp.path().join("config.toml"), &local.config_contents).unwrap(); + std::fs::write(temp.path().join("auth.json"), &local.auth_contents).unwrap(); + + let settings = initialize_local_config_provider(&store, temp.path()).unwrap(); + assert_eq!(settings.relay_profiles.len(), 1); + let imported = &settings.relay_profiles[0]; + assert_eq!(imported.name, "系统默认"); + assert_eq!(imported.relay_mode, RelayMode::PureApi); + assert_eq!(imported.base_url, local.base_url); + assert_eq!(imported.api_key, local.api_key); + assert_eq!(imported.model_list, "local-model"); + let config: toml::Value = imported.config_contents.parse().unwrap(); + assert_eq!(config["model"].as_str(), Some("local-model")); + assert_eq!(config["model_providers"]["local"]["base_url"].as_str(), Some(local.base_url.as_str())); + assert_eq!( + serde_json::from_str::(&imported.auth_contents).unwrap(), + serde_json::from_str::(&local.auth_contents).unwrap() + ); + assert_eq!(settings.active_relay_id, imported.id); + assert!(settings.local_config_provider_imported); + assert!(!imported.use_common_config); + assert!(!settings.relay_profiles_enabled); + assert_eq!( + std::fs::read_to_string(temp.path().join("config.toml")).unwrap(), + local.config_contents + ); + assert_eq!( + std::fs::read_to_string(temp.path().join("auth.json")).unwrap(), + local.auth_contents + ); +} + +#[test] +fn preserves_existing_selection_and_does_not_reimport_after_edit_or_delete() { + let temp = tempfile::tempdir().unwrap(); + let store = SettingsStore::new(temp.path().join("settings.json")); + let existing = api_profile("existing"); + store + .save(&BackendSettings { + relay_profiles: vec![existing.clone()], + active_relay_id: existing.id.clone(), + relay_profiles_enabled: true, + ..BackendSettings::default() + }) + .unwrap(); + let existing = store.load().unwrap().relay_profiles[0].clone(); + let mut settings = initialize_local_config_provider(&store, temp.path()).unwrap(); + assert_eq!(settings.relay_profiles.len(), 2); + assert_eq!(settings.active_relay_id, existing.id); + assert_eq!(settings.relay_profiles[1], existing); + let imported_id = settings.relay_profiles[0].id.clone(); + settings.relay_profiles[0].name = "我的本地配置".to_string(); + store.save(&settings).unwrap(); + std::fs::write(temp.path().join("config.toml"), "invalid [").unwrap(); + let mut reloaded = initialize_local_config_provider(&store, temp.path()).unwrap(); + assert_eq!(reloaded.relay_profiles[0].name, "我的本地配置"); + reloaded + .relay_profiles + .retain(|profile| profile.id != imported_id); + store.save(&reloaded).unwrap(); + assert_eq!( + initialize_local_config_provider(&store, temp.path()) + .unwrap() + .relay_profiles, + vec![existing] + ); +} + +#[test] +fn missing_local_files_create_an_editable_official_provider() { + let temp = tempfile::tempdir().unwrap(); + let store = SettingsStore::new(temp.path().join("settings.json")); + let settings = initialize_local_config_provider(&store, temp.path()).unwrap(); + assert_eq!(settings.relay_profiles.len(), 1); + assert_eq!(settings.relay_profiles[0].relay_mode, RelayMode::Official); + assert!(!settings.relay_profiles[0].official_mix_api_key); + assert!(!temp.path().join("config.toml").exists()); + assert!(!temp.path().join("auth.json").exists()); +} + +#[test] +fn invalid_local_config_does_not_mark_import_complete_or_change_settings() { + let temp = tempfile::tempdir().unwrap(); + let store = SettingsStore::new(temp.path().join("settings.json")); + store.save(&BackendSettings::default()).unwrap(); + let before = std::fs::read(temp.path().join("settings.json")).unwrap(); + std::fs::write(temp.path().join("config.toml"), "invalid [").unwrap(); + assert!(initialize_local_config_provider(&store, temp.path()).is_err()); + assert_eq!( + std::fs::read(temp.path().join("settings.json")).unwrap(), + before + ); + std::fs::write(temp.path().join("config.toml"), "model = \"gpt-5.5\"\n").unwrap(); + assert!( + initialize_local_config_provider(&store, temp.path()) + .unwrap() + .local_config_provider_imported + ); +} + +#[test] +fn keeps_a_previously_imported_default_and_legacy_credentials() { + let temp = tempfile::tempdir().unwrap(); + let store = SettingsStore::new(temp.path().join("settings.json")); + store + .save(&BackendSettings { + relay_base_url: "https://legacy.example/v1".to_string(), + relay_api_key: "legacy-key".to_string(), + ..BackendSettings::default() + }) + .unwrap(); + let settings = initialize_local_config_provider(&store, temp.path()).unwrap(); + assert_eq!(settings.active_relay_id, "default"); + assert_eq!(settings.active_relay_profile().api_key, "legacy-key"); + assert_eq!(settings.relay_profiles.len(), 2); + + let mut existing_default = api_profile("imported"); + existing_default.name = "系统默认配置".to_string(); + store + .save(&BackendSettings { + relay_profiles: vec![existing_default.clone()], + active_relay_id: existing_default.id.clone(), + ..BackendSettings::default() + }) + .unwrap(); + let saved = store.load().unwrap().relay_profiles; + assert_eq!( + initialize_local_config_provider(&store, temp.path()) + .unwrap() + .relay_profiles, + saved + ); +} + +#[test] +fn local_proxy_import_keeps_real_upstream_and_protocol() { + let temp = tempfile::tempdir().unwrap(); + let store = SettingsStore::new(temp.path().join("settings.json")); + let mut existing = api_profile("custom"); + existing.protocol = RelayProtocol::ChatCompletions; + existing.upstream_base_url = existing.base_url.clone(); + store + .save(&BackendSettings { + relay_profiles: vec![existing.clone()], + active_relay_id: existing.id.clone(), + relay_profiles_enabled: true, + ..BackendSettings::default() + }) + .unwrap(); + let proxy = codex_plus_core::protocol_proxy::local_responses_proxy_base_url( + codex_plus_core::protocol_proxy::DEFAULT_PROTOCOL_PROXY_PORT, + ); + std::fs::write( + temp.path().join("config.toml"), + existing.config_contents.replace(&existing.base_url, &proxy), + ) + .unwrap(); + std::fs::write(temp.path().join("auth.json"), &existing.auth_contents).unwrap(); + let settings = initialize_local_config_provider(&store, temp.path()).unwrap(); + assert_eq!( + settings.relay_profiles[0].protocol, + RelayProtocol::ChatCompletions + ); + assert_eq!( + settings.relay_profiles[0].upstream_base_url, + existing.base_url + ); + assert_eq!(settings.relay_profiles[0].api_key, existing.api_key); +} + +#[test] +fn default_provider_uses_the_same_switch_path_as_other_providers() { + let temp = tempfile::tempdir().unwrap(); + let store = SettingsStore::new(temp.path().join("settings.json")); + let local = api_profile("local"); + std::fs::write(temp.path().join("config.toml"), &local.config_contents).unwrap(); + std::fs::write(temp.path().join("auth.json"), &local.auth_contents).unwrap(); + let mut settings = initialize_local_config_provider(&store, temp.path()).unwrap(); + let imported_id = settings.active_relay_id.clone(); + settings.relay_profiles_enabled = true; + settings.relay_profiles.push(api_profile("other")); + settings.active_relay_id = "other".to_string(); + let switched = codex_plus_core::relay_switch::switch_relay_profile_in_home( + &store, + temp.path(), + settings, + &imported_id, + ) + .unwrap(); + assert!( + std::fs::read_to_string(temp.path().join("config.toml")) + .unwrap() + .contains("other.example") + ); + let mut settings = switched.settings; + settings.active_relay_id = imported_id.clone(); + let restored = codex_plus_core::relay_switch::switch_relay_profile_in_home( + &store, + temp.path(), + settings, + "other", + ) + .unwrap(); + assert_eq!(restored.settings.active_relay_id, imported_id); + assert!(restored.settings.relay_profiles_enabled); + assert!( + std::fs::read_to_string(temp.path().join("config.toml")) + .unwrap() + .contains("local.example") + ); + assert!( + std::fs::read_to_string(temp.path().join("auth.json")) + .unwrap() + .contains("test-local") + ); +} + +#[test] +fn switching_back_to_imported_official_provider_restores_its_local_config() { + let temp = tempfile::tempdir().unwrap(); + let store = SettingsStore::new(temp.path().join("settings.json")); + std::fs::write( + temp.path().join("config.toml"), + "model = \"gpt-5.5\"\napproval_policy = \"never\"\n", + ) + .unwrap(); + std::fs::write( + temp.path().join("auth.json"), + r#"{"auth_mode":"chatgpt","tokens":{"access_token":"local-token"}}"#, + ) + .unwrap(); + let mut settings = initialize_local_config_provider(&store, temp.path()).unwrap(); + let imported_id = settings.active_relay_id.clone(); + settings.relay_profiles_enabled = true; + settings.relay_profiles.push(api_profile("other")); + settings.active_relay_id = "other".to_string(); + let switched = codex_plus_core::relay_switch::switch_relay_profile_in_home( + &store, + temp.path(), + settings, + &imported_id, + ) + .unwrap(); + let mut settings = switched.settings; + settings.active_relay_id = imported_id; + codex_plus_core::relay_switch::switch_relay_profile_in_home( + &store, + temp.path(), + settings, + "other", + ) + .unwrap(); + let config = std::fs::read_to_string(temp.path().join("config.toml")).unwrap(); + assert!(config.contains("gpt-5.5")); + assert!(config.contains("approval_policy = \"never\"")); + assert!(!config.contains("other-model")); + assert!( + std::fs::read_to_string(temp.path().join("auth.json")) + .unwrap() + .contains("local-token") + ); +} diff --git a/docs/local-config-provider.md b/docs/local-config-provider.md new file mode 100644 index 000000000..d9bb019a5 --- /dev/null +++ b/docs/local-config-provider.md @@ -0,0 +1,16 @@ +# 本机配置供应商 + +管理器首次读取设置时,将当前 `CODEX_HOME` 的 `config.toml` 和 `auth.json` +保存为普通可编辑的“系统默认”供应商。已有供应商选择和开关保持不变, +不会在导入时写入本机配置。后续编辑、重命名或删除该快照后不重复导入。 + +导入复用现有配置回填和身份校验;本机配置已经指向托管协议代理时, +从当前供应商恢复真实上游,避免把代理自身保存为上游。 +官方登录和 API 配置都通过现有供应商切换流程应用。 + +配置损坏或导入期间身份变化时保留原设置,管理器报告读取失败。 +回归仅使用临时目录,不修改正在使用的 Codex 配置: + +```sh +cargo test -p codex-plus-core --test local_config_provider +``` From 47b2fcf955c63f67815c084d2c3440466a8e4684 Mon Sep 17 00:00:00 2001 From: n33 <1741170479@qq.com> Date: Thu, 15 Oct 2026 14:54:22 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(providers):=20=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E8=81=9A=E5=90=88=E4=BE=9B=E5=BA=94=E5=95=86=E6=97=B6=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E6=88=90=E5=91=98=E5=AE=9A=E4=B9=89=E4=B8=8E=E8=B7=AF?= =?UTF-8?q?=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/codex-plus-core/src/provider_import.rs | 17 +++ .../tests/local_config_provider.rs | 121 +++++++++++++++++- docs/local-config-provider.md | 2 + 3 files changed, 138 insertions(+), 2 deletions(-) diff --git a/crates/codex-plus-core/src/provider_import.rs b/crates/codex-plus-core/src/provider_import.rs index 0a885ada3..2e3cd32c9 100644 --- a/crates/codex-plus-core/src/provider_import.rs +++ b/crates/codex-plus-core/src/provider_import.rs @@ -120,9 +120,26 @@ pub fn initialize_local_config_provider( crate::relay_config::RelayBackfillPolicy::AdoptLiveIdentity, )?; } + let aggregate = if profile.relay_mode == RelayMode::Aggregate { + Some( + settings + .aggregate_relay_profiles + .iter() + .find(|aggregate| aggregate.id == profile.id) + .cloned() + .context("当前聚合供应商缺少成员定义,无法导入本机配置")?, + ) + } else { + None + }; profile.id = format!("relay-{}", uuid::Uuid::new_v4()); profile.name = "系统默认".to_string(); profile.model_list = profile.model.clone(); + if let Some(mut aggregate) = aggregate { + aggregate.id = profile.id.clone(); + aggregate.name = profile.name.clone(); + settings.aggregate_relay_profiles.push(aggregate); + } if settings.relay_profiles == crate::settings::default_relay_profiles() { let legacy = settings.active_relay_profile(); diff --git a/crates/codex-plus-core/tests/local_config_provider.rs b/crates/codex-plus-core/tests/local_config_provider.rs index 919d8bf03..c08348978 100644 --- a/crates/codex-plus-core/tests/local_config_provider.rs +++ b/crates/codex-plus-core/tests/local_config_provider.rs @@ -1,6 +1,7 @@ use codex_plus_core::provider_import::initialize_local_config_provider; use codex_plus_core::settings::{ - BackendSettings, RelayMode, RelayProfile, RelayProtocol, SettingsStore, + AggregateRelayMember, AggregateRelayProfile, AggregateRelayRoute, AggregateRelayStrategy, + BackendSettings, RelayMode, RelayProfile, RelayProtocol, RelaySessionProvider, SettingsStore, }; fn api_profile(id: &str) -> RelayProfile { @@ -43,7 +44,10 @@ fn imports_local_files_as_an_ordinary_selectable_provider() { assert_eq!(imported.model_list, "local-model"); let config: toml::Value = imported.config_contents.parse().unwrap(); assert_eq!(config["model"].as_str(), Some("local-model")); - assert_eq!(config["model_providers"]["local"]["base_url"].as_str(), Some(local.base_url.as_str())); + assert_eq!( + config["model_providers"]["local"]["base_url"].as_str(), + Some(local.base_url.as_str()) + ); assert_eq!( serde_json::from_str::(&imported.auth_contents).unwrap(), serde_json::from_str::(&local.auth_contents).unwrap() @@ -200,6 +204,119 @@ fn local_proxy_import_keeps_real_upstream_and_protocol() { assert_eq!(settings.relay_profiles[0].api_key, existing.api_key); } +#[test] +fn imported_aggregate_preserves_members_and_routes_after_switching_away_and_back() { + let temp = tempfile::tempdir().unwrap(); + let store = SettingsStore::new(temp.path().join("settings.json")); + let aggregate = AggregateRelayProfile { + id: "aggregate".into(), + name: "聚合".into(), + session_provider: RelaySessionProvider::Custom, + model_aggregation_enabled: false, + strategy: AggregateRelayStrategy::WeightedRoundRobin, + members: vec![ + AggregateRelayMember { + relay_id: "first".into(), + weight: 2, + }, + AggregateRelayMember { + relay_id: "second".into(), + weight: 3, + }, + ], + routes: vec![AggregateRelayRoute { + pattern: "second-*".into(), + relay_id: "second".into(), + priority: 5, + }], + default_relay_id: "first".into(), + }; + let settings = BackendSettings { + relay_profiles_enabled: true, + active_relay_id: aggregate.id.clone(), + active_aggregate_relay_id: aggregate.id.clone(), + relay_profiles: vec![ + RelayProfile { + id: aggregate.id.clone(), + name: aggregate.name.clone(), + relay_mode: RelayMode::Aggregate, + use_common_config: false, + ..RelayProfile::default() + }, + api_profile("first"), + api_profile("second"), + ], + aggregate_relay_profiles: vec![aggregate.clone()], + ..BackendSettings::default() + }; + codex_plus_core::relay_switch::switch_relay_profile_in_home(&store, temp.path(), settings, "") + .unwrap(); + assert!(codex_plus_core::relay_config::responses_proxy_configured_in_home(temp.path())); + let mut settings = initialize_local_config_provider(&store, temp.path()).unwrap(); + let imported = settings.relay_profiles[0].clone(); + assert_ne!(imported.id, aggregate.id); + assert_eq!(imported.relay_mode, RelayMode::Aggregate); + assert_eq!(settings.active_relay_id, aggregate.id); + assert_eq!( + settings.active_aggregate_relay_profile(), + Some(aggregate.clone()) + ); + let mut expected = aggregate.clone(); + expected.id = imported.id.clone(); + expected.name = imported.name.clone(); + assert_eq!( + settings + .aggregate_relay_profiles + .iter() + .find(|item| item.id == imported.id), + Some(&expected) + ); + + settings.active_relay_id = "first".into(); + settings.active_aggregate_relay_id.clear(); + let switched = codex_plus_core::relay_switch::switch_relay_profile_in_home( + &store, + temp.path(), + settings, + &aggregate.id, + ) + .unwrap(); + let mut settings = switched.settings; + settings.active_relay_id = imported.id.clone(); + settings.active_aggregate_relay_id = imported.id.clone(); + let restored = codex_plus_core::relay_switch::switch_relay_profile_in_home( + &store, + temp.path(), + settings, + "first", + ) + .unwrap(); + assert_eq!( + restored.settings.active_aggregate_relay_profile(), + Some(expected) + ); + assert!( + restored + .settings + .active_relay_transport_uses_protocol_proxy() + ); + assert!(codex_plus_core::relay_config::responses_proxy_configured_in_home(temp.path())); + let selected = codex_plus_core::relay_rotation::select_relay_for_request( + &restored.settings, + codex_plus_core::relay_rotation::RotationContext { + model: Some("second-model".into()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(selected.id, "second"); + let reloaded = initialize_local_config_provider(&store, temp.path()).unwrap(); + assert_eq!( + reloaded.aggregate_relay_profiles, + restored.settings.aggregate_relay_profiles + ); +} + #[test] fn default_provider_uses_the_same_switch_path_as_other_providers() { let temp = tempfile::tempdir().unwrap(); diff --git a/docs/local-config-provider.md b/docs/local-config-provider.md index d9bb019a5..63153ff1b 100644 --- a/docs/local-config-provider.md +++ b/docs/local-config-provider.md @@ -6,6 +6,8 @@ 导入复用现有配置回填和身份校验;本机配置已经指向托管协议代理时, 从当前供应商恢复真实上游,避免把代理自身保存为上游。 +当前供应商为聚合时,同时复制聚合定义并关联到快照的新 ID,保留成员权重、 +路由、默认成员及模型聚合设置;切换回该快照时继续使用协议代理。 官方登录和 API 配置都通过现有供应商切换流程应用。 配置损坏或导入期间身份变化时保留原设置,管理器报告读取失败。