mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 16:26:16 +08:00
Merge branch 'main' into fix/skill-file-extension-1240
This commit is contained in:
Generated
+1
-1
@@ -672,7 +672,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc-switch"
|
||||
version = "3.12.1"
|
||||
version = "3.12.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cc-switch"
|
||||
version = "3.12.1"
|
||||
version = "3.12.2"
|
||||
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||
authors = ["Jason Young"]
|
||||
license = "MIT"
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::error::AppError;
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use toml_edit::DocumentMut;
|
||||
|
||||
/// 获取 Codex 配置目录路径
|
||||
pub fn get_codex_config_dir() -> PathBuf {
|
||||
@@ -135,3 +136,335 @@ pub fn read_and_validate_codex_config_text() -> Result<String, AppError> {
|
||||
validate_config_toml(&s)?;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// Update a field in Codex config.toml using toml_edit (syntax-preserving).
|
||||
///
|
||||
/// Supported fields:
|
||||
/// - `"base_url"`: writes to `[model_providers.<current>].base_url` if `model_provider` exists,
|
||||
/// otherwise falls back to top-level `base_url`.
|
||||
/// - `"model"`: writes to top-level `model` field.
|
||||
///
|
||||
/// Empty value removes the field.
|
||||
pub fn update_codex_toml_field(toml_str: &str, field: &str, value: &str) -> Result<String, String> {
|
||||
let mut doc = toml_str
|
||||
.parse::<DocumentMut>()
|
||||
.map_err(|e| format!("TOML parse error: {e}"))?;
|
||||
|
||||
let trimmed = value.trim();
|
||||
|
||||
match field {
|
||||
"base_url" => {
|
||||
let model_provider = doc
|
||||
.get("model_provider")
|
||||
.and_then(|item| item.as_str())
|
||||
.map(str::to_string);
|
||||
|
||||
if let Some(provider_key) = model_provider {
|
||||
// Ensure [model_providers] table exists
|
||||
if doc.get("model_providers").is_none() {
|
||||
doc["model_providers"] = toml_edit::table();
|
||||
}
|
||||
|
||||
if let Some(model_providers) = doc["model_providers"].as_table_mut() {
|
||||
// Ensure [model_providers.<provider_key>] table exists
|
||||
if !model_providers.contains_key(&provider_key) {
|
||||
model_providers[&provider_key] = toml_edit::table();
|
||||
}
|
||||
|
||||
if let Some(provider_table) = model_providers[&provider_key].as_table_mut() {
|
||||
if trimmed.is_empty() {
|
||||
provider_table.remove("base_url");
|
||||
} else {
|
||||
provider_table["base_url"] = toml_edit::value(trimmed);
|
||||
}
|
||||
return Ok(doc.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: no model_provider or structure mismatch → top-level base_url
|
||||
if trimmed.is_empty() {
|
||||
doc.as_table_mut().remove("base_url");
|
||||
} else {
|
||||
doc["base_url"] = toml_edit::value(trimmed);
|
||||
}
|
||||
}
|
||||
"model" => {
|
||||
if trimmed.is_empty() {
|
||||
doc.as_table_mut().remove("model");
|
||||
} else {
|
||||
doc["model"] = toml_edit::value(trimmed);
|
||||
}
|
||||
}
|
||||
_ => return Err(format!("unsupported field: {field}")),
|
||||
}
|
||||
|
||||
Ok(doc.to_string())
|
||||
}
|
||||
|
||||
/// Remove `base_url` from the active model_provider section only if it matches `predicate`.
|
||||
/// Also removes top-level `base_url` if it matches.
|
||||
/// Used by proxy cleanup to strip local proxy URLs without touching user-configured URLs.
|
||||
pub fn remove_codex_toml_base_url_if(toml_str: &str, predicate: impl Fn(&str) -> bool) -> String {
|
||||
let mut doc = match toml_str.parse::<DocumentMut>() {
|
||||
Ok(doc) => doc,
|
||||
Err(_) => return toml_str.to_string(),
|
||||
};
|
||||
|
||||
let model_provider = doc
|
||||
.get("model_provider")
|
||||
.and_then(|item| item.as_str())
|
||||
.map(str::to_string);
|
||||
|
||||
if let Some(provider_key) = model_provider {
|
||||
if let Some(model_providers) = doc
|
||||
.get_mut("model_providers")
|
||||
.and_then(|v| v.as_table_mut())
|
||||
{
|
||||
if let Some(provider_table) = model_providers
|
||||
.get_mut(provider_key.as_str())
|
||||
.and_then(|v| v.as_table_mut())
|
||||
{
|
||||
let should_remove = provider_table
|
||||
.get("base_url")
|
||||
.and_then(|item| item.as_str())
|
||||
.map(&predicate)
|
||||
.unwrap_or(false);
|
||||
if should_remove {
|
||||
provider_table.remove("base_url");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: also clean up top-level base_url if it matches
|
||||
let should_remove_root = doc
|
||||
.get("base_url")
|
||||
.and_then(|item| item.as_str())
|
||||
.map(&predicate)
|
||||
.unwrap_or(false);
|
||||
if should_remove_root {
|
||||
doc.as_table_mut().remove("base_url");
|
||||
}
|
||||
|
||||
doc.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn base_url_writes_into_correct_model_provider_section() {
|
||||
let input = r#"model_provider = "any"
|
||||
model = "gpt-5.1-codex"
|
||||
|
||||
[model_providers.any]
|
||||
name = "any"
|
||||
wire_api = "responses"
|
||||
"#;
|
||||
|
||||
let result = update_codex_toml_field(input, "base_url", "https://example.com/v1").unwrap();
|
||||
let parsed: toml::Value = toml::from_str(&result).unwrap();
|
||||
|
||||
let base_url = parsed
|
||||
.get("model_providers")
|
||||
.and_then(|v| v.get("any"))
|
||||
.and_then(|v| v.get("base_url"))
|
||||
.and_then(|v| v.as_str())
|
||||
.expect("base_url should be in model_providers.any");
|
||||
assert_eq!(base_url, "https://example.com/v1");
|
||||
|
||||
// Should NOT have top-level base_url
|
||||
assert!(parsed.get("base_url").is_none());
|
||||
|
||||
// wire_api preserved
|
||||
let wire_api = parsed
|
||||
.get("model_providers")
|
||||
.and_then(|v| v.get("any"))
|
||||
.and_then(|v| v.get("wire_api"))
|
||||
.and_then(|v| v.as_str());
|
||||
assert_eq!(wire_api, Some("responses"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_url_creates_section_when_missing() {
|
||||
let input = r#"model_provider = "custom"
|
||||
model = "gpt-4"
|
||||
"#;
|
||||
|
||||
let result = update_codex_toml_field(input, "base_url", "https://custom.api/v1").unwrap();
|
||||
let parsed: toml::Value = toml::from_str(&result).unwrap();
|
||||
|
||||
let base_url = parsed
|
||||
.get("model_providers")
|
||||
.and_then(|v| v.get("custom"))
|
||||
.and_then(|v| v.get("base_url"))
|
||||
.and_then(|v| v.as_str())
|
||||
.expect("should create section and set base_url");
|
||||
assert_eq!(base_url, "https://custom.api/v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_url_falls_back_to_top_level_without_model_provider() {
|
||||
let input = r#"model = "gpt-4"
|
||||
"#;
|
||||
|
||||
let result = update_codex_toml_field(input, "base_url", "https://fallback.api/v1").unwrap();
|
||||
let parsed: toml::Value = toml::from_str(&result).unwrap();
|
||||
|
||||
let base_url = parsed
|
||||
.get("base_url")
|
||||
.and_then(|v| v.as_str())
|
||||
.expect("should set top-level base_url");
|
||||
assert_eq!(base_url, "https://fallback.api/v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_base_url_removes_only_from_correct_section() {
|
||||
let input = r#"model_provider = "any"
|
||||
|
||||
[model_providers.any]
|
||||
name = "any"
|
||||
base_url = "https://old.api/v1"
|
||||
wire_api = "responses"
|
||||
|
||||
[mcp_servers.context7]
|
||||
command = "npx"
|
||||
"#;
|
||||
|
||||
let result = update_codex_toml_field(input, "base_url", "").unwrap();
|
||||
let parsed: toml::Value = toml::from_str(&result).unwrap();
|
||||
|
||||
// base_url removed from model_providers.any
|
||||
let any_section = parsed
|
||||
.get("model_providers")
|
||||
.and_then(|v| v.get("any"))
|
||||
.expect("model_providers.any should exist");
|
||||
assert!(any_section.get("base_url").is_none());
|
||||
|
||||
// wire_api preserved
|
||||
assert_eq!(
|
||||
any_section.get("wire_api").and_then(|v| v.as_str()),
|
||||
Some("responses")
|
||||
);
|
||||
|
||||
// mcp_servers untouched
|
||||
assert!(parsed.get("mcp_servers").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_field_operates_on_top_level() {
|
||||
let input = r#"model_provider = "any"
|
||||
model = "gpt-4"
|
||||
|
||||
[model_providers.any]
|
||||
name = "any"
|
||||
"#;
|
||||
|
||||
let result = update_codex_toml_field(input, "model", "gpt-5").unwrap();
|
||||
let parsed: toml::Value = toml::from_str(&result).unwrap();
|
||||
assert_eq!(parsed.get("model").and_then(|v| v.as_str()), Some("gpt-5"));
|
||||
|
||||
// Clear model
|
||||
let result2 = update_codex_toml_field(&result, "model", "").unwrap();
|
||||
let parsed2: toml::Value = toml::from_str(&result2).unwrap();
|
||||
assert!(parsed2.get("model").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_comments_and_whitespace() {
|
||||
let input = r#"# My Codex config
|
||||
model_provider = "any"
|
||||
model = "gpt-4"
|
||||
|
||||
# Provider section
|
||||
[model_providers.any]
|
||||
name = "any"
|
||||
base_url = "https://old.api/v1"
|
||||
"#;
|
||||
|
||||
let result = update_codex_toml_field(input, "base_url", "https://new.api/v1").unwrap();
|
||||
|
||||
// Comments should be preserved
|
||||
assert!(result.contains("# My Codex config"));
|
||||
assert!(result.contains("# Provider section"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_misplace_when_profiles_section_follows() {
|
||||
let input = r#"model_provider = "any"
|
||||
|
||||
[model_providers.any]
|
||||
name = "any"
|
||||
base_url = "https://old.api/v1"
|
||||
|
||||
[profiles.default]
|
||||
model = "gpt-4"
|
||||
"#;
|
||||
|
||||
let result = update_codex_toml_field(input, "base_url", "https://new.api/v1").unwrap();
|
||||
let parsed: toml::Value = toml::from_str(&result).unwrap();
|
||||
|
||||
// base_url in correct section
|
||||
let base_url = parsed
|
||||
.get("model_providers")
|
||||
.and_then(|v| v.get("any"))
|
||||
.and_then(|v| v.get("base_url"))
|
||||
.and_then(|v| v.as_str());
|
||||
assert_eq!(base_url, Some("https://new.api/v1"));
|
||||
|
||||
// profiles section untouched
|
||||
let profile_model = parsed
|
||||
.get("profiles")
|
||||
.and_then(|v| v.get("default"))
|
||||
.and_then(|v| v.get("model"))
|
||||
.and_then(|v| v.as_str());
|
||||
assert_eq!(profile_model, Some("gpt-4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_base_url_if_predicate() {
|
||||
let input = r#"model_provider = "any"
|
||||
|
||||
[model_providers.any]
|
||||
name = "any"
|
||||
base_url = "http://127.0.0.1:5000/v1"
|
||||
wire_api = "responses"
|
||||
"#;
|
||||
|
||||
let result =
|
||||
remove_codex_toml_base_url_if(input, |url| url.starts_with("http://127.0.0.1"));
|
||||
let parsed: toml::Value = toml::from_str(&result).unwrap();
|
||||
|
||||
let any_section = parsed
|
||||
.get("model_providers")
|
||||
.and_then(|v| v.get("any"))
|
||||
.unwrap();
|
||||
assert!(any_section.get("base_url").is_none());
|
||||
assert_eq!(
|
||||
any_section.get("wire_api").and_then(|v| v.as_str()),
|
||||
Some("responses")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_base_url_if_keeps_non_matching() {
|
||||
let input = r#"model_provider = "any"
|
||||
|
||||
[model_providers.any]
|
||||
base_url = "https://production.api/v1"
|
||||
"#;
|
||||
|
||||
let result =
|
||||
remove_codex_toml_base_url_if(input, |url| url.starts_with("http://127.0.0.1"));
|
||||
let parsed: toml::Value = toml::from_str(&result).unwrap();
|
||||
|
||||
let base_url = parsed
|
||||
.get("model_providers")
|
||||
.and_then(|v| v.get("any"))
|
||||
.and_then(|v| v.get("base_url"))
|
||||
.and_then(|v| v.as_str());
|
||||
assert_eq!(base_url, Some("https://production.api/v1"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,20 +212,22 @@ pub async fn set_claude_common_config_snippet(
|
||||
snippet: String,
|
||||
state: tauri::State<'_, crate::store::AppState>,
|
||||
) -> Result<(), String> {
|
||||
let is_cleared = snippet.trim().is_empty();
|
||||
|
||||
if !snippet.trim().is_empty() {
|
||||
serde_json::from_str::<serde_json::Value>(&snippet).map_err(invalid_json_format_error)?;
|
||||
}
|
||||
|
||||
let value = if snippet.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(snippet)
|
||||
};
|
||||
let value = if is_cleared { None } else { Some(snippet) };
|
||||
|
||||
state
|
||||
.db
|
||||
.set_config_snippet("claude", value)
|
||||
.map_err(|e| e.to_string())?;
|
||||
state
|
||||
.db
|
||||
.set_config_snippet_cleared("claude", is_cleared)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -246,6 +248,7 @@ pub async fn set_common_config_snippet(
|
||||
snippet: String,
|
||||
state: tauri::State<'_, crate::store::AppState>,
|
||||
) -> Result<(), String> {
|
||||
let is_cleared = snippet.trim().is_empty();
|
||||
let old_snippet = state
|
||||
.db
|
||||
.get_config_snippet(&app_type)
|
||||
@@ -253,11 +256,7 @@ pub async fn set_common_config_snippet(
|
||||
|
||||
validate_common_config_snippet(&app_type, &snippet)?;
|
||||
|
||||
let value = if snippet.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(snippet)
|
||||
};
|
||||
let value = if is_cleared { None } else { Some(snippet) };
|
||||
|
||||
if matches!(app_type.as_str(), "claude" | "codex" | "gemini") {
|
||||
if let Some(legacy_snippet) = old_snippet
|
||||
@@ -278,6 +277,10 @@ pub async fn set_common_config_snippet(
|
||||
.db
|
||||
.set_config_snippet(&app_type, value)
|
||||
.map_err(|e| e.to_string())?;
|
||||
state
|
||||
.db
|
||||
.set_config_snippet_cleared(&app_type, is_cleared)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if matches!(app_type.as_str(), "claude" | "codex" | "gemini") {
|
||||
let app = AppType::from_str(&app_type).map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -103,20 +103,22 @@ fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result
|
||||
// Extract common config snippet (mirrors old startup logic in lib.rs)
|
||||
if state
|
||||
.db
|
||||
.get_config_snippet(app_type.as_str())
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_none()
|
||||
.should_auto_extract_config_snippet(app_type.as_str())?
|
||||
{
|
||||
match ProviderService::extract_common_config_snippet(state, app_type.clone()) {
|
||||
Ok(snippet) if !snippet.is_empty() && snippet != "{}" => {
|
||||
let _ = state
|
||||
.db
|
||||
.set_config_snippet(app_type.as_str(), Some(snippet));
|
||||
let _ = state
|
||||
.db
|
||||
.set_config_snippet_cleared(app_type.as_str(), false);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
ProviderService::migrate_legacy_common_config_usage_if_needed(state, app_type.clone())?;
|
||||
}
|
||||
|
||||
Ok(imported)
|
||||
|
||||
@@ -7,6 +7,12 @@ use crate::error::AppError;
|
||||
use rusqlite::params;
|
||||
|
||||
impl Database {
|
||||
const LEGACY_COMMON_CONFIG_MIGRATED_KEY: &'static str = "common_config_legacy_migrated_v1";
|
||||
|
||||
fn config_snippet_cleared_key(app_type: &str) -> String {
|
||||
format!("common_config_{app_type}_cleared")
|
||||
}
|
||||
|
||||
/// 获取设置值
|
||||
pub fn get_setting(&self, key: &str) -> Result<Option<String>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
@@ -45,6 +51,60 @@ impl Database {
|
||||
self.get_setting(&format!("common_config_{app_type}"))
|
||||
}
|
||||
|
||||
/// 检查通用配置片段是否被用户显式清空
|
||||
pub fn is_config_snippet_cleared(&self, app_type: &str) -> Result<bool, AppError> {
|
||||
Ok(self
|
||||
.get_setting(&Self::config_snippet_cleared_key(app_type))?
|
||||
.as_deref()
|
||||
== Some("true"))
|
||||
}
|
||||
|
||||
/// 设置通用配置片段是否被显式清空
|
||||
pub fn set_config_snippet_cleared(
|
||||
&self,
|
||||
app_type: &str,
|
||||
cleared: bool,
|
||||
) -> Result<(), AppError> {
|
||||
let key = Self::config_snippet_cleared_key(app_type);
|
||||
if cleared {
|
||||
self.set_setting(&key, "true")
|
||||
} else {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.execute("DELETE FROM settings WHERE key = ?1", params![key])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前是否允许从 live 配置自动抽取通用配置片段
|
||||
pub fn should_auto_extract_config_snippet(&self, app_type: &str) -> Result<bool, AppError> {
|
||||
Ok(self.get_config_snippet(app_type)?.is_none()
|
||||
&& !self.is_config_snippet_cleared(app_type)?)
|
||||
}
|
||||
|
||||
/// 检查历史通用配置迁移是否已经执行过
|
||||
pub fn is_legacy_common_config_migrated(&self) -> Result<bool, AppError> {
|
||||
Ok(self
|
||||
.get_setting(Self::LEGACY_COMMON_CONFIG_MIGRATED_KEY)?
|
||||
.as_deref()
|
||||
== Some("true"))
|
||||
}
|
||||
|
||||
/// 标记历史通用配置迁移已经执行完成
|
||||
pub fn set_legacy_common_config_migrated(&self, migrated: bool) -> Result<(), AppError> {
|
||||
if migrated {
|
||||
self.set_setting(Self::LEGACY_COMMON_CONFIG_MIGRATED_KEY, "true")
|
||||
} else {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.execute(
|
||||
"DELETE FROM settings WHERE key = ?1",
|
||||
params![Self::LEGACY_COMMON_CONFIG_MIGRATED_KEY],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置通用配置片段
|
||||
pub fn set_config_snippet(
|
||||
&self,
|
||||
|
||||
+81
-53
@@ -560,59 +560,6 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Auto-extract common config snippets from live files (when snippet is missing)
|
||||
for app_type in crate::app_config::AppType::all() {
|
||||
// Skip if snippet already exists
|
||||
if app_state
|
||||
.db
|
||||
.get_config_snippet(app_type.as_str())
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to read the live config file for this app type
|
||||
let settings =
|
||||
match crate::services::provider::ProviderService::read_live_settings(
|
||||
app_type.clone(),
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue, // No live config file, skip silently
|
||||
};
|
||||
|
||||
// Extract common config (strip provider-specific fields)
|
||||
match crate::services::provider::ProviderService::extract_common_config_snippet_from_settings(
|
||||
app_type.clone(),
|
||||
&settings,
|
||||
) {
|
||||
Ok(snippet) if !snippet.is_empty() && snippet != "{}" => {
|
||||
match app_state
|
||||
.db
|
||||
.set_config_snippet(app_type.as_str(), Some(snippet))
|
||||
{
|
||||
Ok(()) => log::info!(
|
||||
"✓ Auto-extracted common config snippet for {}",
|
||||
app_type.as_str()
|
||||
),
|
||||
Err(e) => log::warn!(
|
||||
"✗ Failed to save config snippet for {}: {e}",
|
||||
app_type.as_str()
|
||||
),
|
||||
}
|
||||
}
|
||||
Ok(_) => log::debug!(
|
||||
"○ Live config for {} has no extractable common fields",
|
||||
app_type.as_str()
|
||||
),
|
||||
Err(e) => log::warn!(
|
||||
"✗ Failed to extract config snippet for {}: {e}",
|
||||
app_type.as_str()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// 迁移旧的 app_config_dir 配置到 Store
|
||||
if let Err(e) = app_store::migrate_app_config_dir_from_settings(app.handle()) {
|
||||
log::warn!("迁移 app_config_dir 失败: {e}");
|
||||
@@ -797,6 +744,8 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
initialize_common_config_snippets(&state);
|
||||
|
||||
// 检查 settings 表中的代理状态,自动恢复代理服务
|
||||
restore_proxy_state_on_startup(&state).await;
|
||||
|
||||
@@ -1305,6 +1254,85 @@ async fn restore_proxy_state_on_startup(state: &store::AppState) {
|
||||
}
|
||||
}
|
||||
|
||||
fn initialize_common_config_snippets(state: &store::AppState) {
|
||||
// Auto-extract common config snippets from clean live files when snippet is missing.
|
||||
// This must run before proxy takeover is restored on startup, otherwise we'd read
|
||||
// proxy-placeholder configs instead of the user's actual live settings.
|
||||
for app_type in crate::app_config::AppType::all() {
|
||||
if !state
|
||||
.db
|
||||
.should_auto_extract_config_snippet(app_type.as_str())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let settings = match crate::services::provider::ProviderService::read_live_settings(
|
||||
app_type.clone(),
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
match crate::services::provider::ProviderService::extract_common_config_snippet_from_settings(
|
||||
app_type.clone(),
|
||||
&settings,
|
||||
) {
|
||||
Ok(snippet) if !snippet.is_empty() && snippet != "{}" => {
|
||||
match state.db.set_config_snippet(app_type.as_str(), Some(snippet)) {
|
||||
Ok(()) => {
|
||||
let _ = state.db.set_config_snippet_cleared(app_type.as_str(), false);
|
||||
log::info!(
|
||||
"✓ Auto-extracted common config snippet for {}",
|
||||
app_type.as_str()
|
||||
);
|
||||
}
|
||||
Err(e) => log::warn!(
|
||||
"✗ Failed to save config snippet for {}: {e}",
|
||||
app_type.as_str()
|
||||
),
|
||||
}
|
||||
}
|
||||
Ok(_) => log::debug!(
|
||||
"○ Live config for {} has no extractable common fields",
|
||||
app_type.as_str()
|
||||
),
|
||||
Err(e) => log::warn!(
|
||||
"✗ Failed to extract config snippet for {}: {e}",
|
||||
app_type.as_str()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
let should_run_legacy_migration = state
|
||||
.db
|
||||
.is_legacy_common_config_migrated()
|
||||
.map(|done| !done)
|
||||
.unwrap_or(true);
|
||||
|
||||
if should_run_legacy_migration {
|
||||
for app_type in [
|
||||
crate::app_config::AppType::Claude,
|
||||
crate::app_config::AppType::Codex,
|
||||
crate::app_config::AppType::Gemini,
|
||||
] {
|
||||
if let Err(e) = crate::services::provider::ProviderService::migrate_legacy_common_config_usage_if_needed(
|
||||
state,
|
||||
app_type.clone(),
|
||||
) {
|
||||
log::warn!(
|
||||
"✗ Failed to migrate legacy common-config usage for {}: {e}",
|
||||
app_type.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = state.db.set_legacy_common_config_migrated(true) {
|
||||
log::warn!("✗ Failed to persist legacy common-config migration flag: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 迁移错误对话框辅助函数
|
||||
// ============================================================
|
||||
|
||||
@@ -461,19 +461,18 @@ fn apply_common_config_to_settings(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn write_live_with_common_config(
|
||||
pub(crate) fn build_effective_settings_with_common_config(
|
||||
db: &Database,
|
||||
app_type: &AppType,
|
||||
provider: &Provider,
|
||||
) -> Result<(), AppError> {
|
||||
) -> Result<Value, AppError> {
|
||||
let snippet = db.get_config_snippet(app_type.as_str())?;
|
||||
let mut effective_provider = provider.clone();
|
||||
let mut effective_settings = provider.settings_config.clone();
|
||||
|
||||
if provider_uses_common_config(app_type, provider, snippet.as_deref()) {
|
||||
if let Some(snippet_text) = snippet.as_deref() {
|
||||
match apply_common_config_to_settings(app_type, &provider.settings_config, snippet_text)
|
||||
{
|
||||
Ok(settings) => effective_provider.settings_config = settings,
|
||||
match apply_common_config_to_settings(app_type, &effective_settings, snippet_text) {
|
||||
Ok(settings) => effective_settings = settings,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to apply common config for {} provider '{}': {err}",
|
||||
@@ -485,6 +484,18 @@ pub(crate) fn write_live_with_common_config(
|
||||
}
|
||||
}
|
||||
|
||||
Ok(effective_settings)
|
||||
}
|
||||
|
||||
pub(crate) fn write_live_with_common_config(
|
||||
db: &Database,
|
||||
app_type: &AppType,
|
||||
provider: &Provider,
|
||||
) -> Result<(), AppError> {
|
||||
let mut effective_provider = provider.clone();
|
||||
effective_provider.settings_config =
|
||||
build_effective_settings_with_common_config(db, app_type, provider)?;
|
||||
|
||||
write_live_snapshot(app_type, &effective_provider)
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,9 @@ pub use live::{
|
||||
// Internal re-exports (pub(crate))
|
||||
pub(crate) use live::sanitize_claude_settings_for_live;
|
||||
pub(crate) use live::{
|
||||
normalize_provider_common_config_for_storage, strip_common_config_from_live_settings,
|
||||
sync_current_provider_for_app_to_live, write_live_with_common_config,
|
||||
build_effective_settings_with_common_config, normalize_provider_common_config_for_storage,
|
||||
strip_common_config_from_live_settings, sync_current_provider_for_app_to_live,
|
||||
write_live_with_common_config,
|
||||
};
|
||||
|
||||
// Internal re-exports
|
||||
@@ -613,6 +614,46 @@ impl ProviderService {
|
||||
state: &AppState,
|
||||
app_type: AppType,
|
||||
) -> Result<(), AppError> {
|
||||
if app_type.is_additive_mode() {
|
||||
return sync_current_provider_for_app_to_live(state, &app_type);
|
||||
}
|
||||
|
||||
let current_id =
|
||||
match crate::settings::get_effective_current_provider(&state.db, &app_type)? {
|
||||
Some(id) => id,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
let Some(provider) = providers.get(¤t_id) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let takeover_enabled =
|
||||
futures::executor::block_on(state.db.get_proxy_config_for_app(app_type.as_str()))
|
||||
.map(|config| config.enabled)
|
||||
.unwrap_or(false);
|
||||
|
||||
let has_live_backup =
|
||||
futures::executor::block_on(state.db.get_live_backup(app_type.as_str()))
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some();
|
||||
|
||||
let live_taken_over = state
|
||||
.proxy_service
|
||||
.detect_takeover_in_live_config_for_app(&app_type);
|
||||
|
||||
if takeover_enabled && (has_live_backup || live_taken_over) {
|
||||
futures::executor::block_on(
|
||||
state
|
||||
.proxy_service
|
||||
.update_live_backup_from_provider(app_type.as_str(), provider),
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
sync_current_provider_for_app_to_live(state, &app_type)
|
||||
}
|
||||
|
||||
@@ -670,6 +711,25 @@ impl ProviderService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn migrate_legacy_common_config_usage_if_needed(
|
||||
state: &AppState,
|
||||
app_type: AppType,
|
||||
) -> Result<(), AppError> {
|
||||
if app_type.is_additive_mode() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(snippet) = state.db.get_config_snippet(app_type.as_str())? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if snippet.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Self::migrate_legacy_common_config_usage(state, app_type, &snippet)
|
||||
}
|
||||
|
||||
/// Extract common config snippet from current provider
|
||||
///
|
||||
/// Extracts the current provider's configuration and removes provider-specific fields
|
||||
|
||||
+390
-108
@@ -8,7 +8,9 @@ use crate::database::Database;
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::server::ProxyServer;
|
||||
use crate::proxy::types::*;
|
||||
use crate::services::provider::write_live_with_common_config;
|
||||
use crate::services::provider::{
|
||||
build_effective_settings_with_common_config, write_live_with_common_config,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
@@ -17,7 +19,7 @@ use tokio::sync::RwLock;
|
||||
/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)
|
||||
const PROXY_TOKEN_PLACEHOLDER: &str = "PROXY_MANAGED";
|
||||
|
||||
/// 代理接管模式下需要从 Claude Live 配置中移除的“模型覆盖”字段。
|
||||
/// 代理接管模式下需要从 Claude Live 配置中移除的"模型覆盖"字段。
|
||||
///
|
||||
/// 原因:接管模式切换供应商时不会写回 Live 配置,如果保留这些字段,
|
||||
/// Claude Code 会继续以旧模型名发起请求,导致新供应商不支持时失败。
|
||||
@@ -50,7 +52,7 @@ impl ProxyService {
|
||||
|
||||
/// 清理接管模式下 Claude Live 配置中的模型覆盖字段。
|
||||
///
|
||||
/// 这可以避免“接管开启后切换供应商仍使用旧模型”的问题。
|
||||
/// 这可以避免"接管开启后切换供应商仍使用旧模型"的问题。
|
||||
/// 注意:此方法不会修改 Token/Base URL 的接管占位符,仅移除模型字段。
|
||||
pub fn cleanup_claude_model_overrides_in_live(&self) -> Result<(), String> {
|
||||
let mut config = self.read_claude_live()?;
|
||||
@@ -1160,7 +1162,7 @@ impl ProxyService {
|
||||
) -> Result<(), String> {
|
||||
let app_type_str = app_type.as_str();
|
||||
|
||||
// 1) 优先从 Live 备份恢复(这是“原始 Live”的唯一可靠来源)
|
||||
// 1) 优先从 Live 备份恢复(这是"原始 Live"的唯一可靠来源)
|
||||
let backup = self
|
||||
.db
|
||||
.get_live_backup(app_type_str)
|
||||
@@ -1179,7 +1181,7 @@ impl ProxyService {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 2.1) 优先从 SSOT(当前供应商)重建 Live(比“清理字段”更可用)
|
||||
// 2.1) 优先从 SSOT(当前供应商)重建 Live(比"清理字段"更可用)
|
||||
match self.restore_live_from_ssot_for_app(app_type) {
|
||||
Ok(true) => {
|
||||
log::info!("{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)");
|
||||
@@ -1356,51 +1358,9 @@ impl ProxyService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove local proxy base_url from TOML(委托给 codex_config 共享实现)
|
||||
fn remove_local_toml_base_url(toml_str: &str) -> String {
|
||||
use toml_edit::DocumentMut;
|
||||
|
||||
let mut doc = match toml_str.parse::<DocumentMut>() {
|
||||
Ok(doc) => doc,
|
||||
Err(_) => return toml_str.to_string(),
|
||||
};
|
||||
|
||||
let model_provider = doc
|
||||
.get("model_provider")
|
||||
.and_then(|item| item.as_str())
|
||||
.map(str::to_string);
|
||||
|
||||
if let Some(provider_key) = model_provider {
|
||||
if let Some(model_providers) = doc
|
||||
.get_mut("model_providers")
|
||||
.and_then(|v| v.as_table_mut())
|
||||
{
|
||||
if let Some(provider_table) = model_providers
|
||||
.get_mut(provider_key.as_str())
|
||||
.and_then(|v| v.as_table_mut())
|
||||
{
|
||||
let should_remove = provider_table
|
||||
.get("base_url")
|
||||
.and_then(|item| item.as_str())
|
||||
.map(Self::is_local_proxy_url)
|
||||
.unwrap_or(false);
|
||||
if should_remove {
|
||||
provider_table.remove("base_url");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底:清理顶层 base_url(仅当它看起来像本地代理地址)
|
||||
let should_remove_root = doc
|
||||
.get("base_url")
|
||||
.and_then(|item| item.as_str())
|
||||
.map(Self::is_local_proxy_url)
|
||||
.unwrap_or(false);
|
||||
if should_remove_root {
|
||||
doc.as_table_mut().remove("base_url");
|
||||
}
|
||||
|
||||
doc.to_string()
|
||||
crate::codex_config::remove_codex_toml_base_url_if(toml_str, Self::is_local_proxy_url)
|
||||
}
|
||||
|
||||
fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> {
|
||||
@@ -1457,7 +1417,7 @@ impl ProxyService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检测 Live 配置是否处于“被接管”的残留状态
|
||||
/// 检测 Live 配置是否处于"被接管"的残留状态
|
||||
///
|
||||
/// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时,
|
||||
/// 启动流程可以据此触发恢复逻辑。
|
||||
@@ -1528,21 +1488,37 @@ impl ProxyService {
|
||||
app_type: &str,
|
||||
provider: &Provider,
|
||||
) -> Result<(), String> {
|
||||
let backup_json = match app_type {
|
||||
"claude" => {
|
||||
// Claude: settings_config 直接作为备份
|
||||
serde_json::to_string(&provider.settings_config)
|
||||
.map_err(|e| format!("序列化 Claude 配置失败: {e}"))?
|
||||
let app_type_enum =
|
||||
AppType::from_str(app_type).map_err(|_| format!("未知的应用类型: {app_type}"))?;
|
||||
let mut effective_settings =
|
||||
build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)
|
||||
.map_err(|e| format!("构建 {app_type} 有效配置失败: {e}"))?;
|
||||
|
||||
if matches!(app_type_enum, AppType::Codex) {
|
||||
let existing_backup = self
|
||||
.db
|
||||
.get_live_backup(app_type)
|
||||
.await
|
||||
.map_err(|e| format!("读取 {app_type} 现有备份失败: {e}"))?;
|
||||
|
||||
if let Some(existing_backup) = existing_backup {
|
||||
let existing_value: Value = serde_json::from_str(&existing_backup.original_config)
|
||||
.map_err(|e| format!("解析 {app_type} 现有备份失败: {e}"))?;
|
||||
Self::preserve_codex_mcp_servers_in_backup(
|
||||
&mut effective_settings,
|
||||
&existing_value,
|
||||
)?;
|
||||
}
|
||||
"codex" => {
|
||||
// Codex: settings_config 包含 {"auth": ..., "config": ...},直接使用
|
||||
serde_json::to_string(&provider.settings_config)
|
||||
.map_err(|e| format!("序列化 Codex 配置失败: {e}"))?
|
||||
}
|
||||
"gemini" => {
|
||||
// Gemini: 只提取 env 字段(与原始备份格式一致)
|
||||
// proxy.rs 的 read_gemini_live() 返回 {"env": {...}}
|
||||
let env_backup = if let Some(env) = provider.settings_config.get("env") {
|
||||
}
|
||||
|
||||
let backup_json = match app_type_enum {
|
||||
AppType::Claude => serde_json::to_string(&effective_settings)
|
||||
.map_err(|e| format!("序列化 Claude 配置失败: {e}"))?,
|
||||
AppType::Codex => serde_json::to_string(&effective_settings)
|
||||
.map_err(|e| format!("序列化 Codex 配置失败: {e}"))?,
|
||||
AppType::Gemini => {
|
||||
// Gemini takeover 仅修改 .env;settings.json(含 mcpServers)保持原样。
|
||||
let env_backup = if let Some(env) = effective_settings.get("env") {
|
||||
json!({ "env": env })
|
||||
} else {
|
||||
json!({ "env": {} })
|
||||
@@ -1550,7 +1526,9 @@ impl ProxyService {
|
||||
serde_json::to_string(&env_backup)
|
||||
.map_err(|e| format!("序列化 Gemini 配置失败: {e}"))?
|
||||
}
|
||||
_ => return Err(format!("未知的应用类型: {app_type}")),
|
||||
AppType::OpenCode | AppType::OpenClaw => {
|
||||
return Err(format!("未知的应用类型: {app_type}"));
|
||||
}
|
||||
};
|
||||
|
||||
self.db
|
||||
@@ -1562,6 +1540,67 @@ impl ProxyService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn preserve_codex_mcp_servers_in_backup(
|
||||
target_settings: &mut Value,
|
||||
existing_backup: &Value,
|
||||
) -> Result<(), String> {
|
||||
let target_obj = target_settings
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| "Codex 备份必须是 JSON 对象".to_string())?;
|
||||
|
||||
let target_config = target_obj
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let mut target_doc = if target_config.trim().is_empty() {
|
||||
toml_edit::DocumentMut::new()
|
||||
} else {
|
||||
target_config
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.map_err(|e| format!("解析新的 Codex config.toml 失败: {e}"))?
|
||||
};
|
||||
|
||||
let existing_config = existing_backup
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if existing_config.trim().is_empty() {
|
||||
target_obj.insert("config".to_string(), json!(target_doc.to_string()));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let existing_doc = existing_config
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.map_err(|e| format!("解析现有 Codex 备份失败: {e}"))?;
|
||||
|
||||
if let Some(existing_mcp_servers) = existing_doc.get("mcp_servers") {
|
||||
match target_doc.get_mut("mcp_servers") {
|
||||
Some(target_mcp_servers) => {
|
||||
if let (Some(target_table), Some(existing_table)) = (
|
||||
target_mcp_servers.as_table_like_mut(),
|
||||
existing_mcp_servers.as_table_like(),
|
||||
) {
|
||||
for (server_id, server_item) in existing_table.iter() {
|
||||
if target_table.get(server_id).is_none() {
|
||||
target_table.insert(server_id, server_item.clone());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::warn!(
|
||||
"Codex config contains a non-table mcp_servers section; skipping backup MCP merge"
|
||||
);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
target_doc["mcp_servers"] = existing_mcp_servers.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
target_obj.insert("config".to_string(), json!(target_doc.to_string()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 代理模式下切换供应商(热切换,不写 Live)
|
||||
pub async fn switch_proxy_target(
|
||||
&self,
|
||||
@@ -1614,49 +1653,10 @@ impl ProxyService {
|
||||
|
||||
// ==================== Live 配置读写辅助方法 ====================
|
||||
|
||||
/// 更新 TOML 字符串中的 base_url
|
||||
/// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现)
|
||||
fn update_toml_base_url(toml_str: &str, new_url: &str) -> String {
|
||||
use toml_edit::DocumentMut;
|
||||
|
||||
let mut doc = match toml_str.parse::<DocumentMut>() {
|
||||
Ok(doc) => doc,
|
||||
Err(_) => return toml_str.to_string(),
|
||||
};
|
||||
|
||||
// Codex 的 config.toml 通常是:
|
||||
// model_provider = "any"
|
||||
//
|
||||
// [model_providers.any]
|
||||
// base_url = "https://.../v1"
|
||||
//
|
||||
// 所以接管时要“精准”修改当前 model_provider 对应的 model_providers.<name>.base_url,
|
||||
// 避免写错位置导致 Codex 仍然走旧地址。
|
||||
let model_provider = doc
|
||||
.get("model_provider")
|
||||
.and_then(|item| item.as_str())
|
||||
.map(str::to_string);
|
||||
|
||||
if let Some(provider_key) = model_provider {
|
||||
if doc.get("model_providers").is_none() {
|
||||
doc["model_providers"] = toml_edit::table();
|
||||
}
|
||||
|
||||
if let Some(model_providers) = doc["model_providers"].as_table_mut() {
|
||||
if !model_providers.contains_key(&provider_key) {
|
||||
model_providers[&provider_key] = toml_edit::table();
|
||||
}
|
||||
|
||||
if let Some(provider_table) = model_providers[&provider_key].as_table_mut() {
|
||||
provider_table["base_url"] = toml_edit::value(new_url);
|
||||
return doc.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底:如果没有 model_provider 或结构不符合预期,则退回修改顶层 base_url。
|
||||
doc["base_url"] = toml_edit::value(new_url);
|
||||
|
||||
doc.to_string()
|
||||
crate::codex_config::update_codex_toml_field(toml_str, "base_url", new_url)
|
||||
.unwrap_or_else(|_| toml_str.to_string())
|
||||
}
|
||||
|
||||
fn read_claude_live(&self) -> Result<Value, String> {
|
||||
@@ -1914,6 +1914,7 @@ impl ProxyService {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::provider::ProviderMeta;
|
||||
use serial_test::serial;
|
||||
use std::env;
|
||||
use tempfile::TempDir;
|
||||
@@ -2166,7 +2167,7 @@ model = "gpt-5.1-codex"
|
||||
db.set_current_provider("claude", "a")
|
||||
.expect("set current provider");
|
||||
|
||||
// 模拟“已接管”状态:存在 Live 备份(内容不重要,会被热切换更新)
|
||||
// 模拟"已接管"状态:存在 Live 备份(内容不重要,会被热切换更新)
|
||||
db.save_live_backup("claude", "{\"env\":{}}")
|
||||
.await
|
||||
.expect("seed live backup");
|
||||
@@ -2191,4 +2192,285 @@ model = "gpt-5.1-codex"
|
||||
let expected = serde_json::to_string(&provider_b.settings_config).expect("serialize");
|
||||
assert_eq!(backup.original_config, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn update_live_backup_from_provider_applies_claude_common_config() {
|
||||
let _home = TempHome::new();
|
||||
crate::settings::reload_settings().expect("reload settings");
|
||||
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
db.set_config_snippet(
|
||||
"claude",
|
||||
Some(
|
||||
serde_json::json!({
|
||||
"includeCoAuthoredBy": false
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
)
|
||||
.expect("set common config snippet");
|
||||
|
||||
let service = ProxyService::new(db.clone());
|
||||
|
||||
let mut provider = Provider::with_id(
|
||||
"p1".to_string(),
|
||||
"P1".to_string(),
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": "token",
|
||||
"ANTHROPIC_BASE_URL": "https://claude.example"
|
||||
}
|
||||
}),
|
||||
None,
|
||||
);
|
||||
provider.meta = Some(ProviderMeta {
|
||||
common_config_enabled: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
service
|
||||
.update_live_backup_from_provider("claude", &provider)
|
||||
.await
|
||||
.expect("update live backup");
|
||||
|
||||
let backup = db
|
||||
.get_live_backup("claude")
|
||||
.await
|
||||
.expect("get live backup")
|
||||
.expect("backup exists");
|
||||
let stored: Value =
|
||||
serde_json::from_str(&backup.original_config).expect("parse backup json");
|
||||
|
||||
assert_eq!(
|
||||
stored.get("includeCoAuthoredBy").and_then(|v| v.as_bool()),
|
||||
Some(false),
|
||||
"common config should be applied into Claude restore backup"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn update_live_backup_from_provider_applies_codex_common_config() {
|
||||
let _home = TempHome::new();
|
||||
crate::settings::reload_settings().expect("reload settings");
|
||||
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
db.set_config_snippet(
|
||||
"codex",
|
||||
Some("disable_response_storage = true\n".to_string()),
|
||||
)
|
||||
.expect("set common config snippet");
|
||||
|
||||
let service = ProxyService::new(db.clone());
|
||||
|
||||
let mut provider = Provider::with_id(
|
||||
"p1".to_string(),
|
||||
"P1".to_string(),
|
||||
json!({
|
||||
"auth": {
|
||||
"OPENAI_API_KEY": "token"
|
||||
},
|
||||
"config": r#"model_provider = "any"
|
||||
model = "gpt-5"
|
||||
|
||||
[model_providers.any]
|
||||
base_url = "https://codex.example/v1"
|
||||
"#
|
||||
}),
|
||||
None,
|
||||
);
|
||||
provider.meta = Some(ProviderMeta {
|
||||
common_config_enabled: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
service
|
||||
.update_live_backup_from_provider("codex", &provider)
|
||||
.await
|
||||
.expect("update live backup");
|
||||
|
||||
let backup = db
|
||||
.get_live_backup("codex")
|
||||
.await
|
||||
.expect("get live backup")
|
||||
.expect("backup exists");
|
||||
let stored: Value =
|
||||
serde_json::from_str(&backup.original_config).expect("parse backup json");
|
||||
let config = stored
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.expect("config string");
|
||||
|
||||
assert!(
|
||||
config.contains("disable_response_storage = true"),
|
||||
"common config should be applied into Codex restore backup"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {
|
||||
let _home = TempHome::new();
|
||||
crate::settings::reload_settings().expect("reload settings");
|
||||
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
let service = ProxyService::new(db.clone());
|
||||
|
||||
db.save_live_backup(
|
||||
"codex",
|
||||
&serde_json::to_string(&json!({
|
||||
"auth": {
|
||||
"OPENAI_API_KEY": "old-token"
|
||||
},
|
||||
"config": r#"model_provider = "any"
|
||||
model = "gpt-4"
|
||||
|
||||
[model_providers.any]
|
||||
base_url = "https://old.example/v1"
|
||||
|
||||
[mcp_servers.echo]
|
||||
command = "npx"
|
||||
args = ["echo-server"]
|
||||
"#
|
||||
}))
|
||||
.expect("serialize seed backup"),
|
||||
)
|
||||
.await
|
||||
.expect("seed live backup");
|
||||
|
||||
let provider = Provider::with_id(
|
||||
"p2".to_string(),
|
||||
"P2".to_string(),
|
||||
json!({
|
||||
"auth": {
|
||||
"OPENAI_API_KEY": "new-token"
|
||||
},
|
||||
"config": r#"model_provider = "any"
|
||||
model = "gpt-5"
|
||||
|
||||
[model_providers.any]
|
||||
base_url = "https://new.example/v1"
|
||||
"#
|
||||
}),
|
||||
None,
|
||||
);
|
||||
|
||||
service
|
||||
.update_live_backup_from_provider("codex", &provider)
|
||||
.await
|
||||
.expect("update live backup");
|
||||
|
||||
let backup = db
|
||||
.get_live_backup("codex")
|
||||
.await
|
||||
.expect("get live backup")
|
||||
.expect("backup exists");
|
||||
let stored: Value =
|
||||
serde_json::from_str(&backup.original_config).expect("parse backup json");
|
||||
let config = stored
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.expect("config string");
|
||||
|
||||
assert!(
|
||||
config.contains("[mcp_servers.echo]"),
|
||||
"existing Codex MCP section should survive proxy hot-switch backup update"
|
||||
);
|
||||
assert!(
|
||||
config.contains("https://new.example/v1"),
|
||||
"provider-specific base_url should still update to the new provider"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {
|
||||
let _home = TempHome::new();
|
||||
crate::settings::reload_settings().expect("reload settings");
|
||||
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
let service = ProxyService::new(db.clone());
|
||||
|
||||
db.save_live_backup(
|
||||
"codex",
|
||||
&serde_json::to_string(&json!({
|
||||
"auth": {
|
||||
"OPENAI_API_KEY": "old-token"
|
||||
},
|
||||
"config": r#"[mcp_servers.shared]
|
||||
command = "old-command"
|
||||
|
||||
[mcp_servers.legacy]
|
||||
command = "legacy-command"
|
||||
"#
|
||||
}))
|
||||
.expect("serialize seed backup"),
|
||||
)
|
||||
.await
|
||||
.expect("seed live backup");
|
||||
|
||||
let provider = Provider::with_id(
|
||||
"p2".to_string(),
|
||||
"P2".to_string(),
|
||||
json!({
|
||||
"auth": {
|
||||
"OPENAI_API_KEY": "new-token"
|
||||
},
|
||||
"config": r#"[mcp_servers.shared]
|
||||
command = "new-command"
|
||||
|
||||
[mcp_servers.latest]
|
||||
command = "latest-command"
|
||||
"#
|
||||
}),
|
||||
None,
|
||||
);
|
||||
|
||||
service
|
||||
.update_live_backup_from_provider("codex", &provider)
|
||||
.await
|
||||
.expect("update live backup");
|
||||
|
||||
let backup = db
|
||||
.get_live_backup("codex")
|
||||
.await
|
||||
.expect("get live backup")
|
||||
.expect("backup exists");
|
||||
let stored: Value =
|
||||
serde_json::from_str(&backup.original_config).expect("parse backup json");
|
||||
let config = stored
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.expect("config string");
|
||||
let parsed: toml::Value = toml::from_str(config).expect("parse merged codex config");
|
||||
|
||||
let mcp_servers = parsed
|
||||
.get("mcp_servers")
|
||||
.expect("mcp_servers should be present");
|
||||
assert_eq!(
|
||||
mcp_servers
|
||||
.get("shared")
|
||||
.and_then(|v| v.get("command"))
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("new-command"),
|
||||
"new provider/common-config MCP definition should win on conflict"
|
||||
);
|
||||
assert_eq!(
|
||||
mcp_servers
|
||||
.get("legacy")
|
||||
.and_then(|v| v.get("command"))
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("legacy-command"),
|
||||
"backup-only MCP entries should still be preserved"
|
||||
);
|
||||
assert_eq!(
|
||||
mcp_servers
|
||||
.get("latest")
|
||||
.and_then(|v| v.get("command"))
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("latest-command"),
|
||||
"new MCP entries should remain in the restore backup"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CC Switch",
|
||||
"version": "3.12.1",
|
||||
"version": "3.12.2",
|
||||
"identifier": "com.ccswitch.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -21,6 +21,80 @@ fn sanitize_provider_name(name: &str) -> String {
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_legacy_common_config_usage_marks_historical_provider_enabled() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let _home = ensure_test_home();
|
||||
|
||||
let mut config = MultiAppConfig::default();
|
||||
{
|
||||
let manager = config
|
||||
.get_manager_mut(&AppType::Claude)
|
||||
.expect("claude manager");
|
||||
manager.current = "legacy-provider".to_string();
|
||||
manager.providers.insert(
|
||||
"legacy-provider".to_string(),
|
||||
Provider::with_id(
|
||||
"legacy-provider".to_string(),
|
||||
"Legacy".to_string(),
|
||||
json!({
|
||||
"includeCoAuthoredBy": false,
|
||||
"env": {
|
||||
"ANTHROPIC_API_KEY": "legacy-key"
|
||||
}
|
||||
}),
|
||||
None,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let state = create_test_state_with_config(&config).expect("create test state");
|
||||
state
|
||||
.db
|
||||
.set_config_snippet(
|
||||
AppType::Claude.as_str(),
|
||||
Some(r#"{ "includeCoAuthoredBy": false }"#.to_string()),
|
||||
)
|
||||
.expect("set common config snippet");
|
||||
|
||||
ProviderService::migrate_legacy_common_config_usage_if_needed(&state, AppType::Claude)
|
||||
.expect("migrate legacy common config");
|
||||
|
||||
let providers = state
|
||||
.db
|
||||
.get_all_providers(AppType::Claude.as_str())
|
||||
.expect("get providers after migration");
|
||||
let provider = providers
|
||||
.get("legacy-provider")
|
||||
.expect("legacy provider exists");
|
||||
|
||||
assert_eq!(
|
||||
provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.common_config_enabled),
|
||||
Some(true),
|
||||
"historical provider should be explicitly marked as using common config"
|
||||
);
|
||||
assert!(
|
||||
provider
|
||||
.settings_config
|
||||
.get("includeCoAuthoredBy")
|
||||
.is_none(),
|
||||
"common config fields should be stripped from provider storage after migration"
|
||||
);
|
||||
assert_eq!(
|
||||
provider
|
||||
.settings_config
|
||||
.get("env")
|
||||
.and_then(|v| v.get("ANTHROPIC_API_KEY"))
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("legacy-key"),
|
||||
"provider-specific auth should remain untouched"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_service_switch_codex_updates_live_and_config() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
@@ -164,6 +238,184 @@ command = "say"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_current_provider_for_app_keeps_live_takeover_and_updates_restore_backup() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let _home = ensure_test_home();
|
||||
|
||||
let mut config = MultiAppConfig::default();
|
||||
{
|
||||
let manager = config
|
||||
.get_manager_mut(&AppType::Claude)
|
||||
.expect("claude manager");
|
||||
manager.current = "current-provider".to_string();
|
||||
|
||||
let mut provider = Provider::with_id(
|
||||
"current-provider".to_string(),
|
||||
"Current".to_string(),
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": "real-token",
|
||||
"ANTHROPIC_BASE_URL": "https://claude.example"
|
||||
}
|
||||
}),
|
||||
None,
|
||||
);
|
||||
provider.meta = Some(ProviderMeta {
|
||||
common_config_enabled: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
manager
|
||||
.providers
|
||||
.insert("current-provider".to_string(), provider);
|
||||
}
|
||||
|
||||
let state = create_test_state_with_config(&config).expect("create test state");
|
||||
state
|
||||
.db
|
||||
.set_config_snippet(
|
||||
AppType::Claude.as_str(),
|
||||
Some(r#"{ "includeCoAuthoredBy": false }"#.to_string()),
|
||||
)
|
||||
.expect("set common config snippet");
|
||||
|
||||
let taken_over_live = json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:5000",
|
||||
"ANTHROPIC_AUTH_TOKEN": "PROXY_MANAGED"
|
||||
}
|
||||
});
|
||||
let settings_path = get_claude_settings_path();
|
||||
std::fs::create_dir_all(settings_path.parent().expect("settings dir")).expect("create dir");
|
||||
std::fs::write(
|
||||
&settings_path,
|
||||
serde_json::to_string_pretty(&taken_over_live).expect("serialize taken over live"),
|
||||
)
|
||||
.expect("write taken over live");
|
||||
|
||||
futures::executor::block_on(state.db.save_live_backup("claude", "{\"env\":{}}"))
|
||||
.expect("seed live backup");
|
||||
|
||||
let mut proxy_config = futures::executor::block_on(state.db.get_proxy_config_for_app("claude"))
|
||||
.expect("get proxy config");
|
||||
proxy_config.enabled = true;
|
||||
futures::executor::block_on(state.db.update_proxy_config_for_app(proxy_config))
|
||||
.expect("enable takeover");
|
||||
|
||||
ProviderService::sync_current_provider_for_app(&state, AppType::Claude)
|
||||
.expect("sync current provider should succeed");
|
||||
|
||||
let live_after: serde_json::Value =
|
||||
read_json_file(&settings_path).expect("read live settings after sync");
|
||||
assert_eq!(
|
||||
live_after, taken_over_live,
|
||||
"sync should not overwrite live config while takeover is active"
|
||||
);
|
||||
|
||||
let backup = futures::executor::block_on(state.db.get_live_backup("claude"))
|
||||
.expect("get live backup")
|
||||
.expect("backup exists");
|
||||
let backup_value: serde_json::Value =
|
||||
serde_json::from_str(&backup.original_config).expect("parse backup value");
|
||||
|
||||
assert_eq!(
|
||||
backup_value
|
||||
.get("includeCoAuthoredBy")
|
||||
.and_then(|v| v.as_bool()),
|
||||
Some(false),
|
||||
"restore backup should receive the updated effective config"
|
||||
);
|
||||
assert_eq!(
|
||||
backup_value
|
||||
.get("env")
|
||||
.and_then(|v| v.get("ANTHROPIC_AUTH_TOKEN"))
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("real-token"),
|
||||
"restore backup should preserve the provider token rather than proxy placeholder"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicitly_cleared_common_snippet_is_not_auto_extracted() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let _home = ensure_test_home();
|
||||
|
||||
let state = create_test_state().expect("create test state");
|
||||
state
|
||||
.db
|
||||
.set_config_snippet_cleared(AppType::Claude.as_str(), true)
|
||||
.expect("mark snippet explicitly cleared");
|
||||
|
||||
assert!(
|
||||
!state
|
||||
.db
|
||||
.should_auto_extract_config_snippet(AppType::Claude.as_str())
|
||||
.expect("check auto-extract eligibility"),
|
||||
"explicitly cleared snippets should block auto-extraction"
|
||||
);
|
||||
|
||||
state
|
||||
.db
|
||||
.set_config_snippet(AppType::Claude.as_str(), Some("{}".to_string()))
|
||||
.expect("set snippet");
|
||||
state
|
||||
.db
|
||||
.set_config_snippet_cleared(AppType::Claude.as_str(), false)
|
||||
.expect("clear explicit-empty marker");
|
||||
|
||||
assert!(
|
||||
!state
|
||||
.db
|
||||
.should_auto_extract_config_snippet(AppType::Claude.as_str())
|
||||
.expect("check auto-extract after snippet saved"),
|
||||
"existing snippets should also block auto-extraction"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_common_config_migration_flag_roundtrip() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let _home = ensure_test_home();
|
||||
|
||||
let state = create_test_state().expect("create test state");
|
||||
|
||||
assert!(
|
||||
!state
|
||||
.db
|
||||
.is_legacy_common_config_migrated()
|
||||
.expect("initial migration flag"),
|
||||
"migration flag should default to false"
|
||||
);
|
||||
|
||||
state
|
||||
.db
|
||||
.set_legacy_common_config_migrated(true)
|
||||
.expect("set migration flag");
|
||||
assert!(
|
||||
state
|
||||
.db
|
||||
.is_legacy_common_config_migrated()
|
||||
.expect("read migration flag"),
|
||||
"migration flag should persist once set"
|
||||
);
|
||||
|
||||
state
|
||||
.db
|
||||
.set_legacy_common_config_migrated(false)
|
||||
.expect("clear migration flag");
|
||||
assert!(
|
||||
!state
|
||||
.db
|
||||
.is_legacy_common_config_migrated()
|
||||
.expect("read migration flag after clear"),
|
||||
"migration flag should be removable for tests/debugging"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switch_packycode_gemini_updates_security_selected_type() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
|
||||
Reference in New Issue
Block a user