mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-04-08 15:10:34 +08:00
2473 lines
94 KiB
Rust
2473 lines
94 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::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(¤t_id).cloned() {
|
||
current_provider.settings_config =
|
||
strip_common_config_from_live_settings(
|
||
state.db.as_ref(),
|
||
&app_type,
|
||
¤t_provider,
|
||
live_config,
|
||
);
|
||
if let Err(e) =
|
||
state.db.save_provider(app_type.as_str(), ¤t_provider)
|
||
{
|
||
log::warn!("Backfill failed: {e}");
|
||
result
|
||
.warnings
|
||
.push(format!("backfill_failed:{current_id}"));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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(¤t_id) else {
|
||
return Ok(());
|
||
};
|
||
|
||
let takeover_enabled =
|
||
futures::executor::block_on(state.db.get_proxy_config_for_app(app_type.as_str()))
|
||
.map(|config| config.enabled)
|
||
.unwrap_or(false);
|
||
|
||
let has_live_backup =
|
||
futures::executor::block_on(state.db.get_live_backup(app_type.as_str()))
|
||
.ok()
|
||
.flatten()
|
||
.is_some();
|
||
|
||
let live_taken_over = state
|
||
.proxy_service
|
||
.detect_takeover_in_live_config_for_app(&app_type);
|
||
|
||
if takeover_enabled && (has_live_backup || live_taken_over) {
|
||
futures::executor::block_on(
|
||
state
|
||
.proxy_service
|
||
.update_live_backup_from_provider(app_type.as_str(), provider),
|
||
)
|
||
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
|
||
return Ok(());
|
||
}
|
||
|
||
sync_current_provider_for_app_to_live(state, &app_type)
|
||
}
|
||
|
||
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(¤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::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)
|
||
}
|
||
|
||
/// 递归合并 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();
|
||
}
|
||
}
|
||
}
|
||
}
|