Files
CC-Switch/src-tauri/src/services/provider/mod.rs
T

5972 lines
236 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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, NewProviderAggregate, ProviderKey,
ProviderRowUpdate, RenameProvider,
};
use crate::error::AppError;
use crate::provider::{Provider, ProviderMutationInput, UsageResult};
use crate::services::mcp::McpService;
use crate::services::pi_catalog::{PiCatalogCoordinator, PiCatalogMutation};
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_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,
};
// 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(&current_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(&state.db, &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;
/// Explicit service-boundary conversion for normalized drafts and live-import
/// records. There is intentionally no `From<Provider>` implementation: a
/// hydrated read projection cannot silently become a write DTO via `.into()`.
pub(crate) fn provider_to_mutation_input(provider: Provider) -> ProviderMutationInput {
ProviderMutationInput {
id: provider.id,
name: provider.name,
settings_config: provider.settings_config,
website_url: provider.website_url,
category: provider.category,
created_at: provider.created_at,
sort_index: provider.sort_index,
notes: provider.notes,
meta: provider.meta,
icon: provider.icon,
icon_color: provider.icon_color,
in_failover_queue: provider.in_failover_queue,
}
}
fn create_provider_record(
state: &AppState,
app_type: &AppType,
input: ProviderMutationInput,
) -> Result<(), AppError> {
state
.db
.create_provider(NewProviderAggregate::from_input(app_type.as_str(), input)?)
}
fn update_provider_record(
state: &AppState,
app_type: &AppType,
input: &ProviderMutationInput,
) -> Result<(), AppError> {
let key = ProviderKey::new(app_type.as_str(), input.id.clone())?;
let row = ProviderRowUpdate::from_input(input)?;
state.db.update_provider(&key, &row)
}
fn update_provider_record_if_unchanged(
state: &AppState,
app_type: &AppType,
observed_fingerprint: String,
input: ProviderMutationInput,
) -> Result<(), AppError> {
reconcile_provider_record_with_precondition(
state.db.as_ref(),
app_type.as_str(),
input,
ReconcilePrecondition::ExpectPresent {
fingerprint: observed_fingerprint,
},
)
}
fn remove_hydrated_endpoints_from_row_update(provider: &mut Provider) {
if let Some(meta) = provider.meta.as_mut() {
meta.custom_endpoints.clear();
}
}
fn lock_additive_provider_mutation(
state: &AppState,
app_type: &AppType,
) -> Option<tokio::sync::OwnedMutexGuard<()>> {
app_type.is_additive_mode().then(|| {
futures::executor::block_on(state.proxy_service.lock_switch_for_app(app_type.as_str()))
})
}
/// Reconcile 的显式前置期望(前置工程 A 认证契约 T9)。
/// check-then-branch 的 TOCTOU 由调用方在观察时声明期望、由本层强制。
#[derive(Debug, Clone)]
pub(crate) enum ReconcilePrecondition {
/// 调用方观察到目标不存在;若已被竞争者创建,必须返回
/// [`AppError::Conflict`],绝不退化为覆盖更新。
ExpectAbsent,
/// 调用方观察到目标存在且内容指纹为 `fingerprint`;指纹过期必须返回
/// [`AppError::Conflict`],由调用方重读重试。
ExpectPresent { fingerprint: String },
}
/// 行内容指纹:并发前置期望的版本标记(纯函数,不含状态列与 endpoint)。
///
/// 决定性要求:仓库启用了 serde_json `preserve_order`,且 `ProviderMeta`
/// 内含 HashMap——直接序列化的键序随机,会产生伪 Conflict。因此必须走
/// 递归排序的规范化哈希;`meta.custom_endpoints` 属 endpoint authority,
/// 不参与内容指纹(不同读 API 对其填充不一致)。
pub(crate) fn provider_row_fingerprint(provider: &crate::provider::Provider) -> String {
provider.row_content_fingerprint()
}
/// Reconcile paths must carry the caller's observed state into the write.
/// Creation is strict, while updates compare the observed row fingerprint and
/// write under one database lock and transaction.
pub(crate) fn reconcile_provider_record_with_precondition(
db: &crate::database::Database,
app_type: &str,
input: ProviderMutationInput,
precondition: ReconcilePrecondition,
) -> Result<(), AppError> {
match precondition {
ReconcilePrecondition::ExpectAbsent => {
db.create_provider(NewProviderAggregate::from_input(app_type, input)?)
}
ReconcilePrecondition::ExpectPresent { fingerprint } => {
let key = ProviderKey::new(app_type, input.id.clone())?;
let row = ProviderRowUpdate::from_input(&input)?;
db.update_provider_if_content_fingerprint(&key, &fingerprint, &row)
}
}
}
/// 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;
#[cfg(any(target_os = "macos", windows))]
use crate::provider::{ClaudeDesktopMode, ClaudeDesktopModelRoute};
use crate::provider::{ProviderMeta, UsageScript};
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::{mpsc, Arc, Mutex, OnceLock};
use std::thread;
use std::time::Duration;
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 pi_provider(id: &str) -> Provider {
Provider {
id: id.to_string(),
name: format!("Pi Provider {id}"),
settings_config: json!({
"name": format!("Pi Provider {id}"),
"api": "openai-responses",
"baseUrl": "https://pi.example/v1",
"apiKey": "test-key",
"models": [
{"id": "model-a", "name": "Model A"},
{"id": "model-b", "name": "Model B"}
]
}),
website_url: None,
category: Some("custom".to_string()),
created_at: Some(1),
sort_index: Some(0),
notes: None,
meta: None,
icon: Some("pi".to_string()),
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}"),
})
}
fn endpoint(url: &str, added_at: Option<i64>, last_used: Option<i64>) -> CustomEndpoint {
CustomEndpoint {
url: url.to_string(),
added_at,
last_used,
}
}
fn provider_snapshot(state: &AppState, app_type: &str, id: &str) -> (Value, i64, i64) {
let aggregate = state
.db
.get_provider_aggregate(app_type, id)
.expect("read aggregate")
.map(|aggregate| serde_json::to_value(aggregate).expect("serialize aggregate"))
.unwrap_or(Value::Null);
let conn = state.db.conn.lock().expect("lock test database");
let state_bits = conn
.query_row(
"SELECT is_current, in_failover_queue
FROM providers
WHERE app_type = ?1 AND id = ?2",
rusqlite::params![app_type, id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.unwrap_or((0, 0));
(aggregate, state_bits.0, state_bits.1)
}
#[test]
#[serial]
fn provider_service_create_owns_initial_endpoints_and_duplicate_is_atomic() {
with_test_home(|state, _| {
let mut provider = opencode_provider("typed-create");
provider.in_failover_queue = true;
let expected_endpoints = HashMap::from([
(
"https://one.example".to_string(),
endpoint("https://one.example", None, Some(11)),
),
(
"https://two.example".to_string(),
endpoint("https://two.example", Some(20), None),
),
]);
provider.meta = Some(ProviderMeta {
custom_endpoints: expected_endpoints.clone(),
..Default::default()
});
let input = provider_to_mutation_input(provider);
ProviderService::add(state, AppType::OpenCode, input.clone(), false)
.expect("strict service create");
let aggregate = state
.db
.get_provider_aggregate("opencode", "typed-create")
.expect("read")
.expect("aggregate");
let hydrated_endpoints = aggregate.endpoints.into_iter().collect::<HashMap<_, _>>();
assert_eq!(
hydrated_endpoints, expected_endpoints,
"the public create entry must hydrate the complete initial endpoint set losslessly"
);
let before = provider_snapshot(state, "opencode", "typed-create");
assert!(
ProviderService::add(state, AppType::OpenCode, input, false).is_err(),
"duplicate create must not reconcile as update"
);
assert_eq!(
provider_snapshot(state, "opencode", "typed-create"),
before,
"row, endpoints, current and failover state remain byte-logically unchanged"
);
});
}
#[test]
#[serial]
fn pi_provider_service_create_hydrates_all_endpoints_and_publishes_native_default() {
with_test_home(|state, home| {
let original_settings = crate::settings::get_settings();
let mut isolated_settings = original_settings.clone();
isolated_settings.pi_config_dir = Some(
home.join(".pi")
.join("agent")
.to_string_lossy()
.into_owned(),
);
isolated_settings.current_provider_pi = None;
crate::settings::update_settings(isolated_settings)
.expect("install isolated Pi settings");
let outcome = (|| -> Result<_, AppError> {
let expected_endpoints = HashMap::from([
(
"https://one.pi.example".to_string(),
endpoint("https://one.pi.example", None, Some(11)),
),
(
"https://two.pi.example".to_string(),
endpoint("https://two.pi.example", Some(20), None),
),
]);
let mut provider = pi_provider("managed-pi");
provider.meta = Some(ProviderMeta {
custom_endpoints: expected_endpoints.clone(),
..Default::default()
});
ProviderService::add(
state,
AppType::Pi,
provider_to_mutation_input(provider),
false,
)?;
let aggregate = state
.db
.get_provider_aggregate("pi", "managed-pi")?
.ok_or_else(|| AppError::NotFound("managed Pi aggregate".to_string()))?;
let hydrated = aggregate.endpoints.into_iter().collect::<HashMap<_, _>>();
let models: Value = serde_json::from_slice(
&fs::read(home.join(".pi/agent/models.json"))
.map_err(|error| AppError::io(home, error))?,
)
.map_err(|error| AppError::json(home, error))?;
let defaults = crate::pi_config::native_settings::read_pi_native_defaults()?;
Ok((expected_endpoints, hydrated, models, defaults))
})();
crate::settings::update_settings(original_settings).expect("restore process settings");
let (expected, hydrated, models, defaults) = outcome.expect("Pi service create");
assert_eq!(hydrated, expected);
assert_eq!(
models.pointer("/providers/managed-pi/models/0/id"),
Some(&json!("model-a"))
);
assert_eq!(defaults.default_provider.as_deref(), Some("managed-pi"));
assert_eq!(defaults.default_model.as_deref(), Some("model-a"));
});
}
#[test]
#[serial]
fn pi_provider_service_update_rehomes_a_removed_active_model() {
with_test_home(|state, home| {
let original_settings = crate::settings::get_settings();
let mut isolated_settings = original_settings.clone();
isolated_settings.pi_config_dir = Some(
home.join(".pi")
.join("agent")
.to_string_lossy()
.into_owned(),
);
isolated_settings.current_provider_pi = None;
crate::settings::update_settings(isolated_settings)
.expect("install isolated Pi settings");
let outcome = (|| -> Result<_, AppError> {
let provider = pi_provider("managed-pi-update");
ProviderService::add(
state,
AppType::Pi,
provider_to_mutation_input(provider.clone()),
false,
)?;
let mut updated = provider;
updated.settings_config["models"] = json!([{"id": "model-b", "name": "Model B"}]);
ProviderService::update(
state,
AppType::Pi,
Some("managed-pi-update"),
provider_to_mutation_input(updated),
)?;
let defaults = crate::pi_config::native_settings::read_pi_native_defaults()?;
let models: Value = serde_json::from_slice(
&fs::read(home.join(".pi/agent/models.json"))
.map_err(|error| AppError::io(home, error))?,
)
.map_err(|error| AppError::json(home, error))?;
Ok((defaults, models))
})();
crate::settings::update_settings(original_settings).expect("restore process settings");
let (defaults, models) = outcome.expect("Pi service update");
assert_eq!(
defaults.default_provider.as_deref(),
Some("managed-pi-update")
);
assert_eq!(defaults.default_model.as_deref(), Some("model-b"));
assert_eq!(
models.pointer("/providers/managed-pi-update/models"),
Some(&json!([{"id": "model-b", "name": "Model B"}]))
);
});
}
#[test]
#[serial]
fn pi_provider_service_current_follows_the_native_default_after_external_edit() {
with_test_home(|state, home| {
let original_settings = crate::settings::get_settings();
let mut isolated_settings = original_settings.clone();
isolated_settings.pi_config_dir = Some(
home.join(".pi")
.join("agent")
.to_string_lossy()
.into_owned(),
);
isolated_settings.current_provider_pi = None;
crate::settings::update_settings(isolated_settings)
.expect("install isolated Pi settings");
let outcome = (|| -> Result<_, AppError> {
for provider_id in ["native-first", "native-second"] {
ProviderService::add(
state,
AppType::Pi,
provider_to_mutation_input(pi_provider(provider_id)),
false,
)?;
}
assert_eq!(
state.db.get_current_provider("pi")?.as_deref(),
Some("native-first")
);
crate::pi_config::native_settings::set_pi_native_default(
"native-second",
"model-b",
)?;
ProviderService::current(state, AppType::Pi)
})();
crate::settings::update_settings(original_settings).expect("restore process settings");
assert_eq!(
outcome.expect("resolve current Pi provider"),
"native-second",
"the UI current marker must follow Pi's live settings, not a stale DB marker"
);
});
}
#[test]
#[serial]
fn provider_service_create_canonicalizes_initial_endpoint_identity() {
with_test_home(|state, _| {
let raw_url = " https://canonical.example/// ";
let mut provider = opencode_provider("canonical-endpoint");
provider.meta = Some(ProviderMeta {
custom_endpoints: HashMap::from([(
raw_url.to_string(),
endpoint(raw_url, None, Some(11)),
)]),
..Default::default()
});
ProviderService::add(
state,
AppType::OpenCode,
provider_to_mutation_input(provider),
false,
)
.expect("create with a non-canonical initial endpoint");
let aggregate = state
.db
.get_provider_aggregate("opencode", "canonical-endpoint")
.expect("read canonical aggregate")
.expect("canonical aggregate");
assert_eq!(aggregate.endpoints.len(), 1);
assert!(aggregate
.endpoints
.contains_key("https://canonical.example"));
ProviderService::update_endpoint_last_used(
state,
AppType::OpenCode,
"canonical-endpoint",
" https://canonical.example/ ".to_string(),
)
.expect("touch must resolve the same canonical endpoint");
ProviderService::remove_custom_endpoint(
state,
AppType::OpenCode,
"canonical-endpoint",
"https://canonical.example///".to_string(),
)
.expect("remove must resolve the same canonical endpoint");
assert!(state
.db
.get_provider_aggregate("opencode", "canonical-endpoint")
.expect("read after remove")
.expect("provider after remove")
.endpoints
.is_empty());
let mut duplicate = opencode_provider("duplicate-canonical-endpoint");
duplicate.meta = Some(ProviderMeta {
custom_endpoints: HashMap::from([
(
"https://duplicate.example".to_string(),
endpoint("https://duplicate.example", None, None),
),
(
" https://duplicate.example/ ".to_string(),
endpoint(" https://duplicate.example/ ", Some(1), None),
),
]),
..Default::default()
});
assert!(matches!(
ProviderService::add(
state,
AppType::OpenCode,
provider_to_mutation_input(duplicate),
false,
),
Err(AppError::InvalidInput(_))
));
assert!(state
.db
.get_provider_aggregate("opencode", "duplicate-canonical-endpoint")
.expect("read duplicate candidate")
.is_none());
});
}
#[test]
#[serial]
fn provider_service_stale_edit_payload_cannot_overwrite_endpoint_operations() {
with_test_home(|state, _| {
let mut provider = opencode_provider("stale-edit");
provider.meta = Some(ProviderMeta {
custom_endpoints: HashMap::from([
(
"https://remove.example".to_string(),
endpoint("https://remove.example", Some(1), None),
),
(
"https://touch.example".to_string(),
endpoint("https://touch.example", None, None),
),
]),
..Default::default()
});
ProviderService::add(
state,
AppType::OpenCode,
provider_to_mutation_input(provider),
false,
)
.expect("create");
// This is the existing-provider form snapshot: endpoints are
// intentionally absent from the update IPC.
let mut edit = state
.db
.get_provider_by_id("stale-edit", "opencode")
.expect("read")
.expect("provider");
edit.name = "Edited row".to_string();
edit.meta
.get_or_insert_with(Default::default)
.custom_endpoints
.clear();
let stale_row_payload = provider_to_mutation_input(edit);
ProviderService::add_custom_endpoint(
state,
AppType::OpenCode,
"stale-edit",
"https://added.example".to_string(),
)
.expect("concurrent add");
ProviderService::remove_custom_endpoint(
state,
AppType::OpenCode,
"stale-edit",
"https://remove.example".to_string(),
)
.expect("concurrent remove");
ProviderService::update_endpoint_last_used(
state,
AppType::OpenCode,
"stale-edit",
"https://touch.example".to_string(),
)
.expect("concurrent touch");
ProviderService::update(state, AppType::OpenCode, None, stale_row_payload)
.expect("row-only service update");
let aggregate = state
.db
.get_provider_aggregate("opencode", "stale-edit")
.expect("read")
.expect("aggregate");
assert_eq!(aggregate.provider.name, "Edited row");
assert!(!aggregate.endpoints.contains_key("https://remove.example"));
assert!(aggregate.endpoints.contains_key("https://added.example"));
assert!(aggregate.endpoints["https://touch.example"]
.last_used
.is_some());
let mut forbidden = provider_to_mutation_input(aggregate.into_provider());
forbidden
.meta
.get_or_insert_with(Default::default)
.custom_endpoints
.insert(
"https://forbidden.example".to_string(),
endpoint("https://forbidden.example", None, None),
);
let before = provider_snapshot(state, "opencode", "stale-edit");
assert!(
ProviderService::update(state, AppType::OpenCode, None, forbidden).is_err(),
"endpoint-bearing update IPC is rejected"
);
assert_eq!(provider_snapshot(state, "opencode", "stale-edit"), before);
});
}
#[test]
#[serial]
fn provider_service_db_only_rename_matrix_is_atomic_and_lossless() {
with_test_home(|state, _| {
let mut source = opencode_provider("rename-source");
let expected_endpoints = HashMap::from([
(
"https://nullable.example".to_string(),
endpoint("https://nullable.example", None, None),
),
(
"https://timed.example".to_string(),
endpoint("https://timed.example", Some(10), Some(11)),
),
]);
source.meta = Some(ProviderMeta {
custom_endpoints: expected_endpoints.clone(),
..Default::default()
});
ProviderService::add(
state,
AppType::OpenCode,
provider_to_mutation_input(source),
false,
)
.expect("DB-only source");
let mut renamed = opencode_provider("rename-target");
renamed.name = "Renamed".to_string();
ProviderService::update(
state,
AppType::OpenCode,
Some("rename-source"),
provider_to_mutation_input(renamed),
)
.expect("DB-only additive rename");
assert!(state
.db
.get_provider_aggregate("opencode", "rename-source")
.expect("old read")
.is_none());
let renamed = state
.db
.get_provider_aggregate("opencode", "rename-target")
.expect("new read")
.expect("renamed");
let renamed_endpoints = renamed.endpoints.into_iter().collect::<HashMap<_, _>>();
assert_eq!(
renamed_endpoints, expected_endpoints,
"rename preserves every endpoint field, including NULL timestamps"
);
for id in ["conflict-source", "conflict-target"] {
ProviderService::add(
state,
AppType::OpenCode,
provider_to_mutation_input(opencode_provider(id)),
false,
)
.expect("conflict fixture");
}
let source_before = provider_snapshot(state, "opencode", "conflict-source");
let target_before = provider_snapshot(state, "opencode", "conflict-target");
assert!(ProviderService::update(
state,
AppType::OpenCode,
Some("conflict-source"),
provider_to_mutation_input(opencode_provider("conflict-target")),
)
.is_err());
assert_eq!(
provider_snapshot(state, "opencode", "conflict-source"),
source_before
);
assert_eq!(
provider_snapshot(state, "opencode", "conflict-target"),
target_before
);
ProviderService::add(
state,
AppType::OpenCode,
provider_to_mutation_input(opencode_provider("live-source")),
true,
)
.expect("live source");
let live_before = provider_snapshot(state, "opencode", "live-source");
assert!(ProviderService::update(
state,
AppType::OpenCode,
Some("live-source"),
provider_to_mutation_input(opencode_provider("live-target")),
)
.is_err());
assert_eq!(
provider_snapshot(state, "opencode", "live-source"),
live_before
);
ProviderService::add(
state,
AppType::OpenCode,
provider_to_mutation_input(opencode_omo_provider("omo-source", "omo")),
false,
)
.expect("OMO source");
let omo_before = provider_snapshot(state, "opencode", "omo-source");
let mut omo_target = opencode_omo_provider("omo-target", "omo");
omo_target.name = "Forbidden OMO rename".to_string();
assert!(ProviderService::update(
state,
AppType::OpenCode,
Some("omo-source"),
provider_to_mutation_input(omo_target),
)
.is_err());
assert_eq!(
provider_snapshot(state, "opencode", "omo-source"),
omo_before
);
ProviderService::add(
state,
AppType::Hermes,
provider_to_mutation_input(hermes_provider("hermes-source")),
false,
)
.expect("Hermes source");
let hermes_before = provider_snapshot(state, "hermes", "hermes-source");
assert!(ProviderService::update(
state,
AppType::Hermes,
Some("hermes-source"),
provider_to_mutation_input(hermes_provider("hermes-target")),
)
.is_err());
assert_eq!(
provider_snapshot(state, "hermes", "hermes-source"),
hermes_before
);
});
}
#[test]
#[serial]
fn provider_service_rename_fails_closed_for_malformed_additive_live_config() {
with_test_home(|state, home| {
let cases = [
(
AppType::OpenCode,
opencode_provider("malformed-source"),
opencode_provider("malformed-target"),
home.join(".config").join("opencode").join("opencode.json"),
r#"{"provider":{"malformed-source":{"npm":"@ai-sdk/openai-compatible"}"#,
),
(
AppType::OpenClaw,
openclaw_provider("corrupt-source"),
openclaw_provider("corrupt-target"),
home.join(".openclaw").join("openclaw.json"),
r#"{"models":{"providers":{"corrupt-target":{"baseUrl":"https://example.test"}"#,
),
];
for (app_type, source, target, live_path, malformed_live) in cases {
let app_name = app_type.as_str().to_string();
let source_id = source.id.clone();
let target_id = target.id.clone();
ProviderService::add(
state,
app_type.clone(),
provider_to_mutation_input(source),
false,
)
.expect("create DB-only rename source");
fs::create_dir_all(live_path.parent().expect("live config parent"))
.expect("create live config directory");
fs::write(&live_path, malformed_live).expect("write malformed live config");
let source_before = provider_snapshot(state, &app_name, &source_id);
let target_before = provider_snapshot(state, &app_name, &target_id);
let error = ProviderService::update(
state,
app_type,
Some(&source_id),
provider_to_mutation_input(target),
)
.expect_err("rename must fail closed when live identity cannot be inspected");
assert!(
matches!(error, AppError::Config(_)),
"rename should surface the live parse error, got {error:?}"
);
assert_eq!(
provider_snapshot(state, &app_name, &source_id),
source_before,
"source aggregate must remain unchanged"
);
assert_eq!(
provider_snapshot(state, &app_name, &target_id),
target_before,
"target aggregate must remain unchanged"
);
assert_eq!(
fs::read_to_string(&live_path).expect("reread malformed live config"),
malformed_live,
"failed rename must not rewrite the live config"
);
}
});
}
#[test]
#[serial]
fn automated_row_transform_conflicts_instead_of_reverting_a_newer_edit() {
with_test_home(|state, _| {
let provider = opencode_provider("observed-row");
ProviderService::add(
state,
AppType::OpenCode,
provider_to_mutation_input(provider),
false,
)
.expect("create observed provider");
let observed = state
.db
.get_provider_by_id("observed-row", "opencode")
.expect("read observed provider")
.expect("observed provider exists");
let fingerprint = provider_row_fingerprint(&observed);
let mut stale_transform = observed.clone();
stale_transform.notes = Some("automatic transform".to_string());
remove_hydrated_endpoints_from_row_update(&mut stale_transform);
let mut user_edit = observed;
user_edit.name = "Concurrent user edit".to_string();
user_edit
.meta
.get_or_insert_with(Default::default)
.custom_endpoints
.clear();
ProviderService::update(
state,
AppType::OpenCode,
None,
provider_to_mutation_input(user_edit),
)
.expect("persist concurrent user edit");
let error = update_provider_record_if_unchanged(
state,
&AppType::OpenCode,
fingerprint,
provider_to_mutation_input(stale_transform),
)
.expect_err("stale automatic transform must conflict");
assert!(matches!(error, AppError::Conflict(_)));
let saved = state
.db
.get_provider_by_id("observed-row", "opencode")
.expect("read saved provider")
.expect("saved provider exists");
assert_eq!(saved.name, "Concurrent user edit");
assert_eq!(saved.notes, None);
});
}
#[test]
#[serial]
fn rename_waits_for_shared_live_lock_and_rechecks_source_ownership() {
let _test_guard = test_guard();
let _home = TempHome::new();
let db = Arc::new(Database::memory().expect("in-memory database"));
let lock_owner_state = AppState::new(db.clone());
let rename_state = AppState::new(db.clone());
ProviderService::add(
&lock_owner_state,
AppType::OpenCode,
provider_to_mutation_input(opencode_provider("race-source")),
false,
)
.expect("create DB-only source");
let live_guard = futures::executor::block_on(
lock_owner_state
.proxy_service
.lock_switch_for_app(AppType::OpenCode.as_str()),
);
let (started_tx, started_rx) = mpsc::channel();
let rename_thread = thread::spawn(move || {
started_tx.send(()).expect("signal rename start");
ProviderService::update(
&rename_state,
AppType::OpenCode,
Some("race-source"),
provider_to_mutation_input(opencode_provider("race-target")),
)
});
started_rx
.recv_timeout(Duration::from_secs(1))
.expect("rename thread started");
thread::sleep(Duration::from_millis(30));
assert!(
!rename_thread.is_finished(),
"a distinct AppState must share the same per-app live mutation lock"
);
let mut switched = db
.get_provider_by_id("race-source", "opencode")
.expect("read source during simulated switch")
.expect("source exists");
ProviderService::set_provider_live_config_managed(&mut switched, true);
remove_hydrated_endpoints_from_row_update(&mut switched);
let key = ProviderKey::new("opencode", "race-source").expect("source key");
let row = ProviderRowUpdate::from_input(&provider_to_mutation_input(switched))
.expect("marker row");
db.update_provider(&key, &row)
.expect("persist simulated switch marker");
drop(live_guard);
let error = rename_thread
.join()
.expect("join rename thread")
.expect_err("live-managed source must not be renamed");
assert!(
matches!(&error, AppError::Conflict(_) | AppError::Message(_)),
"ownership recheck should reject with a structured conflict or service error: {error}"
);
assert!(db
.get_provider_by_id("race-source", "opencode")
.expect("read source")
.is_some());
assert!(db
.get_provider_by_id("race-target", "opencode")
.expect("read target")
.is_none());
let rename = RenameProvider::from_input(
ProviderKey::new("opencode", "race-source").expect("source key"),
&provider_to_mutation_input(opencode_provider("direct-target")),
)
.expect("build direct rename");
assert!(matches!(
db.rename_db_only_additive_provider(rename),
Err(AppError::Conflict(_))
));
}
#[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_to_mutation_input(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
.reconcile_provider_fixture(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,
provider_to_mutation_input(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,
provider_to_mutation_input(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,
provider_to_mutation_input(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
.reconcile_provider_fixture(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,
provider_to_mutation_input(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_to_mutation_input(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_to_mutation_input(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_gemini_common_config_strips_credentials_keeps_shareable() {
// Gemini 的共享片段会被 deep-merge 回**其它** Gemini 供应商的 env
// (live.rs::apply_common_config_to_settings),因此任何凭据都不得进入片段。
// 之前这里只硬编码跳过 GEMINI_API_KEY/GOOGLE_GEMINI_BASE_URL,而
// GOOGLE_API_KEY 是 provider.rs 认可的一等 Gemini 凭据 → 会泄露到别的供应商。
let settings = json!({
"env": {
"GEMINI_API_KEY": "g-gem",
"GOOGLE_API_KEY": "g-legacy-real-key",
"GOOGLE_GEMINI_BASE_URL": "https://gemini.example",
"GOOGLE_APPLICATION_CREDENTIALS": "/path/creds.json",
"SOME_PROXY_AUTH_TOKEN": "tok-proxy",
// 可共享的非机密配置必须保留
"GEMINI_TIMEOUT_MS": "30000"
}
});
let snippet =
ProviderService::extract_gemini_common_config(&settings).expect("extract should work");
let value: Value = serde_json::from_str(&snippet).expect("snippet is valid JSON");
for leaked in [
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"GOOGLE_APPLICATION_CREDENTIALS",
"SOME_PROXY_AUTH_TOKEN",
] {
assert!(
value.get(leaked).is_none(),
"credential {leaked} must not leak into the shared Gemini snippet"
);
}
assert_eq!(
value.get("GEMINI_TIMEOUT_MS").and_then(|v| v.as_str()),
Some("30000"),
"shareable non-secret config must be preserved"
);
}
/// 造一个「已被污染」的现场:片段里带 A 账号的凭据 + 一个合法可共享键。
#[test]
fn sensitive_key_matcher_covers_common_credential_namings() {
for key in [
// 裸 `_KEY`:最常见的写法,却曾被"只枚举 `_API_KEY` 这些子类"漏在外面
"OPENAI_KEY",
"GROQ_KEY",
"XAI_KEY",
// 不带分隔符的复合写法
"VOLC_ACCESSKEY",
"ALIYUN_SECRETKEY",
"SOME_APITOKEN",
// personal access token:既不含 TOKEN 也不含 KEY
"GITHUB_PAT",
"gitlab_pat",
// 口令类缩写
"MYSQL_PWD",
"DB_PASS",
"GPG_PASSPHRASE",
"AWS_CREDS",
] {
assert!(
ProviderService::is_sensitive_config_key(key),
"{key} must be treated as a credential"
);
}
// 后缀必须带下划线,不能把正常配置一起卷进来
for key in [
"PATH",
"OLDPWD",
"GEMINI_COMPAT",
"SSL_BYPASS",
"GEMINI_TIMEOUT_MS",
"CLAUDE_CODE_MAX_OUTPUT_TOKENS",
] {
assert!(
!ProviderService::is_sensitive_config_key(key),
"{key} is ordinary shareable config and must not be stripped"
);
}
}
fn seed_leaked_gemini_state(db: &Arc<Database>) {
db.set_config_snippet(
"gemini",
Some(
json!({
"GOOGLE_API_KEY": "key-A-leaked",
"SOME_PROXY_AUTH_TOKEN": "tok-A-leaked",
"GEMINI_TIMEOUT_MS": "30000"
})
.to_string(),
),
)
.expect("seed snippet");
// 受害者 B:泄漏的密钥已经被合并进它的 env
let victim = Provider::with_id(
"b".into(),
"Relay B".into(),
json!({ "env": {
"GOOGLE_GEMINI_BASE_URL": "https://relay-b.example",
"GOOGLE_API_KEY": "key-A-leaked",
"GEMINI_TIMEOUT_MS": "30000"
}}),
None,
);
db.reconcile_provider_fixture("gemini", &victim)
.expect("save victim");
// 供应商 C:自己写了同名键但值不同,不能被误删
let unrelated = Provider::with_id(
"c".into(),
"Own Key C".into(),
json!({ "env": {
"GOOGLE_GEMINI_BASE_URL": "https://c.example",
"GOOGLE_API_KEY": "key-C-owned"
}}),
None,
);
db.reconcile_provider_fixture("gemini", &unrelated)
.expect("save c");
}
#[tokio::test]
#[serial]
async fn scrub_gemini_removes_leaked_credentials_from_snippet_and_providers() {
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());
seed_leaked_gemini_state(&db);
ProviderService::scrub_leaked_gemini_common_config(&state)
.await
.expect("scrub must succeed");
// 片段:凭据清掉,可共享配置保留
let snippet = db
.get_config_snippet("gemini")
.expect("read snippet")
.expect("snippet must still exist");
let snippet: Value = serde_json::from_str(&snippet).expect("valid json");
assert!(snippet.get("GOOGLE_API_KEY").is_none());
assert!(snippet.get("SOME_PROXY_AUTH_TOKEN").is_none());
assert_eq!(
snippet.get("GEMINI_TIMEOUT_MS").and_then(Value::as_str),
Some("30000"),
"shareable config must survive the scrub"
);
// 受害者 B:扩散过去的那一份被清掉
let providers = db.get_all_providers("gemini").expect("providers");
let victim_env = &providers["b"].settings_config["env"];
assert!(
victim_env.get("GOOGLE_API_KEY").is_none(),
"leaked key must be removed from the victim provider"
);
assert_eq!(
victim_env.get("GEMINI_TIMEOUT_MS").and_then(Value::as_str),
Some("30000"),
"non-credential config must not be touched"
);
}
#[tokio::test]
#[serial]
async fn scrub_gemini_keeps_a_providers_own_differently_valued_key() {
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());
seed_leaked_gemini_state(&db);
ProviderService::scrub_leaked_gemini_common_config(&state)
.await
.expect("scrub must succeed");
// 这条最容易写错成「按键名一刀切」:C 自己的密钥值与片段不同,是它自己的凭据
let providers = db.get_all_providers("gemini").expect("providers");
assert_eq!(
providers["c"].settings_config["env"]
.get("GOOGLE_API_KEY")
.and_then(Value::as_str),
Some("key-C-owned"),
"a provider's own key must not be deleted by name matching"
);
}
#[tokio::test]
#[serial]
async fn scrub_gemini_audit_records_key_names_but_never_values() {
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());
seed_leaked_gemini_state(&db);
ProviderService::scrub_leaked_gemini_common_config(&state)
.await
.expect("scrub must succeed");
let audit_text = db
.get_setting("gemini_common_config_scrub_audit_v1")
.expect("read audit")
.expect("an audit record must exist so the deletion is not silent");
// 值绝不能进这条记录:`settings` 会随 WebDAV/S3 同步上传,留值等于把一次
// 清除换成一份跨设备扩散、没有界面入口、永不过期的明文副本。
assert!(
!audit_text.contains("key-A-leaked") && !audit_text.contains("tok-A-leaked"),
"the audit record must never carry credential values: {audit_text}"
);
// 但必须说清楚删了什么、从哪删的,否则用户只能靠翻日志
let audit: Value = serde_json::from_str(&audit_text).expect("audit is JSON");
let removed: Vec<&str> = audit["removedFromSnippet"]
.as_array()
.expect("removedFromSnippet array")
.iter()
.filter_map(Value::as_str)
.collect();
assert!(
removed.contains(&"GOOGLE_API_KEY") && removed.contains(&"SOME_PROXY_AUTH_TOKEN"),
"every key removed from the snippet must be named: {audit}"
);
let victim = audit["providers"]
.as_array()
.expect("providers array")
.iter()
.find(|entry| entry["id"] == json!("b"))
.expect("every provider whose config gets rewritten must be recorded");
assert_eq!(
victim["removedKeys"],
json!(["GOOGLE_API_KEY"]),
"the record must name what was taken from each provider: {audit}"
);
}
#[tokio::test]
#[serial]
async fn scrub_gemini_never_overwrites_an_existing_audit_record() {
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());
seed_leaked_gemini_state(&db);
// 上一轮改到一半就中止的情形:完成标记没置位,下次启动会重跑,但那时
// 读到的"原始状态"已经残缺。无条件覆盖会拿残缺记录盖掉第一轮那份完整的。
db.set_setting(
"gemini_common_config_scrub_audit_v1",
"{\"from\":\"an earlier, complete run\"}",
)
.expect("seed an existing audit record");
ProviderService::scrub_leaked_gemini_common_config(&state)
.await
.expect("scrub must succeed");
assert_eq!(
db.get_setting("gemini_common_config_scrub_audit_v1")
.expect("read audit")
.as_deref(),
Some("{\"from\":\"an earlier, complete run\"}"),
"an audit record from an earlier run must survive a retry"
);
}
#[tokio::test]
#[serial]
async fn scrub_gemini_cleans_the_live_env_without_a_current_provider() {
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());
seed_leaked_gemini_state(&db);
// 没有当前供应商——这正是 sync_current_provider_for_app 直接返回 Ok 而
// 根本不写文件的分支。此时 live 若清不掉,片段又已被清空,下次切换的
// backfill 就会把残留永久写进受害供应商的配置。
crate::gemini_config::write_gemini_env_atomic(&HashMap::from([
("GOOGLE_API_KEY".to_string(), "key-A-leaked".to_string()),
("GEMINI_TIMEOUT_MS".to_string(), "30000".to_string()),
// 只存在于 live 的手工修改:定向删除必须保住它,全量重投影会抹掉
(
"HTTPS_PROXY".to_string(),
"http://127.0.0.1:7890".to_string(),
),
]))
.expect("seed live env");
ProviderService::scrub_leaked_gemini_common_config(&state)
.await
.expect("scrub must succeed");
let live = crate::gemini_config::read_gemini_env().expect("read live env");
assert!(
!live.contains_key("GOOGLE_API_KEY"),
"the leaked credential must be gone from ~/.gemini/.env: {live:?}"
);
assert_eq!(
live.get("HTTPS_PROXY").map(String::as_str),
Some("http://127.0.0.1:7890"),
"a hand-added live-only var must survive targeted removal: {live:?}"
);
}
#[tokio::test]
#[serial]
async fn scrub_gemini_live_cleanup_preserves_the_rest_of_the_env_file() {
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());
seed_leaked_gemini_state(&db);
// 这是一次用户没主动触发的启动期清理,不该顺手重写与泄漏无关的内容。
// read→HashMap→write 的往返会把注释、空行、无法识别的行全丢掉并按键名重排。
let original = "\
# my own notes
GOOGLE_API_KEY=key-C-owned
GOOGLE_API_KEY=key-A-leaked
this line is not KEY=VALUE at all
GEMINI_TIMEOUT_MS=30000
";
crate::gemini_config::write_gemini_env_text_atomic(original).expect("seed live env");
ProviderService::scrub_leaked_gemini_common_config(&state)
.await
.expect("scrub must succeed");
let raw = std::fs::read_to_string(crate::gemini_config::get_gemini_env_path())
.expect("read live env");
assert!(
!raw.contains("key-A-leaked"),
"the leaked line must be gone: {raw:?}"
);
assert!(
raw.contains("# my own notes"),
"comments must survive a targeted removal: {raw:?}"
);
assert!(
raw.contains("this line is not KEY=VALUE at all"),
"unparseable lines must survive a targeted removal: {raw:?}"
);
// 被泄漏值遮住的那条重新生效——正是想要的结果,遮住它的恰恰是泄漏值
assert_eq!(
crate::gemini_config::read_gemini_env()
.expect("read live env")
.get("GOOGLE_API_KEY")
.map(String::as_str),
Some("key-C-owned"),
"only the matching line may be dropped: {raw:?}"
);
}
#[tokio::test]
#[serial]
async fn scrub_gemini_aborts_before_clearing_the_snippet_when_the_live_backup_fails() {
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());
seed_leaked_gemini_state(&db);
// 关代理时这份快照会被原样写回 live。若清不动它却照样清了片段、置了完成标记,
// 代理一停凭据就复活,而一次性标记保证不会再清第二次。
db.save_live_backup("gemini", "}not json{")
.await
.expect("seed backup");
let result = ProviderService::scrub_leaked_gemini_common_config(&state).await;
assert!(
result.is_err(),
"a backup that cannot be cleaned must abort the scrub"
);
// 片段是「该剥哪些键」的唯一知识来源,中止后必须原样留着,否则下次重试
// 会因为 poison 为空而直接短路,反倒把标记置上
let snippet = db
.get_config_snippet("gemini")
.expect("read snippet")
.expect("snippet must still exist");
assert!(
snippet.contains("key-A-leaked"),
"the snippet must be left intact so the next boot can retry: {snippet}"
);
assert!(
db.get_setting("gemini_common_config_credentials_scrubbed_v1")
.expect("read flag")
.is_none(),
"the one-shot flag must not be set when the scrub aborted"
);
}
#[tokio::test]
#[serial]
async fn scrub_gemini_leaves_no_residue_for_backfill_to_persist() {
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());
seed_leaked_gemini_state(&db);
ProviderService::scrub_leaked_gemini_common_config(&state)
.await
.expect("scrub must succeed");
// 顺序陷阱回归:如果只清了片段,切走供应商时 remove_common_config_from_settings
// 就不再认识这个键,live 里的残留会被 backfill 永久写进供应商配置。
// 清理必须是原子的——清完之后,任何地方都不该再有那个值。
let snippet = db
.get_config_snippet("gemini")
.expect("read snippet")
.unwrap_or_default();
assert!(!snippet.contains("key-A-leaked"));
for (id, provider) in db.get_all_providers("gemini").expect("providers") {
assert!(
!provider
.settings_config
.to_string()
.contains("key-A-leaked"),
"provider '{id}' still carries the leaked value"
);
}
}
#[tokio::test]
#[serial]
async fn scrub_gemini_is_idempotent_and_skips_on_second_run() {
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());
seed_leaked_gemini_state(&db);
ProviderService::scrub_leaked_gemini_common_config(&state)
.await
.expect("first run");
// 第二次必须是 no-op:用户清理后重新填的凭据不能被再抹一遍
db.set_config_snippet(
"gemini",
Some(json!({"GOOGLE_API_KEY": "restored"}).to_string()),
)
.expect("user re-adds a value");
ProviderService::scrub_leaked_gemini_common_config(&state)
.await
.expect("second run");
let snippet = db
.get_config_snippet("gemini")
.expect("read snippet")
.expect("snippet exists");
assert!(
snippet.contains("restored"),
"the one-shot flag must prevent a second scrub: {snippet}"
);
}
#[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.reconcile_provider_fixture("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,
provider_to_mutation_input(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.reconcile_provider_fixture("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,
provider_to_mutation_input(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,
provider_to_mutation_input(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.reconcile_provider_fixture("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,
provider_to_mutation_input(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"),
provider_to_mutation_input(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_to_mutation_input(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,
provider_to_mutation_input(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_to_mutation_input(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_to_mutation_input(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
.reconcile_provider_fixture(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
.reconcile_provider_fixture(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
.reconcile_provider_fixture(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
.reconcile_provider_fixture(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 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
.reconcile_provider_fixture(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
.reconcile_provider_fixture(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
.reconcile_provider_fixture(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
.reconcile_provider_fixture(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,
provider_to_mutation_input(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
.reconcile_provider_fixture(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,
provider_to_mutation_input(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
.reconcile_provider_fixture(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,
provider_to_mutation_input(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
.reconcile_provider_fixture(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,
provider_to_mutation_input(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
.reconcile_provider_fixture(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,
provider_to_mutation_input(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 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 persist_live_config_managed(
state: &AppState,
app_type: &AppType,
provider_id: &str,
managed: bool,
) -> Result<(), AppError> {
// This is a narrow metadata transformation, so a concurrent content
// edit can be preserved by rereading and reapplying it. Every attempt
// still uses the single-lock/single-transaction fingerprint primitive.
for attempt in 0..3 {
let mut provider = state
.db
.get_provider_aggregate(app_type.as_str(), provider_id)?
.ok_or_else(|| {
AppError::NotFound(format!("provider '{}/{}'", app_type.as_str(), provider_id))
})?
.provider;
if Self::provider_live_config_managed(&provider) == Some(managed) {
return Ok(());
}
let fingerprint = provider_row_fingerprint(&provider);
Self::set_provider_live_config_managed(&mut provider, managed);
remove_hydrated_endpoints_from_row_update(&mut provider);
let input = provider_to_mutation_input(provider);
match update_provider_record_if_unchanged(state, app_type, fingerprint, input) {
Err(AppError::Conflict(_)) if attempt < 2 => continue,
result => return result,
}
}
unreachable!("bounded live-config marker retry always returns")
}
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, &current_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, &current_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(&current_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(&current_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());
}
if matches!(app_type, AppType::Pi) {
return PiCatalogCoordinator::current_native_provider(state)
.map(|provider| provider.unwrap_or_default());
}
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,
input: ProviderMutationInput,
add_to_live: bool,
) -> Result<bool, AppError> {
if matches!(app_type, AppType::Pi) {
let provider_key = input.id.clone();
PiCatalogCoordinator::apply(
state,
PiCatalogMutation::CreateProvider {
input,
provider_key,
activate_if_first: true,
},
)?;
return Ok(true);
}
let _provider_mutation_guard = lock_additive_provider_mutation(state, &app_type);
let mut provider: Provider = input.into();
// 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);
}
// Strict create owns both the provider row and initial endpoints.
create_provider_record(
state,
&app_type,
provider_to_mutation_input(provider.clone()),
)?;
// 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(state.db.as_ref(), &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() {
// No current provider, set as current and sync
state
.db
.set_current_provider(app_type.as_str(), &provider.id)?;
write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;
}
Ok(true)
}
/// Update a provider
pub fn update(
state: &AppState,
app_type: AppType,
original_id: Option<&str>,
input: ProviderMutationInput,
) -> Result<bool, AppError> {
// Reject endpoint-bearing edit payloads before any live or DB side
// effect. Endpoints have their own typed mutation API.
ProviderRowUpdate::from_input(&input)?;
if matches!(app_type, AppType::Pi) {
let original_id = original_id.unwrap_or(input.id.as_str());
if original_id != input.id {
return Err(AppError::InvalidInput(
"Pi provider identity and native projection key cannot be renamed".to_string(),
));
}
PiCatalogCoordinator::apply(state, PiCatalogMutation::UpdateProvider { input })?;
return Ok(true);
}
let _provider_mutation_guard = lock_additive_provider_mutation(state, &app_type);
let mut provider: Provider = input.into();
let original_id = original_id.unwrap_or(provider.id.as_str()).to_string();
let provider_id_changed = original_id != provider.id;
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(),
));
}
// A rename changes durable identity, so "cannot inspect live
// config" must never be treated as "not live". DB-only
// same-ID edits deliberately retain their tolerant path below,
// but rename proves both identities absent from a readable live
// config before committing the SQLite transaction.
let original_in_live = provider_exists_in_live_config(&app_type, &original_id)?;
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 = provider_exists_in_live_config(&app_type, &provider.id)?;
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);
let source = ProviderKey::new(app_type.as_str(), original_id.clone())?;
state
.db
.rename_db_only_additive_provider(RenameProvider::from_input(
source,
&provider_to_mutation_input(provider.clone()),
)?)?;
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) = update_provider_record(
state,
&app_type,
&provider_to_mutation_input(provider.clone()),
) {
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.
update_provider_record(
state,
&app_type,
&provider_to_mutation_input(provider.clone()),
)?;
if !live_config_managed {
return Ok(true);
}
write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;
return Ok(true);
}
// Save to database
update_provider_record(
state,
&app_type,
&provider_to_mutation_input(provider.clone()),
)?;
// 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());
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(state.db.as_ref(), &app_type, &provider)?;
} else {
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}")))?;
}
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(state.db.as_ref(), &app_type, &provider)?;
// 重写 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> {
if matches!(app_type, AppType::Pi) {
PiCatalogCoordinator::apply(
state,
PiCatalogMutation::DeleteProvider {
provider_id: id.to_string(),
},
)?;
return Ok(());
}
let _provider_mutation_guard = lock_additive_provider_mutation(state, &app_type);
// 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> {
let _provider_mutation_guard = lock_additive_provider_mutation(state, &app_type);
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 state
.db
.get_provider_aggregate(app_type.as_str(), id)?
.is_some()
{
Self::persist_live_config_managed(state, &app_type, id, false)?;
}
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> {
if matches!(app_type, AppType::Pi) {
let provider = state
.db
.get_provider_aggregate(AppType::Pi.as_str(), id)?
.ok_or_else(|| AppError::NotFound(format!("Pi provider '{id}'")))?;
let config: crate::pi_config::model::PiManagedProviderConfig =
serde_json::from_value(provider.provider.settings_config).map_err(|error| {
AppError::Config(format!("managed Pi provider '{id}' is invalid: {error}"))
})?;
let model_id = config
.models
.first()
.ok_or_else(|| {
AppError::InvalidInput(format!("Pi provider '{id}' has no selectable models"))
})?
.id
.clone();
PiCatalogCoordinator::apply(
state,
PiCatalogMutation::SetDefault {
provider_id: id.to_string(),
model_id,
},
)?;
return Ok(SwitchResult::default());
}
// The same per-app lock also guards additive provider key changes and
// bulk live sync. Acquire it before observing the provider map so a
// queued rename cannot leave this switch holding a stale source key.
let _switch_guard = if app_type.is_additive_mode()
|| 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
};
// 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);
}
// 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)?;
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) = state
.db
.get_provider_aggregate(app_type.as_str(), &current_id)?
.map(|aggregate| aggregate.provider)
{
let fingerprint = provider_row_fingerprint(&current_provider);
// 切走前先把 live 里的可共享改动(含用户直接在应用内
// 装插件/加 hook/改偏好)同步进通用配置片段,再做剥离回填。
// 详见 sync_common_config_snippet_from_live 的文档。
Self::sync_common_config_snippet_from_live(
state,
&app_type,
&current_provider,
&live_config,
&mut result,
);
current_provider.settings_config =
strip_common_config_from_live_settings(
state.db.as_ref(),
&app_type,
&current_provider,
live_config,
);
remove_hydrated_endpoints_from_row_update(&mut current_provider);
if let Err(e) = update_provider_record_if_unchanged(
state,
&app_type,
fingerprint,
provider_to_mutation_input(current_provider),
) {
log::warn!("Backfill failed: {e}");
result
.warnings
.push(format!("backfill_failed:{current_id}"));
}
}
}
}
}
}
// Additive mode apps skip setting is_current (no such concept)
if !app_type.is_additive_mode() {
// Update local settings (device-level, takes priority)
crate::settings::set_current_provider(&app_type, Some(id))?;
// Update database is_current (as default for new devices)
state.db.set_current_provider(app_type.as_str(), id)?;
}
// Sync to live (write_gemini_live handles security flag internally for Gemini)
write_live_with_common_config(state.db.as_ref(), &app_type, provider)?;
// 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)
{
if let Err(e) = Self::persist_live_config_managed(state, &app_type, &provider.id, true)
{
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> {
let _provider_mutation_guard = lock_additive_provider_mutation(state, &app_type);
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(&current_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(state.db.as_ref(), &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_provider_aggregates(app_type.as_str())?;
for aggregate in providers.values() {
let provider = &aggregate.provider;
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 fingerprint = provider_row_fingerprint(provider);
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
);
}
}
remove_hydrated_endpoints_from_row_update(&mut updated_provider);
update_provider_record_if_unchanged(
state,
&app_type,
fingerprint,
provider_to_mutation_input(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(&current_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
AppType::Pi => Ok(String::new()), // Pi owns a shared exact-key catalog, not 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
AppType::Pi => Ok(String::new()),
}
}
/// 判断一个 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 /
/// 私钥命名。
pub(crate) 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] = &[
// 裸 `_KEY` 是最常见的凭据写法(OPENAI_KEY / GROQ_KEY / XAI_KEY…),
// 必须单列:只枚举 `_API_KEY` / `_ACCESS_KEY` 这些子类,等于把最普通
// 的那一种漏在外面。下面几条 `_*_KEY` 被它蕴含,保留是为了说明覆盖面。
"_KEY",
"_API_KEY",
"_ACCESS_KEY",
"_ACCESS_KEY_ID",
"_KEY_ID",
"_PRIVATE_KEY",
// 不带分隔符的复合写法各走各的后缀:`_KEY` 够不着 `..._APIKEY`
// (倒数第四个字符是 I 不是下划线)。VOLC_ACCESSKEY 是火山引擎文档
// 里的正式变量名,本仓库就实现了火山 AK/SK 用量查询。
"_APIKEY",
"_ACCESSKEY",
"_SECRETKEY",
"_APITOKEN",
"_AUTH_TOKEN",
"_TOKEN",
// GITHUB_PAT / GITLAB_PAT 等 personal access token 的惯用写法,
// 既不含 TOKEN 也不含 KEY,前面每一条规则都够不着。
"_PAT",
// 口令类的常见缩写。`_PASS` 不会误伤 `*_BYPASS`(那个以 `_BYPASS`
// 结尾),`_PWD` 也不会误伤 shell 的 PWD / OLDPWD。
"_PWD",
"_PASS",
"_PASSPHRASE",
"_CREDS",
];
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 {
// 端点按名剥离(它不是凭据,模式匹配够不着);凭据全部交给
// `is_sensitive_config_key` 统一模式匹配(与 Claude 提取器一致)。
// 只列固定名单会漏掉下一个 `*_API_KEY` —— 例如 `GOOGLE_API_KEY`
// provider.rs 认可的一等 Gemini 凭据),而共享片段会被 deep-merge
// 回其它 Gemini 供应商,漏剥即等于把 A 账号的密钥写进 B 供应商并
// 发往 B 的 base_url。`GEMINI_API_KEY` 不必单列:`_KEY` 后缀已覆盖。
if key == "GOOGLE_GEMINI_BASE_URL" || Self::is_sensitive_config_key(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}")))
}
/// 一次性清理:把历史泄漏进 Gemini 共享片段的凭据从所有存储位置抹掉。
///
/// 背景:`extract_gemini_common_config` 曾只剥离两个固定键名,`GOOGLE_API_KEY`
/// 等一等凭据会进入共享片段,再被 `apply_common_config_to_settings` 深合并进
/// **其它** Gemini 供应商的 env,随请求发往对方的 base_url。
///
/// 光修提取器不够:Gemini 的片段一旦生成就**永不自动重提取**(启动期
/// auto-extract 与导入后补提取都要求 `snippet.is_none()`,切换时的回写又只对
/// Claude / Codex 生效),所以存量片段会一直带着密钥继续注入。
///
/// 两个关键约束:
///
/// 1. **不能只清片段**。合并与剥离是一对靠「值相等」严格抵消的操作:切走供应商时
/// `remove_common_config_from_settings` 依据片段内容把注入的键删掉。片段里一旦
/// 没了这个键,backfill 就会把 live 中残留的密钥原样写进受害供应商的
/// `settings_config`——泄漏从瞬时污染变成永久污染。所以片段、各供应商配置、
/// live 文件必须一起清。
/// 2. **按值相等定向删除,不按键名一刀切**。复用 `remove_common_config_from_settings`
/// 可以只清掉扩散出去的那一份,保留某个供应商自己写的、值不同的同名键。
///
/// 步骤顺序本身是安全属性的一部分:**清片段必须排在最后**。片段是
/// `remove_common_config_from_settings` 唯一的"该剥哪些键"来源,一旦清空,任何
/// 残留(live 文件里的、下一轮重试要处理的)都再也无法被识别和剥离。所以所有
/// 可能失败的步骤都排在它前面,失败即带错返回,让下次启动能原样重来。
///
/// 清理后部分供应商会显示缺少 API Key,需用户重填——这是正确行为:那把密钥本就
/// 不属于它们。(受害者原有的同名键在合并时已被覆盖,无法恢复。)动手前会往
/// settings 的 `gemini_common_config_scrub_audit_v1` 写一条审计记录,内容是
/// **键名与受影响的供应商 id,不含值**`settings` 会随 WebDAV/S3 同步上传,
/// 而这里处理的正是必须销毁的凭据,留值等于把一次清除换成一份跨设备扩散、
/// 没有界面入口、永不过期的明文副本。
pub async fn scrub_leaked_gemini_common_config(state: &AppState) -> Result<(), AppError> {
const FLAG: &str = "gemini_common_config_credentials_scrubbed_v1";
const AUDIT_KEY: &str = "gemini_common_config_scrub_audit_v1";
let app = AppType::Gemini;
if state.db.get_bool_flag(FLAG).unwrap_or(false) {
return Ok(());
}
let Some(snippet_text) = state.db.get_config_snippet(app.as_str())? else {
state.db.set_setting(FLAG, "true")?;
return Ok(());
};
// 片段解析不了就不动它,只标记完成——乱改用户数据比留着更糟
let Ok(Value::Object(entries)) = serde_json::from_str::<Value>(&snippet_text) else {
state.db.set_setting(FLAG, "true")?;
return Ok(());
};
let mut poison = serde_json::Map::new();
let mut clean = serde_json::Map::new();
for (key, value) in entries {
if Self::is_sensitive_config_key(&key) {
poison.insert(key, value);
} else {
clean.insert(key, value);
}
}
if poison.is_empty() {
state.db.set_setting(FLAG, "true")?;
return Ok(());
}
log::warn!(
"检测到 {} 个凭据键残留在 Gemini 通用配置片段中,开始一次性清理",
poison.len()
);
let poison_keys: Vec<String> = poison.keys().cloned().collect();
let poison_value = Value::Object(poison);
let poison_text = serde_json::to_string(&poison_value)
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))?;
// 1) 先算出各供应商清理后的配置,但**先不落库**
let providers = state.db.get_all_provider_aggregates(app.as_str())?;
let mut pending: Vec<(String, Provider, Value, String)> = Vec::new();
for (id, aggregate) in providers {
let provider = aggregate.provider;
let fingerprint = provider_row_fingerprint(&provider);
let cleaned = match live::remove_common_config_from_settings(
&app,
&provider.settings_config,
&poison_text,
) {
Ok(cleaned) => cleaned,
Err(err) => {
log::warn!("清理供应商 '{id}' 的泄漏凭据失败: {err}");
continue;
}
};
if cleaned != provider.settings_config {
pending.push((id, provider, cleaned, fingerprint));
}
}
// 2) 落库前留一份审计记录:**只记键名与受影响的供应商,不记值**。
//
// 「按值相等定向删除」在一种合法场景下也会命中:用户有意在多个供应商里
// 复用同一把 key。所以必须留下"删了什么、从哪删的",否则用户只能靠翻
// 日志。但不能留值——`settings` 表不在 `SYNC_SKIP_TABLES` 里,会随
// WebDAV/S3 同步上传,而这里处理的恰恰是必须销毁的泄漏凭据:留值等于
// 把一次清除换成一份没有界面入口、永不过期、还会跨设备扩散的明文副本。
// 密钥本来就该轮换,可恢复性不值这个代价。
let removed_env_keys = |before: &Value, after: &Value| -> Vec<String> {
let before_env = before.get("env").and_then(Value::as_object);
let after_env = after.get("env").and_then(Value::as_object);
match (before_env, after_env) {
(Some(before_env), Some(after_env)) => before_env
.keys()
.filter(|key| !after_env.contains_key(*key))
.cloned()
.collect(),
(Some(before_env), None) => before_env.keys().cloned().collect(),
_ => Vec::new(),
}
};
let audit = serde_json::json!({
"removedFromSnippet": poison_keys,
"providers": pending
.iter()
.map(|(id, provider, cleaned, _)| serde_json::json!({
"id": id,
"removedKeys": removed_env_keys(&provider.settings_config, cleaned),
}))
.collect::<Vec<_>>(),
});
let audit_text = serde_json::to_string(&audit)
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))?;
// 只在没有记录时写。provider 的写入不是一个事务(每次类型化行更新各自
// 提交),上一轮可能改到一半就中止;此时完成标记没置位,下次启动会重跑,
// 而重跑看到的"原始状态"已经残缺。无条件 INSERT OR REPLACE 会拿这份残缺
// 记录盖掉第一轮那份完整的。
if state.db.get_setting(AUDIT_KEY)?.is_none() {
state.db.set_setting(AUDIT_KEY, &audit_text)?;
}
// 3) 各供应商 settings_config:按值相等定向删除扩散出去的副本
for (id, mut provider, cleaned, fingerprint) in pending {
provider.settings_config = cleaned;
remove_hydrated_endpoints_from_row_update(&mut provider);
let update = provider_to_mutation_input(provider);
update_provider_record_if_unchanged(state, &app, fingerprint, update)?;
log::info!("已从 Gemini 供应商 '{id}' 中清除泄漏的共享凭据");
}
// 4) 代理接管中的 live 快照里也可能有一份副本。这一步的失败**必须传播**:
//
// 关代理时 `restore_live_config_for_app_with_fallback_inner`proxy.rs:869
// 会把这份快照原样写回 `~/.gemini/.env`。若它仍带毒而我们照样清了片段、置了
// 完成标记,那么代理一停凭据就当场复活,而一次性标记又保证不会再清第二次;
// 此后片段里已没有这个键,下一次切换的 backfill 就把它永久写进受害供应商的
// 配置——还是本函数开头那个顺序陷阱,只是换了扇门进来。
//
// 带错返回是安全的失败方式:调用方(lib.rs:1189)只记 warn 不中断启动,
// 片段和标记都原样留着,下次启动照原样重来。
if let Some(backup) = state.db.get_live_backup(app.as_str()).await? {
let original: Value = serde_json::from_str(&backup.original_config)
.map_err(|e| AppError::Message(format!("解析 Gemini 代理接管备份失败: {e}")))?;
let cleaned = live::remove_common_config_from_settings(&app, &original, &poison_text)?;
if cleaned != original {
let text = serde_json::to_string(&cleaned)
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))?;
state.db.save_live_backup(app.as_str(), &text).await?;
log::info!("已从 Gemini 代理接管备份中清除泄漏的共享凭据");
}
}
// 5) `~/.gemini/.env`**定向**删除,且必须在清片段之前做,失败即中止。
//
// 为什么不用 `sync_current_provider_for_app` 重投影:它在没有当前供应商
// 时直接返回 Ok 而根本不写文件,泄漏值会原样留在 live 里;等片段被清空
// 之后,下次切换时 `remove_common_config_from_settings` 再也认不出这个
// 键,backfill 就把它永久写进受害供应商的配置——正是本函数开头说的那个
// 顺序陷阱,只是由"没修"变成"修了一半更糟"。定向删除还顺带保住了只存在
// 于 live、与供应商无关的手工 env(重投影会把它们抹掉)。
//
// 删除走 `remove_gemini_env_entries` 的**保序**实现而不是 read→HashMap→
// write 往返:后者会顺手抹掉注释、空行和无法识别的行,并按键名重排整个
// 文件。全量投影时那无所谓,但这里是一次用户没主动触发的启动期清理,不该
// 连带改写与泄漏无关的内容。
//
// 失败就带着错误返回:片段此刻还留着毒键,完成标记也没置位,下次启动能
// 照原样重来。清片段是不可逆的一步,必须排在所有会失败的步骤之后。
let poison_env: HashMap<String, String> = poison_value
.as_object()
.map(|map| {
map.iter()
.filter_map(|(key, value)| {
value.as_str().map(|text| (key.clone(), text.to_string()))
})
.collect()
})
.unwrap_or_default();
if crate::gemini_config::remove_gemini_env_entries(&poison_env)? {
log::info!("已从 ~/.gemini/.env 中清除泄漏的共享凭据");
}
// 6) 片段本身:保留可共享的部分。全部清空时删行而不是写 "{}"——留着空行会让
// should_auto_extract_config_snippet 永远为 false,用户的合法共享配置再也
// 重建不回来。同理绝不置 cleared 标记。
if clean.is_empty() {
state.db.set_config_snippet(app.as_str(), None)?;
} else {
let cleaned_snippet = serde_json::to_string_pretty(&Value::Object(clean))
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))?;
state
.db
.set_config_snippet(app.as_str(), Some(cleaned_snippet))?;
}
state.db.set_setting(FLAG, "true")?;
log::info!("Gemini 通用配置凭据清理完成");
Ok(())
}
/// 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> {
if matches!(app_type, AppType::Pi) {
PiCatalogCoordinator::apply(
state,
PiCatalogMutation::AddEndpoint {
provider_id: provider_id.to_string(),
url,
},
)?;
return Ok(());
}
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> {
if matches!(app_type, AppType::Pi) {
PiCatalogCoordinator::apply(
state,
PiCatalogMutation::RemoveEndpoint {
provider_id: provider_id.to_string(),
url,
},
)?;
return Ok(());
}
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> {
let _pi_switch_guard = matches!(app_type, AppType::Pi).then(|| {
futures::executor::block_on(
state
.proxy_service
.lock_switch_for_app(AppType::Pi.as_str()),
)
});
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> {
// Validate the whole payload before opening Pi's odd catalog epoch.
// Returning early with an unclosed epoch would leave gateway admission
// fenced until the next successful catalog mutation.
let updates = updates
.into_iter()
.map(|update| {
ProviderKey::new(app_type.as_str(), update.id).map(|key| (key, update.sort_index))
})
.collect::<Result<Vec<_>, _>>()?;
let _pi_switch_guard = matches!(app_type, AppType::Pi).then(|| {
futures::executor::block_on(
state
.proxy_service
.lock_switch_for_app(AppType::Pi.as_str()),
)
});
let pi_epoch = matches!(app_type, AppType::Pi)
.then(|| futures::executor::block_on(state.proxy_service.begin_pi_catalog_mutation()));
if let Err(error) = state.db.update_provider_sort_index(&updates) {
if let Some(epoch) = pi_epoch {
let _ = futures::executor::block_on(
state.proxy_service.reconcile_pi_runtime_at_epoch(epoch),
);
}
return Err(error);
}
if let Some(epoch) = pi_epoch {
futures::executor::block_on(state.proxy_service.reconcile_pi_runtime_at_epoch(epoch))?;
}
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",
)
})?;
if provider.category.as_deref() == Some("official") {
// 官方条目走 Grok CLI 自带 OAuth:空 config 合法,
// 回填快照只要求 TOML 语法合法。
crate::grok_config::validate_config_toml_syntax(config)?;
} else {
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",
));
}
}
AppType::Pi => {
let config: crate::pi_config::model::PiManagedProviderConfig =
serde_json::from_value(provider.settings_config.clone()).map_err(|error| {
AppError::localized(
"provider.pi.settings.invalid",
format!("Pi 配置无法解析: {error}"),
format!("Pi configuration cannot be decoded: {error}"),
)
})?;
crate::pi_config::model::validate_pi_managed_provider(&config).map_err(
|error| {
AppError::localized(
"provider.pi.settings.invalid",
format!("Pi 配置无效: {error}"),
format!("Invalid Pi configuration: {error}"),
)
},
)?;
}
}
// 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))
}
AppType::Pi => {
let config: crate::pi_config::model::PiManagedProviderConfig =
serde_json::from_value(provider.settings_config.clone()).map_err(|error| {
AppError::Config(format!("invalid Pi provider configuration: {error}"))
})?;
let model = config.models.first().ok_or_else(|| {
AppError::Config("Pi provider has no configured model".to_string())
})?;
let effective = crate::pi_config::model::effective_pi_model(&config, &model.id)
.map_err(|error| AppError::Config(format!("invalid Pi model: {error}")))?;
Ok((effective.api_key.unwrap_or_default(), effective.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() {
// 合并已有配置
let precondition = if let Some(existing) =
state.db.get_provider_by_id(&claude_provider.id, "claude")?
{
let fingerprint = provider_row_fingerprint(&existing);
let mut merged = existing.settings_config.clone();
Self::merge_json(&mut merged, &claude_provider.settings_config);
claude_provider.settings_config = merged;
ReconcilePrecondition::ExpectPresent { fingerprint }
} else {
ReconcilePrecondition::ExpectAbsent
};
reconcile_provider_record_with_precondition(
&state.db,
"claude",
provider_to_mutation_input(claude_provider),
precondition,
)?;
} 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() {
// 合并已有配置
let precondition =
if let Some(existing) = state.db.get_provider_by_id(&codex_provider.id, "codex")? {
let fingerprint = provider_row_fingerprint(&existing);
let mut merged = existing.settings_config.clone();
Self::merge_json(&mut merged, &codex_provider.settings_config);
codex_provider.settings_config = merged;
ReconcilePrecondition::ExpectPresent { fingerprint }
} else {
ReconcilePrecondition::ExpectAbsent
};
reconcile_provider_record_with_precondition(
&state.db,
"codex",
provider_to_mutation_input(codex_provider),
precondition,
)?;
} 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() {
// 合并已有配置
let precondition = if let Some(existing) =
state.db.get_provider_by_id(&gemini_provider.id, "gemini")?
{
let fingerprint = provider_row_fingerprint(&existing);
let mut merged = existing.settings_config.clone();
Self::merge_json(&mut merged, &gemini_provider.settings_config);
gemini_provider.settings_config = merged;
ReconcilePrecondition::ExpectPresent { fingerprint }
} else {
ReconcilePrecondition::ExpectAbsent
};
reconcile_provider_record_with_precondition(
&state.db,
"gemini",
provider_to_mutation_input(gemini_provider),
precondition,
)?;
} else {
let gemini_id = format!("universal-gemini-{id}");
let _ = state.db.delete_provider("gemini", &gemini_id);
}
Ok(true)
}
/// 递归合并 JSONbase 为底,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();
}
}
}
}