mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 19:45:34 +08:00
5633 lines
222 KiB
Rust
5633 lines
222 KiB
Rust
//! Provider service module
|
||
//!
|
||
//! Handles provider CRUD operations, switching, and configuration management.
|
||
|
||
mod endpoints;
|
||
mod gemini_auth;
|
||
mod live;
|
||
mod usage;
|
||
|
||
use indexmap::IndexMap;
|
||
use regex::Regex;
|
||
use serde::Deserialize;
|
||
use serde_json::Value;
|
||
|
||
use crate::app_config::AppType;
|
||
use crate::database::{
|
||
validate_cost_multiplier, validate_pricing_source, NewProviderAggregate, ProviderKey,
|
||
ProviderRowUpdate, RenameProvider,
|
||
};
|
||
use crate::error::AppError;
|
||
use crate::provider::{Provider, ProviderMutationInput, UsageResult};
|
||
use crate::services::mcp::McpService;
|
||
use crate::settings::CustomEndpoint;
|
||
use crate::store::AppState;
|
||
|
||
// Re-export sub-module functions for external access
|
||
pub use live::{
|
||
import_default_config, import_hermes_providers_from_live, import_openclaw_providers_from_live,
|
||
import_opencode_providers_from_live, read_live_settings,
|
||
should_import_default_config_on_startup, sync_current_to_live,
|
||
update_toml_common_config_snippet,
|
||
};
|
||
|
||
// Internal re-exports (pub(crate))
|
||
pub(crate) use live::sanitize_claude_settings_for_live;
|
||
pub(crate) use live::{
|
||
build_effective_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(¤t_id) else {
|
||
return Ok(false);
|
||
};
|
||
if provider.category.as_deref() != Some("official") {
|
||
return Ok(false);
|
||
}
|
||
|
||
// 代理接管期间 live 归代理所有(开启代理时官方供应商只警告不拦截,
|
||
// 二者可以共存)。与切换/保存路径一致:以 backup/占位符为所有权信号,
|
||
// 只更新备份,注入后的配置由接管释放时的恢复路径落盘。
|
||
let has_live_backup =
|
||
futures::executor::block_on(state.db.get_live_backup(AppType::Codex.as_str()))
|
||
.ok()
|
||
.flatten()
|
||
.is_some();
|
||
let live_taken_over = state
|
||
.proxy_service
|
||
.detect_takeover_in_live_config_for_app(&AppType::Codex);
|
||
if has_live_backup || live_taken_over {
|
||
futures::executor::block_on(
|
||
state
|
||
.proxy_service
|
||
.update_live_backup_from_provider(AppType::Codex.as_str(), provider),
|
||
)
|
||
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
|
||
return Ok(true);
|
||
}
|
||
|
||
live::write_live_with_common_config(&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 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 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, ¤t_credentials)
|
||
}) {
|
||
usage_script.api_key = None;
|
||
}
|
||
|
||
if usage_script.base_url.as_deref().is_some_and(|base_url| {
|
||
Self::should_clear_usage_base_url_override(base_url, ¤t_credentials)
|
||
}) {
|
||
usage_script.base_url = None;
|
||
}
|
||
}
|
||
|
||
fn should_clear_usage_api_key_override(
|
||
script_api_key: &str,
|
||
current_credentials: &(String, String),
|
||
) -> bool {
|
||
let candidate = script_api_key.trim();
|
||
if candidate.is_empty() {
|
||
return true;
|
||
}
|
||
|
||
let matches_provider_key = |api_key: &str| {
|
||
let api_key = api_key.trim();
|
||
!api_key.is_empty() && api_key == candidate
|
||
};
|
||
|
||
matches_provider_key(¤t_credentials.1)
|
||
}
|
||
|
||
fn should_clear_usage_base_url_override(
|
||
script_base_url: &str,
|
||
current_credentials: &(String, String),
|
||
) -> bool {
|
||
let candidate = Self::normalize_usage_base_url_for_compare(script_base_url);
|
||
if candidate.is_empty() {
|
||
return true;
|
||
}
|
||
|
||
let matches_provider_base_url = |base_url: &str| {
|
||
let base_url = Self::normalize_usage_base_url_for_compare(base_url);
|
||
!base_url.is_empty() && base_url == candidate
|
||
};
|
||
|
||
matches_provider_base_url(¤t_credentials.0)
|
||
}
|
||
|
||
fn normalize_usage_base_url_for_compare(base_url: &str) -> String {
|
||
base_url.trim().trim_end_matches('/').to_string()
|
||
}
|
||
|
||
/// List all providers for an app type
|
||
pub fn list(
|
||
state: &AppState,
|
||
app_type: AppType,
|
||
) -> Result<IndexMap<String, Provider>, AppError> {
|
||
state.db.get_all_providers(app_type.as_str())
|
||
}
|
||
|
||
/// Get current provider ID
|
||
///
|
||
/// 使用有效的当前供应商 ID(验证过存在性)。
|
||
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
|
||
/// 这确保了云同步场景下多设备可以独立选择供应商,且返回的 ID 一定有效。
|
||
///
|
||
/// 对于累加模式应用(OpenCode, OpenClaw),不存在"当前供应商"概念,直接返回空字符串。
|
||
pub fn current(state: &AppState, app_type: AppType) -> Result<String, AppError> {
|
||
// Additive mode apps have no "current" provider concept
|
||
if app_type.is_additive_mode() {
|
||
return Ok(String::new());
|
||
}
|
||
crate::settings::get_effective_current_provider(&state.db, &app_type)
|
||
.map(|opt| opt.unwrap_or_default())
|
||
}
|
||
|
||
/// Add a new provider
|
||
pub fn add(
|
||
state: &AppState,
|
||
app_type: AppType,
|
||
input: ProviderMutationInput,
|
||
add_to_live: bool,
|
||
) -> Result<bool, AppError> {
|
||
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)?;
|
||
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> {
|
||
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> {
|
||
// 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(), ¤t_id)?
|
||
.map(|aggregate| aggregate.provider)
|
||
{
|
||
let fingerprint = provider_row_fingerprint(¤t_provider);
|
||
// 切走前先把 live 里的可共享改动(含用户直接在应用内
|
||
// 装插件/加 hook/改偏好)同步进通用配置片段,再做剥离回填。
|
||
// 详见 sync_common_config_snippet_from_live 的文档。
|
||
Self::sync_common_config_snippet_from_live(
|
||
state,
|
||
&app_type,
|
||
¤t_provider,
|
||
&live_config,
|
||
&mut result,
|
||
);
|
||
|
||
current_provider.settings_config =
|
||
strip_common_config_from_live_settings(
|
||
state.db.as_ref(),
|
||
&app_type,
|
||
¤t_provider,
|
||
live_config,
|
||
);
|
||
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(¤t_id) else {
|
||
return Ok(());
|
||
};
|
||
|
||
let has_live_backup =
|
||
futures::executor::block_on(state.db.get_live_backup(app_type.as_str()))
|
||
.ok()
|
||
.flatten()
|
||
.is_some();
|
||
|
||
let live_taken_over = state
|
||
.proxy_service
|
||
.detect_takeover_in_live_config_for_app(&app_type);
|
||
|
||
// See the save path above: backup/placeholders are the ownership signal
|
||
// here, not just proxy_config.enabled.
|
||
if has_live_backup || live_taken_over {
|
||
if matches!(app_type, AppType::ClaudeDesktop) {
|
||
write_live_with_common_config(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(¤t_id)
|
||
.ok_or_else(|| AppError::Message(format!("Provider {current_id} not found")))?;
|
||
|
||
match app_type {
|
||
AppType::Claude => Self::extract_claude_common_config(&provider.settings_config),
|
||
AppType::ClaudeDesktop => Ok(String::new()),
|
||
AppType::Codex => Self::extract_codex_common_config(&provider.settings_config),
|
||
AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config),
|
||
AppType::GrokBuild => Ok(String::new()),
|
||
AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config),
|
||
AppType::OpenClaw => Self::extract_openclaw_common_config(&provider.settings_config),
|
||
AppType::Hermes => Ok(String::new()), // Hermes doesn't use common config snippets
|
||
}
|
||
}
|
||
|
||
/// Extract common config snippet from a config value (e.g. editor content).
|
||
pub fn extract_common_config_snippet_from_settings(
|
||
app_type: AppType,
|
||
settings_config: &Value,
|
||
) -> Result<String, AppError> {
|
||
match app_type {
|
||
AppType::Claude => Self::extract_claude_common_config(settings_config),
|
||
AppType::ClaudeDesktop => Ok(String::new()),
|
||
AppType::Codex => Self::extract_codex_common_config(settings_config),
|
||
AppType::Gemini => Self::extract_gemini_common_config(settings_config),
|
||
AppType::GrokBuild => Ok(String::new()),
|
||
AppType::OpenCode => Self::extract_opencode_common_config(settings_config),
|
||
AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config),
|
||
AppType::Hermes => Ok(String::new()), // Hermes doesn't use common config snippets
|
||
}
|
||
}
|
||
|
||
/// 判断一个 env / 顶层配置键名是否为凭据/机密:凡命中一律不得写入共享的
|
||
/// 通用配置片段。**故意从严**——多剥一个非机密键只是它不被共享(可恢复的小
|
||
/// 不便),漏剥一个凭据则会把密钥注入到每个供应商(不可恢复的泄漏)。因此用
|
||
/// 模式匹配覆盖整类,而非枚举具体名字(枚举永远会漏掉下一个 `*_API_KEY`)。
|
||
///
|
||
/// 覆盖:Anthropic / OpenRouter / Google / OpenAI / Gemini 等 `*_API_KEY`
|
||
/// (Claude provider 的凭据见 `Provider::resolve_usage_credentials`,确实支持
|
||
/// `OPENROUTER_API_KEY` / `GOOGLE_API_KEY` 等回退)、各类 `*_AUTH_TOKEN` /
|
||
/// 单数 `*_TOKEN`、AWS Bedrock / Vertex 凭据、以及通用 secret / password /
|
||
/// 私钥命名。
|
||
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> {
|
||
endpoints::add_custom_endpoint(state, app_type, provider_id, url)
|
||
}
|
||
|
||
/// Remove custom endpoint (re-export)
|
||
pub fn remove_custom_endpoint(
|
||
state: &AppState,
|
||
app_type: AppType,
|
||
provider_id: &str,
|
||
url: String,
|
||
) -> Result<(), AppError> {
|
||
endpoints::remove_custom_endpoint(state, app_type, provider_id, url)
|
||
}
|
||
|
||
/// Update endpoint last used timestamp (re-export)
|
||
pub fn update_endpoint_last_used(
|
||
state: &AppState,
|
||
app_type: AppType,
|
||
provider_id: &str,
|
||
url: String,
|
||
) -> Result<(), AppError> {
|
||
endpoints::update_endpoint_last_used(state, app_type, provider_id, url)
|
||
}
|
||
|
||
/// Update provider sort order
|
||
pub fn update_sort_order(
|
||
state: &AppState,
|
||
app_type: AppType,
|
||
updates: Vec<ProviderSortUpdate>,
|
||
) -> Result<bool, AppError> {
|
||
for update in updates {
|
||
let key = ProviderKey::new(app_type.as_str(), update.id)?;
|
||
state
|
||
.db
|
||
.update_provider_sort_index(&key, update.sort_index)?;
|
||
}
|
||
|
||
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",
|
||
));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Validate and clean UsageScript configuration (common for all app types)
|
||
if let Some(meta) = &provider.meta {
|
||
if let Some(multiplier) = meta.cost_multiplier.as_deref() {
|
||
validate_cost_multiplier(multiplier)?;
|
||
}
|
||
if let Some(source) = meta.pricing_model_source.as_deref() {
|
||
validate_pricing_source(source)?;
|
||
}
|
||
if let Some(usage_script) = &meta.usage_script {
|
||
validate_usage_script(usage_script)?;
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
fn extract_credentials(
|
||
provider: &Provider,
|
||
app_type: &AppType,
|
||
) -> Result<(String, String), AppError> {
|
||
match app_type {
|
||
AppType::Claude => {
|
||
let env = provider
|
||
.settings_config
|
||
.get("env")
|
||
.and_then(|v| v.as_object())
|
||
.ok_or_else(|| {
|
||
AppError::localized(
|
||
"provider.claude.env.missing",
|
||
"配置格式错误: 缺少 env",
|
||
"Invalid configuration: missing env section",
|
||
)
|
||
})?;
|
||
|
||
let api_key = env
|
||
.get("ANTHROPIC_AUTH_TOKEN")
|
||
.or_else(|| env.get("ANTHROPIC_API_KEY"))
|
||
.and_then(|v| v.as_str())
|
||
.ok_or_else(|| {
|
||
AppError::localized(
|
||
"provider.claude.api_key.missing",
|
||
"缺少 API Key",
|
||
"API key is missing",
|
||
)
|
||
})?
|
||
.to_string();
|
||
|
||
let base_url = env
|
||
.get("ANTHROPIC_BASE_URL")
|
||
.and_then(|v| v.as_str())
|
||
.ok_or_else(|| {
|
||
AppError::localized(
|
||
"provider.claude.base_url.missing",
|
||
"缺少 ANTHROPIC_BASE_URL 配置",
|
||
"Missing ANTHROPIC_BASE_URL configuration",
|
||
)
|
||
})?
|
||
.to_string();
|
||
|
||
Ok((api_key, base_url))
|
||
}
|
||
AppType::GrokBuild => {
|
||
let config_toml = provider
|
||
.settings_config
|
||
.get("config")
|
||
.and_then(Value::as_str)
|
||
.ok_or_else(|| {
|
||
AppError::localized(
|
||
"provider.grokbuild.config.missing",
|
||
"Grok Build 配置缺少 config 字段",
|
||
"Grok Build configuration is missing the config field",
|
||
)
|
||
})?;
|
||
let (base_url, api_key) = crate::grok_config::extract_credentials(config_toml)
|
||
.ok_or_else(|| {
|
||
AppError::localized(
|
||
"provider.grokbuild.credentials.missing",
|
||
"Grok Build 配置缺少 Base URL 或 API Key",
|
||
"Grok Build configuration is missing the base URL or API key",
|
||
)
|
||
})?;
|
||
Ok((api_key, base_url))
|
||
}
|
||
AppType::ClaudeDesktop => {
|
||
let credentials =
|
||
crate::claude_desktop_config::direct_gateway_credentials(provider)?;
|
||
Ok((credentials.api_key, credentials.base_url))
|
||
}
|
||
AppType::Codex => {
|
||
let _auth = provider
|
||
.settings_config
|
||
.get("auth")
|
||
.and_then(|v| v.as_object())
|
||
.ok_or_else(|| {
|
||
AppError::localized(
|
||
"provider.codex.auth.missing",
|
||
"配置格式错误: 缺少 auth",
|
||
"Invalid configuration: missing auth section",
|
||
)
|
||
})?;
|
||
|
||
let config_toml = provider
|
||
.settings_config
|
||
.get("config")
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or("");
|
||
|
||
let api_key = crate::codex_config::extract_codex_api_key(
|
||
provider.settings_config.get("auth"),
|
||
Some(config_toml),
|
||
)
|
||
.ok_or_else(|| {
|
||
AppError::localized(
|
||
"provider.codex.api_key.missing",
|
||
"缺少 API Key",
|
||
"API key is missing",
|
||
)
|
||
})?;
|
||
|
||
let base_url = if config_toml.contains("base_url") {
|
||
let re = Regex::new(r#"base_url\s*=\s*["']([^"']+)["']"#).map_err(|e| {
|
||
AppError::localized(
|
||
"provider.regex_init_failed",
|
||
format!("正则初始化失败: {e}"),
|
||
format!("Failed to initialize regex: {e}"),
|
||
)
|
||
})?;
|
||
re.captures(config_toml)
|
||
.and_then(|caps| caps.get(1))
|
||
.map(|m| m.as_str().to_string())
|
||
.ok_or_else(|| {
|
||
AppError::localized(
|
||
"provider.codex.base_url.invalid",
|
||
"config.toml 中 base_url 格式错误",
|
||
"base_url in config.toml has invalid format",
|
||
)
|
||
})?
|
||
} else {
|
||
return Err(AppError::localized(
|
||
"provider.codex.base_url.missing",
|
||
"config.toml 中缺少 base_url 配置",
|
||
"base_url is missing from config.toml",
|
||
));
|
||
};
|
||
|
||
Ok((api_key, base_url))
|
||
}
|
||
AppType::Gemini => {
|
||
use crate::gemini_config::json_to_env;
|
||
|
||
let env_map = json_to_env(&provider.settings_config)?;
|
||
|
||
let api_key = env_map.get("GEMINI_API_KEY").cloned().ok_or_else(|| {
|
||
AppError::localized(
|
||
"gemini.missing_api_key",
|
||
"缺少 GEMINI_API_KEY",
|
||
"Missing GEMINI_API_KEY",
|
||
)
|
||
})?;
|
||
|
||
let base_url = env_map
|
||
.get("GOOGLE_GEMINI_BASE_URL")
|
||
.cloned()
|
||
.unwrap_or_else(|| "https://generativelanguage.googleapis.com".to_string());
|
||
|
||
Ok((api_key, base_url))
|
||
}
|
||
AppType::OpenCode => {
|
||
// OpenCode uses options.apiKey and options.baseURL
|
||
let options = provider
|
||
.settings_config
|
||
.get("options")
|
||
.and_then(|v| v.as_object())
|
||
.ok_or_else(|| {
|
||
AppError::localized(
|
||
"provider.opencode.options.missing",
|
||
"配置格式错误: 缺少 options",
|
||
"Invalid configuration: missing options section",
|
||
)
|
||
})?;
|
||
|
||
let api_key = options
|
||
.get("apiKey")
|
||
.and_then(|v| v.as_str())
|
||
.ok_or_else(|| {
|
||
AppError::localized(
|
||
"provider.opencode.api_key.missing",
|
||
"缺少 API Key",
|
||
"API key is missing",
|
||
)
|
||
})?
|
||
.to_string();
|
||
|
||
let base_url = options
|
||
.get("baseURL")
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or("")
|
||
.to_string();
|
||
|
||
Ok((api_key, base_url))
|
||
}
|
||
AppType::OpenClaw | AppType::Hermes => {
|
||
// OpenClaw/Hermes use apiKey and baseUrl directly on the object
|
||
let api_key = provider
|
||
.settings_config
|
||
.get("apiKey")
|
||
.and_then(|v| v.as_str())
|
||
.ok_or_else(|| {
|
||
AppError::localized(
|
||
"provider.openclaw.api_key.missing",
|
||
"缺少 API Key",
|
||
"API key is missing",
|
||
)
|
||
})?
|
||
.to_string();
|
||
|
||
let base_url = provider
|
||
.settings_config
|
||
.get("baseUrl")
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or("")
|
||
.to_string();
|
||
|
||
Ok((api_key, base_url))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Normalize Claude model keys in a JSON value
|
||
///
|
||
/// Reads old key (ANTHROPIC_SMALL_FAST_MODEL), writes new keys (DEFAULT_*), and deletes old key.
|
||
pub(crate) fn normalize_claude_models_in_value(settings: &mut Value) -> bool {
|
||
let mut changed = false;
|
||
let env = match settings.get_mut("env").and_then(|v| v.as_object_mut()) {
|
||
Some(obj) => obj,
|
||
None => return changed,
|
||
};
|
||
|
||
let model = env
|
||
.get("ANTHROPIC_MODEL")
|
||
.and_then(|v| v.as_str())
|
||
.map(|s| s.to_string());
|
||
let small_fast = env
|
||
.get("ANTHROPIC_SMALL_FAST_MODEL")
|
||
.and_then(|v| v.as_str())
|
||
.map(|s| s.to_string());
|
||
|
||
let current_haiku = env
|
||
.get("ANTHROPIC_DEFAULT_HAIKU_MODEL")
|
||
.and_then(|v| v.as_str())
|
||
.map(|s| s.to_string());
|
||
let current_sonnet = env
|
||
.get("ANTHROPIC_DEFAULT_SONNET_MODEL")
|
||
.and_then(|v| v.as_str())
|
||
.map(|s| s.to_string());
|
||
let current_opus = env
|
||
.get("ANTHROPIC_DEFAULT_OPUS_MODEL")
|
||
.and_then(|v| v.as_str())
|
||
.map(|s| s.to_string());
|
||
|
||
let target_haiku = current_haiku
|
||
.or_else(|| small_fast.clone())
|
||
.or_else(|| model.clone());
|
||
let target_sonnet = current_sonnet
|
||
.or_else(|| model.clone())
|
||
.or_else(|| small_fast.clone());
|
||
let target_opus = current_opus
|
||
.or_else(|| model.clone())
|
||
.or_else(|| small_fast.clone());
|
||
|
||
if env.get("ANTHROPIC_DEFAULT_HAIKU_MODEL").is_none() {
|
||
if let Some(v) = target_haiku {
|
||
env.insert(
|
||
"ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(),
|
||
Value::String(v),
|
||
);
|
||
changed = true;
|
||
}
|
||
}
|
||
if env.get("ANTHROPIC_DEFAULT_SONNET_MODEL").is_none() {
|
||
if let Some(v) = target_sonnet {
|
||
env.insert(
|
||
"ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(),
|
||
Value::String(v),
|
||
);
|
||
changed = true;
|
||
}
|
||
}
|
||
if env.get("ANTHROPIC_DEFAULT_OPUS_MODEL").is_none() {
|
||
if let Some(v) = target_opus {
|
||
env.insert("ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(), Value::String(v));
|
||
changed = true;
|
||
}
|
||
}
|
||
|
||
if env.remove("ANTHROPIC_SMALL_FAST_MODEL").is_some() {
|
||
changed = true;
|
||
}
|
||
|
||
changed
|
||
}
|
||
|
||
#[derive(Debug, Clone, Deserialize)]
|
||
pub struct ProviderSortUpdate {
|
||
pub id: String,
|
||
#[serde(rename = "sortIndex")]
|
||
pub sort_index: usize,
|
||
}
|
||
|
||
// ============================================================================
|
||
// 统一供应商(Universal Provider)服务方法
|
||
// ============================================================================
|
||
|
||
use crate::provider::UniversalProvider;
|
||
use std::collections::HashMap;
|
||
|
||
impl ProviderService {
|
||
/// 获取所有统一供应商
|
||
pub fn list_universal(
|
||
state: &AppState,
|
||
) -> Result<HashMap<String, UniversalProvider>, AppError> {
|
||
state.db.get_all_universal_providers()
|
||
}
|
||
|
||
/// 获取单个统一供应商
|
||
pub fn get_universal(
|
||
state: &AppState,
|
||
id: &str,
|
||
) -> Result<Option<UniversalProvider>, AppError> {
|
||
state.db.get_universal_provider(id)
|
||
}
|
||
|
||
/// 添加或更新统一供应商(不自动同步,需手动调用 sync_universal_to_apps)
|
||
pub fn upsert_universal(
|
||
state: &AppState,
|
||
provider: UniversalProvider,
|
||
) -> Result<bool, AppError> {
|
||
// 保存统一供应商
|
||
state.db.save_universal_provider(&provider)?;
|
||
|
||
Ok(true)
|
||
}
|
||
|
||
/// 删除统一供应商
|
||
pub fn delete_universal(state: &AppState, id: &str) -> Result<bool, AppError> {
|
||
// 获取统一供应商(用于删除生成的子供应商)
|
||
let provider = state.db.get_universal_provider(id)?;
|
||
|
||
// 删除统一供应商
|
||
state.db.delete_universal_provider(id)?;
|
||
|
||
// 删除生成的子供应商
|
||
if let Some(p) = provider {
|
||
if p.apps.claude {
|
||
let claude_id = format!("universal-claude-{id}");
|
||
let _ = state.db.delete_provider("claude", &claude_id);
|
||
}
|
||
if p.apps.codex {
|
||
let codex_id = format!("universal-codex-{id}");
|
||
let _ = state.db.delete_provider("codex", &codex_id);
|
||
}
|
||
if p.apps.gemini {
|
||
let gemini_id = format!("universal-gemini-{id}");
|
||
let _ = state.db.delete_provider("gemini", &gemini_id);
|
||
}
|
||
}
|
||
|
||
Ok(true)
|
||
}
|
||
|
||
/// 同步统一供应商到各应用
|
||
pub fn sync_universal_to_apps(state: &AppState, id: &str) -> Result<bool, AppError> {
|
||
let provider = state
|
||
.db
|
||
.get_universal_provider(id)?
|
||
.ok_or_else(|| AppError::Message(format!("统一供应商 {id} 不存在")))?;
|
||
|
||
// 同步到 Claude
|
||
if let Some(mut claude_provider) = provider.to_claude_provider() {
|
||
// 合并已有配置
|
||
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)
|
||
}
|
||
|
||
/// 递归合并 JSON:base 为底,patch 覆盖同名字段
|
||
fn merge_json(base: &mut serde_json::Value, patch: &serde_json::Value) {
|
||
use serde_json::Value;
|
||
|
||
match (base, patch) {
|
||
(Value::Object(base_map), Value::Object(patch_map)) => {
|
||
for (k, v_patch) in patch_map {
|
||
match base_map.get_mut(k) {
|
||
Some(v_base) => Self::merge_json(v_base, v_patch),
|
||
None => {
|
||
base_map.insert(k.clone(), v_patch.clone());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 其它类型:直接覆盖
|
||
(base_val, patch_val) => {
|
||
*base_val = patch_val.clone();
|
||
}
|
||
}
|
||
}
|
||
}
|