Files
cc-switch/src-tauri/src/services/provider/mod.rs
2026-04-02 17:32:33 +08:00

2473 lines
94 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Provider service module
//!
//! Handles provider CRUD operations, switching, and configuration management.
mod endpoints;
mod gemini_auth;
mod live;
mod usage;
use indexmap::IndexMap;
use regex::Regex;
use serde::Deserialize;
use serde_json::Value;
use crate::app_config::AppType;
use crate::error::AppError;
use crate::provider::{Provider, UsageResult};
use crate::services::mcp::McpService;
use crate::settings::CustomEndpoint;
use crate::store::AppState;
// Re-export sub-module functions for external access
pub use live::{
import_default_config, import_openclaw_providers_from_live,
import_opencode_providers_from_live, read_live_settings, sync_current_to_live,
};
// 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_openclaw_provider_from_live, remove_opencode_provider_from_live, write_gemini_live,
};
use usage::validate_usage_script;
/// Provider business logic service
pub struct ProviderService;
/// Result of a provider switch operation, including any non-fatal warnings
#[derive(Debug, serde::Serialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct SwitchResult {
pub warnings: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{get_claude_settings_path, read_json_file, write_json_file};
use crate::database::Database;
use crate::provider::ProviderMeta;
use crate::proxy::types::ProxyConfig;
use crate::store::AppState;
use serde_json::json;
use serial_test::serial;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use tempfile::TempDir;
struct TempHome {
#[allow(dead_code)]
dir: TempDir,
original_home: Option<String>,
original_userprofile: Option<String>,
}
impl TempHome {
fn new() -> Self {
let dir = TempDir::new().expect("failed to create temp home");
let original_home = env::var("HOME").ok();
let original_userprofile = env::var("USERPROFILE").ok();
env::set_var("HOME", dir.path());
env::set_var("USERPROFILE", dir.path());
Self {
dir,
original_home,
original_userprofile,
}
}
}
impl Drop for TempHome {
fn drop(&mut self) {
match &self.original_home {
Some(value) => env::set_var("HOME", value),
None => env::remove_var("HOME"),
}
match &self.original_userprofile {
Some(value) => env::set_var("USERPROFILE", value),
None => env::remove_var("USERPROFILE"),
}
}
}
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 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 opencode_provider(id: &str) -> Provider {
Provider {
id: id.to_string(),
name: format!("Provider {id}"),
settings_config: json!({
"npm": "@ai-sdk/openai-compatible",
"name": format!("Provider {id}"),
"options": {
"baseURL": "https://api.example.com/v1",
"apiKey": "test-key"
},
"models": {
"gpt-4o": {
"name": "GPT-4o"
}
}
}),
website_url: None,
category: Some("custom".to_string()),
created_at: Some(1),
sort_index: Some(0),
notes: None,
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
fn opencode_omo_provider(id: &str, category: &str) -> Provider {
let mut settings = serde_json::Map::new();
settings.insert(
"agents".to_string(),
json!({
"writer": {
"model": "gpt-4o-mini"
}
}),
);
if category == "omo" {
settings.insert(
"categories".to_string(),
json!({
"default": ["writer"]
}),
);
}
settings.insert(
"otherFields".to_string(),
json!({
"theme": "dark"
}),
);
Provider {
id: id.to_string(),
name: format!("Provider {id}"),
settings_config: Value::Object(settings),
website_url: None,
category: Some(category.to_string()),
created_at: Some(1),
sort_index: Some(0),
notes: None,
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
fn omo_config_path(home: &Path, category: &str) -> PathBuf {
home.join(".config").join("opencode").join(match category {
"omo" => crate::services::omo::STANDARD.preferred_filename,
"omo-slim" => crate::services::omo::SLIM.preferred_filename,
other => panic!("unexpected OMO category in test: {other}"),
})
}
#[test]
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_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_preserves_mcp_servers_base_url() {
let config_toml = r#"model_provider = "azure"
model = "gpt-4"
disable_response_storage = true
[model_providers.azure]
name = "Azure OpenAI"
base_url = "https://azure.example/v1"
wire_api = "responses"
[mcp_servers.my_server]
base_url = "http://localhost:8080"
"#;
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"
);
assert!(
extracted.contains("http://localhost:8080"),
"should keep mcp_servers.* base_url"
);
}
#[tokio::test]
#[serial]
async fn update_current_claude_provider_syncs_live_when_proxy_takeover_detected_without_backup()
{
let _home = TempHome::new();
crate::settings::reload_settings().expect("reload settings");
let db = Arc::new(Database::memory().expect("init db"));
let state = AppState::new(db.clone());
let original = Provider::with_id(
"p1".into(),
"Claude A".into(),
json!({
"env": {
"ANTHROPIC_API_KEY": "token-a",
"ANTHROPIC_BASE_URL": "https://api.a.example",
"ANTHROPIC_MODEL": "model-a"
},
"permissions": { "allow": ["Bash"] }
}),
None,
);
db.save_provider("claude", &original)
.expect("save provider");
db.set_current_provider("claude", "p1")
.expect("set current provider");
crate::settings::set_current_provider(&AppType::Claude, Some("p1"))
.expect("set local current provider");
db.update_proxy_config(ProxyConfig {
live_takeover_active: true,
..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");
state
.proxy_service
.start()
.await
.expect("start proxy service");
let updated = Provider::with_id(
"p1".into(),
"Claude A".into(),
json!({
"env": {
"ANTHROPIC_API_KEY": "token-updated",
"ANTHROPIC_BASE_URL": "https://api.updated.example",
"ANTHROPIC_MODEL": "model-updated"
},
"permissions": { "allow": ["Read"] }
}),
None,
);
ProviderService::update(&state, AppType::Claude, None, updated.clone())
.expect("update current provider");
let backup = db
.get_live_backup("claude")
.await
.expect("get live backup")
.expect("backup exists");
let stored_provider = db
.get_provider_by_id("p1", "claude")
.expect("get stored provider")
.expect("stored provider exists");
let expected_backup =
serde_json::to_string(&stored_provider.settings_config).expect("serialize");
assert_eq!(backup.original_config, expected_backup);
let live: Value = read_json_file(&get_claude_settings_path()).expect("read live");
assert_eq!(
live.get("permissions"),
updated.settings_config.get("permissions"),
"provider edits should propagate into Claude live config during takeover"
);
assert_eq!(
live.get("env")
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
.and_then(|v| v.as_str()),
Some("PROXY_MANAGED"),
"takeover placeholder should stay intact"
);
assert_eq!(
live.get("env")
.and_then(|env| env.get("ANTHROPIC_BASE_URL"))
.and_then(|v| v.as_str()),
Some("http://127.0.0.1:15721"),
"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"
);
}
#[test]
fn rename_rejects_missing_original_provider() {
with_test_home(|state, _| {
let original = openclaw_provider("deepseek");
ProviderService::add(state, AppType::OpenClaw, original.clone(), false)
.expect("seed db-only provider");
let mut renamed = original.clone();
renamed.id = "deepseek-copy".to_string();
let err = ProviderService::update(
state,
AppType::OpenClaw,
Some("missing-provider"),
renamed,
)
.expect_err("stale originalId should be rejected");
assert!(
err.to_string().contains("Original provider"),
"expected missing original provider error, got {err:?}"
);
assert!(
state
.db
.get_provider_by_id("deepseek-copy", AppType::OpenClaw.as_str())
.expect("query renamed provider")
.is_none(),
"rename must not create a new row when originalId is stale"
);
});
}
#[test]
fn db_only_additive_update_survives_live_config_parse_errors() {
with_test_home(|state, home| {
let provider = openclaw_provider("deepseek");
ProviderService::add(state, AppType::OpenClaw, provider.clone(), false)
.expect("seed db-only provider");
let stored = state
.db
.get_provider_by_id("deepseek", AppType::OpenClaw.as_str())
.expect("query stored provider")
.expect("provider should exist");
assert_eq!(
stored
.meta
.as_ref()
.and_then(|meta| meta.live_config_managed),
Some(false),
"db-only provider should be marked as not live-managed"
);
let openclaw_dir = home.join(".openclaw");
fs::create_dir_all(&openclaw_dir).expect("create openclaw dir");
fs::write(openclaw_dir.join("openclaw.json"), "{ invalid json5")
.expect("write malformed config");
let mut updated = stored.clone();
updated.name = "DeepSeek Edited".to_string();
updated.meta.get_or_insert_with(ProviderMeta::default);
ProviderService::update(state, AppType::OpenClaw, None, updated)
.expect("db-only update should ignore live parse errors");
let saved = state
.db
.get_provider_by_id("deepseek", AppType::OpenClaw.as_str())
.expect("query updated provider")
.expect("updated provider should exist");
assert_eq!(saved.name, "DeepSeek Edited");
});
}
#[test]
fn sync_current_provider_for_app_skips_db_only_opencode_provider() {
with_test_home(|state, _| {
let provider = opencode_provider("db-only-opencode");
ProviderService::add(state, AppType::OpenCode, provider.clone(), false)
.expect("seed db-only opencode provider");
ProviderService::sync_current_provider_for_app(state, AppType::OpenCode)
.expect("sync additive opencode providers");
let live_providers = crate::opencode_config::get_providers()
.expect("read opencode providers after sync");
assert!(
!live_providers.contains_key(&provider.id),
"db-only opencode provider should not be written to live during sync"
);
});
}
#[test]
fn sync_current_provider_for_app_skips_db_only_openclaw_provider() {
with_test_home(|state, _| {
let provider = openclaw_provider("db-only-openclaw");
ProviderService::add(state, AppType::OpenClaw, provider.clone(), false)
.expect("seed db-only openclaw provider");
ProviderService::sync_current_provider_for_app(state, AppType::OpenClaw)
.expect("sync additive openclaw providers");
let live_providers = crate::openclaw_config::get_providers()
.expect("read openclaw providers after sync");
assert!(
!live_providers.contains_key(&provider.id),
"db-only openclaw provider should not be written to live during sync"
);
});
}
#[test]
fn sync_current_provider_for_app_preserves_legacy_live_opencode_provider() {
with_test_home(|state, _| {
let provider = opencode_provider("legacy-opencode");
crate::opencode_config::set_provider(&provider.id, provider.settings_config.clone())
.expect("seed opencode live provider");
state
.db
.save_provider(AppType::OpenCode.as_str(), &provider)
.expect("seed legacy opencode provider in db");
let mut updated = provider.clone();
updated.settings_config["options"]["apiKey"] = Value::String("updated-key".to_string());
state
.db
.save_provider(AppType::OpenCode.as_str(), &updated)
.expect("update legacy opencode provider in db");
ProviderService::sync_current_provider_for_app(state, AppType::OpenCode)
.expect("sync legacy opencode provider");
let live_providers =
crate::opencode_config::get_providers().expect("read opencode providers");
assert_eq!(
live_providers
.get(&provider.id)
.and_then(|config| config.get("options"))
.and_then(|options| options.get("apiKey")),
Some(&Value::String("updated-key".to_string())),
"legacy provider that already exists in live should still be synced"
);
});
}
#[test]
fn sync_current_provider_for_app_restores_legacy_opencode_provider_after_live_reset() {
with_test_home(|state, _| {
let provider = opencode_provider("legacy-opencode-reset");
state
.db
.save_provider(AppType::OpenCode.as_str(), &provider)
.expect("seed legacy opencode provider in db");
ProviderService::sync_current_provider_for_app(state, AppType::OpenCode)
.expect("sync legacy opencode provider after reset");
let live_providers =
crate::opencode_config::get_providers().expect("read opencode providers");
assert!(
live_providers.contains_key(&provider.id),
"legacy opencode provider should be restored when live config is reset"
);
});
}
#[test]
fn sync_current_provider_for_app_restores_legacy_openclaw_provider_after_live_reset() {
with_test_home(|state, _| {
let mut provider = openclaw_provider("legacy-openclaw-reset");
provider.settings_config["models"] = json!([
{
"id": "claude-sonnet-4",
"name": "Claude Sonnet 4"
}
]);
state
.db
.save_provider(AppType::OpenClaw.as_str(), &provider)
.expect("seed legacy openclaw provider in db");
ProviderService::sync_current_provider_for_app(state, AppType::OpenClaw)
.expect("sync legacy openclaw provider after reset");
let live_providers =
crate::openclaw_config::get_providers().expect("read openclaw providers");
assert!(
live_providers.contains_key(&provider.id),
"legacy openclaw provider should be restored when live config is reset"
);
});
}
#[test]
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]
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]
fn legacy_additive_provider_still_errors_on_live_config_parse_failure() {
with_test_home(|state, home| {
let provider = openclaw_provider("legacy-provider");
state
.db
.save_provider(AppType::OpenClaw.as_str(), &provider)
.expect("seed legacy provider without live_config_managed marker");
let openclaw_dir = home.join(".openclaw");
fs::create_dir_all(&openclaw_dir).expect("create openclaw dir");
fs::write(openclaw_dir.join("openclaw.json"), "{ invalid json5")
.expect("write malformed config");
let mut updated = provider.clone();
updated.name = "Legacy Edited".to_string();
let err = ProviderService::update(state, AppType::OpenClaw, None, updated)
.expect_err("legacy providers should still surface live parse errors");
assert!(
err.to_string().contains("Failed to parse OpenClaw config"),
"expected parse error, got {err:?}"
);
});
}
#[test]
fn update_persists_non_current_omo_variants_in_database() {
with_test_home(|state, _| {
for category in ["omo", "omo-slim"] {
let provider = opencode_omo_provider(&format!("{category}-provider"), category);
state
.db
.save_provider(AppType::OpenCode.as_str(), &provider)
.unwrap_or_else(|err| panic!("seed {category} provider: {err}"));
let mut updated = provider.clone();
updated.name = format!("Updated {category}");
updated.settings_config["agents"]["writer"]["model"] =
Value::String(format!("{category}-next-model"));
ProviderService::update(state, AppType::OpenCode, None, updated)
.unwrap_or_else(|err| panic!("update {category} provider: {err}"));
let saved = state
.db
.get_provider_by_id(&provider.id, AppType::OpenCode.as_str())
.unwrap_or_else(|err| panic!("query updated {category} provider: {err}"))
.unwrap_or_else(|| panic!("{category} provider should exist"));
assert_eq!(saved.name, format!("Updated {category}"));
assert_eq!(
saved.settings_config["agents"]["writer"]["model"],
Value::String(format!("{category}-next-model")),
"{category} updates should persist in the database"
);
}
});
}
#[test]
fn update_current_omo_variant_rewrites_config_from_saved_provider() {
with_test_home(|state, home| {
for category in ["omo", "omo-slim"] {
let provider = opencode_omo_provider(&format!("{category}-current"), category);
state
.db
.save_provider(AppType::OpenCode.as_str(), &provider)
.unwrap_or_else(|err| panic!("seed current {category} provider: {err}"));
state
.db
.set_omo_provider_current(AppType::OpenCode.as_str(), &provider.id, category)
.unwrap_or_else(|err| panic!("set current {category} provider: {err}"));
let mut updated = provider.clone();
updated.name = format!("Current {category} updated");
updated.settings_config["agents"]["writer"]["model"] =
Value::String(format!("{category}-saved-model"));
updated.settings_config["otherFields"]["theme"] =
Value::String(format!("{category}-light"));
ProviderService::update(state, AppType::OpenCode, None, updated)
.unwrap_or_else(|err| panic!("update current {category} provider: {err}"));
let saved = state
.db
.get_provider_by_id(&provider.id, AppType::OpenCode.as_str())
.unwrap_or_else(|err| panic!("query current {category} provider: {err}"))
.unwrap_or_else(|| panic!("current {category} provider should exist"));
assert_eq!(saved.name, format!("Current {category} updated"));
let written = fs::read_to_string(omo_config_path(home, category))
.unwrap_or_else(|err| panic!("read written {category} config: {err}"));
let written_json: Value = serde_json::from_str(&written)
.unwrap_or_else(|err| panic!("parse written {category} config: {err}"));
assert_eq!(
written_json["agents"]["writer"]["model"],
Value::String(format!("{category}-saved-model")),
"{category} config should be written from the saved provider state"
);
assert_eq!(
written_json["theme"],
Value::String(format!("{category}-light")),
"{category} top-level config should reflect updated otherFields"
);
}
});
}
#[test]
fn update_current_omo_variant_does_not_persist_database_when_file_write_fails() {
with_test_home(|state, home| {
let provider = opencode_omo_provider("omo-current", "omo");
state
.db
.save_provider(AppType::OpenCode.as_str(), &provider)
.unwrap_or_else(|err| panic!("seed current omo provider: {err}"));
state
.db
.set_omo_provider_current(AppType::OpenCode.as_str(), &provider.id, "omo")
.unwrap_or_else(|err| panic!("set current omo provider: {err}"));
let config_dir = home.join(".config").join("opencode");
fs::create_dir_all(config_dir.parent().expect("config dir parent"))
.expect("create .config dir");
fs::write(&config_dir, "not a directory").expect("block opencode config dir");
let mut updated = provider.clone();
updated.name = "Current omo updated".to_string();
updated.settings_config["agents"]["writer"]["model"] =
Value::String("omo-saved-model".to_string());
ProviderService::update(state, AppType::OpenCode, None, updated)
.expect_err("update should fail when current omo file write fails");
let saved = state
.db
.get_provider_by_id(&provider.id, AppType::OpenCode.as_str())
.unwrap_or_else(|err| panic!("query current omo provider: {err}"))
.unwrap_or_else(|| panic!("current omo provider should exist"));
assert_eq!(saved.name, provider.name);
assert_eq!(
saved.settings_config["agents"]["writer"]["model"],
provider.settings_config["agents"]["writer"]["model"],
"database should remain unchanged when file write fails"
);
});
}
#[test]
fn update_current_omo_variant_rolls_back_file_when_plugin_sync_fails() {
with_test_home(|state, home| {
let provider = opencode_omo_provider("omo-current", "omo");
state
.db
.save_provider(AppType::OpenCode.as_str(), &provider)
.unwrap_or_else(|err| panic!("seed current omo provider: {err}"));
state
.db
.set_omo_provider_current(AppType::OpenCode.as_str(), &provider.id, "omo")
.unwrap_or_else(|err| panic!("set current omo provider: {err}"));
let config_path = omo_config_path(home, "omo");
fs::create_dir_all(config_path.parent().expect("omo config parent"))
.expect("create omo config dir");
let previous_content = serde_json::to_string_pretty(&json!({
"theme": "legacy-live-theme",
"agents": {
"writer": {
"model": "legacy-live-model"
}
},
"categories": {
"default": ["writer"]
}
}))
.expect("serialize previous config");
fs::write(&config_path, &previous_content).expect("seed previous omo config");
let opencode_config_path = home.join(".config").join("opencode").join("opencode.json");
fs::write(&opencode_config_path, "{ invalid json").expect("seed malformed opencode");
let mut updated = provider.clone();
updated.name = "Current omo updated".to_string();
updated.settings_config["agents"]["writer"]["model"] =
Value::String("omo-saved-model".to_string());
updated.settings_config["otherFields"]["theme"] =
Value::String("omo-light".to_string());
ProviderService::update(state, AppType::OpenCode, None, updated)
.expect_err("update should fail when plugin sync fails");
let saved = state
.db
.get_provider_by_id(&provider.id, AppType::OpenCode.as_str())
.unwrap_or_else(|err| panic!("query current omo provider: {err}"))
.unwrap_or_else(|| panic!("current omo provider should exist"));
assert_eq!(saved.name, provider.name);
assert_eq!(
saved.settings_config["agents"]["writer"]["model"],
provider.settings_config["agents"]["writer"]["model"],
"database should remain unchanged when plugin sync fails"
);
let written =
fs::read_to_string(&config_path).expect("read rolled back omo config content");
assert_eq!(
written, previous_content,
"OMO config should roll back to its previous on-disk contents"
);
});
}
}
impl ProviderService {
fn 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);
}
/// List all providers for an app type
pub fn list(
state: &AppState,
app_type: AppType,
) -> Result<IndexMap<String, Provider>, AppError> {
state.db.get_all_providers(app_type.as_str())
}
/// Get current provider ID
///
/// 使用有效的当前供应商 ID验证过存在性
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
/// 这确保了云同步场景下多设备可以独立选择供应商,且返回的 ID 一定有效。
///
/// 对于累加模式应用OpenCode, OpenClaw不存在"当前供应商"概念,直接返回空字符串。
pub fn current(state: &AppState, app_type: AppType) -> Result<String, AppError> {
// Additive mode apps have no "current" provider concept
if app_type.is_additive_mode() {
return Ok(String::new());
}
crate::settings::get_effective_current_provider(&state.db, &app_type)
.map(|opt| opt.unwrap_or_default())
}
/// Add a new provider
pub fn add(
state: &AppState,
app_type: AppType,
provider: Provider,
add_to_live: bool,
) -> Result<bool, AppError> {
let mut provider = provider;
// Normalize Claude model keys
Self::normalize_provider_if_claude(&app_type, &mut provider);
Self::validate_provider_settings(&app_type, &provider)?;
normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?;
if app_type.is_additive_mode() {
Self::set_provider_live_config_managed(&mut provider, add_to_live);
}
// Save to database
state.db.save_provider(app_type.as_str(), &provider)?;
// Additive mode apps (OpenCode, OpenClaw): optionally write to live config.
if app_type.is_additive_mode() {
// OMO / OMO Slim providers use exclusive mode and write to dedicated config file.
if matches!(app_type, AppType::OpenCode)
&& matches!(provider.category.as_deref(), Some("omo") | Some("omo-slim"))
{
// Do not auto-enable newly added OMO / OMO Slim providers.
// Users must explicitly switch/apply an OMO provider to activate it.
return Ok(true);
}
if !add_to_live {
return Ok(true);
}
write_live_with_common_config(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>,
provider: Provider,
) -> Result<bool, AppError> {
let mut provider = provider;
let original_id = original_id.unwrap_or(provider.id.as_str()).to_string();
let provider_id_changed = original_id != provider.id;
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)?;
if provider_id_changed {
if !app_type.is_additive_mode() {
return Err(AppError::Message(
"Only additive-mode providers support changing provider key".to_string(),
));
}
let Some(existing_provider) = existing_provider else {
return Err(AppError::Message(format!(
"Original provider '{}' does not exist in app '{}'",
original_id,
app_type.as_str()
)));
};
// OMO / OMO Slim providers are activated via a dedicated current-state mechanism
// (set_omo_provider_current) that is NOT captured by provider_exists_in_live_config,
// which only checks opencode.json. A rename would orphan that current-state marker
// and silently break subsequent OMO file syncs. Block it unconditionally.
if matches!(app_type, AppType::OpenCode)
&& matches!(
existing_provider.category.as_deref(),
Some("omo") | Some("omo-slim")
)
{
return Err(AppError::Message(
"Provider key cannot be changed for OMO/OMO Slim providers".to_string(),
));
}
let original_in_live = Self::check_live_config_exists(
&app_type,
&original_id,
Self::provider_live_config_managed(&existing_provider),
)?;
if original_in_live {
return Err(AppError::Message(
"Provider key cannot be changed after the provider has been added to the app config"
.to_string(),
));
}
let next_id_in_live = Self::check_live_config_exists(
&app_type,
&provider.id,
Self::provider_live_config_managed(&existing_provider),
)?;
if state
.db
.get_provider_by_id(&provider.id, app_type.as_str())?
.is_some()
|| next_id_in_live
{
return Err(AppError::Message(format!(
"Provider '{}' already exists in app '{}'",
provider.id,
app_type.as_str()
)));
}
Self::set_provider_live_config_managed(&mut provider, false);
state.db.save_provider(app_type.as_str(), &provider)?;
state.db.delete_provider(app_type.as_str(), &original_id)?;
if crate::settings::get_current_provider(&app_type).as_deref() == Some(&original_id) {
crate::settings::set_current_provider(&app_type, Some(provider.id.as_str()))?;
}
return Ok(true);
}
// Additive mode apps (OpenCode, OpenClaw): only sync to live when the provider
// already exists in live config. Editing a DB-only provider must not auto-add it.
if app_type.is_additive_mode() {
let omo_variant = if matches!(app_type, AppType::OpenCode) {
match provider.category.as_deref() {
Some("omo") => Some(&crate::services::omo::STANDARD),
Some("omo-slim") => Some(&crate::services::omo::SLIM),
_ => None,
}
} else {
None
};
if let Some(variant) = omo_variant {
let is_current = state.db.is_omo_provider_current(
app_type.as_str(),
&provider.id,
variant.category,
)?;
if is_current {
crate::services::OmoService::write_provider_config_to_file(&provider, variant)?;
}
if let Err(err) = state.db.save_provider(app_type.as_str(), &provider) {
if is_current {
if let Err(rollback_err) =
crate::services::OmoService::write_config_to_file(state, variant)
{
log::warn!(
"Failed to roll back {} config after DB save error: {}",
variant.label,
rollback_err
);
}
}
return Err(err);
}
return Ok(true);
}
let live_config_managed = Self::check_live_config_exists(
&app_type,
&provider.id,
Self::provider_live_config_managed(&provider).or_else(|| {
existing_provider
.as_ref()
.and_then(Self::provider_live_config_managed)
}),
)?;
Self::set_provider_live_config_managed(&mut provider, live_config_managed);
// Save to database after live-config presence is resolved so parse errors
// do not report failure after already mutating DB state.
state.db.save_provider(app_type.as_str(), &provider)?;
if !live_config_managed {
return Ok(true);
}
write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;
return Ok(true);
}
// Save to database
state.db.save_provider(app_type.as_str(), &provider)?;
// For other apps: Check if this is current provider (use effective current, not just DB)
let effective_current =
crate::settings::get_effective_current_provider(&state.db, &app_type)?;
let is_current = effective_current.as_deref() == Some(provider.id.as_str());
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 is_proxy_running = futures::executor::block_on(state.proxy_service.is_running());
let live_taken_over = state
.proxy_service
.detect_takeover_in_live_config_for_app(&app_type);
let should_sync_via_proxy = is_proxy_running && (has_live_backup || live_taken_over);
if should_sync_via_proxy {
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 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 {
write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;
// Sync MCP
McpService::sync_all_enabled(state)?;
}
}
Ok(true)
}
/// Delete a provider
///
/// 同时检查本地 settings 和数据库的当前供应商,防止删除任一端正在使用的供应商。
/// 对于累加模式应用OpenCode, OpenClaw可以随时删除任意供应商同时从 live 配置中移除。
pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
// Additive mode apps - no current provider concept
if app_type.is_additive_mode() {
// Single DB read shared across all additive-mode sub-paths below.
let existing = state.db.get_provider_by_id(id, app_type.as_str())?;
if matches!(app_type, AppType::OpenCode) {
let provider_category = existing.as_ref().and_then(|p| p.category.clone());
let omo_variant = match provider_category.as_deref() {
Some("omo") => Some(&crate::services::omo::STANDARD),
Some("omo-slim") => Some(&crate::services::omo::SLIM),
_ => None,
};
if let Some(variant) = omo_variant {
let was_current = state.db.is_omo_provider_current(
app_type.as_str(),
id,
variant.category,
)?;
state.db.delete_provider(app_type.as_str(), id)?;
if was_current {
crate::services::OmoService::delete_config_file(variant)?;
}
return Ok(());
}
}
// Non-OMO path for both OpenCode and OpenClaw:
// remove from live first (atomicity), then DB.
//
// Use check_live_config_exists rather than trusting the flag alone: the flag
// can be stale (Some(false) for a provider that was written to live before the
// live_config_managed flip was introduced). check_live_config_exists reads the
// actual file when the flag is Some(false), so it handles historical data correctly.
let live_managed = existing
.as_ref()
.and_then(Self::provider_live_config_managed);
if Self::check_live_config_exists(&app_type, id, live_managed)? {
match app_type {
AppType::OpenCode => remove_opencode_provider_from_live(id)?,
AppType::OpenClaw => remove_openclaw_provider_from_live(id)?,
_ => {}
}
}
state.db.delete_provider(app_type.as_str(), id)?;
return Ok(());
}
// For other apps: Check both local settings and database
let local_current = crate::settings::get_current_provider(&app_type);
let db_current = state.db.get_current_provider(app_type.as_str())?;
if local_current.as_deref() == Some(id) || db_current.as_deref() == Some(id) {
return Err(AppError::Message(
"无法删除当前正在使用的供应商".to_string(),
));
}
state.db.delete_provider(app_type.as_str(), id)
}
/// Remove provider from live config only (for additive mode apps like OpenCode, OpenClaw)
///
/// Does NOT delete from database - provider remains in the list.
/// This is used when user wants to "remove" a provider from active config
/// but keep it available for future use.
pub fn remove_from_live_config(
state: &AppState,
app_type: AppType,
id: &str,
) -> Result<(), AppError> {
match app_type {
AppType::OpenCode => {
let provider_category = state
.db
.get_provider_by_id(id, app_type.as_str())?
.and_then(|p| p.category);
let omo_variant = match provider_category.as_deref() {
Some("omo") => Some(&crate::services::omo::STANDARD),
Some("omo-slim") => Some(&crate::services::omo::SLIM),
_ => None,
};
if let Some(variant) = omo_variant {
state
.db
.clear_omo_provider_current(app_type.as_str(), id, variant.category)?;
let still_has_current = state
.db
.get_current_omo_provider("opencode", variant.category)?
.is_some();
if still_has_current {
crate::services::OmoService::write_config_to_file(state, variant)?;
} else {
crate::services::OmoService::delete_config_file(variant)?;
}
} else {
remove_opencode_provider_from_live(id)?;
}
}
AppType::OpenClaw => {
remove_openclaw_provider_from_live(id)?;
}
_ => {
return Err(AppError::Message(format!(
"App {} does not support remove from live config",
app_type.as_str()
)));
}
}
if let Some(mut provider) = state.db.get_provider_by_id(id, app_type.as_str())? {
Self::set_provider_live_config_managed(&mut provider, false);
state.db.save_provider(app_type.as_str(), &provider)?;
}
Ok(())
}
/// Switch to a provider
///
/// Switch flow:
/// 1. Validate target provider exists
/// 2. Check if proxy takeover mode is active AND proxy server is running
/// 3. If takeover mode active: hot-switch proxy target only (no Live config write)
/// 4. If normal mode:
/// a. **Backfill mechanism**: Backfill current live config to current provider
/// b. Update local settings current_provider_xxx (device-level)
/// c. Update database is_current (as default for new devices)
/// d. Write target provider config to live files
/// e. Sync MCP configuration
pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<SwitchResult, AppError> {
// Check if provider exists
let providers = state.db.get_all_providers(app_type.as_str())?;
let _provider = providers
.get(id)
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
// OMO providers are switched through their own exclusive path.
if matches!(app_type, AppType::OpenCode) && _provider.category.as_deref() == Some("omo") {
return Self::switch_normal(state, app_type, id, &providers);
}
// OMO Slim providers are switched through their own exclusive path.
if matches!(app_type, AppType::OpenCode)
&& _provider.category.as_deref() == Some("omo-slim")
{
return Self::switch_normal(state, app_type, id, &providers);
}
// Check if proxy takeover mode is active AND proxy server is actually running
// Both conditions must be true to use hot-switch mode
// Use blocking wait since this is a sync function
let is_app_taken_over =
futures::executor::block_on(state.db.get_live_backup(app_type.as_str()))
.ok()
.flatten()
.is_some();
let is_proxy_running = futures::executor::block_on(state.proxy_service.is_running());
let live_taken_over = state
.proxy_service
.detect_takeover_in_live_config_for_app(&app_type);
// Hot-switch only when BOTH: this app is taken over AND proxy server is actually running
let should_hot_switch = (is_app_taken_over || live_taken_over) && is_proxy_running;
if should_hot_switch {
// Proxy takeover mode: hot-switch only, don't write Live config
log::info!(
"代理接管模式:热切换 {} 的目标供应商为 {}",
app_type.as_str(),
id
);
futures::executor::block_on(
state
.proxy_service
.hot_switch_provider(app_type.as_str(), id),
)
.map_err(|e| AppError::Message(format!("热切换失败: {e}")))?;
// Note: No Live config write, no MCP sync
// The proxy server will route requests to the new provider via is_current
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) = providers.get(&current_id).cloned() {
current_provider.settings_config =
strip_common_config_from_live_settings(
state.db.as_ref(),
&app_type,
&current_provider,
live_config,
);
if let Err(e) =
state.db.save_provider(app_type.as_str(), &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)?;
// For additive-mode providers that were DB-only (live_config_managed == Some(false)),
// flip the flag to true now that the provider has been successfully written to the live
// file. This ensures sync_all_providers_to_live() will include it on future syncs.
//
// If persisting the marker fails, roll back the just-written live config so we don't leave
// the provider in a silent inconsistent state (present in live, but still marked DB-only).
if app_type.is_additive_mode() && Self::provider_live_config_managed(provider) != Some(true)
{
let mut updated = provider.clone();
Self::set_provider_live_config_managed(&mut updated, true);
if let Err(e) = state.db.save_provider(app_type.as_str(), &updated) {
let rollback_result = match app_type {
AppType::OpenCode => remove_opencode_provider_from_live(&provider.id),
AppType::OpenClaw => remove_openclaw_provider_from_live(&provider.id),
_ => 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
)));
}
}
}
}
// Sync MCP
McpService::sync_all_enabled(state)?;
Ok(result)
}
/// Sync current provider to live configuration (re-export)
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
sync_current_to_live(state)
}
pub fn sync_current_provider_for_app(
state: &AppState,
app_type: AppType,
) -> Result<(), AppError> {
if app_type.is_additive_mode() {
return sync_current_provider_for_app_to_live(state, &app_type);
}
let current_id =
match crate::settings::get_effective_current_provider(&state.db, &app_type)? {
Some(id) => id,
None => return Ok(()),
};
let providers = state.db.get_all_providers(app_type.as_str())?;
let Some(provider) = providers.get(&current_id) else {
return Ok(());
};
let takeover_enabled =
futures::executor::block_on(state.db.get_proxy_config_for_app(app_type.as_str()))
.map(|config| config.enabled)
.unwrap_or(false);
let has_live_backup =
futures::executor::block_on(state.db.get_live_backup(app_type.as_str()))
.ok()
.flatten()
.is_some();
let live_taken_over = state
.proxy_service
.detect_takeover_in_live_config_for_app(&app_type);
if takeover_enabled && (has_live_backup || live_taken_over) {
futures::executor::block_on(
state
.proxy_service
.update_live_backup_from_provider(app_type.as_str(), provider),
)
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
return Ok(());
}
sync_current_provider_for_app_to_live(state, &app_type)
}
pub fn migrate_legacy_common_config_usage(
state: &AppState,
app_type: AppType,
legacy_snippet: &str,
) -> Result<(), AppError> {
if app_type.is_additive_mode() || legacy_snippet.trim().is_empty() {
return Ok(());
}
let providers = state.db.get_all_providers(app_type.as_str())?;
for provider in providers.values() {
if provider
.meta
.as_ref()
.and_then(|meta| meta.common_config_enabled)
.is_some()
{
continue;
}
if !live::provider_uses_common_config(&app_type, provider, Some(legacy_snippet)) {
continue;
}
let mut updated_provider = provider.clone();
updated_provider
.meta
.get_or_insert_with(Default::default)
.common_config_enabled = Some(true);
match live::remove_common_config_from_settings(
&app_type,
&updated_provider.settings_config,
legacy_snippet,
) {
Ok(settings) => updated_provider.settings_config = settings,
Err(err) => {
log::warn!(
"Failed to normalize legacy common config for {} provider '{}': {err}",
app_type.as_str(),
updated_provider.id
);
}
}
state
.db
.save_provider(app_type.as_str(), &updated_provider)?;
}
Ok(())
}
pub fn migrate_legacy_common_config_usage_if_needed(
state: &AppState,
app_type: AppType,
) -> Result<(), AppError> {
if app_type.is_additive_mode() {
return Ok(());
}
let Some(snippet) = state.db.get_config_snippet(app_type.as_str())? else {
return Ok(());
};
if snippet.trim().is_empty() {
return Ok(());
}
Self::migrate_legacy_common_config_usage(state, app_type, &snippet)
}
/// Extract common config snippet from current provider
///
/// Extracts the current provider's configuration and removes provider-specific fields
/// (API keys, model settings, endpoints) to create a reusable common config snippet.
pub fn extract_common_config_snippet(
state: &AppState,
app_type: AppType,
) -> Result<String, AppError> {
// Get current provider
let current_id = Self::current(state, app_type.clone())?;
if current_id.is_empty() {
return Err(AppError::Message("No current provider".to_string()));
}
let providers = state.db.get_all_providers(app_type.as_str())?;
let provider = providers
.get(&current_id)
.ok_or_else(|| AppError::Message(format!("Provider {current_id} not found")))?;
match app_type {
AppType::Claude => Self::extract_claude_common_config(&provider.settings_config),
AppType::Codex => Self::extract_codex_common_config(&provider.settings_config),
AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config),
AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config),
AppType::OpenClaw => Self::extract_openclaw_common_config(&provider.settings_config),
}
}
/// 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::Codex => Self::extract_codex_common_config(settings_config),
AppType::Gemini => Self::extract_gemini_common_config(settings_config),
AppType::OpenCode => Self::extract_opencode_common_config(settings_config),
AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config),
}
}
/// Extract common config for Claude (JSON format)
fn extract_claude_common_config(settings: &Value) -> Result<String, AppError> {
let mut config = settings.clone();
// Fields to exclude from common config
const ENV_EXCLUDES: &[&str] = &[
// Auth
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
// Models (5 fields)
"ANTHROPIC_MODEL",
"ANTHROPIC_REASONING_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
// Endpoint
"ANTHROPIC_BASE_URL",
];
const TOP_LEVEL_EXCLUDES: &[&str] = &[
"apiBaseUrl",
// Legacy model fields
"primaryModel",
"smallFastModel",
];
// Remove env fields
if let Some(env) = config.get_mut("env").and_then(|v| v.as_object_mut()) {
for key in ENV_EXCLUDES {
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
if let Some(obj) = config.as_object_mut() {
for key in TOP_LEVEL_EXCLUDES {
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");
// Remove entire model_providers table (provider-specific configuration)
root.remove("model_providers");
// Clean up multiple empty lines (keep at most one blank line).
let mut cleaned = String::new();
let mut blank_run = 0usize;
for line in doc.to_string().lines() {
if line.trim().is_empty() {
blank_run += 1;
if blank_run <= 1 {
cleaned.push('\n');
}
continue;
}
blank_run = 0;
cleaned.push_str(line);
cleaned.push('\n');
}
Ok(cleaned.trim().to_string())
}
/// Extract common config for Gemini (JSON format)
///
/// Extracts `.env` values while excluding provider-specific credentials:
/// - GOOGLE_GEMINI_BASE_URL
/// - GEMINI_API_KEY
fn extract_gemini_common_config(settings: &Value) -> Result<String, AppError> {
let env = settings.get("env").and_then(|v| v.as_object());
let mut snippet = serde_json::Map::new();
if let Some(env) = env {
for (key, value) in env {
if key == "GOOGLE_GEMINI_BASE_URL" || key == "GEMINI_API_KEY" {
continue;
}
let Value::String(v) = value else {
continue;
};
let trimmed = v.trim();
if !trimmed.is_empty() {
snippet.insert(key.to_string(), Value::String(trimmed.to_string()));
}
}
}
if snippet.is_empty() {
return Ok("{}".to_string());
}
serde_json::to_string_pretty(&Value::Object(snippet))
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
}
/// Extract common config for OpenCode (JSON format)
fn extract_opencode_common_config(settings: &Value) -> Result<String, AppError> {
// OpenCode uses a different config structure with npm, options, models
// For common config, we exclude provider-specific fields like apiKey
let mut config = settings.clone();
// Remove provider-specific fields
if let Some(obj) = config.as_object_mut() {
if let Some(options) = obj.get_mut("options").and_then(|v| v.as_object_mut()) {
options.remove("apiKey");
options.remove("baseURL");
}
// Keep npm and models as they might be common
}
if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) {
return Ok("{}".to_string());
}
serde_json::to_string_pretty(&config)
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
}
/// Extract common config for OpenClaw (JSON format)
fn extract_openclaw_common_config(settings: &Value) -> Result<String, AppError> {
// OpenClaw uses a different config structure with baseUrl, apiKey, api, models
// For common config, we exclude provider-specific fields like apiKey
let mut config = settings.clone();
// Remove provider-specific fields
if let Some(obj) = config.as_object_mut() {
obj.remove("apiKey");
obj.remove("baseUrl");
// Keep api and models as they might be common
}
if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) {
return Ok("{}".to_string());
}
serde_json::to_string_pretty(&config)
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
}
/// Import default configuration from live files (re-export)
///
/// Returns `Ok(true)` if imported, `Ok(false)` if skipped.
pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
import_default_config(state, app_type)
}
/// Read current live settings (re-export)
pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
read_live_settings(app_type)
}
/// Get custom endpoints list (re-export)
pub fn get_custom_endpoints(
state: &AppState,
app_type: AppType,
provider_id: &str,
) -> Result<Vec<CustomEndpoint>, AppError> {
endpoints::get_custom_endpoints(state, app_type, provider_id)
}
/// Add custom endpoint (re-export)
pub fn add_custom_endpoint(
state: &AppState,
app_type: AppType,
provider_id: &str,
url: String,
) -> Result<(), AppError> {
endpoints::add_custom_endpoint(state, app_type, provider_id, url)
}
/// Remove custom endpoint (re-export)
pub fn remove_custom_endpoint(
state: &AppState,
app_type: AppType,
provider_id: &str,
url: String,
) -> Result<(), AppError> {
endpoints::remove_custom_endpoint(state, app_type, provider_id, url)
}
/// Update endpoint last used timestamp (re-export)
pub fn update_endpoint_last_used(
state: &AppState,
app_type: AppType,
provider_id: &str,
url: String,
) -> Result<(), AppError> {
endpoints::update_endpoint_last_used(state, app_type, provider_id, url)
}
/// Update provider sort order
pub fn update_sort_order(
state: &AppState,
app_type: AppType,
updates: Vec<ProviderSortUpdate>,
) -> Result<bool, AppError> {
let mut providers = state.db.get_all_providers(app_type.as_str())?;
for update in updates {
if let Some(provider) = providers.get_mut(&update.id) {
provider.sort_index = Some(update.sort_index);
state.db.save_provider(app_type.as_str(), provider)?;
}
}
Ok(true)
}
/// Query provider usage (re-export)
pub async fn query_usage(
state: &AppState,
app_type: AppType,
provider_id: &str,
) -> Result<UsageResult, AppError> {
usage::query_usage(state, app_type, provider_id).await
}
/// Test usage script (re-export)
#[allow(clippy::too_many_arguments)]
pub async fn test_usage_script(
state: &AppState,
app_type: AppType,
provider_id: &str,
script_code: &str,
timeout: u64,
api_key: Option<&str>,
base_url: Option<&str>,
access_token: Option<&str>,
user_id: Option<&str>,
template_type: Option<&str>,
) -> Result<UsageResult, AppError> {
usage::test_usage_script(
state,
app_type,
provider_id,
script_code,
timeout,
api_key,
base_url,
access_token,
user_id,
template_type,
)
.await
}
pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
write_gemini_live(provider)
}
fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {
match app_type {
AppType::Claude => {
if !provider.settings_config.is_object() {
return Err(AppError::localized(
"provider.claude.settings.not_object",
"Claude 配置必须是 JSON 对象",
"Claude configuration must be a JSON object",
));
}
}
AppType::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::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",
));
}
}
}
// Validate and clean UsageScript configuration (common for all app types)
if let Some(meta) = &provider.meta {
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::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 api_key = auth
.get("OPENAI_API_KEY")
.and_then(|v| v.as_str())
.ok_or_else(|| {
AppError::localized(
"provider.codex.api_key.missing",
"缺少 API Key",
"API key is missing",
)
})?
.to_string();
let config_toml = provider
.settings_config
.get("config")
.and_then(|v| v.as_str())
.unwrap_or("");
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 => {
// OpenClaw uses apiKey and baseUrl directly on the object
let api_key = provider
.settings_config
.get("apiKey")
.and_then(|v| v.as_str())
.ok_or_else(|| {
AppError::localized(
"provider.openclaw.api_key.missing",
"缺少 API Key",
"API key is missing",
)
})?
.to_string();
let base_url = provider
.settings_config
.get("baseUrl")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Ok((api_key, base_url))
}
}
}
}
/// Normalize Claude model keys in a JSON value
///
/// Reads old key (ANTHROPIC_SMALL_FAST_MODEL), writes new keys (DEFAULT_*), and deletes old key.
pub(crate) fn normalize_claude_models_in_value(settings: &mut Value) -> bool {
let mut changed = false;
let env = match settings.get_mut("env").and_then(|v| v.as_object_mut()) {
Some(obj) => obj,
None => return changed,
};
let model = env
.get("ANTHROPIC_MODEL")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let small_fast = env
.get("ANTHROPIC_SMALL_FAST_MODEL")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let current_haiku = env
.get("ANTHROPIC_DEFAULT_HAIKU_MODEL")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let current_sonnet = env
.get("ANTHROPIC_DEFAULT_SONNET_MODEL")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let current_opus = env
.get("ANTHROPIC_DEFAULT_OPUS_MODEL")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let target_haiku = current_haiku
.or_else(|| small_fast.clone())
.or_else(|| model.clone());
let target_sonnet = current_sonnet
.or_else(|| model.clone())
.or_else(|| small_fast.clone());
let target_opus = current_opus
.or_else(|| model.clone())
.or_else(|| small_fast.clone());
if env.get("ANTHROPIC_DEFAULT_HAIKU_MODEL").is_none() {
if let Some(v) = target_haiku {
env.insert(
"ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(),
Value::String(v),
);
changed = true;
}
}
if env.get("ANTHROPIC_DEFAULT_SONNET_MODEL").is_none() {
if let Some(v) = target_sonnet {
env.insert(
"ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(),
Value::String(v),
);
changed = true;
}
}
if env.get("ANTHROPIC_DEFAULT_OPUS_MODEL").is_none() {
if let Some(v) = target_opus {
env.insert("ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(), Value::String(v));
changed = true;
}
}
if env.remove("ANTHROPIC_SMALL_FAST_MODEL").is_some() {
changed = true;
}
changed
}
#[derive(Debug, Clone, Deserialize)]
pub struct ProviderSortUpdate {
pub id: String,
#[serde(rename = "sortIndex")]
pub sort_index: usize,
}
// ============================================================================
// 统一供应商Universal Provider服务方法
// ============================================================================
use crate::provider::UniversalProvider;
use std::collections::HashMap;
impl ProviderService {
/// 获取所有统一供应商
pub fn list_universal(
state: &AppState,
) -> Result<HashMap<String, UniversalProvider>, AppError> {
state.db.get_all_universal_providers()
}
/// 获取单个统一供应商
pub fn get_universal(
state: &AppState,
id: &str,
) -> Result<Option<UniversalProvider>, AppError> {
state.db.get_universal_provider(id)
}
/// 添加或更新统一供应商(不自动同步,需手动调用 sync_universal_to_apps
pub fn upsert_universal(
state: &AppState,
provider: UniversalProvider,
) -> Result<bool, AppError> {
// 保存统一供应商
state.db.save_universal_provider(&provider)?;
Ok(true)
}
/// 删除统一供应商
pub fn delete_universal(state: &AppState, id: &str) -> Result<bool, AppError> {
// 获取统一供应商(用于删除生成的子供应商)
let provider = state.db.get_universal_provider(id)?;
// 删除统一供应商
state.db.delete_universal_provider(id)?;
// 删除生成的子供应商
if let Some(p) = provider {
if p.apps.claude {
let claude_id = format!("universal-claude-{id}");
let _ = state.db.delete_provider("claude", &claude_id);
}
if p.apps.codex {
let codex_id = format!("universal-codex-{id}");
let _ = state.db.delete_provider("codex", &codex_id);
}
if p.apps.gemini {
let gemini_id = format!("universal-gemini-{id}");
let _ = state.db.delete_provider("gemini", &gemini_id);
}
}
Ok(true)
}
/// 同步统一供应商到各应用
pub fn sync_universal_to_apps(state: &AppState, id: &str) -> Result<bool, AppError> {
let provider = state
.db
.get_universal_provider(id)?
.ok_or_else(|| AppError::Message(format!("统一供应商 {id} 不存在")))?;
// 同步到 Claude
if let Some(mut claude_provider) = provider.to_claude_provider() {
// 合并已有配置
if let Some(existing) = state.db.get_provider_by_id(&claude_provider.id, "claude")? {
let mut merged = existing.settings_config.clone();
Self::merge_json(&mut merged, &claude_provider.settings_config);
claude_provider.settings_config = merged;
}
state.db.save_provider("claude", &claude_provider)?;
} else {
// 如果禁用了 Claude删除对应的子供应商
let claude_id = format!("universal-claude-{id}");
let _ = state.db.delete_provider("claude", &claude_id);
}
// 同步到 Codex
if let Some(mut codex_provider) = provider.to_codex_provider() {
// 合并已有配置
if let Some(existing) = state.db.get_provider_by_id(&codex_provider.id, "codex")? {
let mut merged = existing.settings_config.clone();
Self::merge_json(&mut merged, &codex_provider.settings_config);
codex_provider.settings_config = merged;
}
state.db.save_provider("codex", &codex_provider)?;
} else {
let codex_id = format!("universal-codex-{id}");
let _ = state.db.delete_provider("codex", &codex_id);
}
// 同步到 Gemini
if let Some(mut gemini_provider) = provider.to_gemini_provider() {
// 合并已有配置
if let Some(existing) = state.db.get_provider_by_id(&gemini_provider.id, "gemini")? {
let mut merged = existing.settings_config.clone();
Self::merge_json(&mut merged, &gemini_provider.settings_config);
gemini_provider.settings_config = merged;
}
state.db.save_provider("gemini", &gemini_provider)?;
} else {
let gemini_id = format!("universal-gemini-{id}");
let _ = state.db.delete_provider("gemini", &gemini_id);
}
Ok(true)
}
/// 递归合并 JSONbase 为底patch 覆盖同名字段
fn merge_json(base: &mut serde_json::Value, patch: &serde_json::Value) {
use serde_json::Value;
match (base, patch) {
(Value::Object(base_map), Value::Object(patch_map)) => {
for (k, v_patch) in patch_map {
match base_map.get_mut(k) {
Some(v_base) => Self::merge_json(v_base, v_patch),
None => {
base_map.insert(k.clone(), v_patch.clone());
}
}
}
}
// 其它类型:直接覆盖
(base_val, patch_val) => {
*base_val = patch_val.clone();
}
}
}
}