mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 08:44:41 +08:00
4947 lines
196 KiB
Rust
4947 lines
196 KiB
Rust
//! Provider service module
|
||
//!
|
||
//! Handles provider CRUD operations, switching, and configuration management.
|
||
|
||
mod endpoints;
|
||
mod gemini_auth;
|
||
mod live;
|
||
mod usage;
|
||
|
||
use indexmap::IndexMap;
|
||
use regex::Regex;
|
||
use serde::Deserialize;
|
||
use serde_json::Value;
|
||
|
||
use crate::app_config::AppType;
|
||
use crate::database::{validate_cost_multiplier, validate_pricing_source};
|
||
use crate::error::AppError;
|
||
use crate::provider::{Provider, UsageResult};
|
||
use crate::services::mcp::McpService;
|
||
use crate::settings::CustomEndpoint;
|
||
use crate::store::AppState;
|
||
|
||
// Re-export sub-module functions for external access
|
||
pub use live::{
|
||
import_default_config, import_hermes_providers_from_live, import_openclaw_providers_from_live,
|
||
import_opencode_providers_from_live, read_live_settings,
|
||
should_import_default_config_on_startup, sync_current_to_live,
|
||
update_toml_common_config_snippet,
|
||
};
|
||
|
||
// Internal re-exports (pub(crate))
|
||
pub(crate) use live::sanitize_claude_settings_for_live;
|
||
pub(crate) use live::{
|
||
build_effective_provider_for_live_with_codex_oauth_manager,
|
||
build_effective_settings_with_common_config, normalize_provider_common_config_for_storage,
|
||
provider_exists_in_live_config, strip_common_config_from_live_settings,
|
||
sync_current_provider_for_app_to_live, write_live_with_common_config_for_codex_oauth_manager,
|
||
write_live_with_common_config_for_state,
|
||
};
|
||
|
||
// Internal re-exports
|
||
use live::{
|
||
remove_hermes_provider_from_live, remove_openclaw_provider_from_live,
|
||
remove_opencode_provider_from_live, write_gemini_live,
|
||
};
|
||
use usage::validate_usage_script;
|
||
|
||
/// The built-in Codex official provider is safe to select during takeover:
|
||
/// Codex keeps ownership of its ChatGPT login and the proxy only forwards the
|
||
/// authenticated request. Other official providers retain the existing block.
|
||
pub fn official_provider_supports_proxy_takeover(app_type: &AppType, provider: &Provider) -> bool {
|
||
matches!(app_type, AppType::Codex)
|
||
&& crate::proxy::providers::is_codex_official_provider(provider)
|
||
}
|
||
|
||
/// 统一会话开关变更后,立即按新开关状态重写当前官方 Codex 供应商的
|
||
/// live 配置,使开关即时生效(无需等下一次切换)。
|
||
/// 当前供应商非官方(或不存在)时为 no-op:注入只作用于官方配置,
|
||
/// 第三方 live 配置不受开关影响。
|
||
pub fn reapply_current_codex_official_live(state: &AppState) -> Result<bool, AppError> {
|
||
let current_id = ProviderService::current(state, AppType::Codex)?;
|
||
if current_id.is_empty() {
|
||
return Ok(false);
|
||
}
|
||
let providers = state.db.get_all_providers(AppType::Codex.as_str())?;
|
||
let Some(provider) = providers.get(¤t_id) else {
|
||
return Ok(false);
|
||
};
|
||
if provider.category.as_deref() != Some("official") {
|
||
return Ok(false);
|
||
}
|
||
|
||
// 代理接管期间 live 归代理所有(开启代理时官方供应商只警告不拦截,
|
||
// 二者可以共存)。与切换/保存路径一致:以 backup/占位符为所有权信号,
|
||
// 只更新备份,注入后的配置由接管释放时的恢复路径落盘。
|
||
let has_live_backup =
|
||
futures::executor::block_on(state.db.get_live_backup(AppType::Codex.as_str()))
|
||
.ok()
|
||
.flatten()
|
||
.is_some();
|
||
let live_taken_over = state
|
||
.proxy_service
|
||
.detect_takeover_in_live_config_for_app(&AppType::Codex);
|
||
if has_live_backup || live_taken_over {
|
||
futures::executor::block_on(
|
||
state
|
||
.proxy_service
|
||
.update_live_backup_from_provider(AppType::Codex.as_str(), provider),
|
||
)
|
||
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
|
||
return Ok(true);
|
||
}
|
||
|
||
live::write_live_with_common_config_for_state(state, &AppType::Codex, provider)?;
|
||
// 重写 live 会整体替换 config.toml(有意设计),[mcp_servers] 随之丢失,
|
||
// 写完必须立刻从 DB 重新投影启用的 MCP。只投影 Codex 而非
|
||
// sync_all_enabled:后者按 AppType::all() 顺序逐应用短路,排在 Codex
|
||
// 前面的无关应用 live 损坏(如 ~/.claude.json 坏 JSON)会阻断 Codex
|
||
// 的重投影,让刚被清掉的 [mcp_servers] 无人补回。
|
||
// 投影失败降级为警告:走到这里 live 已按新开关状态落盘,开关事实上
|
||
// 已生效;若把错误上抛,save_settings 会回滚开关设置,制造"设置=旧值、
|
||
// live=新桶"的会话分裂——正是该回滚要防止的状态。MCP 投影可自愈
|
||
// (下次切换 / 任一 MCP 启停操作都会重新投影)。
|
||
if let Err(err) = McpService::sync_enabled_for_app(state, &AppType::Codex) {
|
||
log::warn!("统一会话开关重写 live 后重投影 Codex MCP 失败(将在下次同步时自愈): {err}");
|
||
}
|
||
Ok(true)
|
||
}
|
||
|
||
/// Provider business logic service
|
||
pub struct ProviderService;
|
||
|
||
/// Result of a provider switch operation, including any non-fatal warnings
|
||
#[derive(Debug, serde::Serialize, Default)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct SwitchResult {
|
||
pub warnings: Vec<String>,
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
#[cfg(any(target_os = "macos", windows))]
|
||
use crate::claude_desktop_config::PROFILE_ID;
|
||
use crate::config::{get_claude_settings_path, read_json_file, write_json_file};
|
||
use crate::database::Database;
|
||
use crate::provider::{AuthBinding, AuthBindingSource, ProviderMeta, UsageScript};
|
||
#[cfg(any(target_os = "macos", windows))]
|
||
use crate::provider::{ClaudeDesktopMode, ClaudeDesktopModelRoute};
|
||
use crate::proxy::types::ProxyConfig;
|
||
use crate::store::AppState;
|
||
use serde_json::json;
|
||
use serial_test::serial;
|
||
use std::env;
|
||
use std::fs;
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::{Arc, Mutex, OnceLock};
|
||
use tempfile::TempDir;
|
||
|
||
struct TempHome {
|
||
#[allow(dead_code)]
|
||
dir: TempDir,
|
||
original_home: Option<String>,
|
||
#[cfg(windows)]
|
||
original_local_app_data: Option<String>,
|
||
original_userprofile: Option<String>,
|
||
original_test_home: Option<String>,
|
||
}
|
||
|
||
impl TempHome {
|
||
fn new() -> Self {
|
||
let dir = TempDir::new().expect("failed to create temp home");
|
||
let original_home = env::var("HOME").ok();
|
||
#[cfg(windows)]
|
||
let original_local_app_data = env::var("LOCALAPPDATA").ok();
|
||
let original_userprofile = env::var("USERPROFILE").ok();
|
||
let original_test_home = env::var("CC_SWITCH_TEST_HOME").ok();
|
||
|
||
env::set_var("HOME", dir.path());
|
||
#[cfg(windows)]
|
||
env::set_var("LOCALAPPDATA", dir.path().join("AppData").join("Local"));
|
||
env::set_var("USERPROFILE", dir.path());
|
||
env::set_var("CC_SWITCH_TEST_HOME", dir.path());
|
||
|
||
Self {
|
||
dir,
|
||
original_home,
|
||
#[cfg(windows)]
|
||
original_local_app_data,
|
||
original_userprofile,
|
||
original_test_home,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Drop for TempHome {
|
||
fn drop(&mut self) {
|
||
match &self.original_home {
|
||
Some(value) => env::set_var("HOME", value),
|
||
None => env::remove_var("HOME"),
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
{
|
||
match &self.original_local_app_data {
|
||
Some(value) => env::set_var("LOCALAPPDATA", value),
|
||
None => env::remove_var("LOCALAPPDATA"),
|
||
}
|
||
}
|
||
|
||
match &self.original_userprofile {
|
||
Some(value) => env::set_var("USERPROFILE", value),
|
||
None => env::remove_var("USERPROFILE"),
|
||
}
|
||
|
||
match &self.original_test_home {
|
||
Some(value) => env::set_var("CC_SWITCH_TEST_HOME", value),
|
||
None => env::remove_var("CC_SWITCH_TEST_HOME"),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
fn claude_desktop_profile_path(home: &Path) -> PathBuf {
|
||
home.join("AppData")
|
||
.join("Local")
|
||
.join("Claude-3p")
|
||
.join("configLibrary")
|
||
.join(format!("{PROFILE_ID}.json"))
|
||
}
|
||
|
||
#[cfg(target_os = "macos")]
|
||
fn claude_desktop_profile_path(home: &Path) -> PathBuf {
|
||
home.join("Library")
|
||
.join("Application Support")
|
||
.join("Claude-3p")
|
||
.join("configLibrary")
|
||
.join(format!("{PROFILE_ID}.json"))
|
||
}
|
||
|
||
fn test_guard() -> std::sync::MutexGuard<'static, ()> {
|
||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||
LOCK.get_or_init(|| Mutex::new(()))
|
||
.lock()
|
||
.unwrap_or_else(|err| err.into_inner())
|
||
}
|
||
|
||
fn with_test_home<T>(test: impl FnOnce(&AppState, &Path) -> T) -> T {
|
||
let _guard = test_guard();
|
||
let temp = tempfile::tempdir().expect("tempdir");
|
||
let old_test_home = std::env::var_os("CC_SWITCH_TEST_HOME");
|
||
let old_home = std::env::var_os("HOME");
|
||
std::env::set_var("CC_SWITCH_TEST_HOME", temp.path());
|
||
std::env::set_var("HOME", temp.path());
|
||
|
||
let db = Arc::new(Database::memory().expect("in-memory database"));
|
||
let state = AppState::new(db);
|
||
let result = test(&state, temp.path());
|
||
|
||
match old_test_home {
|
||
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
|
||
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
|
||
}
|
||
match old_home {
|
||
Some(value) => std::env::set_var("HOME", value),
|
||
None => std::env::remove_var("HOME"),
|
||
}
|
||
|
||
result
|
||
}
|
||
|
||
fn codex_settings(base_url: &str, api_key: &str) -> Value {
|
||
json!({
|
||
"auth": {
|
||
"OPENAI_API_KEY": api_key
|
||
},
|
||
"config": format!(
|
||
"model_provider = \"custom\"\n\
|
||
[model_providers.custom]\n\
|
||
name = \"custom\"\n\
|
||
base_url = \"{base_url}\"\n\
|
||
wire_api = \"chat\"\n"
|
||
)
|
||
})
|
||
}
|
||
|
||
fn usage_script_with_credentials(
|
||
api_key: Option<&str>,
|
||
base_url: Option<&str>,
|
||
template_type: Option<&str>,
|
||
) -> UsageScript {
|
||
UsageScript {
|
||
enabled: true,
|
||
language: "javascript".to_string(),
|
||
code: "return { remaining: 1, unit: 'USD' };".to_string(),
|
||
timeout: Some(10),
|
||
api_key: api_key.map(str::to_string),
|
||
base_url: base_url.map(str::to_string),
|
||
access_token: None,
|
||
user_id: None,
|
||
template_type: template_type.map(str::to_string),
|
||
auto_query_interval: None,
|
||
coding_plan_provider: None,
|
||
access_key_id: Some("ak-test".to_string()),
|
||
secret_access_key: Some("sk-test".to_string()),
|
||
team_organization_id: None,
|
||
team_project_id: None,
|
||
}
|
||
}
|
||
|
||
fn codex_provider_with_usage(
|
||
id: &str,
|
||
base_url: &str,
|
||
api_key: &str,
|
||
usage_api_key: Option<&str>,
|
||
usage_base_url: Option<&str>,
|
||
template_type: Option<&str>,
|
||
) -> Provider {
|
||
let mut provider = Provider::with_id(
|
||
id.to_string(),
|
||
format!("Provider {id}"),
|
||
codex_settings(base_url, api_key),
|
||
None,
|
||
);
|
||
provider.meta = Some(ProviderMeta {
|
||
usage_script: Some(usage_script_with_credentials(
|
||
usage_api_key,
|
||
usage_base_url,
|
||
template_type,
|
||
)),
|
||
..Default::default()
|
||
});
|
||
provider
|
||
}
|
||
|
||
fn openclaw_provider(id: &str) -> Provider {
|
||
Provider {
|
||
id: id.to_string(),
|
||
name: format!("Provider {id}"),
|
||
settings_config: json!({
|
||
"baseUrl": "https://api.deepseek.com",
|
||
"apiKey": "test-key",
|
||
"api": "openai-completions",
|
||
"models": [],
|
||
}),
|
||
website_url: None,
|
||
category: Some("custom".to_string()),
|
||
created_at: Some(1),
|
||
sort_index: Some(0),
|
||
notes: None,
|
||
meta: None,
|
||
icon: None,
|
||
icon_color: None,
|
||
in_failover_queue: false,
|
||
}
|
||
}
|
||
|
||
fn hermes_provider(id: &str) -> Provider {
|
||
Provider {
|
||
id: id.to_string(),
|
||
name: format!("Provider {id}"),
|
||
settings_config: json!({
|
||
"api": "openai-chat",
|
||
"base_url": "https://api.example.com/v1",
|
||
"api_key": "test-key",
|
||
"models": {
|
||
"gpt-4o": {
|
||
"name": "GPT-4o"
|
||
}
|
||
}
|
||
}),
|
||
website_url: None,
|
||
category: Some("custom".to_string()),
|
||
created_at: Some(1),
|
||
sort_index: Some(0),
|
||
notes: None,
|
||
meta: None,
|
||
icon: None,
|
||
icon_color: None,
|
||
in_failover_queue: false,
|
||
}
|
||
}
|
||
|
||
fn opencode_provider(id: &str) -> Provider {
|
||
Provider {
|
||
id: id.to_string(),
|
||
name: format!("Provider {id}"),
|
||
settings_config: json!({
|
||
"npm": "@ai-sdk/openai-compatible",
|
||
"name": format!("Provider {id}"),
|
||
"options": {
|
||
"baseURL": "https://api.example.com/v1",
|
||
"apiKey": "test-key"
|
||
},
|
||
"models": {
|
||
"gpt-4o": {
|
||
"name": "GPT-4o"
|
||
}
|
||
}
|
||
}),
|
||
website_url: None,
|
||
category: Some("custom".to_string()),
|
||
created_at: Some(1),
|
||
sort_index: Some(0),
|
||
notes: None,
|
||
meta: None,
|
||
icon: None,
|
||
icon_color: None,
|
||
in_failover_queue: false,
|
||
}
|
||
}
|
||
|
||
fn opencode_omo_provider(id: &str, category: &str) -> Provider {
|
||
let mut settings = serde_json::Map::new();
|
||
settings.insert(
|
||
"agents".to_string(),
|
||
json!({
|
||
"writer": {
|
||
"model": "gpt-4o-mini"
|
||
}
|
||
}),
|
||
);
|
||
if category == "omo" {
|
||
settings.insert(
|
||
"categories".to_string(),
|
||
json!({
|
||
"default": ["writer"]
|
||
}),
|
||
);
|
||
}
|
||
settings.insert(
|
||
"otherFields".to_string(),
|
||
json!({
|
||
"theme": "dark"
|
||
}),
|
||
);
|
||
|
||
Provider {
|
||
id: id.to_string(),
|
||
name: format!("Provider {id}"),
|
||
settings_config: Value::Object(settings),
|
||
website_url: None,
|
||
category: Some(category.to_string()),
|
||
created_at: Some(1),
|
||
sort_index: Some(0),
|
||
notes: None,
|
||
meta: None,
|
||
icon: None,
|
||
icon_color: None,
|
||
in_failover_queue: false,
|
||
}
|
||
}
|
||
|
||
fn omo_config_path(home: &Path, category: &str) -> PathBuf {
|
||
home.join(".config").join("opencode").join(match category {
|
||
"omo" => crate::services::omo::STANDARD.preferred_filename,
|
||
"omo-slim" => crate::services::omo::SLIM.preferred_filename,
|
||
other => panic!("unexpected OMO category in test: {other}"),
|
||
})
|
||
}
|
||
|
||
#[test]
|
||
#[serial]
|
||
fn add_clears_usage_credentials_that_match_provider_config() {
|
||
with_test_home(|state, _| {
|
||
let provider = codex_provider_with_usage(
|
||
"codex-a",
|
||
"https://api.a.example/v1/",
|
||
"sk-a",
|
||
Some(" sk-a "),
|
||
Some(" https://api.a.example/v1/ "),
|
||
None,
|
||
);
|
||
|
||
ProviderService::add(state, AppType::Codex, provider, false).expect("add provider");
|
||
|
||
let saved = state
|
||
.db
|
||
.get_provider_by_id("codex-a", AppType::Codex.as_str())
|
||
.expect("query saved provider")
|
||
.expect("saved provider should exist");
|
||
let script = saved
|
||
.meta
|
||
.as_ref()
|
||
.and_then(|meta| meta.usage_script.as_ref())
|
||
.expect("usage script should remain");
|
||
|
||
assert_eq!(script.api_key, None);
|
||
assert_eq!(script.base_url, None);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
#[serial]
|
||
fn update_preserves_usage_credentials_that_only_match_previous_config() {
|
||
with_test_home(|state, _| {
|
||
let provider = codex_provider_with_usage(
|
||
"codex-usage-old",
|
||
"https://api.a.example/v1/",
|
||
"sk-a",
|
||
Some("sk-a"),
|
||
Some("https://api.a.example/v1/"),
|
||
None,
|
||
);
|
||
state
|
||
.db
|
||
.save_provider(AppType::Codex.as_str(), &provider)
|
||
.expect("seed provider with explicit usage credentials");
|
||
|
||
let mut updated = provider.clone();
|
||
updated.settings_config = codex_settings("https://api.b.example/v1/", "sk-b");
|
||
|
||
ProviderService::update(state, AppType::Codex, None, updated)
|
||
.expect("update provider main credentials");
|
||
|
||
let saved = state
|
||
.db
|
||
.get_provider_by_id("codex-usage-old", AppType::Codex.as_str())
|
||
.expect("query updated provider")
|
||
.expect("updated provider should exist");
|
||
let script = saved
|
||
.meta
|
||
.as_ref()
|
||
.and_then(|meta| meta.usage_script.as_ref())
|
||
.expect("usage script should remain");
|
||
|
||
assert_eq!(script.api_key.as_deref(), Some("sk-a"));
|
||
assert_eq!(
|
||
script.base_url.as_deref(),
|
||
Some("https://api.a.example/v1/")
|
||
);
|
||
assert_eq!(
|
||
saved.resolve_usage_credentials(&AppType::Codex),
|
||
("https://api.b.example/v1".to_string(), "sk-b".to_string())
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
#[serial]
|
||
fn copied_provider_uses_edited_credentials_after_add_clears_mirrored_usage_credentials() {
|
||
with_test_home(|state, _| {
|
||
let copied_provider = codex_provider_with_usage(
|
||
"codex-copy",
|
||
"https://api.a.example/v1/",
|
||
"sk-a",
|
||
Some("sk-a"),
|
||
Some("https://api.a.example/v1/"),
|
||
None,
|
||
);
|
||
|
||
ProviderService::add(state, AppType::Codex, copied_provider, false)
|
||
.expect("add copied provider");
|
||
|
||
let saved_after_add = state
|
||
.db
|
||
.get_provider_by_id("codex-copy", AppType::Codex.as_str())
|
||
.expect("query copied provider")
|
||
.expect("copied provider should exist");
|
||
let script_after_add = saved_after_add
|
||
.meta
|
||
.as_ref()
|
||
.and_then(|meta| meta.usage_script.as_ref())
|
||
.expect("usage script should remain");
|
||
assert_eq!(script_after_add.api_key, None);
|
||
assert_eq!(script_after_add.base_url, None);
|
||
|
||
let mut edited_provider = saved_after_add.clone();
|
||
edited_provider.settings_config = codex_settings("https://api.b.example/v1/", "sk-b");
|
||
|
||
ProviderService::update(state, AppType::Codex, None, edited_provider)
|
||
.expect("edit copied provider credentials");
|
||
|
||
let saved_after_update = state
|
||
.db
|
||
.get_provider_by_id("codex-copy", AppType::Codex.as_str())
|
||
.expect("query edited provider")
|
||
.expect("edited provider should exist");
|
||
let script_after_update = saved_after_update
|
||
.meta
|
||
.as_ref()
|
||
.and_then(|meta| meta.usage_script.as_ref())
|
||
.expect("usage script should remain");
|
||
|
||
assert_eq!(script_after_update.api_key, None);
|
||
assert_eq!(script_after_update.base_url, None);
|
||
assert_eq!(
|
||
saved_after_update.resolve_usage_credentials(&AppType::Codex),
|
||
("https://api.b.example/v1".to_string(), "sk-b".to_string())
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
#[serial]
|
||
fn update_clears_usage_credentials_that_match_current_config() {
|
||
with_test_home(|state, _| {
|
||
let provider = codex_provider_with_usage(
|
||
"codex-current",
|
||
"https://api.a.example/v1",
|
||
"sk-a",
|
||
Some("sk-usage"),
|
||
Some("https://usage.example/api"),
|
||
None,
|
||
);
|
||
state
|
||
.db
|
||
.save_provider(AppType::Codex.as_str(), &provider)
|
||
.expect("seed provider with distinct usage credentials");
|
||
|
||
let mut updated = provider.clone();
|
||
updated.settings_config = codex_settings("https://api.b.example/v1/", "sk-b");
|
||
updated.meta = Some(ProviderMeta {
|
||
usage_script: Some(usage_script_with_credentials(
|
||
Some(" sk-b "),
|
||
Some(" https://api.b.example/v1/ "),
|
||
None,
|
||
)),
|
||
..Default::default()
|
||
});
|
||
|
||
ProviderService::update(state, AppType::Codex, None, updated)
|
||
.expect("update provider with redundant usage credentials");
|
||
|
||
let saved = state
|
||
.db
|
||
.get_provider_by_id("codex-current", AppType::Codex.as_str())
|
||
.expect("query updated provider")
|
||
.expect("updated provider should exist");
|
||
let script = saved
|
||
.meta
|
||
.as_ref()
|
||
.and_then(|meta| meta.usage_script.as_ref())
|
||
.expect("usage script should remain");
|
||
|
||
assert_eq!(script.api_key, None);
|
||
assert_eq!(script.base_url, None);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
#[serial]
|
||
fn add_preserves_distinct_usage_credentials() {
|
||
with_test_home(|state, _| {
|
||
let provider = codex_provider_with_usage(
|
||
"codex-distinct",
|
||
"https://api.main.example/v1",
|
||
"sk-main",
|
||
Some("sk-usage"),
|
||
Some("https://usage.example/api"),
|
||
None,
|
||
);
|
||
|
||
ProviderService::add(state, AppType::Codex, provider, false).expect("add provider");
|
||
|
||
let saved = state
|
||
.db
|
||
.get_provider_by_id("codex-distinct", AppType::Codex.as_str())
|
||
.expect("query saved provider")
|
||
.expect("saved provider should exist");
|
||
let script = saved
|
||
.meta
|
||
.as_ref()
|
||
.and_then(|meta| meta.usage_script.as_ref())
|
||
.expect("usage script should remain");
|
||
|
||
assert_eq!(script.api_key.as_deref(), Some("sk-usage"));
|
||
assert_eq!(
|
||
script.base_url.as_deref(),
|
||
Some("https://usage.example/api")
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
#[serial]
|
||
fn add_does_not_clear_token_plan_credentials() {
|
||
with_test_home(|state, _| {
|
||
let provider = codex_provider_with_usage(
|
||
"codex-token-plan",
|
||
"https://api.plan.example/v1",
|
||
"sk-plan",
|
||
Some("sk-plan"),
|
||
Some("https://api.plan.example/v1"),
|
||
Some("token_plan"),
|
||
);
|
||
|
||
ProviderService::add(state, AppType::Codex, provider, false).expect("add provider");
|
||
|
||
let saved = state
|
||
.db
|
||
.get_provider_by_id("codex-token-plan", AppType::Codex.as_str())
|
||
.expect("query saved provider")
|
||
.expect("saved provider should exist");
|
||
let script = saved
|
||
.meta
|
||
.as_ref()
|
||
.and_then(|meta| meta.usage_script.as_ref())
|
||
.expect("usage script should remain");
|
||
|
||
assert_eq!(script.api_key.as_deref(), Some("sk-plan"));
|
||
assert_eq!(
|
||
script.base_url.as_deref(),
|
||
Some("https://api.plan.example/v1")
|
||
);
|
||
assert_eq!(script.access_key_id.as_deref(), Some("ak-test"));
|
||
assert_eq!(script.secret_access_key.as_deref(), Some("sk-test"));
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn validate_provider_settings_rejects_missing_auth() {
|
||
let provider = Provider::with_id(
|
||
"codex".into(),
|
||
"Codex".into(),
|
||
json!({ "config": "base_url = \"https://example.com\"" }),
|
||
None,
|
||
);
|
||
let err = ProviderService::validate_provider_settings(&AppType::Codex, &provider)
|
||
.expect_err("missing auth should be rejected");
|
||
assert!(
|
||
err.to_string().contains("auth"),
|
||
"expected auth error, got {err:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn extract_claude_common_config_strips_all_credentials_keeps_shareable() {
|
||
// env 混入多种凭据(Anthropic/OpenRouter/Google/OpenAI/Gemini + AWS/Vertex)
|
||
// 与可共享配置;顶层混入非标准的 apiKey/api_key 凭据与正常设置。
|
||
let settings = json!({
|
||
"env": {
|
||
"ANTHROPIC_API_KEY": "sk-ant",
|
||
"ANTHROPIC_AUTH_TOKEN": "tok-ant",
|
||
"OPENROUTER_API_KEY": "sk-or",
|
||
"GOOGLE_API_KEY": "g-key",
|
||
"OPENAI_API_KEY": "sk-oai",
|
||
"GEMINI_API_KEY": "g-gem",
|
||
"AWS_ACCESS_KEY_ID": "AKIA",
|
||
"AWS_SECRET_ACCESS_KEY": "secret",
|
||
"AWS_SESSION_TOKEN": "sess",
|
||
"GOOGLE_APPLICATION_CREDENTIALS": "/path/creds.json",
|
||
"AWS_BEARER_TOKEN_BEDROCK": "bedrock-tok",
|
||
"ANTHROPIC_BASE_URL": "https://example.com",
|
||
"ANTHROPIC_MODEL": "claude-x",
|
||
"CLAUDE_CODE_SUBAGENT_MODEL": "gpt-5.4-mini",
|
||
"CLAUDE_CODE_MAX_CONTEXT_TOKENS": "400000",
|
||
"CLAUDE_CODE_AUTO_COMPACT_WINDOW": "400000",
|
||
// 可共享、非机密配置(复数 _TOKENS 不应被误剥)
|
||
"ENABLE_TOOL_SEARCH": "true",
|
||
"CLAUDE_CODE_MAX_OUTPUT_TOKENS": "8192"
|
||
},
|
||
"apiKey": "sk-top",
|
||
"api_key": "sk-top2",
|
||
"theme": "dark",
|
||
"includeCoAuthoredBy": false
|
||
});
|
||
|
||
let snippet = ProviderService::extract_claude_common_config(&settings)
|
||
.expect("extract should succeed");
|
||
let value: Value = serde_json::from_str(&snippet).expect("snippet is valid JSON");
|
||
|
||
// 所有凭据都不得出现在共享片段里
|
||
let env = value.get("env");
|
||
for leaked in [
|
||
"ANTHROPIC_API_KEY",
|
||
"ANTHROPIC_AUTH_TOKEN",
|
||
"OPENROUTER_API_KEY",
|
||
"GOOGLE_API_KEY",
|
||
"OPENAI_API_KEY",
|
||
"GEMINI_API_KEY",
|
||
"AWS_ACCESS_KEY_ID",
|
||
"AWS_SECRET_ACCESS_KEY",
|
||
"AWS_SESSION_TOKEN",
|
||
"GOOGLE_APPLICATION_CREDENTIALS",
|
||
"AWS_BEARER_TOKEN_BEDROCK",
|
||
] {
|
||
assert!(
|
||
env.and_then(|e| e.get(leaked)).is_none(),
|
||
"credential {leaked} must not leak into common config"
|
||
);
|
||
}
|
||
assert!(
|
||
value.get("apiKey").is_none() && value.get("api_key").is_none(),
|
||
"top-level credentials must be stripped"
|
||
);
|
||
|
||
// 端点/模型(provider-specific 非机密)也应剥掉
|
||
assert!(env.and_then(|e| e.get("ANTHROPIC_BASE_URL")).is_none());
|
||
assert!(env.and_then(|e| e.get("ANTHROPIC_MODEL")).is_none());
|
||
assert!(env
|
||
.and_then(|e| e.get("CLAUDE_CODE_SUBAGENT_MODEL"))
|
||
.is_none());
|
||
assert!(env
|
||
.and_then(|e| e.get("CLAUDE_CODE_MAX_CONTEXT_TOKENS"))
|
||
.is_none());
|
||
assert!(env
|
||
.and_then(|e| e.get("CLAUDE_CODE_AUTO_COMPACT_WINDOW"))
|
||
.is_none());
|
||
|
||
// 可共享的非机密配置必须保留(含复数 _TOKENS 不被误剥)
|
||
assert_eq!(
|
||
env.and_then(|e| e.get("ENABLE_TOOL_SEARCH"))
|
||
.and_then(|v| v.as_str()),
|
||
Some("true")
|
||
);
|
||
assert_eq!(
|
||
env.and_then(|e| e.get("CLAUDE_CODE_MAX_OUTPUT_TOKENS"))
|
||
.and_then(|v| v.as_str()),
|
||
Some("8192")
|
||
);
|
||
assert_eq!(value.get("theme").and_then(|v| v.as_str()), Some("dark"));
|
||
assert_eq!(value.get("includeCoAuthoredBy"), Some(&json!(false)));
|
||
}
|
||
|
||
/// Regression for issue #4272: Fable tier env keys must not enter the shared
|
||
/// Claude common-config snippet (same class as haiku/sonnet/opus model pins).
|
||
#[test]
|
||
fn extract_claude_common_config_strips_fable_model_env_keys() {
|
||
let settings = json!({
|
||
"env": {
|
||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "haiku-mapped",
|
||
"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME": "Haiku Mapped",
|
||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "sonnet-mapped[1M]",
|
||
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "Sonnet Mapped",
|
||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped[1M]",
|
||
"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME": "Opus Mapped",
|
||
"ANTHROPIC_DEFAULT_FABLE_MODEL": "deepseek-v4-flash[1M]",
|
||
"ANTHROPIC_DEFAULT_FABLE_MODEL_NAME": "deepseek-v4-flash",
|
||
"ANTHROPIC_MODEL": "default-mapped",
|
||
"ENABLE_TOOL_SEARCH": "true"
|
||
},
|
||
"theme": "dark"
|
||
});
|
||
|
||
let snippet = ProviderService::extract_claude_common_config(&settings)
|
||
.expect("extract should succeed");
|
||
let value: Value = serde_json::from_str(&snippet).expect("snippet is valid JSON");
|
||
let env = value.get("env");
|
||
|
||
for stripped in [
|
||
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||
"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME",
|
||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME",
|
||
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||
"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME",
|
||
"ANTHROPIC_DEFAULT_FABLE_MODEL",
|
||
"ANTHROPIC_DEFAULT_FABLE_MODEL_NAME",
|
||
"ANTHROPIC_MODEL",
|
||
] {
|
||
assert!(
|
||
env.and_then(|e| e.get(stripped)).is_none(),
|
||
"provider-specific model key {stripped} must not enter common config"
|
||
);
|
||
}
|
||
|
||
assert_eq!(
|
||
env.and_then(|e| e.get("ENABLE_TOOL_SEARCH"))
|
||
.and_then(|v| v.as_str()),
|
||
Some("true")
|
||
);
|
||
assert_eq!(value.get("theme").and_then(|v| v.as_str()), Some("dark"));
|
||
}
|
||
|
||
#[test]
|
||
fn validate_provider_settings_rejects_negative_cost_multiplier() {
|
||
let mut provider = Provider::with_id(
|
||
"claude".into(),
|
||
"Claude".into(),
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_AUTH_TOKEN": "token",
|
||
"ANTHROPIC_BASE_URL": "https://claude.example"
|
||
}
|
||
}),
|
||
None,
|
||
);
|
||
provider.meta = Some(ProviderMeta {
|
||
cost_multiplier: Some("-1".to_string()),
|
||
..ProviderMeta::default()
|
||
});
|
||
|
||
let err = ProviderService::validate_provider_settings(&AppType::Claude, &provider)
|
||
.expect_err("negative multiplier should be rejected");
|
||
assert!(matches!(
|
||
err,
|
||
AppError::Localized {
|
||
key: "error.invalidMultiplier",
|
||
..
|
||
}
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn extract_credentials_returns_expected_values() {
|
||
let provider = Provider::with_id(
|
||
"claude".into(),
|
||
"Claude".into(),
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_AUTH_TOKEN": "token",
|
||
"ANTHROPIC_BASE_URL": "https://claude.example"
|
||
}
|
||
}),
|
||
None,
|
||
);
|
||
let (api_key, base_url) =
|
||
ProviderService::extract_credentials(&provider, &AppType::Claude).unwrap();
|
||
assert_eq!(api_key, "token");
|
||
assert_eq!(base_url, "https://claude.example");
|
||
}
|
||
|
||
#[test]
|
||
fn extract_codex_common_config_strips_provider_fields_and_injected_artifacts() {
|
||
// 顶层 experimental_bearer_token 模拟无活跃路由时的 fallback 注入;
|
||
// web_search = "disabled" 是 cc-switch 对黑名单网关注入的哨兵;
|
||
// 顶层 wire_api 模拟无 model_provider 时的 fallback 写法;
|
||
// [mcp.servers] 是历史错误格式,sync_all_enabled 清不掉它。
|
||
let config_toml = r#"model_provider = "azure"
|
||
model = "gpt-4"
|
||
wire_api = "chat"
|
||
disable_response_storage = true
|
||
experimental_bearer_token = "sk-live-secret"
|
||
model_catalog_json = "cc-switch-model-catalog.json"
|
||
web_search = "disabled"
|
||
|
||
[model_providers.azure]
|
||
name = "Azure OpenAI"
|
||
base_url = "https://azure.example/v1"
|
||
wire_api = "responses"
|
||
|
||
[mcp_servers.my_server]
|
||
base_url = "http://localhost:8080"
|
||
|
||
[mcp.servers.legacy_server]
|
||
command = "legacy-cmd"
|
||
"#;
|
||
|
||
let settings = json!({ "config": config_toml });
|
||
let extracted = ProviderService::extract_codex_common_config(&settings)
|
||
.expect("extract_codex_common_config should succeed");
|
||
|
||
assert!(
|
||
!extracted
|
||
.lines()
|
||
.any(|line| line.trim_start().starts_with("model_provider")),
|
||
"should remove top-level model_provider"
|
||
);
|
||
assert!(
|
||
!extracted
|
||
.lines()
|
||
.any(|line| line.trim_start().starts_with("model =")),
|
||
"should remove top-level model"
|
||
);
|
||
assert!(
|
||
!extracted.contains("[model_providers"),
|
||
"should remove entire model_providers table"
|
||
);
|
||
// MCP 归 DB mcp_servers 表所有,不得进共享片段(含历史错误格式 [mcp.servers])
|
||
assert!(
|
||
!extracted.contains("mcp_servers") && !extracted.contains("http://localhost:8080"),
|
||
"should strip mcp_servers from the shared snippet, got: {extracted}"
|
||
);
|
||
assert!(
|
||
!extracted.contains("[mcp") && !extracted.contains("legacy-cmd"),
|
||
"should strip the legacy [mcp.servers] form from the shared snippet, got: {extracted}"
|
||
);
|
||
// 顶层 wire_api 是供应商路由语义(model_providers 整表已剥,
|
||
// 剩余任何 wire_api 都意味着泄漏)
|
||
assert!(
|
||
!extracted.contains("wire_api"),
|
||
"should strip top-level wire_api from the shared snippet, got: {extracted}"
|
||
);
|
||
// 注入产物不得进共享片段(bearer token 泄漏为密钥级问题)
|
||
assert!(
|
||
!extracted.contains("experimental_bearer_token")
|
||
&& !extracted.contains("sk-live-secret"),
|
||
"should strip top-level fallback bearer token, got: {extracted}"
|
||
);
|
||
assert!(
|
||
!extracted.contains("model_catalog_json"),
|
||
"should strip catalog projection pointer, got: {extracted}"
|
||
);
|
||
assert!(
|
||
!extracted.contains("web_search"),
|
||
"should strip the cc-switch web_search disabled sentinel, got: {extracted}"
|
||
);
|
||
// 真正可共享的键保留
|
||
assert!(
|
||
extracted.contains("disable_response_storage = true"),
|
||
"shareable keys must survive extraction, got: {extracted}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn extract_codex_common_config_keeps_user_set_web_search() {
|
||
let config_toml = "web_search = \"enabled\"\ndisable_response_storage = true\n";
|
||
let settings = json!({ "config": config_toml });
|
||
let extracted = ProviderService::extract_codex_common_config(&settings)
|
||
.expect("extract should succeed");
|
||
assert!(
|
||
extracted.contains("web_search = \"enabled\""),
|
||
"a user-set web_search value is a shareable preference, got: {extracted}"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[serial]
|
||
async fn update_current_claude_provider_syncs_live_when_proxy_takeover_detected_without_backup()
|
||
{
|
||
let _home = TempHome::new();
|
||
crate::settings::reload_settings().expect("reload settings");
|
||
|
||
let db = Arc::new(Database::memory().expect("init db"));
|
||
let state = AppState::new(db.clone());
|
||
|
||
let original = Provider::with_id(
|
||
"p1".into(),
|
||
"Claude A".into(),
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_API_KEY": "token-a",
|
||
"ANTHROPIC_BASE_URL": "https://api.a.example",
|
||
"ANTHROPIC_MODEL": "model-a"
|
||
},
|
||
"permissions": { "allow": ["Bash"] }
|
||
}),
|
||
None,
|
||
);
|
||
db.save_provider("claude", &original)
|
||
.expect("save provider");
|
||
db.set_current_provider("claude", "p1")
|
||
.expect("set current provider");
|
||
crate::settings::set_current_provider(&AppType::Claude, Some("p1"))
|
||
.expect("set local current provider");
|
||
|
||
db.update_proxy_config(ProxyConfig {
|
||
live_takeover_active: true,
|
||
listen_port: 0,
|
||
..Default::default()
|
||
})
|
||
.await
|
||
.expect("update proxy config");
|
||
{
|
||
let mut config = db
|
||
.get_proxy_config_for_app("claude")
|
||
.await
|
||
.expect("get app proxy config");
|
||
config.enabled = true;
|
||
db.update_proxy_config_for_app(config)
|
||
.await
|
||
.expect("update app proxy config");
|
||
}
|
||
|
||
write_json_file(
|
||
&get_claude_settings_path(),
|
||
&json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721",
|
||
"ANTHROPIC_API_KEY": "PROXY_MANAGED",
|
||
"ANTHROPIC_MODEL": "stale-model"
|
||
},
|
||
"permissions": { "allow": ["Bash"] }
|
||
}),
|
||
)
|
||
.expect("seed taken-over live file");
|
||
|
||
let proxy_info = state
|
||
.proxy_service
|
||
.start()
|
||
.await
|
||
.expect("start proxy service");
|
||
|
||
let updated = Provider::with_id(
|
||
"p1".into(),
|
||
"Claude A".into(),
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_API_KEY": "token-updated",
|
||
"ANTHROPIC_BASE_URL": "https://api.updated.example",
|
||
"ANTHROPIC_MODEL": "model-updated"
|
||
},
|
||
"permissions": { "allow": ["Read"] }
|
||
}),
|
||
None,
|
||
);
|
||
|
||
ProviderService::update(&state, AppType::Claude, None, updated.clone())
|
||
.expect("update current provider");
|
||
|
||
let backup = db
|
||
.get_live_backup("claude")
|
||
.await
|
||
.expect("get live backup")
|
||
.expect("backup exists");
|
||
let stored_provider = db
|
||
.get_provider_by_id("p1", "claude")
|
||
.expect("get stored provider")
|
||
.expect("stored provider exists");
|
||
let expected_backup =
|
||
serde_json::to_string(&stored_provider.settings_config).expect("serialize");
|
||
assert_eq!(backup.original_config, expected_backup);
|
||
|
||
let live: Value = read_json_file(&get_claude_settings_path()).expect("read live");
|
||
assert_eq!(
|
||
live.get("permissions"),
|
||
updated.settings_config.get("permissions"),
|
||
"provider edits should propagate into Claude live config during takeover"
|
||
);
|
||
assert_eq!(
|
||
live.get("env")
|
||
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
|
||
.and_then(|v| v.as_str()),
|
||
Some("PROXY_MANAGED"),
|
||
"takeover placeholder should stay intact"
|
||
);
|
||
assert_eq!(
|
||
live.get("env")
|
||
.and_then(|env| env.get("ANTHROPIC_BASE_URL"))
|
||
.and_then(|v| v.as_str()),
|
||
Some(format!("http://127.0.0.1:{}", proxy_info.port).as_str()),
|
||
"proxy base URL should stay intact"
|
||
);
|
||
assert!(
|
||
live.get("env")
|
||
.and_then(|env| env.get("ANTHROPIC_MODEL"))
|
||
.is_none(),
|
||
"model override should be removed in takeover live config"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[serial]
|
||
async fn update_current_codex_provider_refreshes_and_clears_catalog_during_takeover() {
|
||
let _home = TempHome::new();
|
||
crate::settings::reload_settings().expect("reload settings");
|
||
|
||
let db = Arc::new(Database::memory().expect("init db"));
|
||
let state = AppState::new(db.clone());
|
||
|
||
let mut original = Provider::with_id(
|
||
"p1".into(),
|
||
"Codex A".into(),
|
||
json!({
|
||
"auth": { "OPENAI_API_KEY": "token-a" },
|
||
"config": r#"model_provider = "custom"
|
||
model = "old-model"
|
||
|
||
[model_providers.custom]
|
||
name = "Codex A"
|
||
base_url = "https://api.a.example/v1"
|
||
wire_api = "responses"
|
||
requires_openai_auth = true
|
||
"#,
|
||
"modelCatalog": {
|
||
"models": [{ "model": "old-model" }]
|
||
}
|
||
}),
|
||
None,
|
||
);
|
||
original.meta = Some(ProviderMeta {
|
||
api_format: Some("openai_responses".into()),
|
||
..Default::default()
|
||
});
|
||
db.save_provider("codex", &original).expect("save provider");
|
||
db.set_current_provider("codex", "p1")
|
||
.expect("set current provider");
|
||
crate::settings::set_current_provider(&AppType::Codex, Some("p1"))
|
||
.expect("set local current provider");
|
||
|
||
db.update_proxy_config(ProxyConfig {
|
||
live_takeover_active: true,
|
||
listen_port: 0,
|
||
..Default::default()
|
||
})
|
||
.await
|
||
.expect("update proxy config");
|
||
{
|
||
let mut config = db
|
||
.get_proxy_config_for_app("codex")
|
||
.await
|
||
.expect("get app proxy config");
|
||
config.enabled = true;
|
||
db.update_proxy_config_for_app(config)
|
||
.await
|
||
.expect("enable Codex proxy config");
|
||
}
|
||
db.save_live_backup(
|
||
"codex",
|
||
&serde_json::to_string(&original.settings_config).expect("serialize backup"),
|
||
)
|
||
.await
|
||
.expect("seed live backup");
|
||
|
||
state
|
||
.proxy_service
|
||
.start()
|
||
.await
|
||
.expect("start proxy service");
|
||
state
|
||
.proxy_service
|
||
.sync_codex_live_from_provider_while_proxy_active(&original)
|
||
.await
|
||
.expect("seed taken-over Codex live config");
|
||
assert!(
|
||
state
|
||
.proxy_service
|
||
.detect_takeover_in_live_config_for_app(&AppType::Codex),
|
||
"seeded Codex live config should be recognized as takeover-owned"
|
||
);
|
||
|
||
let mut updated = original.clone();
|
||
updated.settings_config["config"] = json!(
|
||
r#"model_provider = "custom"
|
||
model = "gpt-5.4"
|
||
|
||
[model_providers.custom]
|
||
name = "Codex A"
|
||
base_url = "https://api.updated.example/v1"
|
||
wire_api = "responses"
|
||
requires_openai_auth = true
|
||
"#
|
||
);
|
||
updated.settings_config["modelCatalog"] = json!({
|
||
"models": [{ "model": "gpt-5.4", "displayName": "GPT 5.4" }]
|
||
});
|
||
|
||
ProviderService::update(&state, AppType::Codex, None, updated.clone())
|
||
.expect("update current Codex provider mapping");
|
||
|
||
let catalog_path = crate::codex_config::get_codex_model_catalog_path();
|
||
let catalog: Value = read_json_file(&catalog_path).expect("read generated catalog");
|
||
assert_eq!(catalog["models"][0]["slug"], "gpt-5.4");
|
||
assert_eq!(
|
||
catalog["models"][0]["input_modalities"],
|
||
json!(["text", "image"]),
|
||
"unknown/GPT models must fail open to image input"
|
||
);
|
||
let live_config = fs::read_to_string(crate::codex_config::get_codex_config_path())
|
||
.expect("read Codex config.toml");
|
||
assert!(live_config.contains("model_catalog_json"));
|
||
|
||
updated.settings_config["modelCatalog"] = json!({ "models": [] });
|
||
ProviderService::update(&state, AppType::Codex, None, updated)
|
||
.expect("remove current Codex provider mapping");
|
||
|
||
let live_config = fs::read_to_string(crate::codex_config::get_codex_config_path())
|
||
.expect("read Codex config.toml after mapping removal");
|
||
assert!(
|
||
!live_config.contains("model_catalog_json"),
|
||
"removing mappings during takeover must clear the stale catalog pointer"
|
||
);
|
||
|
||
state
|
||
.proxy_service
|
||
.stop()
|
||
.await
|
||
.expect("stop proxy service");
|
||
}
|
||
|
||
#[cfg(any(target_os = "macos", windows))]
|
||
#[tokio::test]
|
||
#[serial]
|
||
async fn update_current_claude_desktop_provider_syncs_profile_when_proxy_takeover_is_active() {
|
||
let home = TempHome::new();
|
||
crate::settings::reload_settings().expect("reload settings");
|
||
|
||
let db = Arc::new(Database::memory().expect("init db"));
|
||
let state = AppState::new(db.clone());
|
||
|
||
let mut original = Provider::with_id(
|
||
"p1".into(),
|
||
"Desktop A".into(),
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_AUTH_TOKEN": "token-a",
|
||
"ANTHROPIC_BASE_URL": "https://opencode.ai/zen/go"
|
||
}
|
||
}),
|
||
None,
|
||
);
|
||
original.meta = Some(ProviderMeta {
|
||
api_format: Some("openai_chat".into()),
|
||
claude_desktop_mode: Some(ClaudeDesktopMode::Proxy),
|
||
claude_desktop_model_routes: std::collections::HashMap::from([(
|
||
"claude-sonnet-4-6".into(),
|
||
ClaudeDesktopModelRoute {
|
||
model: "deepseek-v4-flash".into(),
|
||
label_override: Some("DeepSeek V4 Flash".into()),
|
||
supports_1m: None,
|
||
},
|
||
)]),
|
||
..Default::default()
|
||
});
|
||
db.save_provider("claude-desktop", &original)
|
||
.expect("save provider");
|
||
db.set_current_provider("claude-desktop", "p1")
|
||
.expect("set current provider");
|
||
crate::settings::set_current_provider(&AppType::ClaudeDesktop, Some("p1"))
|
||
.expect("set local current provider");
|
||
|
||
// Claude Desktop keeps backup state from takeover startup; this sentinel only
|
||
// marks takeover as active so provider updates rewrite the 3P profile.
|
||
db.save_live_backup("claude-desktop", "{}")
|
||
.await
|
||
.expect("seed live backup");
|
||
{
|
||
let mut config = db
|
||
.get_proxy_config_for_app("claude-desktop")
|
||
.await
|
||
.expect("get app proxy config");
|
||
config.enabled = true;
|
||
db.update_proxy_config_for_app(config)
|
||
.await
|
||
.expect("update app proxy config");
|
||
}
|
||
|
||
state
|
||
.proxy_service
|
||
.start()
|
||
.await
|
||
.expect("start proxy service");
|
||
|
||
let mut updated = Provider::with_id(
|
||
"p1".into(),
|
||
"Desktop A".into(),
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_AUTH_TOKEN": "token-updated",
|
||
"ANTHROPIC_BASE_URL": "https://opencode.ai/zen/go"
|
||
}
|
||
}),
|
||
None,
|
||
);
|
||
updated.meta = Some(ProviderMeta {
|
||
api_format: Some("openai_chat".into()),
|
||
claude_desktop_mode: Some(ClaudeDesktopMode::Proxy),
|
||
claude_desktop_model_routes: std::collections::HashMap::from([(
|
||
"claude-sonnet-4-6".into(),
|
||
ClaudeDesktopModelRoute {
|
||
model: "deepseek-v4-flash".into(),
|
||
label_override: Some("DeepSeek V4 Flash Updated".into()),
|
||
supports_1m: Some(true),
|
||
},
|
||
)]),
|
||
..Default::default()
|
||
});
|
||
|
||
ProviderService::update(&state, AppType::ClaudeDesktop, None, updated.clone())
|
||
.expect("update current provider");
|
||
|
||
let backup = db
|
||
.get_live_backup("claude-desktop")
|
||
.await
|
||
.expect("get live backup")
|
||
.expect("backup exists");
|
||
assert_eq!(
|
||
backup.original_config, "{}",
|
||
"Claude Desktop provider edits should not rewrite takeover backup"
|
||
);
|
||
|
||
let profile_path = claude_desktop_profile_path(home.dir.path());
|
||
let profile: Value = read_json_file(&profile_path).expect("read desktop profile");
|
||
assert_eq!(
|
||
profile["inferenceGatewayBaseUrl"],
|
||
json!("http://127.0.0.1:15721/claude-desktop"),
|
||
"desktop profile should stay pointed at the local gateway during takeover"
|
||
);
|
||
assert_eq!(profile["inferenceGatewayAuthScheme"], json!("bearer"));
|
||
assert_eq!(
|
||
profile["inferenceModels"],
|
||
json!([{ "name": "claude-sonnet-4-6", "labelOverride": "DeepSeek V4 Flash Updated", "supports1m": true }]),
|
||
"provider edits should propagate into the Claude Desktop 3P profile during takeover"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
#[serial]
|
||
fn rename_rejects_missing_original_provider() {
|
||
with_test_home(|state, _| {
|
||
let original = openclaw_provider("deepseek");
|
||
ProviderService::add(state, AppType::OpenClaw, original.clone(), false)
|
||
.expect("seed db-only provider");
|
||
|
||
let mut renamed = original.clone();
|
||
renamed.id = "deepseek-copy".to_string();
|
||
|
||
let err = ProviderService::update(
|
||
state,
|
||
AppType::OpenClaw,
|
||
Some("missing-provider"),
|
||
renamed,
|
||
)
|
||
.expect_err("stale originalId should be rejected");
|
||
|
||
assert!(
|
||
err.to_string().contains("Original provider"),
|
||
"expected missing original provider error, got {err:?}"
|
||
);
|
||
assert!(
|
||
state
|
||
.db
|
||
.get_provider_by_id("deepseek-copy", AppType::OpenClaw.as_str())
|
||
.expect("query renamed provider")
|
||
.is_none(),
|
||
"rename must not create a new row when originalId is stale"
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
#[serial]
|
||
fn db_only_additive_update_survives_live_config_parse_errors() {
|
||
with_test_home(|state, home| {
|
||
let provider = openclaw_provider("deepseek");
|
||
ProviderService::add(state, AppType::OpenClaw, provider.clone(), false)
|
||
.expect("seed db-only provider");
|
||
|
||
let stored = state
|
||
.db
|
||
.get_provider_by_id("deepseek", AppType::OpenClaw.as_str())
|
||
.expect("query stored provider")
|
||
.expect("provider should exist");
|
||
assert_eq!(
|
||
stored
|
||
.meta
|
||
.as_ref()
|
||
.and_then(|meta| meta.live_config_managed),
|
||
Some(false),
|
||
"db-only provider should be marked as not live-managed"
|
||
);
|
||
|
||
let openclaw_dir = home.join(".openclaw");
|
||
fs::create_dir_all(&openclaw_dir).expect("create openclaw dir");
|
||
fs::write(openclaw_dir.join("openclaw.json"), "{ invalid json5")
|
||
.expect("write malformed config");
|
||
|
||
let mut updated = stored.clone();
|
||
updated.name = "DeepSeek Edited".to_string();
|
||
updated.meta.get_or_insert_with(ProviderMeta::default);
|
||
|
||
ProviderService::update(state, AppType::OpenClaw, None, updated)
|
||
.expect("db-only update should ignore live parse errors");
|
||
|
||
let saved = state
|
||
.db
|
||
.get_provider_by_id("deepseek", AppType::OpenClaw.as_str())
|
||
.expect("query updated provider")
|
||
.expect("updated provider should exist");
|
||
assert_eq!(saved.name, "DeepSeek Edited");
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
#[serial]
|
||
fn sync_current_provider_for_app_skips_db_only_opencode_provider() {
|
||
with_test_home(|state, _| {
|
||
let provider = opencode_provider("db-only-opencode");
|
||
ProviderService::add(state, AppType::OpenCode, provider.clone(), false)
|
||
.expect("seed db-only opencode provider");
|
||
|
||
ProviderService::sync_current_provider_for_app(state, AppType::OpenCode)
|
||
.expect("sync additive opencode providers");
|
||
|
||
let live_providers = crate::opencode_config::get_providers()
|
||
.expect("read opencode providers after sync");
|
||
assert!(
|
||
!live_providers.contains_key(&provider.id),
|
||
"db-only opencode provider should not be written to live during sync"
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
#[serial]
|
||
fn sync_current_provider_for_app_skips_db_only_openclaw_provider() {
|
||
with_test_home(|state, _| {
|
||
let provider = openclaw_provider("db-only-openclaw");
|
||
ProviderService::add(state, AppType::OpenClaw, provider.clone(), false)
|
||
.expect("seed db-only openclaw provider");
|
||
|
||
ProviderService::sync_current_provider_for_app(state, AppType::OpenClaw)
|
||
.expect("sync additive openclaw providers");
|
||
|
||
let live_providers = crate::openclaw_config::get_providers()
|
||
.expect("read openclaw providers after sync");
|
||
assert!(
|
||
!live_providers.contains_key(&provider.id),
|
||
"db-only openclaw provider should not be written to live during sync"
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
#[serial]
|
||
fn sync_current_provider_for_app_preserves_legacy_live_opencode_provider() {
|
||
with_test_home(|state, _| {
|
||
let provider = opencode_provider("legacy-opencode");
|
||
crate::opencode_config::set_provider(&provider.id, provider.settings_config.clone())
|
||
.expect("seed opencode live provider");
|
||
state
|
||
.db
|
||
.save_provider(AppType::OpenCode.as_str(), &provider)
|
||
.expect("seed legacy opencode provider in db");
|
||
|
||
let mut updated = provider.clone();
|
||
updated.settings_config["options"]["apiKey"] = Value::String("updated-key".to_string());
|
||
state
|
||
.db
|
||
.save_provider(AppType::OpenCode.as_str(), &updated)
|
||
.expect("update legacy opencode provider in db");
|
||
|
||
ProviderService::sync_current_provider_for_app(state, AppType::OpenCode)
|
||
.expect("sync legacy opencode provider");
|
||
|
||
let live_providers =
|
||
crate::opencode_config::get_providers().expect("read opencode providers");
|
||
assert_eq!(
|
||
live_providers
|
||
.get(&provider.id)
|
||
.and_then(|config| config.get("options"))
|
||
.and_then(|options| options.get("apiKey")),
|
||
Some(&Value::String("updated-key".to_string())),
|
||
"legacy provider that already exists in live should still be synced"
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
#[serial]
|
||
fn sync_current_provider_for_app_restores_legacy_opencode_provider_after_live_reset() {
|
||
with_test_home(|state, _| {
|
||
let provider = opencode_provider("legacy-opencode-reset");
|
||
state
|
||
.db
|
||
.save_provider(AppType::OpenCode.as_str(), &provider)
|
||
.expect("seed legacy opencode provider in db");
|
||
|
||
ProviderService::sync_current_provider_for_app(state, AppType::OpenCode)
|
||
.expect("sync legacy opencode provider after reset");
|
||
|
||
let live_providers =
|
||
crate::opencode_config::get_providers().expect("read opencode providers");
|
||
assert!(
|
||
live_providers.contains_key(&provider.id),
|
||
"legacy opencode provider should be restored when live config is reset"
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
#[serial]
|
||
fn sync_current_provider_for_app_restores_legacy_openclaw_provider_after_live_reset() {
|
||
with_test_home(|state, _| {
|
||
let mut provider = openclaw_provider("legacy-openclaw-reset");
|
||
provider.settings_config["models"] = json!([
|
||
{
|
||
"id": "claude-sonnet-4",
|
||
"name": "Claude Sonnet 4"
|
||
}
|
||
]);
|
||
state
|
||
.db
|
||
.save_provider(AppType::OpenClaw.as_str(), &provider)
|
||
.expect("seed legacy openclaw provider in db");
|
||
|
||
ProviderService::sync_current_provider_for_app(state, AppType::OpenClaw)
|
||
.expect("sync legacy openclaw provider after reset");
|
||
|
||
let live_providers =
|
||
crate::openclaw_config::get_providers().expect("read openclaw providers");
|
||
assert!(
|
||
live_providers.contains_key(&provider.id),
|
||
"legacy openclaw provider should be restored when live config is reset"
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
#[serial]
|
||
fn switch_from_managed_codex_official_to_unbound_clears_live_without_backfilling_token() {
|
||
with_test_home(|state, _| {
|
||
crate::settings::reload_settings().expect("reload settings");
|
||
tauri::async_runtime::block_on(async {
|
||
state
|
||
.codex_oauth_manager
|
||
.add_test_account_with_access_token(
|
||
"acct-managed",
|
||
"managed-token",
|
||
Some("managed-id-token"),
|
||
)
|
||
.await
|
||
.expect("seed managed Codex OAuth account");
|
||
});
|
||
|
||
let mut managed = Provider::with_id(
|
||
"managed-official".to_string(),
|
||
"Managed Official".to_string(),
|
||
json!({
|
||
"auth": {},
|
||
"config": ""
|
||
}),
|
||
None,
|
||
);
|
||
managed.category = Some("official".to_string());
|
||
managed.meta = Some(ProviderMeta {
|
||
auth_binding: Some(AuthBinding {
|
||
source: AuthBindingSource::ManagedAccount,
|
||
auth_provider: Some("codex_oauth".to_string()),
|
||
account_id: Some("acct-managed".to_string()),
|
||
}),
|
||
..Default::default()
|
||
});
|
||
|
||
let mut unbound = Provider::with_id(
|
||
"unbound-official".to_string(),
|
||
"Unbound Official".to_string(),
|
||
json!({
|
||
"auth": {},
|
||
"config": ""
|
||
}),
|
||
None,
|
||
);
|
||
unbound.category = Some("official".to_string());
|
||
|
||
state
|
||
.db
|
||
.save_provider(AppType::Codex.as_str(), &managed)
|
||
.expect("save managed provider");
|
||
state
|
||
.db
|
||
.save_provider(AppType::Codex.as_str(), &unbound)
|
||
.expect("save unbound provider");
|
||
|
||
ProviderService::switch(state, AppType::Codex, "managed-official")
|
||
.expect("switch to managed official");
|
||
let live_auth: Value = read_json_file(&crate::codex_config::get_codex_auth_path())
|
||
.expect("read managed live auth");
|
||
assert_eq!(
|
||
live_auth
|
||
.pointer("/tokens/access_token")
|
||
.and_then(Value::as_str),
|
||
Some("managed-token"),
|
||
"managed switch should write the selected ChatGPT token to live auth"
|
||
);
|
||
|
||
ProviderService::switch(state, AppType::Codex, "unbound-official")
|
||
.expect("switch to unbound official");
|
||
|
||
assert!(
|
||
!crate::codex_config::get_codex_auth_path().exists(),
|
||
"switching to an unbound official provider should clear the recorded managed live auth"
|
||
);
|
||
|
||
let saved_managed = state
|
||
.db
|
||
.get_provider_by_id("managed-official", AppType::Codex.as_str())
|
||
.expect("query managed provider")
|
||
.expect("managed provider should exist");
|
||
assert_eq!(
|
||
saved_managed.settings_config.get("auth"),
|
||
Some(&json!({})),
|
||
"switch-away backfill must not persist the managed access token into provider storage"
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
#[serial]
|
||
fn managed_codex_switch_db_current_failure_restores_live_bundle_and_current() {
|
||
with_test_home(|state, _| {
|
||
crate::settings::reload_settings().expect("reload settings");
|
||
tauri::async_runtime::block_on(async {
|
||
state
|
||
.codex_oauth_manager
|
||
.add_test_account_with_access_token(
|
||
"acct-managed-a",
|
||
"managed-token-a",
|
||
Some("managed-id-token-a"),
|
||
)
|
||
.await
|
||
.expect("seed first managed Codex OAuth account");
|
||
state
|
||
.codex_oauth_manager
|
||
.add_test_account_with_access_token(
|
||
"acct-managed-b",
|
||
"managed-token-b",
|
||
Some("managed-id-token-b"),
|
||
)
|
||
.await
|
||
.expect("seed second managed Codex OAuth account");
|
||
});
|
||
|
||
let managed_provider = |id: &str, account_id: &str, model: &str| {
|
||
let mut provider = Provider::with_id(
|
||
id.to_string(),
|
||
format!("Managed {id}"),
|
||
json!({
|
||
"auth": {},
|
||
"config": format!("model = \"{model}\"\n"),
|
||
"modelCatalog": {
|
||
"models": [{ "model": model }]
|
||
}
|
||
}),
|
||
None,
|
||
);
|
||
provider.category = Some("official".to_string());
|
||
provider.meta = Some(ProviderMeta {
|
||
auth_binding: Some(AuthBinding {
|
||
source: AuthBindingSource::ManagedAccount,
|
||
auth_provider: Some("codex_oauth".to_string()),
|
||
account_id: Some(account_id.to_string()),
|
||
}),
|
||
..Default::default()
|
||
});
|
||
provider
|
||
};
|
||
|
||
let provider_a = managed_provider("managed-a", "acct-managed-a", "gpt-5.4-managed-a");
|
||
let provider_b = managed_provider("managed-b", "acct-managed-b", "gpt-5.4-managed-b");
|
||
state
|
||
.db
|
||
.save_provider(AppType::Codex.as_str(), &provider_a)
|
||
.expect("save first managed provider");
|
||
state
|
||
.db
|
||
.save_provider(AppType::Codex.as_str(), &provider_b)
|
||
.expect("save second managed provider");
|
||
|
||
ProviderService::switch(state, AppType::Codex, &provider_a.id)
|
||
.expect("activate first managed provider");
|
||
let auth_before: Value = read_json_file(&crate::codex_config::get_codex_auth_path())
|
||
.expect("read first managed auth");
|
||
assert!(
|
||
crate::codex_config::get_codex_config_path().exists(),
|
||
"baseline must include config.toml"
|
||
);
|
||
assert!(
|
||
crate::codex_config::get_codex_model_catalog_path().exists(),
|
||
"baseline must include the generated model catalog"
|
||
);
|
||
assert!(
|
||
crate::codex_config::codex_auth_matches_recorded_managed_oauth(
|
||
&auth_before,
|
||
"acct-managed-a",
|
||
)
|
||
.expect("check first managed auth marker"),
|
||
"baseline must include a marker owned by the first managed account"
|
||
);
|
||
let live_before = crate::codex_config::CodexLiveStateSnapshot::capture()
|
||
.expect("capture auth/config/catalog/marker before failed switch");
|
||
|
||
{
|
||
let conn = state.db.conn.lock().expect("lock database");
|
||
conn.execute_batch(
|
||
"CREATE TRIGGER reject_managed_b_current_update
|
||
BEFORE UPDATE OF is_current ON providers
|
||
WHEN NEW.app_type = 'codex'
|
||
AND NEW.id = 'managed-b'
|
||
AND NEW.is_current = 1
|
||
BEGIN
|
||
SELECT RAISE(ABORT, 'forced managed Codex current failure');
|
||
END;",
|
||
)
|
||
.expect("install current-provider failure trigger");
|
||
}
|
||
|
||
let error = ProviderService::switch(state, AppType::Codex, &provider_b.id)
|
||
.expect_err("DB current failure should abort managed switch");
|
||
assert!(
|
||
error
|
||
.to_string()
|
||
.contains("forced managed Codex current failure"),
|
||
"switch should surface the DB commit failure, got: {error}"
|
||
);
|
||
|
||
let live_after = crate::codex_config::CodexLiveStateSnapshot::capture()
|
||
.expect("capture auth/config/catalog/marker after rollback");
|
||
assert_eq!(
|
||
live_after, live_before,
|
||
"failed switch must exactly restore auth, config, catalog, and managed marker"
|
||
);
|
||
assert_eq!(
|
||
crate::settings::get_current_provider(&AppType::Codex).as_deref(),
|
||
Some(provider_a.id.as_str()),
|
||
"failed switch must restore the device-local current provider"
|
||
);
|
||
assert_eq!(
|
||
state
|
||
.db
|
||
.get_current_provider(AppType::Codex.as_str())
|
||
.expect("read DB current after rollback")
|
||
.as_deref(),
|
||
Some(provider_a.id.as_str()),
|
||
"failed switch must keep the DB current provider unchanged"
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
#[serial]
|
||
fn managed_codex_takeover_update_db_failure_restores_backup_live_and_binding() {
|
||
with_test_home(|state, _| {
|
||
crate::settings::reload_settings().expect("reload settings");
|
||
tauri::async_runtime::block_on(async {
|
||
state
|
||
.codex_oauth_manager
|
||
.add_test_account_with_access_token(
|
||
"acct-managed-a",
|
||
"managed-token-a",
|
||
Some("managed-id-a"),
|
||
)
|
||
.await
|
||
.expect("seed managed account A");
|
||
state
|
||
.codex_oauth_manager
|
||
.add_test_account_with_access_token(
|
||
"acct-managed-b",
|
||
"managed-token-b",
|
||
Some("managed-id-b"),
|
||
)
|
||
.await
|
||
.expect("seed managed account B");
|
||
});
|
||
|
||
let mut provider = Provider::with_id(
|
||
crate::database::CODEX_OFFICIAL_PROVIDER_ID.to_string(),
|
||
"OpenAI Official A".to_string(),
|
||
json!({
|
||
"auth": {},
|
||
"config": "model = \"gpt-5.4\"\n"
|
||
}),
|
||
None,
|
||
);
|
||
provider.category = Some("official".to_string());
|
||
provider.meta = Some(ProviderMeta {
|
||
auth_binding: Some(AuthBinding {
|
||
source: AuthBindingSource::ManagedAccount,
|
||
auth_provider: Some("codex_oauth".to_string()),
|
||
account_id: Some("acct-managed-a".to_string()),
|
||
}),
|
||
..Default::default()
|
||
});
|
||
state
|
||
.db
|
||
.save_provider(AppType::Codex.as_str(), &provider)
|
||
.expect("save official provider A");
|
||
state
|
||
.db
|
||
.set_current_provider(AppType::Codex.as_str(), &provider.id)
|
||
.expect("set DB current");
|
||
crate::settings::set_current_provider(&AppType::Codex, Some(&provider.id))
|
||
.expect("set local current");
|
||
|
||
tauri::async_runtime::block_on(async {
|
||
state
|
||
.db
|
||
.update_proxy_config(ProxyConfig {
|
||
listen_port: 15_721,
|
||
..Default::default()
|
||
})
|
||
.await
|
||
.expect("set proxy port");
|
||
state
|
||
.db
|
||
.save_live_backup(
|
||
AppType::Codex.as_str(),
|
||
&serde_json::to_string(&json!({
|
||
"config": "model = \"gpt-5.4\"\n"
|
||
}))
|
||
.expect("serialize baseline backup"),
|
||
)
|
||
.await
|
||
.expect("save baseline backup");
|
||
state
|
||
.proxy_service
|
||
.sync_codex_live_from_provider_while_proxy_active(&provider)
|
||
.await
|
||
.expect("seed managed takeover live");
|
||
});
|
||
|
||
let backup_before =
|
||
tauri::async_runtime::block_on(state.db.get_live_backup(AppType::Codex.as_str()))
|
||
.expect("read baseline backup")
|
||
.expect("baseline backup exists");
|
||
let live_before = crate::codex_config::CodexLiveStateSnapshot::capture()
|
||
.expect("capture managed takeover live");
|
||
|
||
{
|
||
let conn = state.db.conn.lock().expect("lock database");
|
||
conn.execute_batch(
|
||
"CREATE TRIGGER reject_managed_takeover_provider_update
|
||
BEFORE UPDATE ON providers
|
||
WHEN NEW.app_type = 'codex'
|
||
AND NEW.id = 'codex-official'
|
||
AND NEW.name = 'OpenAI Official B'
|
||
BEGIN
|
||
SELECT RAISE(ABORT, 'forced managed takeover provider failure');
|
||
END;",
|
||
)
|
||
.expect("install provider failure trigger");
|
||
}
|
||
|
||
let mut updated = provider.clone();
|
||
updated.name = "OpenAI Official B".to_string();
|
||
updated
|
||
.meta
|
||
.as_mut()
|
||
.and_then(|meta| meta.auth_binding.as_mut())
|
||
.expect("managed binding")
|
||
.account_id = Some("acct-managed-b".to_string());
|
||
|
||
let error = ProviderService::update(state, AppType::Codex, None, updated)
|
||
.expect_err("DB failure should abort takeover update");
|
||
assert!(
|
||
error
|
||
.to_string()
|
||
.contains("forced managed takeover provider failure"),
|
||
"update should surface DB failure: {error}"
|
||
);
|
||
|
||
let saved = state
|
||
.db
|
||
.get_provider_by_id(&provider.id, AppType::Codex.as_str())
|
||
.expect("read provider after rollback")
|
||
.expect("provider still exists");
|
||
assert_eq!(saved.name, "OpenAI Official A");
|
||
assert_eq!(
|
||
saved
|
||
.meta
|
||
.as_ref()
|
||
.and_then(|meta| meta.managed_account_id_for("codex_oauth")),
|
||
Some("acct-managed-a".to_string())
|
||
);
|
||
|
||
let backup_after =
|
||
tauri::async_runtime::block_on(state.db.get_live_backup(AppType::Codex.as_str()))
|
||
.expect("read backup after rollback")
|
||
.expect("backup still exists");
|
||
assert_eq!(backup_after.original_config, backup_before.original_config);
|
||
assert_eq!(
|
||
crate::codex_config::CodexLiveStateSnapshot::capture()
|
||
.expect("capture live after rollback"),
|
||
live_before,
|
||
"failed takeover update must restore auth/config/catalog/marker exactly"
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
#[serial]
|
||
fn managed_codex_update_rechecks_current_after_waiting_for_switch_lock() {
|
||
with_test_home(|state, _| {
|
||
crate::settings::reload_settings().expect("reload settings");
|
||
tauri::async_runtime::block_on(async {
|
||
state
|
||
.codex_oauth_manager
|
||
.add_test_account_with_access_token(
|
||
"acct-managed-a",
|
||
"managed-token-a",
|
||
Some("managed-id-a"),
|
||
)
|
||
.await
|
||
.expect("seed managed account A");
|
||
state
|
||
.codex_oauth_manager
|
||
.add_test_account_with_access_token(
|
||
"acct-managed-b",
|
||
"managed-token-b",
|
||
Some("managed-id-b"),
|
||
)
|
||
.await
|
||
.expect("seed managed account B");
|
||
});
|
||
|
||
let mut official = Provider::with_id(
|
||
crate::database::CODEX_OFFICIAL_PROVIDER_ID.to_string(),
|
||
"OpenAI Official".to_string(),
|
||
json!({ "auth": {}, "config": "model = \"gpt-5.4\"\n" }),
|
||
None,
|
||
);
|
||
official.category = Some("official".to_string());
|
||
official.meta = Some(ProviderMeta {
|
||
auth_binding: Some(AuthBinding {
|
||
source: AuthBindingSource::ManagedAccount,
|
||
auth_provider: Some("codex_oauth".to_string()),
|
||
account_id: Some("acct-managed-a".to_string()),
|
||
}),
|
||
..Default::default()
|
||
});
|
||
state
|
||
.db
|
||
.save_provider(AppType::Codex.as_str(), &official)
|
||
.expect("save official A");
|
||
state
|
||
.db
|
||
.set_current_provider(AppType::Codex.as_str(), &official.id)
|
||
.expect("set official current");
|
||
crate::settings::set_current_provider(&AppType::Codex, Some(&official.id))
|
||
.expect("set local official current");
|
||
|
||
let mut third_party = Provider::with_id(
|
||
"third-party-current".to_string(),
|
||
"Third Party".to_string(),
|
||
json!({
|
||
"auth": { "OPENAI_API_KEY": "sk-third" },
|
||
"config": r#"model_provider = "third"
|
||
[model_providers.third]
|
||
name = "Third"
|
||
base_url = "https://third.example/v1"
|
||
wire_api = "responses"
|
||
"#
|
||
}),
|
||
None,
|
||
);
|
||
third_party.category = Some("custom".to_string());
|
||
state
|
||
.db
|
||
.save_provider(AppType::Codex.as_str(), &third_party)
|
||
.expect("save third party");
|
||
|
||
let mut updated = official.clone();
|
||
updated
|
||
.meta
|
||
.as_mut()
|
||
.and_then(|meta| meta.auth_binding.as_mut())
|
||
.expect("managed binding")
|
||
.account_id = Some("acct-managed-b".to_string());
|
||
|
||
let switch_guard = tauri::async_runtime::block_on(
|
||
state
|
||
.proxy_service
|
||
.lock_switch_for_app(AppType::Codex.as_str()),
|
||
);
|
||
let (started_tx, started_rx) = std::sync::mpsc::channel();
|
||
let (update_result, live_after_switch) = std::thread::scope(|scope| {
|
||
let updater = scope.spawn(move || {
|
||
started_tx.send(()).expect("signal updater start");
|
||
ProviderService::update(state, AppType::Codex, None, updated)
|
||
});
|
||
started_rx.recv().expect("wait for updater");
|
||
|
||
// This emulates a switch that already owns the per-app lock and
|
||
// commits a different current target before the queued update is
|
||
// allowed to inspect current/existing state.
|
||
state
|
||
.db
|
||
.set_current_provider(AppType::Codex.as_str(), &third_party.id)
|
||
.expect("switch DB current to third party");
|
||
crate::settings::set_current_provider(
|
||
&AppType::Codex,
|
||
Some(third_party.id.as_str()),
|
||
)
|
||
.expect("switch local current to third party");
|
||
write_live_with_common_config_for_state(state, &AppType::Codex, &third_party)
|
||
.expect("write third-party live");
|
||
let live_after_switch = crate::codex_config::CodexLiveStateSnapshot::capture()
|
||
.expect("capture third-party live");
|
||
|
||
drop(switch_guard);
|
||
let result = updater.join().expect("join managed updater");
|
||
(result, live_after_switch)
|
||
});
|
||
|
||
update_result.expect("save queued non-current managed row");
|
||
assert_eq!(
|
||
state
|
||
.db
|
||
.get_current_provider(AppType::Codex.as_str())
|
||
.expect("read DB current")
|
||
.as_deref(),
|
||
Some(third_party.id.as_str())
|
||
);
|
||
assert_eq!(
|
||
crate::codex_config::CodexLiveStateSnapshot::capture()
|
||
.expect("capture live after queued update"),
|
||
live_after_switch,
|
||
"queued provider edit must not rewrite the newly switched current live"
|
||
);
|
||
let saved_official = state
|
||
.db
|
||
.get_provider_by_id(&official.id, AppType::Codex.as_str())
|
||
.expect("read saved official")
|
||
.expect("official exists");
|
||
assert_eq!(
|
||
saved_official
|
||
.meta
|
||
.as_ref()
|
||
.and_then(|meta| meta.managed_account_id_for("codex_oauth")),
|
||
Some("acct-managed-b".to_string())
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
#[serial]
|
||
fn switch_to_managed_codex_official_with_unresolvable_account_keeps_current_unchanged() {
|
||
with_test_home(|state, _| {
|
||
crate::settings::reload_settings().expect("reload settings");
|
||
|
||
// 基线:一个普通第三方 provider,可正常切换,作为初始 current。
|
||
let mut baseline = Provider::with_id(
|
||
"baseline".to_string(),
|
||
"Baseline".to_string(),
|
||
json!({ "auth": { "OPENAI_API_KEY": "sk-baseline" }, "config": "" }),
|
||
None,
|
||
);
|
||
baseline.category = Some("custom".to_string());
|
||
|
||
// 托管 official provider,绑定一个 manager 中不存在的账号:切换预检
|
||
// 取 token 必然失败。
|
||
let mut managed = Provider::with_id(
|
||
"managed-official".to_string(),
|
||
"Managed Official".to_string(),
|
||
json!({ "auth": {}, "config": "" }),
|
||
None,
|
||
);
|
||
managed.category = Some("official".to_string());
|
||
managed.meta = Some(ProviderMeta {
|
||
auth_binding: Some(AuthBinding {
|
||
source: AuthBindingSource::ManagedAccount,
|
||
auth_provider: Some("codex_oauth".to_string()),
|
||
account_id: Some("acct-missing".to_string()),
|
||
}),
|
||
..Default::default()
|
||
});
|
||
|
||
state
|
||
.db
|
||
.save_provider(AppType::Codex.as_str(), &baseline)
|
||
.expect("save baseline");
|
||
state
|
||
.db
|
||
.save_provider(AppType::Codex.as_str(), &managed)
|
||
.expect("save managed");
|
||
|
||
ProviderService::switch(state, AppType::Codex, "baseline").expect("switch to baseline");
|
||
|
||
// 切到绑定了不存在账号的托管 provider:预检失败 → 返回 Err。
|
||
let result = ProviderService::switch(state, AppType::Codex, "managed-official");
|
||
assert!(
|
||
result.is_err(),
|
||
"switch must fail when the managed OAuth token cannot be resolved"
|
||
);
|
||
|
||
// current 必须仍是 baseline:预检在提交 current 之前失败,不留下
|
||
// 「DB/UI 指向新 provider、但 live 仍是旧 provider」的不一致状态。
|
||
let current =
|
||
crate::settings::get_effective_current_provider(&state.db, &AppType::Codex)
|
||
.expect("read current");
|
||
assert_eq!(
|
||
current.as_deref(),
|
||
Some("baseline"),
|
||
"a failed managed switch must not move current off the previous provider"
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
#[serial]
|
||
fn import_opencode_providers_from_live_marks_provider_as_live_managed() {
|
||
with_test_home(|state, _| {
|
||
let provider = opencode_provider("imported-opencode");
|
||
crate::opencode_config::set_provider(&provider.id, provider.settings_config.clone())
|
||
.expect("seed opencode live provider");
|
||
|
||
let imported = import_opencode_providers_from_live(state)
|
||
.expect("import opencode providers from live");
|
||
assert_eq!(imported, 1);
|
||
|
||
let saved = state
|
||
.db
|
||
.get_provider_by_id(&provider.id, AppType::OpenCode.as_str())
|
||
.expect("query imported opencode provider")
|
||
.expect("imported opencode provider should exist");
|
||
assert_eq!(
|
||
saved
|
||
.meta
|
||
.as_ref()
|
||
.and_then(|meta| meta.live_config_managed),
|
||
Some(true),
|
||
"providers imported from live should be treated as live-managed"
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
#[serial]
|
||
fn import_opencode_providers_from_live_updates_existing_provider_from_live() {
|
||
with_test_home(|state, _| {
|
||
let provider = opencode_provider("existing-opencode");
|
||
state
|
||
.db
|
||
.save_provider(AppType::OpenCode.as_str(), &provider)
|
||
.expect("seed existing opencode provider");
|
||
|
||
let mut live_settings = provider.settings_config.clone();
|
||
live_settings.as_object_mut().unwrap().remove("name");
|
||
live_settings["npm"] = Value::String("@ai-sdk/anthropic".to_string());
|
||
live_settings["models"]["gpt-4o"]["name"] = Value::String("Claude Sonnet".to_string());
|
||
crate::opencode_config::set_provider(&provider.id, live_settings)
|
||
.expect("seed edited live opencode provider");
|
||
|
||
let updated = import_opencode_providers_from_live(state)
|
||
.expect("import opencode providers from live");
|
||
assert_eq!(updated, 1);
|
||
|
||
let saved = state
|
||
.db
|
||
.get_provider_by_id(&provider.id, AppType::OpenCode.as_str())
|
||
.expect("query updated opencode provider")
|
||
.expect("opencode provider should exist");
|
||
assert_eq!(saved.name, provider.name);
|
||
assert_eq!(saved.settings_config["npm"], json!("@ai-sdk/anthropic"));
|
||
assert_eq!(
|
||
saved.settings_config["models"]["gpt-4o"]["name"],
|
||
json!("Claude Sonnet")
|
||
);
|
||
});
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn import_openclaw_providers_from_live_marks_provider_as_live_managed() {
|
||
with_test_home(|state, _| {
|
||
let mut provider = openclaw_provider("imported-openclaw");
|
||
provider.settings_config["models"] = json!([
|
||
{
|
||
"id": "claude-sonnet-4",
|
||
"name": "Claude Sonnet 4"
|
||
}
|
||
]);
|
||
crate::openclaw_config::set_provider(&provider.id, provider.settings_config.clone())
|
||
.expect("seed openclaw live provider");
|
||
|
||
let imported = import_openclaw_providers_from_live(state)
|
||
.expect("import openclaw providers from live");
|
||
assert_eq!(imported, 1);
|
||
|
||
let saved = state
|
||
.db
|
||
.get_provider_by_id(&provider.id, AppType::OpenClaw.as_str())
|
||
.expect("query imported openclaw provider")
|
||
.expect("imported openclaw provider should exist");
|
||
assert_eq!(
|
||
saved
|
||
.meta
|
||
.as_ref()
|
||
.and_then(|meta| meta.live_config_managed),
|
||
Some(true),
|
||
"providers imported from live should be treated as live-managed"
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
#[serial]
|
||
fn import_openclaw_providers_from_live_updates_existing_provider_from_live() {
|
||
with_test_home(|state, _| {
|
||
let mut provider = openclaw_provider("existing-openclaw");
|
||
provider.settings_config["models"] = json!([
|
||
{
|
||
"id": "claude-sonnet-4",
|
||
"name": "Claude Sonnet 4"
|
||
}
|
||
]);
|
||
state
|
||
.db
|
||
.save_provider(AppType::OpenClaw.as_str(), &provider)
|
||
.expect("seed existing openclaw provider");
|
||
|
||
let mut live_settings = provider.settings_config.clone();
|
||
live_settings["baseUrl"] = Value::String("https://api.example.com/v1".to_string());
|
||
live_settings["models"][0]["name"] = Value::String("Claude Sonnet 4.1".to_string());
|
||
crate::openclaw_config::set_provider(&provider.id, live_settings)
|
||
.expect("seed edited live openclaw provider");
|
||
|
||
let updated = import_openclaw_providers_from_live(state)
|
||
.expect("import openclaw providers from live");
|
||
assert_eq!(updated, 1);
|
||
|
||
let saved = state
|
||
.db
|
||
.get_provider_by_id(&provider.id, AppType::OpenClaw.as_str())
|
||
.expect("query updated openclaw provider")
|
||
.expect("openclaw provider should exist");
|
||
assert_eq!(saved.name, provider.name);
|
||
assert_eq!(
|
||
saved.settings_config["baseUrl"],
|
||
json!("https://api.example.com/v1")
|
||
);
|
||
assert_eq!(
|
||
saved.settings_config["models"][0]["name"],
|
||
json!("Claude Sonnet 4.1")
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
#[serial]
|
||
fn import_hermes_providers_from_live_updates_existing_provider_from_live() {
|
||
with_test_home(|state, _| {
|
||
let provider = hermes_provider("existing-hermes");
|
||
state
|
||
.db
|
||
.save_provider(AppType::Hermes.as_str(), &provider)
|
||
.expect("seed existing hermes provider");
|
||
|
||
let mut live_settings = provider.settings_config.clone();
|
||
live_settings["base_url"] = Value::String("https://api.hermes.example/v1".to_string());
|
||
live_settings["models"]["gpt-4o"]["name"] = Value::String("GPT-4o Updated".to_string());
|
||
crate::hermes_config::set_provider(&provider.id, live_settings)
|
||
.expect("seed edited live hermes provider");
|
||
|
||
let updated = import_hermes_providers_from_live(state)
|
||
.expect("import hermes providers from live");
|
||
assert_eq!(updated, 1);
|
||
|
||
let saved = state
|
||
.db
|
||
.get_provider_by_id(&provider.id, AppType::Hermes.as_str())
|
||
.expect("query updated hermes provider")
|
||
.expect("hermes provider should exist");
|
||
assert_eq!(saved.name, provider.name);
|
||
assert_eq!(
|
||
saved.settings_config["base_url"],
|
||
json!("https://api.hermes.example/v1")
|
||
);
|
||
// models are denormalized from YAML dict to UI-friendly array by
|
||
// get_providers(), so access by index rather than dict key
|
||
assert_eq!(
|
||
saved.settings_config["models"][0]["name"],
|
||
json!("GPT-4o Updated")
|
||
);
|
||
assert_eq!(saved.settings_config["models"][0]["id"], json!("gpt-4o"));
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
#[serial]
|
||
fn legacy_additive_provider_still_errors_on_live_config_parse_failure() {
|
||
with_test_home(|state, home| {
|
||
let provider = openclaw_provider("legacy-provider");
|
||
state
|
||
.db
|
||
.save_provider(AppType::OpenClaw.as_str(), &provider)
|
||
.expect("seed legacy provider without live_config_managed marker");
|
||
|
||
let openclaw_dir = home.join(".openclaw");
|
||
fs::create_dir_all(&openclaw_dir).expect("create openclaw dir");
|
||
fs::write(openclaw_dir.join("openclaw.json"), "{ invalid json5")
|
||
.expect("write malformed config");
|
||
|
||
let mut updated = provider.clone();
|
||
updated.name = "Legacy Edited".to_string();
|
||
|
||
let err = ProviderService::update(state, AppType::OpenClaw, None, updated)
|
||
.expect_err("legacy providers should still surface live parse errors");
|
||
assert!(
|
||
err.to_string().contains("Failed to parse OpenClaw config"),
|
||
"expected parse error, got {err:?}"
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
#[serial]
|
||
fn update_persists_non_current_omo_variants_in_database() {
|
||
with_test_home(|state, _| {
|
||
for category in ["omo", "omo-slim"] {
|
||
let provider = opencode_omo_provider(&format!("{category}-provider"), category);
|
||
state
|
||
.db
|
||
.save_provider(AppType::OpenCode.as_str(), &provider)
|
||
.unwrap_or_else(|err| panic!("seed {category} provider: {err}"));
|
||
|
||
let mut updated = provider.clone();
|
||
updated.name = format!("Updated {category}");
|
||
updated.settings_config["agents"]["writer"]["model"] =
|
||
Value::String(format!("{category}-next-model"));
|
||
|
||
ProviderService::update(state, AppType::OpenCode, None, updated)
|
||
.unwrap_or_else(|err| panic!("update {category} provider: {err}"));
|
||
|
||
let saved = state
|
||
.db
|
||
.get_provider_by_id(&provider.id, AppType::OpenCode.as_str())
|
||
.unwrap_or_else(|err| panic!("query updated {category} provider: {err}"))
|
||
.unwrap_or_else(|| panic!("{category} provider should exist"));
|
||
|
||
assert_eq!(saved.name, format!("Updated {category}"));
|
||
assert_eq!(
|
||
saved.settings_config["agents"]["writer"]["model"],
|
||
Value::String(format!("{category}-next-model")),
|
||
"{category} updates should persist in the database"
|
||
);
|
||
}
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
#[serial]
|
||
fn update_current_omo_variant_rewrites_config_from_saved_provider() {
|
||
with_test_home(|state, home| {
|
||
for category in ["omo", "omo-slim"] {
|
||
let provider = opencode_omo_provider(&format!("{category}-current"), category);
|
||
state
|
||
.db
|
||
.save_provider(AppType::OpenCode.as_str(), &provider)
|
||
.unwrap_or_else(|err| panic!("seed current {category} provider: {err}"));
|
||
state
|
||
.db
|
||
.set_omo_provider_current(AppType::OpenCode.as_str(), &provider.id, category)
|
||
.unwrap_or_else(|err| panic!("set current {category} provider: {err}"));
|
||
|
||
let mut updated = provider.clone();
|
||
updated.name = format!("Current {category} updated");
|
||
updated.settings_config["agents"]["writer"]["model"] =
|
||
Value::String(format!("{category}-saved-model"));
|
||
updated.settings_config["otherFields"]["theme"] =
|
||
Value::String(format!("{category}-light"));
|
||
|
||
ProviderService::update(state, AppType::OpenCode, None, updated)
|
||
.unwrap_or_else(|err| panic!("update current {category} provider: {err}"));
|
||
|
||
let saved = state
|
||
.db
|
||
.get_provider_by_id(&provider.id, AppType::OpenCode.as_str())
|
||
.unwrap_or_else(|err| panic!("query current {category} provider: {err}"))
|
||
.unwrap_or_else(|| panic!("current {category} provider should exist"));
|
||
assert_eq!(saved.name, format!("Current {category} updated"));
|
||
|
||
let written = fs::read_to_string(omo_config_path(home, category))
|
||
.unwrap_or_else(|err| panic!("read written {category} config: {err}"));
|
||
let written_json: Value = serde_json::from_str(&written)
|
||
.unwrap_or_else(|err| panic!("parse written {category} config: {err}"));
|
||
|
||
assert_eq!(
|
||
written_json["agents"]["writer"]["model"],
|
||
Value::String(format!("{category}-saved-model")),
|
||
"{category} config should be written from the saved provider state"
|
||
);
|
||
assert_eq!(
|
||
written_json["theme"],
|
||
Value::String(format!("{category}-light")),
|
||
"{category} top-level config should reflect updated otherFields"
|
||
);
|
||
}
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
#[serial]
|
||
fn update_current_omo_variant_does_not_persist_database_when_file_write_fails() {
|
||
with_test_home(|state, home| {
|
||
let provider = opencode_omo_provider("omo-current", "omo");
|
||
state
|
||
.db
|
||
.save_provider(AppType::OpenCode.as_str(), &provider)
|
||
.unwrap_or_else(|err| panic!("seed current omo provider: {err}"));
|
||
state
|
||
.db
|
||
.set_omo_provider_current(AppType::OpenCode.as_str(), &provider.id, "omo")
|
||
.unwrap_or_else(|err| panic!("set current omo provider: {err}"));
|
||
|
||
let config_dir = home.join(".config").join("opencode");
|
||
fs::create_dir_all(config_dir.parent().expect("config dir parent"))
|
||
.expect("create .config dir");
|
||
fs::write(&config_dir, "not a directory").expect("block opencode config dir");
|
||
|
||
let mut updated = provider.clone();
|
||
updated.name = "Current omo updated".to_string();
|
||
updated.settings_config["agents"]["writer"]["model"] =
|
||
Value::String("omo-saved-model".to_string());
|
||
|
||
ProviderService::update(state, AppType::OpenCode, None, updated)
|
||
.expect_err("update should fail when current omo file write fails");
|
||
|
||
let saved = state
|
||
.db
|
||
.get_provider_by_id(&provider.id, AppType::OpenCode.as_str())
|
||
.unwrap_or_else(|err| panic!("query current omo provider: {err}"))
|
||
.unwrap_or_else(|| panic!("current omo provider should exist"));
|
||
|
||
assert_eq!(saved.name, provider.name);
|
||
assert_eq!(
|
||
saved.settings_config["agents"]["writer"]["model"],
|
||
provider.settings_config["agents"]["writer"]["model"],
|
||
"database should remain unchanged when file write fails"
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
#[serial]
|
||
fn update_current_omo_variant_rolls_back_file_when_plugin_sync_fails() {
|
||
with_test_home(|state, home| {
|
||
let provider = opencode_omo_provider("omo-current", "omo");
|
||
state
|
||
.db
|
||
.save_provider(AppType::OpenCode.as_str(), &provider)
|
||
.unwrap_or_else(|err| panic!("seed current omo provider: {err}"));
|
||
state
|
||
.db
|
||
.set_omo_provider_current(AppType::OpenCode.as_str(), &provider.id, "omo")
|
||
.unwrap_or_else(|err| panic!("set current omo provider: {err}"));
|
||
|
||
let config_path = omo_config_path(home, "omo");
|
||
fs::create_dir_all(config_path.parent().expect("omo config parent"))
|
||
.expect("create omo config dir");
|
||
let previous_content = serde_json::to_string_pretty(&json!({
|
||
"theme": "legacy-live-theme",
|
||
"agents": {
|
||
"writer": {
|
||
"model": "legacy-live-model"
|
||
}
|
||
},
|
||
"categories": {
|
||
"default": ["writer"]
|
||
}
|
||
}))
|
||
.expect("serialize previous config");
|
||
fs::write(&config_path, &previous_content).expect("seed previous omo config");
|
||
|
||
let opencode_config_path = home.join(".config").join("opencode").join("opencode.json");
|
||
fs::write(&opencode_config_path, "{ invalid json").expect("seed malformed opencode");
|
||
|
||
let mut updated = provider.clone();
|
||
updated.name = "Current omo updated".to_string();
|
||
updated.settings_config["agents"]["writer"]["model"] =
|
||
Value::String("omo-saved-model".to_string());
|
||
updated.settings_config["otherFields"]["theme"] =
|
||
Value::String("omo-light".to_string());
|
||
|
||
ProviderService::update(state, AppType::OpenCode, None, updated)
|
||
.expect_err("update should fail when plugin sync fails");
|
||
|
||
let saved = state
|
||
.db
|
||
.get_provider_by_id(&provider.id, AppType::OpenCode.as_str())
|
||
.unwrap_or_else(|err| panic!("query current omo provider: {err}"))
|
||
.unwrap_or_else(|| panic!("current omo provider should exist"));
|
||
|
||
assert_eq!(saved.name, provider.name);
|
||
assert_eq!(
|
||
saved.settings_config["agents"]["writer"]["model"],
|
||
provider.settings_config["agents"]["writer"]["model"],
|
||
"database should remain unchanged when plugin sync fails"
|
||
);
|
||
|
||
let written =
|
||
fs::read_to_string(&config_path).expect("read rolled back omo config content");
|
||
assert_eq!(
|
||
written, previous_content,
|
||
"OMO config should roll back to its previous on-disk contents"
|
||
);
|
||
});
|
||
}
|
||
}
|
||
|
||
impl ProviderService {
|
||
fn managed_codex_oauth_account_id(provider: &Provider) -> Option<String> {
|
||
provider
|
||
.meta
|
||
.as_ref()
|
||
.and_then(|meta| meta.managed_account_id_for("codex_oauth"))
|
||
.map(|id| id.trim().to_string())
|
||
.filter(|id| !id.is_empty())
|
||
}
|
||
|
||
/// 提交 current(settings/DB)前的预检:若目标是托管 Codex official provider,
|
||
/// 先解析一次有效 live 配置(会联网换取并缓存 token)。同时返回这份已解析配置,
|
||
/// 让后续落盘直接复用同一 token bundle,避免一次操作重复解析/刷新。
|
||
fn preflight_managed_codex_live(
|
||
state: &AppState,
|
||
app_type: &AppType,
|
||
provider: &Provider,
|
||
) -> Result<Option<Provider>, AppError> {
|
||
if matches!(app_type, AppType::Codex)
|
||
&& Self::managed_codex_oauth_account_id(provider).is_some()
|
||
{
|
||
return build_effective_provider_for_live_with_codex_oauth_manager(
|
||
state.db.as_ref(),
|
||
app_type,
|
||
provider,
|
||
&state.codex_oauth_manager,
|
||
)
|
||
.map(Some);
|
||
}
|
||
Ok(None)
|
||
}
|
||
|
||
fn write_preflighted_or_current_live(
|
||
state: &AppState,
|
||
app_type: &AppType,
|
||
provider: &Provider,
|
||
preflighted_provider: Option<&Provider>,
|
||
) -> Result<(), AppError> {
|
||
if let Some(effective_provider) = preflighted_provider {
|
||
live::write_live_snapshot(app_type, effective_provider)
|
||
} else {
|
||
write_live_with_common_config_for_state(state, app_type, provider)
|
||
}
|
||
}
|
||
|
||
fn managed_codex_transaction_error(
|
||
operation: &str,
|
||
error: AppError,
|
||
snapshot: &crate::codex_config::CodexLiveStateSnapshot,
|
||
restore_local_current: Option<(&AppType, Option<&str>)>,
|
||
) -> AppError {
|
||
let mut rollback_failures = Vec::new();
|
||
if let Some((app_type, previous_local_current)) = restore_local_current {
|
||
if let Err(rollback_error) =
|
||
crate::settings::set_current_provider(app_type, previous_local_current)
|
||
{
|
||
rollback_failures.push(format!("恢复本地 current 失败: {rollback_error}"));
|
||
}
|
||
}
|
||
if let Err(rollback_error) = snapshot.restore_preserving_newer_same_account_auth() {
|
||
rollback_failures.push(rollback_error.to_string());
|
||
}
|
||
|
||
if rollback_failures.is_empty() {
|
||
error
|
||
} else {
|
||
AppError::Message(format!(
|
||
"{operation}失败: {error}; 回滚同时失败: {}",
|
||
rollback_failures.join("; ")
|
||
))
|
||
}
|
||
}
|
||
|
||
fn managed_codex_takeover_transaction_error(
|
||
state: &AppState,
|
||
operation: &str,
|
||
error: AppError,
|
||
snapshot: &crate::codex_config::CodexLiveStateSnapshot,
|
||
previous_backup: Option<&crate::proxy::types::LiveBackup>,
|
||
) -> AppError {
|
||
let mut rollback_failures = Vec::new();
|
||
let backup_restore = match previous_backup {
|
||
Some(backup) => futures::executor::block_on(
|
||
state
|
||
.db
|
||
.save_live_backup(AppType::Codex.as_str(), &backup.original_config),
|
||
),
|
||
None => {
|
||
futures::executor::block_on(state.db.delete_live_backup(AppType::Codex.as_str()))
|
||
}
|
||
};
|
||
if let Err(rollback_error) = backup_restore {
|
||
rollback_failures.push(format!("恢复 Codex Live 备份失败: {rollback_error}"));
|
||
}
|
||
if let Err(rollback_error) = snapshot.restore_preserving_newer_same_account_auth() {
|
||
rollback_failures.push(rollback_error.to_string());
|
||
}
|
||
|
||
if rollback_failures.is_empty() {
|
||
error
|
||
} else {
|
||
AppError::Message(format!(
|
||
"{operation}失败: {error}; 回滚同时失败: {}",
|
||
rollback_failures.join("; ")
|
||
))
|
||
}
|
||
}
|
||
|
||
fn unbound_managed_codex_oauth_account_id(
|
||
app_type: &AppType,
|
||
existing_provider: Option<&Provider>,
|
||
provider: &Provider,
|
||
) -> Option<String> {
|
||
if !matches!(app_type, AppType::Codex) || provider.category.as_deref() != Some("official") {
|
||
return None;
|
||
}
|
||
|
||
let existing_provider = existing_provider
|
||
.filter(|existing| existing.category.as_deref() == Some("official"))?;
|
||
let old_account_id = Self::managed_codex_oauth_account_id(existing_provider)?;
|
||
|
||
if Self::managed_codex_oauth_account_id(provider).is_some() {
|
||
return None;
|
||
}
|
||
|
||
Some(old_account_id)
|
||
}
|
||
|
||
fn normalize_provider_if_claude(app_type: &AppType, provider: &mut Provider) {
|
||
if matches!(app_type, AppType::Claude) {
|
||
let mut v = provider.settings_config.clone();
|
||
if normalize_claude_models_in_value(&mut v) {
|
||
provider.settings_config = v;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Check whether a provider exists in live config, tolerating parse errors
|
||
/// only for providers that are explicitly marked as DB-only.
|
||
fn check_live_config_exists(
|
||
app_type: &AppType,
|
||
provider_id: &str,
|
||
live_config_managed: Option<bool>,
|
||
) -> Result<bool, AppError> {
|
||
if live_config_managed == Some(false) {
|
||
Ok(provider_exists_in_live_config(app_type, provider_id).unwrap_or(false))
|
||
} else {
|
||
provider_exists_in_live_config(app_type, provider_id)
|
||
}
|
||
}
|
||
|
||
fn provider_live_config_managed(provider: &Provider) -> Option<bool> {
|
||
provider
|
||
.meta
|
||
.as_ref()
|
||
.and_then(|meta| meta.live_config_managed)
|
||
}
|
||
|
||
fn set_provider_live_config_managed(provider: &mut Provider, managed: bool) {
|
||
provider
|
||
.meta
|
||
.get_or_insert_with(Default::default)
|
||
.live_config_managed = Some(managed);
|
||
}
|
||
|
||
fn normalize_usage_script_credential_overrides(app_type: &AppType, provider: &mut Provider) {
|
||
let current_credentials = provider.resolve_usage_credentials(app_type);
|
||
|
||
let Some(usage_script) = provider
|
||
.meta
|
||
.as_mut()
|
||
.and_then(|meta| meta.usage_script.as_mut())
|
||
else {
|
||
return;
|
||
};
|
||
|
||
if usage_script.template_type.as_deref() == Some("token_plan") {
|
||
return;
|
||
}
|
||
|
||
if usage_script.api_key.as_deref().is_some_and(|api_key| {
|
||
Self::should_clear_usage_api_key_override(api_key, ¤t_credentials)
|
||
}) {
|
||
usage_script.api_key = None;
|
||
}
|
||
|
||
if usage_script.base_url.as_deref().is_some_and(|base_url| {
|
||
Self::should_clear_usage_base_url_override(base_url, ¤t_credentials)
|
||
}) {
|
||
usage_script.base_url = None;
|
||
}
|
||
}
|
||
|
||
fn should_clear_usage_api_key_override(
|
||
script_api_key: &str,
|
||
current_credentials: &(String, String),
|
||
) -> bool {
|
||
let candidate = script_api_key.trim();
|
||
if candidate.is_empty() {
|
||
return true;
|
||
}
|
||
|
||
let matches_provider_key = |api_key: &str| {
|
||
let api_key = api_key.trim();
|
||
!api_key.is_empty() && api_key == candidate
|
||
};
|
||
|
||
matches_provider_key(¤t_credentials.1)
|
||
}
|
||
|
||
fn should_clear_usage_base_url_override(
|
||
script_base_url: &str,
|
||
current_credentials: &(String, String),
|
||
) -> bool {
|
||
let candidate = Self::normalize_usage_base_url_for_compare(script_base_url);
|
||
if candidate.is_empty() {
|
||
return true;
|
||
}
|
||
|
||
let matches_provider_base_url = |base_url: &str| {
|
||
let base_url = Self::normalize_usage_base_url_for_compare(base_url);
|
||
!base_url.is_empty() && base_url == candidate
|
||
};
|
||
|
||
matches_provider_base_url(¤t_credentials.0)
|
||
}
|
||
|
||
fn normalize_usage_base_url_for_compare(base_url: &str) -> String {
|
||
base_url.trim().trim_end_matches('/').to_string()
|
||
}
|
||
|
||
/// List all providers for an app type
|
||
pub fn list(
|
||
state: &AppState,
|
||
app_type: AppType,
|
||
) -> Result<IndexMap<String, Provider>, AppError> {
|
||
state.db.get_all_providers(app_type.as_str())
|
||
}
|
||
|
||
/// Get current provider ID
|
||
///
|
||
/// 使用有效的当前供应商 ID(验证过存在性)。
|
||
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
|
||
/// 这确保了云同步场景下多设备可以独立选择供应商,且返回的 ID 一定有效。
|
||
///
|
||
/// 对于累加模式应用(OpenCode, OpenClaw),不存在"当前供应商"概念,直接返回空字符串。
|
||
pub fn current(state: &AppState, app_type: AppType) -> Result<String, AppError> {
|
||
// Additive mode apps have no "current" provider concept
|
||
if app_type.is_additive_mode() {
|
||
return Ok(String::new());
|
||
}
|
||
crate::settings::get_effective_current_provider(&state.db, &app_type)
|
||
.map(|opt| opt.unwrap_or_default())
|
||
}
|
||
|
||
/// Add a new provider
|
||
pub fn add(
|
||
state: &AppState,
|
||
app_type: AppType,
|
||
provider: Provider,
|
||
add_to_live: bool,
|
||
) -> Result<bool, AppError> {
|
||
let mut provider = provider;
|
||
// Normalize Claude model keys
|
||
Self::normalize_provider_if_claude(&app_type, &mut provider);
|
||
Self::validate_provider_settings(&app_type, &provider)?;
|
||
normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?;
|
||
Self::normalize_usage_script_credential_overrides(&app_type, &mut provider);
|
||
if app_type.is_additive_mode() {
|
||
Self::set_provider_live_config_managed(&mut provider, add_to_live);
|
||
}
|
||
|
||
// Save to database
|
||
state.db.save_provider(app_type.as_str(), &provider)?;
|
||
|
||
// Additive mode apps (OpenCode, OpenClaw): optionally write to live config.
|
||
if app_type.is_additive_mode() {
|
||
// OMO / OMO Slim providers use exclusive mode and write to dedicated config file.
|
||
if matches!(app_type, AppType::OpenCode)
|
||
&& matches!(provider.category.as_deref(), Some("omo") | Some("omo-slim"))
|
||
{
|
||
// Do not auto-enable newly added OMO / OMO Slim providers.
|
||
// Users must explicitly switch/apply an OMO provider to activate it.
|
||
return Ok(true);
|
||
}
|
||
if !add_to_live {
|
||
return Ok(true);
|
||
}
|
||
write_live_with_common_config_for_state(state, &app_type, &provider)?;
|
||
return Ok(true);
|
||
}
|
||
|
||
// For other apps: Check if sync is needed (if this is current provider, or no current provider)
|
||
let current = state.db.get_current_provider(app_type.as_str())?;
|
||
if current.is_none() {
|
||
// 预检托管 Codex token:失败则不将其设为 current(见 switch)。
|
||
let preflighted_provider =
|
||
Self::preflight_managed_codex_live(state, &app_type, &provider)?;
|
||
if preflighted_provider.is_some() {
|
||
let snapshot = crate::codex_config::CodexLiveStateSnapshot::capture()?;
|
||
if let Err(error) = Self::write_preflighted_or_current_live(
|
||
state,
|
||
&app_type,
|
||
&provider,
|
||
preflighted_provider.as_ref(),
|
||
) {
|
||
return Err(Self::managed_codex_transaction_error(
|
||
"写入首个托管 Codex provider",
|
||
error,
|
||
&snapshot,
|
||
None,
|
||
));
|
||
}
|
||
if let Err(error) = state
|
||
.db
|
||
.set_current_provider(app_type.as_str(), &provider.id)
|
||
{
|
||
return Err(Self::managed_codex_transaction_error(
|
||
"设置首个托管 Codex provider 为 current",
|
||
error,
|
||
&snapshot,
|
||
None,
|
||
));
|
||
}
|
||
} else {
|
||
// No current provider, set as current and sync.
|
||
state
|
||
.db
|
||
.set_current_provider(app_type.as_str(), &provider.id)?;
|
||
write_live_with_common_config_for_state(state, &app_type, &provider)?;
|
||
}
|
||
}
|
||
|
||
Ok(true)
|
||
}
|
||
|
||
/// Update a provider
|
||
pub fn update(
|
||
state: &AppState,
|
||
app_type: AppType,
|
||
original_id: Option<&str>,
|
||
provider: Provider,
|
||
) -> Result<bool, AppError> {
|
||
let mut provider = provider;
|
||
let original_id = original_id.unwrap_or(provider.id.as_str()).to_string();
|
||
let provider_id_changed = original_id != provider.id;
|
||
// Serialize the read/decide/commit window for every Codex update. We do
|
||
// not yet know whether the stored row is managed (the request may be an
|
||
// unbind), so the existing row and effective current must both be read
|
||
// only after this lock is held. Non-managed Codex updates release it
|
||
// before entering the legacy path, whose proxy helpers take the lock
|
||
// themselves.
|
||
let codex_update_switch_guard = if matches!(app_type, AppType::Codex) {
|
||
Some(futures::executor::block_on(
|
||
state.proxy_service.lock_switch_for_app(app_type.as_str()),
|
||
))
|
||
} else {
|
||
None
|
||
};
|
||
let existing_provider = state
|
||
.db
|
||
.get_provider_by_id(&original_id, app_type.as_str())?;
|
||
// Normalize Claude model keys
|
||
Self::normalize_provider_if_claude(&app_type, &mut provider);
|
||
Self::validate_provider_settings(&app_type, &provider)?;
|
||
normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?;
|
||
Self::normalize_usage_script_credential_overrides(&app_type, &mut provider);
|
||
|
||
if provider_id_changed {
|
||
if !app_type.is_additive_mode() {
|
||
return Err(AppError::Message(
|
||
"Only additive-mode providers support changing provider key".to_string(),
|
||
));
|
||
}
|
||
|
||
let Some(existing_provider) = existing_provider else {
|
||
return Err(AppError::Message(format!(
|
||
"Original provider '{}' does not exist in app '{}'",
|
||
original_id,
|
||
app_type.as_str()
|
||
)));
|
||
};
|
||
|
||
// OMO / OMO Slim providers are activated via a dedicated current-state mechanism
|
||
// (set_omo_provider_current) that is NOT captured by provider_exists_in_live_config,
|
||
// which only checks opencode.json. A rename would orphan that current-state marker
|
||
// and silently break subsequent OMO file syncs. Block it unconditionally.
|
||
if matches!(app_type, AppType::OpenCode)
|
||
&& matches!(
|
||
existing_provider.category.as_deref(),
|
||
Some("omo") | Some("omo-slim")
|
||
)
|
||
{
|
||
return Err(AppError::Message(
|
||
"Provider key cannot be changed for OMO/OMO Slim providers".to_string(),
|
||
));
|
||
}
|
||
|
||
let original_in_live = Self::check_live_config_exists(
|
||
&app_type,
|
||
&original_id,
|
||
Self::provider_live_config_managed(&existing_provider),
|
||
)?;
|
||
if original_in_live {
|
||
return Err(AppError::Message(
|
||
"Provider key cannot be changed after the provider has been added to the app config"
|
||
.to_string(),
|
||
));
|
||
}
|
||
|
||
let next_id_in_live = Self::check_live_config_exists(
|
||
&app_type,
|
||
&provider.id,
|
||
Self::provider_live_config_managed(&existing_provider),
|
||
)?;
|
||
if state
|
||
.db
|
||
.get_provider_by_id(&provider.id, app_type.as_str())?
|
||
.is_some()
|
||
|| next_id_in_live
|
||
{
|
||
return Err(AppError::Message(format!(
|
||
"Provider '{}' already exists in app '{}'",
|
||
provider.id,
|
||
app_type.as_str()
|
||
)));
|
||
}
|
||
|
||
Self::set_provider_live_config_managed(&mut provider, false);
|
||
state.db.save_provider(app_type.as_str(), &provider)?;
|
||
state.db.delete_provider(app_type.as_str(), &original_id)?;
|
||
|
||
if crate::settings::get_current_provider(&app_type).as_deref() == Some(&original_id) {
|
||
crate::settings::set_current_provider(&app_type, Some(provider.id.as_str()))?;
|
||
}
|
||
|
||
return Ok(true);
|
||
}
|
||
|
||
// Additive mode apps (OpenCode, OpenClaw): only sync to live when the provider
|
||
// already exists in live config. Editing a DB-only provider must not auto-add it.
|
||
if app_type.is_additive_mode() {
|
||
let omo_variant = if matches!(app_type, AppType::OpenCode) {
|
||
match provider.category.as_deref() {
|
||
Some("omo") => Some(&crate::services::omo::STANDARD),
|
||
Some("omo-slim") => Some(&crate::services::omo::SLIM),
|
||
_ => None,
|
||
}
|
||
} else {
|
||
None
|
||
};
|
||
if let Some(variant) = omo_variant {
|
||
let is_current = state.db.is_omo_provider_current(
|
||
app_type.as_str(),
|
||
&provider.id,
|
||
variant.category,
|
||
)?;
|
||
if is_current {
|
||
crate::services::OmoService::write_provider_config_to_file(&provider, variant)?;
|
||
}
|
||
if let Err(err) = state.db.save_provider(app_type.as_str(), &provider) {
|
||
if is_current {
|
||
if let Err(rollback_err) =
|
||
crate::services::OmoService::write_config_to_file(state, variant)
|
||
{
|
||
log::warn!(
|
||
"Failed to roll back {} config after DB save error: {}",
|
||
variant.label,
|
||
rollback_err
|
||
);
|
||
}
|
||
}
|
||
return Err(err);
|
||
}
|
||
return Ok(true);
|
||
}
|
||
let live_config_managed = Self::check_live_config_exists(
|
||
&app_type,
|
||
&provider.id,
|
||
Self::provider_live_config_managed(&provider).or_else(|| {
|
||
existing_provider
|
||
.as_ref()
|
||
.and_then(Self::provider_live_config_managed)
|
||
}),
|
||
)?;
|
||
Self::set_provider_live_config_managed(&mut provider, live_config_managed);
|
||
|
||
// Save to database after live-config presence is resolved so parse errors
|
||
// do not report failure after already mutating DB state.
|
||
state.db.save_provider(app_type.as_str(), &provider)?;
|
||
|
||
if !live_config_managed {
|
||
return Ok(true);
|
||
}
|
||
write_live_with_common_config_for_state(state, &app_type, &provider)?;
|
||
return Ok(true);
|
||
}
|
||
|
||
let unbound_codex_managed_account_id = Self::unbound_managed_codex_oauth_account_id(
|
||
&app_type,
|
||
existing_provider.as_ref(),
|
||
&provider,
|
||
);
|
||
|
||
// For other apps: Check if this is current provider (use effective current, not just DB)
|
||
let effective_current =
|
||
crate::settings::get_effective_current_provider(&state.db, &app_type)?;
|
||
let is_current = effective_current.as_deref() == Some(provider.id.as_str());
|
||
|
||
let existing_managed_codex_account_id = existing_provider
|
||
.as_ref()
|
||
.and_then(Self::managed_codex_oauth_account_id);
|
||
let target_managed_codex_account_id = Self::managed_codex_oauth_account_id(&provider);
|
||
let managed_codex_update = matches!(app_type, AppType::Codex)
|
||
&& (existing_managed_codex_account_id.is_some()
|
||
|| target_managed_codex_account_id.is_some());
|
||
|
||
if managed_codex_update {
|
||
// A non-current managed row still commits under the same lock: once
|
||
// the row is saved, a waiting switch observes the new binding. If we
|
||
// released first, a switch could activate the old binding and leave
|
||
// DB current/live inconsistent with the subsequent save.
|
||
if !is_current {
|
||
state.db.save_provider(app_type.as_str(), &provider)?;
|
||
return Ok(true);
|
||
}
|
||
|
||
// The lock acquired before reading existing/current spans the
|
||
// complete direct/takeover transaction. Backup update and takeover
|
||
// Live sync therefore cannot expose a gap to concurrent hot-switch.
|
||
let previous_backup =
|
||
futures::executor::block_on(state.db.get_live_backup(app_type.as_str()))?;
|
||
let has_live_backup = previous_backup.is_some();
|
||
let live_taken_over = state
|
||
.proxy_service
|
||
.detect_takeover_in_live_config_for_app(&app_type);
|
||
let preflighted_provider =
|
||
Self::preflight_managed_codex_live(state, &app_type, &provider)?;
|
||
// Capture after preflight: a legitimate refresh may have advanced
|
||
// auth.json, and rollback must never restore the older generation.
|
||
let snapshot = crate::codex_config::CodexLiveStateSnapshot::capture()?;
|
||
|
||
if !has_live_backup && !live_taken_over {
|
||
let commit_result = (|| {
|
||
Self::write_preflighted_or_current_live(
|
||
state,
|
||
&app_type,
|
||
&provider,
|
||
preflighted_provider.as_ref(),
|
||
)?;
|
||
if let Some(account_id) = unbound_codex_managed_account_id.as_deref() {
|
||
crate::codex_config::clear_codex_live_auth_for_managed_account(account_id)?;
|
||
}
|
||
state.db.save_provider(app_type.as_str(), &provider)?;
|
||
Ok::<(), AppError>(())
|
||
})();
|
||
if let Err(error) = commit_result {
|
||
return Err(Self::managed_codex_transaction_error(
|
||
"更新托管 Codex provider",
|
||
error,
|
||
&snapshot,
|
||
None,
|
||
));
|
||
}
|
||
|
||
if let Err(err) = McpService::sync_enabled_for_app(state, &app_type) {
|
||
log::warn!(
|
||
"保存供应商后重投影 {app_type:?} MCP 失败(将在下次同步时自愈): {err}"
|
||
);
|
||
}
|
||
return Ok(true);
|
||
}
|
||
|
||
let commit_result = (|| {
|
||
futures::executor::block_on(
|
||
state.proxy_service.update_live_backup_from_provider_inner(
|
||
app_type.as_str(),
|
||
&provider,
|
||
unbound_codex_managed_account_id.as_deref(),
|
||
),
|
||
)
|
||
.map_err(|error| AppError::Message(format!("更新 Live 备份失败: {error}")))?;
|
||
|
||
if live_taken_over {
|
||
futures::executor::block_on(
|
||
state
|
||
.proxy_service
|
||
.sync_codex_live_from_provider_while_proxy_active(&provider),
|
||
)
|
||
.map_err(|error| {
|
||
AppError::Message(format!("同步 Codex Live 配置失败: {error}"))
|
||
})?;
|
||
} else {
|
||
// A backup without a takeover marker is a recoverable
|
||
// half-takeover state. Keep the actual Live bundle aligned
|
||
// with the edited current provider as well as the backup.
|
||
Self::write_preflighted_or_current_live(
|
||
state,
|
||
&app_type,
|
||
&provider,
|
||
preflighted_provider.as_ref(),
|
||
)?;
|
||
}
|
||
|
||
if let Some(account_id) = unbound_codex_managed_account_id.as_deref() {
|
||
crate::codex_config::clear_codex_live_auth_for_managed_account(account_id)?;
|
||
}
|
||
|
||
// DB is the final commit. Every fallible side effect above can be
|
||
// restored exactly while the previous provider row is untouched.
|
||
state.db.save_provider(app_type.as_str(), &provider)?;
|
||
Ok::<(), AppError>(())
|
||
})();
|
||
if let Err(error) = commit_result {
|
||
return Err(Self::managed_codex_takeover_transaction_error(
|
||
state,
|
||
"更新接管中的托管 Codex provider",
|
||
error,
|
||
&snapshot,
|
||
previous_backup.as_ref(),
|
||
));
|
||
}
|
||
|
||
return Ok(true);
|
||
}
|
||
|
||
drop(codex_update_switch_guard);
|
||
|
||
// Save to database
|
||
state.db.save_provider(app_type.as_str(), &provider)?;
|
||
|
||
if is_current {
|
||
// 如果 Claude 代理接管处于激活状态,并且代理服务正在运行:
|
||
// - 不直接走普通 Live 写入逻辑
|
||
// - 改为更新 Live 备份,并在 Claude 下同步代理安全的 Live 配置
|
||
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);
|
||
// Backup or live placeholders mean the live file is currently owned
|
||
// by proxy takeover, including the short activation window before
|
||
// proxy_config.enabled is committed.
|
||
let should_sync_via_proxy = has_live_backup || live_taken_over;
|
||
|
||
if should_sync_via_proxy {
|
||
if matches!(app_type, AppType::ClaudeDesktop) {
|
||
write_live_with_common_config_for_state(state, &app_type, &provider)?;
|
||
} else {
|
||
let update_backup_result =
|
||
if let Some(account_id) = unbound_codex_managed_account_id.as_deref() {
|
||
futures::executor::block_on(
|
||
state
|
||
.proxy_service
|
||
.update_live_backup_from_provider_clearing_codex_auth(
|
||
app_type.as_str(),
|
||
&provider,
|
||
account_id,
|
||
),
|
||
)
|
||
} else {
|
||
futures::executor::block_on(
|
||
state
|
||
.proxy_service
|
||
.update_live_backup_from_provider(app_type.as_str(), &provider),
|
||
)
|
||
};
|
||
update_backup_result
|
||
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
|
||
|
||
if let Some(account_id) = unbound_codex_managed_account_id.as_deref() {
|
||
crate::codex_config::clear_codex_live_auth_for_managed_account(account_id)?;
|
||
}
|
||
}
|
||
|
||
if futures::executor::block_on(state.proxy_service.is_running()) {
|
||
if matches!(app_type, AppType::Claude) {
|
||
futures::executor::block_on(
|
||
state
|
||
.proxy_service
|
||
.sync_claude_live_from_provider_while_proxy_active(&provider),
|
||
)
|
||
.map_err(|e| {
|
||
AppError::Message(format!("同步 Claude Live 配置失败: {e}"))
|
||
})?;
|
||
} else if live_taken_over && matches!(app_type, AppType::Codex) {
|
||
// Codex model mappings are projected into a generated
|
||
// model_catalog_json file. Refresh takeover-owned Live
|
||
// immediately so adding/removing mappings cannot leave
|
||
// the previous catalog pointer and capabilities active.
|
||
futures::executor::block_on(
|
||
state
|
||
.proxy_service
|
||
.sync_codex_live_from_provider_while_proxy_active(&provider),
|
||
)
|
||
.map_err(|e| AppError::Message(format!("同步 Codex Live 配置失败: {e}")))?;
|
||
}
|
||
}
|
||
} else {
|
||
write_live_with_common_config_for_state(state, &app_type, &provider)?;
|
||
if let Some(account_id) = unbound_codex_managed_account_id.as_deref() {
|
||
crate::codex_config::clear_codex_live_auth_for_managed_account(account_id)?;
|
||
}
|
||
// 重写 live 后只重投影本应用的 MCP:全量 sync_all_enabled 会把
|
||
// 无关应用的 live 损坏(如 ~/.claude.json 坏 JSON)牵连进保存
|
||
// 流程。走到这里 DB 与 live 都已按新配置落盘,保存事实上已
|
||
// 成功;投影失败降级为警告,避免制造"保存失败"假象(MCP
|
||
// 投影可自愈:下次切换 / 任一 MCP 启停都会重新投影)。
|
||
if let Err(err) = McpService::sync_enabled_for_app(state, &app_type) {
|
||
log::warn!(
|
||
"保存供应商后重投影 {app_type:?} MCP 失败(将在下次同步时自愈): {err}"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(true)
|
||
}
|
||
|
||
/// Delete a provider
|
||
///
|
||
/// 同时检查本地 settings 和数据库的当前供应商,防止删除任一端正在使用的供应商。
|
||
/// 对于累加模式应用(OpenCode, OpenClaw),可以随时删除任意供应商,同时从 live 配置中移除。
|
||
pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
||
// Additive mode apps - no current provider concept
|
||
if app_type.is_additive_mode() {
|
||
// Single DB read shared across all additive-mode sub-paths below.
|
||
let existing = state.db.get_provider_by_id(id, app_type.as_str())?;
|
||
|
||
if matches!(app_type, AppType::OpenCode) {
|
||
let provider_category = existing.as_ref().and_then(|p| p.category.clone());
|
||
let omo_variant = match provider_category.as_deref() {
|
||
Some("omo") => Some(&crate::services::omo::STANDARD),
|
||
Some("omo-slim") => Some(&crate::services::omo::SLIM),
|
||
_ => None,
|
||
};
|
||
if let Some(variant) = omo_variant {
|
||
let was_current = state.db.is_omo_provider_current(
|
||
app_type.as_str(),
|
||
id,
|
||
variant.category,
|
||
)?;
|
||
state.db.delete_provider(app_type.as_str(), id)?;
|
||
if was_current {
|
||
crate::services::OmoService::delete_config_file(variant)?;
|
||
}
|
||
return Ok(());
|
||
}
|
||
}
|
||
|
||
// Non-OMO path for both OpenCode and OpenClaw:
|
||
// remove from live first (atomicity), then DB.
|
||
//
|
||
// Use check_live_config_exists rather than trusting the flag alone: the flag
|
||
// can be stale (Some(false) for a provider that was written to live before the
|
||
// live_config_managed flip was introduced). check_live_config_exists reads the
|
||
// actual file when the flag is Some(false), so it handles historical data correctly.
|
||
let live_managed = existing
|
||
.as_ref()
|
||
.and_then(Self::provider_live_config_managed);
|
||
if Self::check_live_config_exists(&app_type, id, live_managed)? {
|
||
match app_type {
|
||
AppType::OpenCode => remove_opencode_provider_from_live(id)?,
|
||
AppType::OpenClaw => remove_openclaw_provider_from_live(id)?,
|
||
AppType::Hermes => remove_hermes_provider_from_live(id)?,
|
||
_ => {}
|
||
}
|
||
}
|
||
state.db.delete_provider(app_type.as_str(), id)?;
|
||
return Ok(());
|
||
}
|
||
|
||
// For other apps: Check both local settings and database
|
||
let local_current = crate::settings::get_current_provider(&app_type);
|
||
let db_current = state.db.get_current_provider(app_type.as_str())?;
|
||
|
||
if local_current.as_deref() == Some(id) || db_current.as_deref() == Some(id) {
|
||
return Err(AppError::Message(
|
||
"无法删除当前正在使用的供应商".to_string(),
|
||
));
|
||
}
|
||
|
||
state.db.delete_provider(app_type.as_str(), id)
|
||
}
|
||
|
||
/// Remove provider from live config only (for additive mode apps like OpenCode, OpenClaw)
|
||
///
|
||
/// Does NOT delete from database - provider remains in the list.
|
||
/// This is used when user wants to "remove" a provider from active config
|
||
/// but keep it available for future use.
|
||
pub fn remove_from_live_config(
|
||
state: &AppState,
|
||
app_type: AppType,
|
||
id: &str,
|
||
) -> Result<(), AppError> {
|
||
match app_type {
|
||
AppType::OpenCode => {
|
||
let provider_category = state
|
||
.db
|
||
.get_provider_by_id(id, app_type.as_str())?
|
||
.and_then(|p| p.category);
|
||
|
||
let omo_variant = match provider_category.as_deref() {
|
||
Some("omo") => Some(&crate::services::omo::STANDARD),
|
||
Some("omo-slim") => Some(&crate::services::omo::SLIM),
|
||
_ => None,
|
||
};
|
||
if let Some(variant) = omo_variant {
|
||
state
|
||
.db
|
||
.clear_omo_provider_current(app_type.as_str(), id, variant.category)?;
|
||
let still_has_current = state
|
||
.db
|
||
.get_current_omo_provider("opencode", variant.category)?
|
||
.is_some();
|
||
if still_has_current {
|
||
crate::services::OmoService::write_config_to_file(state, variant)?;
|
||
} else {
|
||
crate::services::OmoService::delete_config_file(variant)?;
|
||
}
|
||
} else {
|
||
remove_opencode_provider_from_live(id)?;
|
||
}
|
||
}
|
||
AppType::OpenClaw => {
|
||
remove_openclaw_provider_from_live(id)?;
|
||
}
|
||
AppType::Hermes => {
|
||
remove_hermes_provider_from_live(id)?;
|
||
}
|
||
_ => {
|
||
return Err(AppError::Message(format!(
|
||
"App {} does not support remove from live config",
|
||
app_type.as_str()
|
||
)));
|
||
}
|
||
}
|
||
|
||
if let Some(mut provider) = state.db.get_provider_by_id(id, app_type.as_str())? {
|
||
Self::set_provider_live_config_managed(&mut provider, false);
|
||
state.db.save_provider(app_type.as_str(), &provider)?;
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Switch to a provider
|
||
///
|
||
/// Switch flow:
|
||
/// 1. Validate target provider exists
|
||
/// 2. Check if proxy takeover mode is active AND proxy server is running
|
||
/// 3. If takeover mode active: hot-switch proxy target and refresh proxy-safe Live labels
|
||
/// 4. If normal mode:
|
||
/// a. **Backfill mechanism**: Backfill current live config to current provider
|
||
/// b. Update local settings current_provider_xxx (device-level)
|
||
/// c. Update database is_current (as default for new devices)
|
||
/// d. Write target provider config to live files
|
||
/// e. Sync MCP configuration
|
||
pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<SwitchResult, AppError> {
|
||
// Check if provider exists
|
||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||
let _provider = providers
|
||
.get(id)
|
||
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
|
||
|
||
// OMO providers are switched through their own exclusive path.
|
||
if matches!(app_type, AppType::OpenCode) && _provider.category.as_deref() == Some("omo") {
|
||
return Self::switch_normal(state, app_type, id, &providers);
|
||
}
|
||
|
||
// OMO Slim providers are switched through their own exclusive path.
|
||
if matches!(app_type, AppType::OpenCode)
|
||
&& _provider.category.as_deref() == Some("omo-slim")
|
||
{
|
||
return Self::switch_normal(state, app_type, id, &providers);
|
||
}
|
||
|
||
if matches!(app_type, AppType::ClaudeDesktop) {
|
||
return Self::switch_normal(state, app_type, id, &providers);
|
||
}
|
||
|
||
// Provider switches and takeover toggles both mutate live config and the
|
||
// restore backup. Serialize them per app, then decide from the locked
|
||
// current state so a just-started takeover cannot be overwritten by a
|
||
// normal live write.
|
||
let _switch_guard = if matches!(
|
||
app_type,
|
||
AppType::Claude | AppType::Codex | AppType::Gemini | AppType::GrokBuild
|
||
) {
|
||
Some(futures::executor::block_on(
|
||
state.proxy_service.lock_switch_for_app(app_type.as_str()),
|
||
))
|
||
} else {
|
||
None
|
||
};
|
||
|
||
// Backup or live placeholders mean the live file is owned by proxy
|
||
// takeover, even if the proxy server is temporarily stopped or is in the
|
||
// activation window before enabled=true is committed.
|
||
let is_app_taken_over =
|
||
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);
|
||
|
||
let should_hot_switch = is_app_taken_over || live_taken_over;
|
||
|
||
// Block switching to official providers when proxy takeover is active.
|
||
// Using a proxy with official APIs (Anthropic/OpenAI/Google) may cause account bans.
|
||
if should_hot_switch
|
||
&& _provider.category.as_deref() == Some("official")
|
||
&& !official_provider_supports_proxy_takeover(&app_type, _provider)
|
||
{
|
||
return Err(AppError::localized(
|
||
"switch.official_blocked_by_proxy",
|
||
"代理接管模式下不能切换到官方供应商,使用代理访问官方 API 可能导致账号被封禁。请先关闭代理接管,或选择第三方供应商。",
|
||
"Cannot switch to official provider while proxy takeover is active. Using proxy with official APIs may cause account bans.",
|
||
));
|
||
}
|
||
|
||
if should_hot_switch {
|
||
// Proxy takeover mode: hot-switch without restoring upstream Live config.
|
||
// The proxy layer may still refresh proxy-safe Live fields so client labels
|
||
// follow the selected provider while endpoints remain local.
|
||
log::info!(
|
||
"代理接管模式:热切换 {} 的目标供应商为 {}",
|
||
app_type.as_str(),
|
||
id
|
||
);
|
||
|
||
futures::executor::block_on(
|
||
state
|
||
.proxy_service
|
||
.hot_switch_provider_inner(app_type.as_str(), id),
|
||
)
|
||
.map_err(|e| AppError::Message(format!("热切换失败: {e}")))?;
|
||
|
||
// The proxy server will route requests to the new provider via is_current.
|
||
// MCP sync is intentionally skipped while Live config is owned by takeover.
|
||
return Ok(SwitchResult::default());
|
||
}
|
||
|
||
// Normal mode: full switch with Live config write
|
||
Self::switch_normal(state, app_type, id, &providers)
|
||
}
|
||
|
||
/// Normal switch flow (non-proxy mode)
|
||
fn switch_normal(
|
||
state: &AppState,
|
||
app_type: AppType,
|
||
id: &str,
|
||
providers: &indexmap::IndexMap<String, Provider>,
|
||
) -> Result<SwitchResult, AppError> {
|
||
let provider = providers
|
||
.get(id)
|
||
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
|
||
|
||
// OMO ↔ OMO Slim are mutually exclusive; activating one removes the other's config file.
|
||
if matches!(app_type, AppType::OpenCode) {
|
||
let omo_pair = match provider.category.as_deref() {
|
||
Some("omo") => Some((&crate::services::omo::STANDARD, &crate::services::omo::SLIM)),
|
||
Some("omo-slim") => {
|
||
Some((&crate::services::omo::SLIM, &crate::services::omo::STANDARD))
|
||
}
|
||
_ => None,
|
||
};
|
||
if let Some((enable, disable)) = omo_pair {
|
||
state
|
||
.db
|
||
.set_omo_provider_current(app_type.as_str(), id, enable.category)?;
|
||
crate::services::OmoService::write_config_to_file(state, enable)?;
|
||
let _ = crate::services::OmoService::delete_config_file(disable);
|
||
return Ok(SwitchResult::default());
|
||
}
|
||
}
|
||
|
||
let mut result = SwitchResult::default();
|
||
|
||
// Backfill: Backfill current live config to current provider
|
||
// Use effective current provider (validated existence) to ensure backfill targets valid provider
|
||
let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?;
|
||
let current_managed_codex_account_id = current_id
|
||
.as_deref()
|
||
.and_then(|current_id| providers.get(current_id))
|
||
.and_then(Self::managed_codex_oauth_account_id);
|
||
|
||
if let Some(current_id) = current_id {
|
||
if current_id != id {
|
||
// Additive mode apps - all providers coexist in the same file,
|
||
// no backfill needed (backfill is for exclusive mode apps like Claude/Codex/Gemini)
|
||
if !app_type.is_additive_mode() {
|
||
// Only backfill when switching to a different provider
|
||
if let Ok(live_config) = read_live_settings(app_type.clone()) {
|
||
if let Some(mut current_provider) = providers.get(¤t_id).cloned() {
|
||
// 切走前先把 live 里的可共享改动(含用户直接在应用内
|
||
// 装插件/加 hook/改偏好)同步进通用配置片段,再做剥离回填。
|
||
// 详见 sync_common_config_snippet_from_live 的文档。
|
||
Self::sync_common_config_snippet_from_live(
|
||
state,
|
||
&app_type,
|
||
¤t_provider,
|
||
&live_config,
|
||
&mut result,
|
||
);
|
||
|
||
current_provider.settings_config =
|
||
strip_common_config_from_live_settings(
|
||
state.db.as_ref(),
|
||
&app_type,
|
||
¤t_provider,
|
||
live_config,
|
||
);
|
||
if let Err(e) =
|
||
state.db.save_provider(app_type.as_str(), ¤t_provider)
|
||
{
|
||
log::warn!("Backfill failed: {e}");
|
||
result
|
||
.warnings
|
||
.push(format!("backfill_failed:{current_id}"));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 提交 current 前预检托管 Codex token(见 preflight_managed_codex_live)。
|
||
let preflighted_provider = Self::preflight_managed_codex_live(state, &app_type, provider)?;
|
||
let target_managed_codex_account_id = Self::managed_codex_oauth_account_id(provider);
|
||
let use_managed_codex_transaction = matches!(app_type, AppType::Codex)
|
||
&& (current_managed_codex_account_id.is_some()
|
||
|| target_managed_codex_account_id.is_some());
|
||
|
||
if use_managed_codex_transaction {
|
||
// auth/config/catalog/marker form one logical live commit. Write them
|
||
// before current, then restore the exact four-file snapshot on any
|
||
// failure so native logins and CLI-rotated tokens are not reconstructed
|
||
// from a stale provider row.
|
||
let snapshot = crate::codex_config::CodexLiveStateSnapshot::capture()?;
|
||
let live_result = (|| {
|
||
Self::write_preflighted_or_current_live(
|
||
state,
|
||
&app_type,
|
||
provider,
|
||
preflighted_provider.as_ref(),
|
||
)?;
|
||
if target_managed_codex_account_id.is_none() {
|
||
if let Some(account_id) = current_managed_codex_account_id.as_deref() {
|
||
crate::codex_config::clear_codex_live_auth_for_managed_account(account_id)?;
|
||
}
|
||
}
|
||
Ok::<(), AppError>(())
|
||
})();
|
||
if let Err(error) = live_result {
|
||
return Err(Self::managed_codex_transaction_error(
|
||
"写入 Codex Live",
|
||
error,
|
||
&snapshot,
|
||
None,
|
||
));
|
||
}
|
||
|
||
let previous_local_current = crate::settings::get_current_provider(&app_type);
|
||
if let Err(error) = crate::settings::set_current_provider(&app_type, Some(id)) {
|
||
return Err(Self::managed_codex_transaction_error(
|
||
"更新本地 current",
|
||
error,
|
||
&snapshot,
|
||
Some((&app_type, previous_local_current.as_deref())),
|
||
));
|
||
}
|
||
if let Err(error) = state.db.set_current_provider(app_type.as_str(), id) {
|
||
return Err(Self::managed_codex_transaction_error(
|
||
"更新数据库 current",
|
||
error,
|
||
&snapshot,
|
||
Some((&app_type, previous_local_current.as_deref())),
|
||
));
|
||
}
|
||
} else {
|
||
// Additive mode apps skip setting is_current (no such concept).
|
||
if !app_type.is_additive_mode() {
|
||
crate::settings::set_current_provider(&app_type, Some(id))?;
|
||
state.db.set_current_provider(app_type.as_str(), id)?;
|
||
}
|
||
|
||
// Sync to live (write_gemini_live handles security flag internally for Gemini).
|
||
Self::write_preflighted_or_current_live(
|
||
state,
|
||
&app_type,
|
||
provider,
|
||
preflighted_provider.as_ref(),
|
||
)?;
|
||
}
|
||
|
||
// Hermes is additive, so "switching" doesn't overwrite a live config file
|
||
// — we instead update the top-level `model:` section to point at this
|
||
// provider's first declared model. Without this, clicking "switch" would
|
||
// only shuffle entries in custom_providers[] while Hermes keeps using
|
||
// whatever `model.provider` was set before.
|
||
if matches!(app_type, AppType::Hermes) {
|
||
if let Err(e) =
|
||
crate::hermes_config::apply_switch_defaults(&provider.id, &provider.settings_config)
|
||
{
|
||
log::warn!(
|
||
"Failed to update Hermes model defaults after switching to '{}': {e}",
|
||
provider.id
|
||
);
|
||
result
|
||
.warnings
|
||
.push(format!("hermes_model_defaults_failed:{}", provider.id));
|
||
}
|
||
}
|
||
|
||
// For additive-mode providers that were DB-only (live_config_managed == Some(false)),
|
||
// flip the flag to true now that the provider has been successfully written to the live
|
||
// file. This ensures sync_all_providers_to_live() will include it on future syncs.
|
||
//
|
||
// If persisting the marker fails, roll back the just-written live config so we don't leave
|
||
// the provider in a silent inconsistent state (present in live, but still marked DB-only).
|
||
if app_type.is_additive_mode() && Self::provider_live_config_managed(provider) != Some(true)
|
||
{
|
||
let mut updated = provider.clone();
|
||
Self::set_provider_live_config_managed(&mut updated, true);
|
||
if let Err(e) = state.db.save_provider(app_type.as_str(), &updated) {
|
||
let rollback_result = match app_type {
|
||
AppType::OpenCode => remove_opencode_provider_from_live(&provider.id),
|
||
AppType::OpenClaw => remove_openclaw_provider_from_live(&provider.id),
|
||
AppType::Hermes => remove_hermes_provider_from_live(&provider.id),
|
||
_ => Ok(()),
|
||
};
|
||
|
||
match rollback_result {
|
||
Ok(()) => {
|
||
return Err(AppError::Message(format!(
|
||
"Failed to persist live_config_managed for '{}' after writing live config; live changes were rolled back: {e}",
|
||
provider.id
|
||
)));
|
||
}
|
||
Err(rollback_err) => {
|
||
return Err(AppError::Message(format!(
|
||
"Failed to persist live_config_managed for '{}' after writing live config: {e}; additionally failed to roll back live config: {rollback_err}",
|
||
provider.id
|
||
)));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 切换重写了目标应用的 live,只重投影该应用的 MCP(Codex 的
|
||
// [mcp_servers] 与 live 同文件,整体替换后必须补回;其余应用的
|
||
// MCP 文件独立于 live,投影是幂等维护)。不用全量 sync_all_enabled:
|
||
// 无关应用的 live 损坏(如 ~/.claude.json 坏 JSON)不该阻断切换。
|
||
// 走到这里 DB is_current 与 live 都已落盘,切换事实上已成功;
|
||
// 投影失败上抛会让前端报"切换失败"制造分裂假象,故降级为警告
|
||
// (MCP 投影可自愈:下次切换 / 任一 MCP 启停都会重新投影)。
|
||
if let Err(err) = McpService::sync_enabled_for_app(state, &app_type) {
|
||
log::warn!("切换供应商后重投影 {app_type:?} MCP 失败(将在下次同步时自愈): {err}");
|
||
}
|
||
|
||
Ok(result)
|
||
}
|
||
|
||
/// Sync current provider to live configuration (re-export)
|
||
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
|
||
sync_current_to_live(state)
|
||
}
|
||
|
||
pub fn sync_current_provider_for_app(
|
||
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 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);
|
||
|
||
// See the save path above: backup/placeholders are the ownership signal
|
||
// here, not just proxy_config.enabled.
|
||
if has_live_backup || live_taken_over {
|
||
if matches!(app_type, AppType::ClaudeDesktop) {
|
||
write_live_with_common_config_for_state(state, &app_type, provider)?;
|
||
return Ok(());
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
pub fn migrate_legacy_common_config_usage(
|
||
state: &AppState,
|
||
app_type: AppType,
|
||
legacy_snippet: &str,
|
||
) -> Result<(), AppError> {
|
||
if app_type.is_additive_mode() || legacy_snippet.trim().is_empty() {
|
||
return Ok(());
|
||
}
|
||
|
||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||
|
||
for provider in providers.values() {
|
||
if provider
|
||
.meta
|
||
.as_ref()
|
||
.and_then(|meta| meta.common_config_enabled)
|
||
.is_some()
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if !live::provider_uses_common_config(&app_type, provider, Some(legacy_snippet)) {
|
||
continue;
|
||
}
|
||
|
||
let mut updated_provider = provider.clone();
|
||
updated_provider
|
||
.meta
|
||
.get_or_insert_with(Default::default)
|
||
.common_config_enabled = Some(true);
|
||
|
||
match live::remove_common_config_from_settings(
|
||
&app_type,
|
||
&updated_provider.settings_config,
|
||
legacy_snippet,
|
||
) {
|
||
Ok(settings) => updated_provider.settings_config = settings,
|
||
Err(err) => {
|
||
log::warn!(
|
||
"Failed to normalize legacy common config for {} provider '{}': {err}",
|
||
app_type.as_str(),
|
||
updated_provider.id
|
||
);
|
||
}
|
||
}
|
||
|
||
state
|
||
.db
|
||
.save_provider(app_type.as_str(), &updated_provider)?;
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
/// 切走某供应商前,把它 live 配置里的可共享部分重新提取并**整体替换**到
|
||
/// 通用配置片段,使在 live 应用里直接做的改动不会因切换而丢失。
|
||
///
|
||
/// 采用"整体重提取 + 替换"而非"只合并新增",是为了同时覆盖三种情况:
|
||
/// - **新增**:用户直接在应用里装了插件、加了 hook、改了 env/主题/权限等共享
|
||
/// 偏好,被捕获进通用配置,切到别的供应商也带得过去;
|
||
/// - **删除**:被删掉的键不在新提取结果里,于是从片段里消失、下次切换不会被
|
||
/// 重新注入——否则会出现"插件怎么删也删不掉"的反直觉 bug;
|
||
/// - **密钥安全**:提取器已剥掉 auth / model / endpoint,密钥永不进共享片段。
|
||
///
|
||
/// 之所以"整体替换"是安全的:每次写 live 都会把当前片段合并进去,所以切走时
|
||
/// 读到的 live 一定是"片段 + 本地改动"的超集,重提取只会丢掉用户真正删掉的键,
|
||
/// 不会误删其它供应商共享的内容。
|
||
///
|
||
/// **作用域**:Claude + Codex。Codex 提取器(`extract_codex_common_config`)
|
||
/// 已剥离全部供应商专属与 cc-switch 注入内容:`model` / `model_provider` /
|
||
/// 顶层 `base_url` / 整张 `model_providers` 表(含端点与统一会话桶)、
|
||
/// `mcp_servers`(SSOT 在 DB 表)、顶层 `experimental_bearer_token`
|
||
/// fallback、`model_catalog_json`、`web_search = "disabled"` 哨兵——密钥与
|
||
/// 注入产物不会进共享片段。Gemini 暂未纳入,如需支持应单独验证后再加。
|
||
///
|
||
/// 仅对**显式勾选"写入通用配置"**(`meta.common_config_enabled == Some(true)`)的
|
||
/// 供应商生效;用户**显式清空**过片段(`_cleared`)时跳过,避免把用户主动清掉的
|
||
/// 配置又塞回来。所有失败均为非致命,只记 warning,绝不阻断切换。
|
||
fn sync_common_config_snippet_from_live(
|
||
state: &AppState,
|
||
app_type: &AppType,
|
||
provider: &Provider,
|
||
live_config: &Value,
|
||
result: &mut SwitchResult,
|
||
) {
|
||
// 作用域限定 Claude + Codex(见函数文档)。
|
||
if !matches!(app_type, AppType::Claude | AppType::Codex) {
|
||
return;
|
||
}
|
||
|
||
let opted_in = provider
|
||
.meta
|
||
.as_ref()
|
||
.and_then(|meta| meta.common_config_enabled)
|
||
== Some(true);
|
||
if !opted_in {
|
||
return;
|
||
}
|
||
|
||
match state.db.is_config_snippet_cleared(app_type.as_str()) {
|
||
Ok(true) => return, // 用户显式清空过通用配置,尊重其选择,不再自动塞回
|
||
Ok(false) => {}
|
||
Err(err) => {
|
||
log::warn!(
|
||
"Failed to read common config cleared flag for {}: {err}",
|
||
app_type.as_str()
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
|
||
let new_snippet = match Self::extract_common_config_snippet_from_settings(
|
||
app_type.clone(),
|
||
live_config,
|
||
) {
|
||
Ok(snippet) => snippet,
|
||
Err(err) => {
|
||
log::warn!(
|
||
"Failed to extract common config from live for {} provider '{}': {err}",
|
||
app_type.as_str(),
|
||
provider.id
|
||
);
|
||
return;
|
||
}
|
||
};
|
||
|
||
// 未变化则跳过,避免无谓写库(不切 live 配置时这是常态路径)。
|
||
let current = state
|
||
.db
|
||
.get_config_snippet(app_type.as_str())
|
||
.ok()
|
||
.flatten();
|
||
if current.as_deref() == Some(new_snippet.as_str()) {
|
||
return;
|
||
}
|
||
|
||
if let Err(err) = state
|
||
.db
|
||
.set_config_snippet(app_type.as_str(), Some(new_snippet))
|
||
{
|
||
log::warn!(
|
||
"Failed to persist synced common config for {} provider '{}': {err}",
|
||
app_type.as_str(),
|
||
provider.id
|
||
);
|
||
result
|
||
.warnings
|
||
.push(format!("common_config_sync_failed:{}", provider.id));
|
||
}
|
||
}
|
||
|
||
/// Extract common config snippet from current provider
|
||
///
|
||
/// Extracts the current provider's configuration and removes provider-specific fields
|
||
/// (API keys, model settings, endpoints) to create a reusable common config snippet.
|
||
pub fn extract_common_config_snippet(
|
||
state: &AppState,
|
||
app_type: AppType,
|
||
) -> Result<String, AppError> {
|
||
// Get current provider
|
||
let current_id = Self::current(state, app_type.clone())?;
|
||
if current_id.is_empty() {
|
||
return Err(AppError::Message("No current provider".to_string()));
|
||
}
|
||
|
||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||
let provider = providers
|
||
.get(¤t_id)
|
||
.ok_or_else(|| AppError::Message(format!("Provider {current_id} not found")))?;
|
||
|
||
match app_type {
|
||
AppType::Claude => Self::extract_claude_common_config(&provider.settings_config),
|
||
AppType::ClaudeDesktop => Ok(String::new()),
|
||
AppType::Codex => Self::extract_codex_common_config(&provider.settings_config),
|
||
AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config),
|
||
AppType::GrokBuild => Ok(String::new()),
|
||
AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config),
|
||
AppType::OpenClaw => Self::extract_openclaw_common_config(&provider.settings_config),
|
||
AppType::Hermes => Ok(String::new()), // Hermes doesn't use common config snippets
|
||
}
|
||
}
|
||
|
||
/// Extract common config snippet from a config value (e.g. editor content).
|
||
pub fn extract_common_config_snippet_from_settings(
|
||
app_type: AppType,
|
||
settings_config: &Value,
|
||
) -> Result<String, AppError> {
|
||
match app_type {
|
||
AppType::Claude => Self::extract_claude_common_config(settings_config),
|
||
AppType::ClaudeDesktop => Ok(String::new()),
|
||
AppType::Codex => Self::extract_codex_common_config(settings_config),
|
||
AppType::Gemini => Self::extract_gemini_common_config(settings_config),
|
||
AppType::GrokBuild => Ok(String::new()),
|
||
AppType::OpenCode => Self::extract_opencode_common_config(settings_config),
|
||
AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config),
|
||
AppType::Hermes => Ok(String::new()), // Hermes doesn't use common config snippets
|
||
}
|
||
}
|
||
|
||
/// 判断一个 env / 顶层配置键名是否为凭据/机密:凡命中一律不得写入共享的
|
||
/// 通用配置片段。**故意从严**——多剥一个非机密键只是它不被共享(可恢复的小
|
||
/// 不便),漏剥一个凭据则会把密钥注入到每个供应商(不可恢复的泄漏)。因此用
|
||
/// 模式匹配覆盖整类,而非枚举具体名字(枚举永远会漏掉下一个 `*_API_KEY`)。
|
||
///
|
||
/// 覆盖:Anthropic / OpenRouter / Google / OpenAI / Gemini 等 `*_API_KEY`
|
||
/// (Claude provider 的凭据见 `Provider::resolve_usage_credentials`,确实支持
|
||
/// `OPENROUTER_API_KEY` / `GOOGLE_API_KEY` 等回退)、各类 `*_AUTH_TOKEN` /
|
||
/// 单数 `*_TOKEN`、AWS Bedrock / Vertex 凭据、以及通用 secret / password /
|
||
/// 私钥命名。
|
||
fn is_sensitive_config_key(name: &str) -> bool {
|
||
let upper = name.to_ascii_uppercase();
|
||
|
||
// 单数 `_TOKEN` 命中 AWS_SESSION_TOKEN 等,但**不**误伤复数 `_TOKENS`
|
||
// (CLAUDE_CODE_MAX_OUTPUT_TOKENS / MAX_THINKING_TOKENS 是正常可共享配置)。
|
||
const SENSITIVE_SUFFIXES: &[&str] = &[
|
||
"_API_KEY",
|
||
"_APIKEY",
|
||
"_AUTH_TOKEN",
|
||
"_TOKEN",
|
||
"_ACCESS_KEY",
|
||
"_ACCESS_KEY_ID",
|
||
"_KEY_ID",
|
||
"_PRIVATE_KEY",
|
||
];
|
||
const SENSITIVE_EXACT: &[&str] = &[
|
||
"APIKEY",
|
||
"API_KEY",
|
||
"TOKEN",
|
||
"SECRET",
|
||
"PASSWORD",
|
||
"CREDENTIALS",
|
||
];
|
||
// contains:覆盖 AWS_SECRET_ACCESS_KEY / *_CLIENT_SECRET /
|
||
// GOOGLE_APPLICATION_CREDENTIALS / AWS_BEARER_TOKEN_BEDROCK 等变体。
|
||
const SENSITIVE_CONTAINS: &[&str] = &[
|
||
"SECRET",
|
||
"PASSWORD",
|
||
"PASSWD",
|
||
"CREDENTIAL",
|
||
"PRIVATE_KEY",
|
||
"BEARER_TOKEN",
|
||
];
|
||
|
||
SENSITIVE_EXACT.contains(&upper.as_str())
|
||
|| SENSITIVE_SUFFIXES.iter().any(|s| upper.ends_with(s))
|
||
|| SENSITIVE_CONTAINS.iter().any(|c| upper.contains(c))
|
||
}
|
||
|
||
/// Extract common config for Claude (JSON format)
|
||
fn extract_claude_common_config(settings: &Value) -> Result<String, AppError> {
|
||
let mut config = settings.clone();
|
||
|
||
// 供应商专属的**非机密**字段(模型 + 端点),不应共享。凭据/机密不在此列举,
|
||
// 改由 `is_sensitive_config_key`(模式匹配)统一剥离,新供应商的 `*_API_KEY`
|
||
// 等无需再手工补名单即可被覆盖。
|
||
const ENV_PROVIDER_SPECIFIC_EXCLUDES: &[&str] = &[
|
||
"ANTHROPIC_MODEL",
|
||
"ANTHROPIC_REASONING_MODEL", // legacy: 已废弃,但旧配置可能残留
|
||
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||
"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME",
|
||
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||
"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME",
|
||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME",
|
||
// Fable 是 v3.16.3 新增的第四档模型映射,与 haiku/sonnet/opus 同属供应商专属,
|
||
// 不得进入通用配置片段,否则会污染其它供应商(issue #4272)。
|
||
"ANTHROPIC_DEFAULT_FABLE_MODEL",
|
||
"ANTHROPIC_DEFAULT_FABLE_MODEL_NAME",
|
||
"CLAUDE_CODE_SUBAGENT_MODEL",
|
||
// Context limits follow the actual upstream model. Sharing these
|
||
// across providers can cap GPT/Kimi to the wrong window and make
|
||
// Claude Code compact too early or miss the upstream limit.
|
||
"CLAUDE_CODE_MAX_CONTEXT_TOKENS",
|
||
"CLAUDE_CODE_AUTO_COMPACT_WINDOW",
|
||
"ANTHROPIC_BASE_URL",
|
||
];
|
||
|
||
const TOP_LEVEL_EXCLUDES: &[&str] = &[
|
||
"apiBaseUrl",
|
||
// Legacy model fields
|
||
"primaryModel",
|
||
"smallFastModel",
|
||
];
|
||
|
||
// Remove env fields: provider-specific (models/endpoint) + 任何凭据键。
|
||
if let Some(env) = config.get_mut("env").and_then(|v| v.as_object_mut()) {
|
||
let sensitive: Vec<String> = env
|
||
.keys()
|
||
.filter(|k| Self::is_sensitive_config_key(k))
|
||
.cloned()
|
||
.collect();
|
||
for key in ENV_PROVIDER_SPECIFIC_EXCLUDES {
|
||
env.remove(*key);
|
||
}
|
||
for key in &sensitive {
|
||
env.remove(key);
|
||
}
|
||
// If env is empty after removal, remove the env object itself
|
||
if env.is_empty() {
|
||
config.as_object_mut().map(|obj| obj.remove("env"));
|
||
}
|
||
}
|
||
|
||
// Remove top-level fields: legacy model fields + 任何凭据键
|
||
// (例如非标准的顶层 apiKey / api_key / *_TOKEN)。
|
||
if let Some(obj) = config.as_object_mut() {
|
||
let sensitive: Vec<String> = obj
|
||
.keys()
|
||
.filter(|k| Self::is_sensitive_config_key(k))
|
||
.cloned()
|
||
.collect();
|
||
for key in TOP_LEVEL_EXCLUDES {
|
||
obj.remove(*key);
|
||
}
|
||
for key in &sensitive {
|
||
obj.remove(key);
|
||
}
|
||
}
|
||
|
||
// Check if result is empty
|
||
if config.as_object().is_none_or(|obj| obj.is_empty()) {
|
||
return Ok("{}".to_string());
|
||
}
|
||
|
||
serde_json::to_string_pretty(&config)
|
||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
||
}
|
||
|
||
/// Extract common config for Codex (TOML format)
|
||
fn extract_codex_common_config(settings: &Value) -> Result<String, AppError> {
|
||
// Codex config is stored as { "auth": {...}, "config": "toml string" }
|
||
let config_toml = settings
|
||
.get("config")
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or("");
|
||
|
||
if config_toml.is_empty() {
|
||
return Ok(String::new());
|
||
}
|
||
|
||
let mut doc = config_toml
|
||
.parse::<toml_edit::DocumentMut>()
|
||
.map_err(|e| AppError::Message(format!("TOML parse error: {e}")))?;
|
||
|
||
// Remove provider-specific fields.
|
||
let root = doc.as_table_mut();
|
||
root.remove("model");
|
||
root.remove("model_provider");
|
||
// Legacy/alt formats might use a top-level base_url.
|
||
root.remove("base_url");
|
||
// wire_api 与 base_url 同属供应商路由语义:无 model_provider 时
|
||
// update_codex_toml_field / 前端 setCodexWireApi 都会把它落在顶层,
|
||
// 进了片段会改写其它供应商的协议选择(chat vs responses)。
|
||
root.remove("wire_api");
|
||
|
||
// Remove entire model_providers table (provider-specific configuration)
|
||
root.remove("model_providers");
|
||
|
||
// MCP 服务器归 DB mcp_servers 表所有:进了共享片段会绕过按应用的
|
||
// 启用状态被合并进所有勾选通用配置的供应商,且在通用配置编辑框里
|
||
// 显示为一份"重复"的 MCP 配置。
|
||
root.remove("mcp_servers");
|
||
// 历史错误格式 [mcp.servers] 一并剥离(与 strip_codex_mcp_servers_from_settings
|
||
// 一致):sync_all_enabled 只管理 [mcp_servers.*],legacy 形态一旦进了
|
||
// 片段就会被合并进所有供应商,且没有任何同步路径能清掉这个孤儿。
|
||
if let Some(mcp_tbl) = root
|
||
.get_mut("mcp")
|
||
.and_then(|item| item.as_table_like_mut())
|
||
{
|
||
mcp_tbl.remove("servers");
|
||
if mcp_tbl.is_empty() {
|
||
root.remove("mcp");
|
||
}
|
||
}
|
||
|
||
// cc-switch 写 live 时注入的产物一律不进共享片段:
|
||
// - experimental_bearer_token 正常写在 [model_providers.<id>] 内(上面
|
||
// 整表已剥),但无活跃路由 / 内建保留 id / 路由表缺失三种 fallback
|
||
// 会落在顶层——不剥等于把 API 密钥写进共享片段。
|
||
root.remove("experimental_bearer_token");
|
||
// - model_catalog_json 指向按供应商生成的 catalog 投影文件(DB 为 SSOT)。
|
||
root.remove("model_catalog_json");
|
||
// - web_search 只剥 cc-switch 注入的 "disabled" 哨兵;用户手设的其它值
|
||
// 属于可共享偏好,保留。
|
||
if root
|
||
.get(crate::codex_config::CODEX_WEB_SEARCH_FIELD)
|
||
.and_then(|item| item.as_str())
|
||
== Some(crate::codex_config::CODEX_WEB_SEARCH_DISABLED)
|
||
{
|
||
root.remove(crate::codex_config::CODEX_WEB_SEARCH_FIELD);
|
||
}
|
||
|
||
// Clean up multiple empty lines (keep at most one blank line).
|
||
let mut cleaned = String::new();
|
||
let mut blank_run = 0usize;
|
||
for line in doc.to_string().lines() {
|
||
if line.trim().is_empty() {
|
||
blank_run += 1;
|
||
if blank_run <= 1 {
|
||
cleaned.push('\n');
|
||
}
|
||
continue;
|
||
}
|
||
blank_run = 0;
|
||
cleaned.push_str(line);
|
||
cleaned.push('\n');
|
||
}
|
||
|
||
Ok(cleaned.trim().to_string())
|
||
}
|
||
|
||
/// Extract common config for Gemini (JSON format)
|
||
///
|
||
/// Extracts `.env` values while excluding provider-specific credentials:
|
||
/// - GOOGLE_GEMINI_BASE_URL
|
||
/// - GEMINI_API_KEY
|
||
fn extract_gemini_common_config(settings: &Value) -> Result<String, AppError> {
|
||
let env = settings.get("env").and_then(|v| v.as_object());
|
||
|
||
let mut snippet = serde_json::Map::new();
|
||
if let Some(env) = env {
|
||
for (key, value) in env {
|
||
if key == "GOOGLE_GEMINI_BASE_URL" || key == "GEMINI_API_KEY" {
|
||
continue;
|
||
}
|
||
let Value::String(v) = value else {
|
||
continue;
|
||
};
|
||
let trimmed = v.trim();
|
||
if !trimmed.is_empty() {
|
||
snippet.insert(key.to_string(), Value::String(trimmed.to_string()));
|
||
}
|
||
}
|
||
}
|
||
|
||
if snippet.is_empty() {
|
||
return Ok("{}".to_string());
|
||
}
|
||
|
||
serde_json::to_string_pretty(&Value::Object(snippet))
|
||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
||
}
|
||
|
||
/// Extract common config for OpenCode (JSON format)
|
||
fn extract_opencode_common_config(settings: &Value) -> Result<String, AppError> {
|
||
// OpenCode uses a different config structure with npm, options, models
|
||
// For common config, we exclude provider-specific fields like apiKey
|
||
let mut config = settings.clone();
|
||
|
||
// Remove provider-specific fields
|
||
if let Some(obj) = config.as_object_mut() {
|
||
if let Some(options) = obj.get_mut("options").and_then(|v| v.as_object_mut()) {
|
||
options.remove("apiKey");
|
||
options.remove("baseURL");
|
||
}
|
||
// Keep npm and models as they might be common
|
||
}
|
||
|
||
if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) {
|
||
return Ok("{}".to_string());
|
||
}
|
||
|
||
serde_json::to_string_pretty(&config)
|
||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
||
}
|
||
|
||
/// Extract common config for OpenClaw (JSON format)
|
||
fn extract_openclaw_common_config(settings: &Value) -> Result<String, AppError> {
|
||
// OpenClaw uses a different config structure with baseUrl, apiKey, api, models
|
||
// For common config, we exclude provider-specific fields like apiKey
|
||
let mut config = settings.clone();
|
||
|
||
// Remove provider-specific fields
|
||
if let Some(obj) = config.as_object_mut() {
|
||
obj.remove("apiKey");
|
||
obj.remove("baseUrl");
|
||
// Keep api and models as they might be common
|
||
}
|
||
|
||
if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) {
|
||
return Ok("{}".to_string());
|
||
}
|
||
|
||
serde_json::to_string_pretty(&config)
|
||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
||
}
|
||
|
||
/// Import default configuration from live files (re-export)
|
||
///
|
||
/// Returns `Ok(true)` if imported, `Ok(false)` if skipped.
|
||
pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
|
||
import_default_config(state, app_type)
|
||
}
|
||
|
||
pub fn should_import_default_config_on_startup(
|
||
state: &AppState,
|
||
app_type: &AppType,
|
||
) -> Result<bool, AppError> {
|
||
should_import_default_config_on_startup(state, app_type)
|
||
}
|
||
|
||
/// Read current live settings (re-export)
|
||
pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
|
||
read_live_settings(app_type)
|
||
}
|
||
|
||
/// Get custom endpoints list (re-export)
|
||
pub fn get_custom_endpoints(
|
||
state: &AppState,
|
||
app_type: AppType,
|
||
provider_id: &str,
|
||
) -> Result<Vec<CustomEndpoint>, AppError> {
|
||
endpoints::get_custom_endpoints(state, app_type, provider_id)
|
||
}
|
||
|
||
/// Add custom endpoint (re-export)
|
||
pub fn add_custom_endpoint(
|
||
state: &AppState,
|
||
app_type: AppType,
|
||
provider_id: &str,
|
||
url: String,
|
||
) -> Result<(), AppError> {
|
||
endpoints::add_custom_endpoint(state, app_type, provider_id, url)
|
||
}
|
||
|
||
/// Remove custom endpoint (re-export)
|
||
pub fn remove_custom_endpoint(
|
||
state: &AppState,
|
||
app_type: AppType,
|
||
provider_id: &str,
|
||
url: String,
|
||
) -> Result<(), AppError> {
|
||
endpoints::remove_custom_endpoint(state, app_type, provider_id, url)
|
||
}
|
||
|
||
/// Update endpoint last used timestamp (re-export)
|
||
pub fn update_endpoint_last_used(
|
||
state: &AppState,
|
||
app_type: AppType,
|
||
provider_id: &str,
|
||
url: String,
|
||
) -> Result<(), AppError> {
|
||
endpoints::update_endpoint_last_used(state, app_type, provider_id, url)
|
||
}
|
||
|
||
/// Update provider sort order
|
||
pub fn update_sort_order(
|
||
state: &AppState,
|
||
app_type: AppType,
|
||
updates: Vec<ProviderSortUpdate>,
|
||
) -> Result<bool, AppError> {
|
||
let mut providers = state.db.get_all_providers(app_type.as_str())?;
|
||
|
||
for update in updates {
|
||
if let Some(provider) = providers.get_mut(&update.id) {
|
||
provider.sort_index = Some(update.sort_index);
|
||
state.db.save_provider(app_type.as_str(), provider)?;
|
||
}
|
||
}
|
||
|
||
Ok(true)
|
||
}
|
||
|
||
/// Query provider usage (re-export)
|
||
pub async fn query_usage(
|
||
state: &AppState,
|
||
app_type: AppType,
|
||
provider_id: &str,
|
||
) -> Result<UsageResult, AppError> {
|
||
usage::query_usage(state, app_type, provider_id).await
|
||
}
|
||
|
||
/// Test usage script (re-export)
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub async fn test_usage_script(
|
||
state: &AppState,
|
||
app_type: AppType,
|
||
provider_id: &str,
|
||
script_code: &str,
|
||
timeout: u64,
|
||
api_key: Option<&str>,
|
||
base_url: Option<&str>,
|
||
access_token: Option<&str>,
|
||
user_id: Option<&str>,
|
||
template_type: Option<&str>,
|
||
) -> Result<UsageResult, AppError> {
|
||
usage::test_usage_script(
|
||
state,
|
||
app_type,
|
||
provider_id,
|
||
script_code,
|
||
timeout,
|
||
api_key,
|
||
base_url,
|
||
access_token,
|
||
user_id,
|
||
template_type,
|
||
)
|
||
.await
|
||
}
|
||
|
||
pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
|
||
write_gemini_live(provider)
|
||
}
|
||
|
||
fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {
|
||
match app_type {
|
||
AppType::Claude => {
|
||
if !provider.settings_config.is_object() {
|
||
return Err(AppError::localized(
|
||
"provider.claude.settings.not_object",
|
||
"Claude 配置必须是 JSON 对象",
|
||
"Claude configuration must be a JSON object",
|
||
));
|
||
}
|
||
}
|
||
AppType::ClaudeDesktop => {
|
||
crate::claude_desktop_config::validate_provider(provider)?;
|
||
}
|
||
AppType::Codex => {
|
||
let settings = provider.settings_config.as_object().ok_or_else(|| {
|
||
AppError::localized(
|
||
"provider.codex.settings.not_object",
|
||
"Codex 配置必须是 JSON 对象",
|
||
"Codex configuration must be a JSON object",
|
||
)
|
||
})?;
|
||
|
||
let auth = settings.get("auth").ok_or_else(|| {
|
||
AppError::localized(
|
||
"provider.codex.auth.missing",
|
||
format!("供应商 {} 缺少 auth 配置", provider.id),
|
||
format!("Provider {} is missing auth configuration", provider.id),
|
||
)
|
||
})?;
|
||
if !auth.is_object() {
|
||
return Err(AppError::localized(
|
||
"provider.codex.auth.not_object",
|
||
format!("供应商 {} 的 auth 配置必须是 JSON 对象", provider.id),
|
||
format!(
|
||
"Provider {} auth configuration must be a JSON object",
|
||
provider.id
|
||
),
|
||
));
|
||
}
|
||
|
||
if let Some(config_value) = settings.get("config") {
|
||
if !(config_value.is_string() || config_value.is_null()) {
|
||
return Err(AppError::localized(
|
||
"provider.codex.config.invalid_type",
|
||
"Codex config 字段必须是字符串",
|
||
"Codex config field must be a string",
|
||
));
|
||
}
|
||
if let Some(cfg_text) = config_value.as_str() {
|
||
crate::codex_config::validate_config_toml(cfg_text)?;
|
||
}
|
||
}
|
||
}
|
||
AppType::Gemini => {
|
||
use crate::gemini_config::validate_gemini_settings;
|
||
validate_gemini_settings(&provider.settings_config)?
|
||
}
|
||
AppType::GrokBuild => {
|
||
let settings = provider.settings_config.as_object().ok_or_else(|| {
|
||
AppError::localized(
|
||
"provider.grokbuild.settings.not_object",
|
||
"Grok Build 配置必须是 JSON 对象",
|
||
"Grok Build configuration must be a JSON object",
|
||
)
|
||
})?;
|
||
let config = settings
|
||
.get("config")
|
||
.and_then(Value::as_str)
|
||
.ok_or_else(|| {
|
||
AppError::localized(
|
||
"provider.grokbuild.config.missing",
|
||
"Grok Build 配置缺少 config 字段",
|
||
"Grok Build configuration is missing the config field",
|
||
)
|
||
})?;
|
||
crate::grok_config::validate_config_toml(config)?;
|
||
}
|
||
AppType::OpenCode => {
|
||
// OpenCode uses a different config structure: { npm, options, models }
|
||
// Basic validation - must be an object
|
||
if !provider.settings_config.is_object() {
|
||
return Err(AppError::localized(
|
||
"provider.opencode.settings.not_object",
|
||
"OpenCode 配置必须是 JSON 对象",
|
||
"OpenCode configuration must be a JSON object",
|
||
));
|
||
}
|
||
}
|
||
AppType::OpenClaw => {
|
||
// OpenClaw uses config structure: { baseUrl, apiKey, api, models }
|
||
// Basic validation - must be an object
|
||
if !provider.settings_config.is_object() {
|
||
return Err(AppError::localized(
|
||
"provider.openclaw.settings.not_object",
|
||
"OpenClaw 配置必须是 JSON 对象",
|
||
"OpenClaw configuration must be a JSON object",
|
||
));
|
||
}
|
||
}
|
||
AppType::Hermes => {
|
||
// Hermes: accept any JSON object for now
|
||
if !provider.settings_config.is_object() {
|
||
return Err(AppError::localized(
|
||
"provider.hermes.settings.not_object",
|
||
"Hermes 配置必须是 JSON 对象",
|
||
"Hermes configuration must be a JSON object",
|
||
));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Validate and clean UsageScript configuration (common for all app types)
|
||
if let Some(meta) = &provider.meta {
|
||
if let Some(multiplier) = meta.cost_multiplier.as_deref() {
|
||
validate_cost_multiplier(multiplier)?;
|
||
}
|
||
if let Some(source) = meta.pricing_model_source.as_deref() {
|
||
validate_pricing_source(source)?;
|
||
}
|
||
if let Some(usage_script) = &meta.usage_script {
|
||
validate_usage_script(usage_script)?;
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
fn extract_credentials(
|
||
provider: &Provider,
|
||
app_type: &AppType,
|
||
) -> Result<(String, String), AppError> {
|
||
match app_type {
|
||
AppType::Claude => {
|
||
let env = provider
|
||
.settings_config
|
||
.get("env")
|
||
.and_then(|v| v.as_object())
|
||
.ok_or_else(|| {
|
||
AppError::localized(
|
||
"provider.claude.env.missing",
|
||
"配置格式错误: 缺少 env",
|
||
"Invalid configuration: missing env section",
|
||
)
|
||
})?;
|
||
|
||
let api_key = env
|
||
.get("ANTHROPIC_AUTH_TOKEN")
|
||
.or_else(|| env.get("ANTHROPIC_API_KEY"))
|
||
.and_then(|v| v.as_str())
|
||
.ok_or_else(|| {
|
||
AppError::localized(
|
||
"provider.claude.api_key.missing",
|
||
"缺少 API Key",
|
||
"API key is missing",
|
||
)
|
||
})?
|
||
.to_string();
|
||
|
||
let base_url = env
|
||
.get("ANTHROPIC_BASE_URL")
|
||
.and_then(|v| v.as_str())
|
||
.ok_or_else(|| {
|
||
AppError::localized(
|
||
"provider.claude.base_url.missing",
|
||
"缺少 ANTHROPIC_BASE_URL 配置",
|
||
"Missing ANTHROPIC_BASE_URL configuration",
|
||
)
|
||
})?
|
||
.to_string();
|
||
|
||
Ok((api_key, base_url))
|
||
}
|
||
AppType::GrokBuild => {
|
||
let config_toml = provider
|
||
.settings_config
|
||
.get("config")
|
||
.and_then(Value::as_str)
|
||
.ok_or_else(|| {
|
||
AppError::localized(
|
||
"provider.grokbuild.config.missing",
|
||
"Grok Build 配置缺少 config 字段",
|
||
"Grok Build configuration is missing the config field",
|
||
)
|
||
})?;
|
||
let (base_url, api_key) = crate::grok_config::extract_credentials(config_toml)
|
||
.ok_or_else(|| {
|
||
AppError::localized(
|
||
"provider.grokbuild.credentials.missing",
|
||
"Grok Build 配置缺少 Base URL 或 API Key",
|
||
"Grok Build configuration is missing the base URL or API key",
|
||
)
|
||
})?;
|
||
Ok((api_key, base_url))
|
||
}
|
||
AppType::ClaudeDesktop => {
|
||
let credentials =
|
||
crate::claude_desktop_config::direct_gateway_credentials(provider)?;
|
||
Ok((credentials.api_key, credentials.base_url))
|
||
}
|
||
AppType::Codex => {
|
||
let _auth = provider
|
||
.settings_config
|
||
.get("auth")
|
||
.and_then(|v| v.as_object())
|
||
.ok_or_else(|| {
|
||
AppError::localized(
|
||
"provider.codex.auth.missing",
|
||
"配置格式错误: 缺少 auth",
|
||
"Invalid configuration: missing auth section",
|
||
)
|
||
})?;
|
||
|
||
let config_toml = provider
|
||
.settings_config
|
||
.get("config")
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or("");
|
||
|
||
let api_key = crate::codex_config::extract_codex_api_key(
|
||
provider.settings_config.get("auth"),
|
||
Some(config_toml),
|
||
)
|
||
.ok_or_else(|| {
|
||
AppError::localized(
|
||
"provider.codex.api_key.missing",
|
||
"缺少 API Key",
|
||
"API key is missing",
|
||
)
|
||
})?;
|
||
|
||
let base_url = if config_toml.contains("base_url") {
|
||
let re = Regex::new(r#"base_url\s*=\s*["']([^"']+)["']"#).map_err(|e| {
|
||
AppError::localized(
|
||
"provider.regex_init_failed",
|
||
format!("正则初始化失败: {e}"),
|
||
format!("Failed to initialize regex: {e}"),
|
||
)
|
||
})?;
|
||
re.captures(config_toml)
|
||
.and_then(|caps| caps.get(1))
|
||
.map(|m| m.as_str().to_string())
|
||
.ok_or_else(|| {
|
||
AppError::localized(
|
||
"provider.codex.base_url.invalid",
|
||
"config.toml 中 base_url 格式错误",
|
||
"base_url in config.toml has invalid format",
|
||
)
|
||
})?
|
||
} else {
|
||
return Err(AppError::localized(
|
||
"provider.codex.base_url.missing",
|
||
"config.toml 中缺少 base_url 配置",
|
||
"base_url is missing from config.toml",
|
||
));
|
||
};
|
||
|
||
Ok((api_key, base_url))
|
||
}
|
||
AppType::Gemini => {
|
||
use crate::gemini_config::json_to_env;
|
||
|
||
let env_map = json_to_env(&provider.settings_config)?;
|
||
|
||
let api_key = env_map.get("GEMINI_API_KEY").cloned().ok_or_else(|| {
|
||
AppError::localized(
|
||
"gemini.missing_api_key",
|
||
"缺少 GEMINI_API_KEY",
|
||
"Missing GEMINI_API_KEY",
|
||
)
|
||
})?;
|
||
|
||
let base_url = env_map
|
||
.get("GOOGLE_GEMINI_BASE_URL")
|
||
.cloned()
|
||
.unwrap_or_else(|| "https://generativelanguage.googleapis.com".to_string());
|
||
|
||
Ok((api_key, base_url))
|
||
}
|
||
AppType::OpenCode => {
|
||
// OpenCode uses options.apiKey and options.baseURL
|
||
let options = provider
|
||
.settings_config
|
||
.get("options")
|
||
.and_then(|v| v.as_object())
|
||
.ok_or_else(|| {
|
||
AppError::localized(
|
||
"provider.opencode.options.missing",
|
||
"配置格式错误: 缺少 options",
|
||
"Invalid configuration: missing options section",
|
||
)
|
||
})?;
|
||
|
||
let api_key = options
|
||
.get("apiKey")
|
||
.and_then(|v| v.as_str())
|
||
.ok_or_else(|| {
|
||
AppError::localized(
|
||
"provider.opencode.api_key.missing",
|
||
"缺少 API Key",
|
||
"API key is missing",
|
||
)
|
||
})?
|
||
.to_string();
|
||
|
||
let base_url = options
|
||
.get("baseURL")
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or("")
|
||
.to_string();
|
||
|
||
Ok((api_key, base_url))
|
||
}
|
||
AppType::OpenClaw | AppType::Hermes => {
|
||
// OpenClaw/Hermes use apiKey and baseUrl directly on the object
|
||
let api_key = provider
|
||
.settings_config
|
||
.get("apiKey")
|
||
.and_then(|v| v.as_str())
|
||
.ok_or_else(|| {
|
||
AppError::localized(
|
||
"provider.openclaw.api_key.missing",
|
||
"缺少 API Key",
|
||
"API key is missing",
|
||
)
|
||
})?
|
||
.to_string();
|
||
|
||
let base_url = provider
|
||
.settings_config
|
||
.get("baseUrl")
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or("")
|
||
.to_string();
|
||
|
||
Ok((api_key, base_url))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Normalize Claude model keys in a JSON value
|
||
///
|
||
/// Reads old key (ANTHROPIC_SMALL_FAST_MODEL), writes new keys (DEFAULT_*), and deletes old key.
|
||
pub(crate) fn normalize_claude_models_in_value(settings: &mut Value) -> bool {
|
||
let mut changed = false;
|
||
let env = match settings.get_mut("env").and_then(|v| v.as_object_mut()) {
|
||
Some(obj) => obj,
|
||
None => return changed,
|
||
};
|
||
|
||
let model = env
|
||
.get("ANTHROPIC_MODEL")
|
||
.and_then(|v| v.as_str())
|
||
.map(|s| s.to_string());
|
||
let small_fast = env
|
||
.get("ANTHROPIC_SMALL_FAST_MODEL")
|
||
.and_then(|v| v.as_str())
|
||
.map(|s| s.to_string());
|
||
|
||
let current_haiku = env
|
||
.get("ANTHROPIC_DEFAULT_HAIKU_MODEL")
|
||
.and_then(|v| v.as_str())
|
||
.map(|s| s.to_string());
|
||
let current_sonnet = env
|
||
.get("ANTHROPIC_DEFAULT_SONNET_MODEL")
|
||
.and_then(|v| v.as_str())
|
||
.map(|s| s.to_string());
|
||
let current_opus = env
|
||
.get("ANTHROPIC_DEFAULT_OPUS_MODEL")
|
||
.and_then(|v| v.as_str())
|
||
.map(|s| s.to_string());
|
||
|
||
let target_haiku = current_haiku
|
||
.or_else(|| small_fast.clone())
|
||
.or_else(|| model.clone());
|
||
let target_sonnet = current_sonnet
|
||
.or_else(|| model.clone())
|
||
.or_else(|| small_fast.clone());
|
||
let target_opus = current_opus
|
||
.or_else(|| model.clone())
|
||
.or_else(|| small_fast.clone());
|
||
|
||
if env.get("ANTHROPIC_DEFAULT_HAIKU_MODEL").is_none() {
|
||
if let Some(v) = target_haiku {
|
||
env.insert(
|
||
"ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(),
|
||
Value::String(v),
|
||
);
|
||
changed = true;
|
||
}
|
||
}
|
||
if env.get("ANTHROPIC_DEFAULT_SONNET_MODEL").is_none() {
|
||
if let Some(v) = target_sonnet {
|
||
env.insert(
|
||
"ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(),
|
||
Value::String(v),
|
||
);
|
||
changed = true;
|
||
}
|
||
}
|
||
if env.get("ANTHROPIC_DEFAULT_OPUS_MODEL").is_none() {
|
||
if let Some(v) = target_opus {
|
||
env.insert("ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(), Value::String(v));
|
||
changed = true;
|
||
}
|
||
}
|
||
|
||
if env.remove("ANTHROPIC_SMALL_FAST_MODEL").is_some() {
|
||
changed = true;
|
||
}
|
||
|
||
changed
|
||
}
|
||
|
||
#[derive(Debug, Clone, Deserialize)]
|
||
pub struct ProviderSortUpdate {
|
||
pub id: String,
|
||
#[serde(rename = "sortIndex")]
|
||
pub sort_index: usize,
|
||
}
|
||
|
||
// ============================================================================
|
||
// 统一供应商(Universal Provider)服务方法
|
||
// ============================================================================
|
||
|
||
use crate::provider::UniversalProvider;
|
||
use std::collections::HashMap;
|
||
|
||
impl ProviderService {
|
||
/// 获取所有统一供应商
|
||
pub fn list_universal(
|
||
state: &AppState,
|
||
) -> Result<HashMap<String, UniversalProvider>, AppError> {
|
||
state.db.get_all_universal_providers()
|
||
}
|
||
|
||
/// 获取单个统一供应商
|
||
pub fn get_universal(
|
||
state: &AppState,
|
||
id: &str,
|
||
) -> Result<Option<UniversalProvider>, AppError> {
|
||
state.db.get_universal_provider(id)
|
||
}
|
||
|
||
/// 添加或更新统一供应商(不自动同步,需手动调用 sync_universal_to_apps)
|
||
pub fn upsert_universal(
|
||
state: &AppState,
|
||
provider: UniversalProvider,
|
||
) -> Result<bool, AppError> {
|
||
// 保存统一供应商
|
||
state.db.save_universal_provider(&provider)?;
|
||
|
||
Ok(true)
|
||
}
|
||
|
||
/// 删除统一供应商
|
||
pub fn delete_universal(state: &AppState, id: &str) -> Result<bool, AppError> {
|
||
// 获取统一供应商(用于删除生成的子供应商)
|
||
let provider = state.db.get_universal_provider(id)?;
|
||
|
||
// 删除统一供应商
|
||
state.db.delete_universal_provider(id)?;
|
||
|
||
// 删除生成的子供应商
|
||
if let Some(p) = provider {
|
||
if p.apps.claude {
|
||
let claude_id = format!("universal-claude-{id}");
|
||
let _ = state.db.delete_provider("claude", &claude_id);
|
||
}
|
||
if p.apps.codex {
|
||
let codex_id = format!("universal-codex-{id}");
|
||
let _ = state.db.delete_provider("codex", &codex_id);
|
||
}
|
||
if p.apps.gemini {
|
||
let gemini_id = format!("universal-gemini-{id}");
|
||
let _ = state.db.delete_provider("gemini", &gemini_id);
|
||
}
|
||
}
|
||
|
||
Ok(true)
|
||
}
|
||
|
||
/// 同步统一供应商到各应用
|
||
pub fn sync_universal_to_apps(state: &AppState, id: &str) -> Result<bool, AppError> {
|
||
let provider = state
|
||
.db
|
||
.get_universal_provider(id)?
|
||
.ok_or_else(|| AppError::Message(format!("统一供应商 {id} 不存在")))?;
|
||
|
||
// 同步到 Claude
|
||
if let Some(mut claude_provider) = provider.to_claude_provider() {
|
||
// 合并已有配置
|
||
if let Some(existing) = state.db.get_provider_by_id(&claude_provider.id, "claude")? {
|
||
let mut merged = existing.settings_config.clone();
|
||
Self::merge_json(&mut merged, &claude_provider.settings_config);
|
||
claude_provider.settings_config = merged;
|
||
}
|
||
state.db.save_provider("claude", &claude_provider)?;
|
||
} else {
|
||
// 如果禁用了 Claude,删除对应的子供应商
|
||
let claude_id = format!("universal-claude-{id}");
|
||
let _ = state.db.delete_provider("claude", &claude_id);
|
||
}
|
||
|
||
// 同步到 Codex
|
||
if let Some(mut codex_provider) = provider.to_codex_provider() {
|
||
// 合并已有配置
|
||
if let Some(existing) = state.db.get_provider_by_id(&codex_provider.id, "codex")? {
|
||
let mut merged = existing.settings_config.clone();
|
||
Self::merge_json(&mut merged, &codex_provider.settings_config);
|
||
codex_provider.settings_config = merged;
|
||
}
|
||
state.db.save_provider("codex", &codex_provider)?;
|
||
} else {
|
||
let codex_id = format!("universal-codex-{id}");
|
||
let _ = state.db.delete_provider("codex", &codex_id);
|
||
}
|
||
|
||
// 同步到 Gemini
|
||
if let Some(mut gemini_provider) = provider.to_gemini_provider() {
|
||
// 合并已有配置
|
||
if let Some(existing) = state.db.get_provider_by_id(&gemini_provider.id, "gemini")? {
|
||
let mut merged = existing.settings_config.clone();
|
||
Self::merge_json(&mut merged, &gemini_provider.settings_config);
|
||
gemini_provider.settings_config = merged;
|
||
}
|
||
state.db.save_provider("gemini", &gemini_provider)?;
|
||
} else {
|
||
let gemini_id = format!("universal-gemini-{id}");
|
||
let _ = state.db.delete_provider("gemini", &gemini_id);
|
||
}
|
||
|
||
Ok(true)
|
||
}
|
||
|
||
/// 递归合并 JSON:base 为底,patch 覆盖同名字段
|
||
fn merge_json(base: &mut serde_json::Value, patch: &serde_json::Value) {
|
||
use serde_json::Value;
|
||
|
||
match (base, patch) {
|
||
(Value::Object(base_map), Value::Object(patch_map)) => {
|
||
for (k, v_patch) in patch_map {
|
||
match base_map.get_mut(k) {
|
||
Some(v_base) => Self::merge_json(v_base, v_patch),
|
||
None => {
|
||
base_map.insert(k.clone(), v_patch.clone());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 其它类型:直接覆盖
|
||
(base_val, patch_val) => {
|
||
*base_val = patch_val.clone();
|
||
}
|
||
}
|
||
}
|
||
}
|