Files
CC-Switch/src-tauri/src/commands/provider.rs
T
Dex Miller 3553d05bf0 feat(provider): additive provider key lifecycle & fix openclaw serializer panic (#1724)
* feat(provider): support additive provider key lifecycle management

Add `addToLive` parameter to add_provider so callers can opt out of
writing to the live config (e.g. when duplicating an inactive provider).
Add `originalId` parameter to update_provider to support provider key
renames — the old key is removed from live config before the new one
is written.

Frontend: ProviderForm now exposes provider-key input for openclaw app
type, and EditProviderDialog forwards originalId on save. Deep-link
import passes addToLive=true to preserve existing behavior.

* test(provider): add integration tests for additive provider key flows

Cover openclaw provider duplication scenario to verify that a generated
provider key is assigned automatically. Add MSW handlers for
get_openclaw_live_provider_ids, get_openclaw_default_model,
scan_openclaw_config_health, and check_env_conflicts endpoints.
Update EditProviderDialog mock to pass originalId alongside provider.

* fix(openclaw): replace json-five serializer to prevent panic on empty collections

json-five 0.3.1 panics when pretty-printing nested empty maps/arrays.
Switch value_to_rt_value() to serde_json::to_string_pretty() which
produces valid JSON5 output without the panic. Add regression test for
removing the last provider (empty providers map).

* style: apply rustfmt formatting to proxy and provider modules

Reformat chained .header() calls in ClaudeAdapter and StreamCheckService
for consistent alignment. Reorder imports alphabetically in stream_check.
Fix trailing whitespace in transform.rs and merge import lines in
provider/mod.rs.

* style: fix clippy warnings in live.rs and tray.rs

* refactor(provider): simplify live_config_managed and deduplicate tolerant live config checks

- Change live_config_managed from Option<bool> to bool with #[serde(default)]
- Extract repeated tolerant live config query into check_live_config_exists helper
- Fix duplicate key generation to also check live-only provider IDs
- Fix updateProvider test to match new { provider, originalId } call signature
- Add streaming_responses test type annotation for compiler inference

* fix(provider): distinguish legacy providers from db-only when tolerating live config errors

Change `ProviderMeta.live_config_managed` from `bool` to `Option<bool>`
to introduce a three-state semantic:
- `Some(true)`: provider has been written to live config
- `Some(false)`: explicitly db-only, never written to live config
- `None`: legacy data or unknown state (pre-existing providers)

Previously, legacy providers defaulted to `live_config_managed = false`
via `#[serde(default)]`, which silently swallowed live config parse
errors. This could mask genuine configuration issues for providers that
had actually been synced to live config before the field was introduced.

Now, only providers with an explicit `Some(false)` marker tolerate parse
errors; legacy `None` providers surface errors as before, preserving
safety for already-managed configurations.

Also wrap the `ensureQueryData` call for live provider IDs during
duplication in a try/catch so that a malformed config file shows a
user-facing toast instead of silently failing.

Add tests for both the legacy error propagation path and the frontend
duplication failure scenario.

* refactor(provider): unify OMO variant updates with atomic file-then-db writes and rollback

Consolidate the duplicated omo/omo-slim update branches into a single
match on the variant. Write the OMO config file from the in-memory
provider state *before* persisting to the database, so a file-write or
plugin-sync failure leaves the database unchanged. If `add_plugin`
fails after the config file is already written, roll back to the
previous on-disk contents via snapshot/restore.

Also:
- `sync_all_providers_to_live` now skips db-only providers
  (`live_config_managed == Some(false)`) instead of attempting to write
  them to live config.
- `import_{opencode,openclaw}_providers_from_live` mark imported
  providers as `live_config_managed: Some(true)` so they are correctly
  recognized during subsequent syncs.
- Extract OmoService helpers: `profile_data_from_provider`,
  `snapshot_config_file`, `restore_config_file`, `write_profile_config`,
  and the new public `write_provider_config_to_file`.
- Add 9 new tests covering sync skip, legacy restore, import marking,
  OMO persistence, file-write failure, and plugin-sync rollback.

* fix(provider): fix additive provider delete/switch regressions and redundancy

- fix(delete): replace stale live_config_managed flag check with
  check_live_config_exists so providers written to live before the
  flag-flip logic was introduced are still cleaned up on delete
- fix(switch): make write_live_with_common_config return Err instead of
  silently returning Ok when config structure is invalid, preventing
  live_config_managed from being incorrectly flipped to true
- fix(update): block provider key rename for OMO/OMO Slim categories to
  prevent orphaned current-state markers breaking OMO file syncs
- fix(switch): flip live_config_managed to true after successful live
  write for DB-only additive providers so sync_all_providers_to_live
  includes them on future syncs; roll back live write if DB update fails
- refactor(delete): merge symmetric OMO/OMO-Slim blocks into single
  match-on-variant path; hoist DB read to top of additive branch
- refactor(remove_from_live_config): merge OMO/OMO-Slim if/else-if
  into single match-on-variant path
- refactor(switch_normal): merge two OMO/OMO-Slim if blocks into one
  OpenCode guard with (enable, disable) variant pair
- fix(update): remove redundant duplicate return Ok(true) after OMO
  current-state write

* fix(test): use preferred_filename after OMO field rename

The merge from main brought in #1746 which renamed
OmoVariant.filename → preferred_filename, but the test helper
omo_config_path() was not updated, breaking compilation of all
new provider tests.

---------

Co-authored-by: Jason <farion1231@gmail.com>
2026-04-01 21:16:41 +08:00

418 lines
13 KiB
Rust

use indexmap::IndexMap;
use tauri::State;
use crate::app_config::AppType;
use crate::commands::copilot::CopilotAuthState;
use crate::error::AppError;
use crate::provider::Provider;
use crate::services::{
EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService, SwitchResult,
};
use crate::store::AppState;
use std::str::FromStr;
// 常量定义
const TEMPLATE_TYPE_GITHUB_COPILOT: &str = "github_copilot";
const COPILOT_UNIT_PREMIUM: &str = "requests";
/// 获取所有供应商
#[tauri::command]
pub fn get_providers(
state: State<'_, AppState>,
app: String,
) -> Result<IndexMap<String, Provider>, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::list(state.inner(), app_type).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_current_provider(state: State<'_, AppState>, app: String) -> Result<String, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::current(state.inner(), app_type).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn add_provider(
state: State<'_, AppState>,
app: String,
provider: Provider,
#[allow(non_snake_case)] addToLive: Option<bool>,
) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::add(state.inner(), app_type, provider, addToLive.unwrap_or(true))
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn update_provider(
state: State<'_, AppState>,
app: String,
provider: Provider,
#[allow(non_snake_case)] originalId: Option<String>,
) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::update(state.inner(), app_type, originalId.as_deref(), provider)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn delete_provider(
state: State<'_, AppState>,
app: String,
id: String,
) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::delete(state.inner(), app_type, &id)
.map(|_| true)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn remove_provider_from_live_config(
state: tauri::State<'_, AppState>,
app: String,
id: String,
) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::remove_from_live_config(state.inner(), app_type, &id)
.map(|_| true)
.map_err(|e| e.to_string())
}
fn switch_provider_internal(
state: &AppState,
app_type: AppType,
id: &str,
) -> Result<SwitchResult, AppError> {
ProviderService::switch(state, app_type, id)
}
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
pub fn switch_provider_test_hook(
state: &AppState,
app_type: AppType,
id: &str,
) -> Result<SwitchResult, AppError> {
switch_provider_internal(state, app_type, id)
}
#[tauri::command]
pub fn switch_provider(
state: State<'_, AppState>,
app: String,
id: String,
) -> Result<SwitchResult, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
switch_provider_internal(&state, app_type, &id).map_err(|e| e.to_string())
}
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
let imported = ProviderService::import_default_config(state, app_type.clone())?;
if imported {
// Extract common config snippet (mirrors old startup logic in lib.rs)
if state
.db
.should_auto_extract_config_snippet(app_type.as_str())?
{
match ProviderService::extract_common_config_snippet(state, app_type.clone()) {
Ok(snippet) if !snippet.is_empty() && snippet != "{}" => {
let _ = state
.db
.set_config_snippet(app_type.as_str(), Some(snippet));
let _ = state
.db
.set_config_snippet_cleared(app_type.as_str(), false);
}
_ => {}
}
}
ProviderService::migrate_legacy_common_config_usage_if_needed(state, app_type.clone())?;
}
Ok(imported)
}
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
pub fn import_default_config_test_hook(
state: &AppState,
app_type: AppType,
) -> Result<bool, AppError> {
import_default_config_internal(state, app_type)
}
#[tauri::command]
pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
import_default_config_internal(&state, app_type).map_err(Into::into)
}
#[allow(non_snake_case)]
#[tauri::command]
pub async fn queryProviderUsage(
state: State<'_, AppState>,
copilot_state: State<'_, CopilotAuthState>,
#[allow(non_snake_case)] providerId: String, // 使用 camelCase 匹配前端
app: String,
) -> Result<crate::provider::UsageResult, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
// 检查是否为 GitHub Copilot 模板类型,并解析绑定账号
let (is_copilot_template, copilot_account_id) = {
let providers = state
.db
.get_all_providers(app_type.as_str())
.map_err(|e| format!("Failed to get providers: {e}"))?;
let provider = providers.get(&providerId);
let is_copilot = provider
.and_then(|p| p.meta.as_ref())
.and_then(|m| m.usage_script.as_ref())
.and_then(|s| s.template_type.as_ref())
.map(|t| t == TEMPLATE_TYPE_GITHUB_COPILOT)
.unwrap_or(false);
let account_id = provider
.and_then(|p| p.meta.as_ref())
.and_then(|m| m.managed_account_id_for(TEMPLATE_TYPE_GITHUB_COPILOT));
(is_copilot, account_id)
};
if is_copilot_template {
// 使用 Copilot 专用 API
let auth_manager = copilot_state.0.read().await;
let usage = match copilot_account_id.as_deref() {
Some(account_id) => auth_manager
.fetch_usage_for_account(account_id)
.await
.map_err(|e| format!("Failed to fetch Copilot usage: {e}"))?,
None => auth_manager
.fetch_usage()
.await
.map_err(|e| format!("Failed to fetch Copilot usage: {e}"))?,
};
let premium = &usage.quota_snapshots.premium_interactions;
let used = premium.entitlement - premium.remaining;
return Ok(crate::provider::UsageResult {
success: true,
data: Some(vec![crate::provider::UsageData {
plan_name: Some(usage.copilot_plan),
remaining: Some(premium.remaining as f64),
total: Some(premium.entitlement as f64),
used: Some(used as f64),
unit: Some(COPILOT_UNIT_PREMIUM.to_string()),
is_valid: Some(true),
invalid_message: None,
extra: Some(format!("Reset: {}", usage.quota_reset_date)),
}]),
error: None,
});
}
ProviderService::query_usage(state.inner(), app_type, &providerId)
.await
.map_err(|e| e.to_string())
}
#[allow(non_snake_case)]
#[allow(clippy::too_many_arguments)]
#[tauri::command]
pub async fn testUsageScript(
state: State<'_, AppState>,
#[allow(non_snake_case)] providerId: String,
app: String,
#[allow(non_snake_case)] scriptCode: String,
timeout: Option<u64>,
#[allow(non_snake_case)] apiKey: Option<String>,
#[allow(non_snake_case)] baseUrl: Option<String>,
#[allow(non_snake_case)] accessToken: Option<String>,
#[allow(non_snake_case)] userId: Option<String>,
#[allow(non_snake_case)] templateType: Option<String>,
) -> Result<crate::provider::UsageResult, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::test_usage_script(
state.inner(),
app_type,
&providerId,
&scriptCode,
timeout.unwrap_or(10),
apiKey.as_deref(),
baseUrl.as_deref(),
accessToken.as_deref(),
userId.as_deref(),
templateType.as_deref(),
)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn read_live_provider_settings(app: String) -> Result<serde_json::Value, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::read_live_settings(app_type).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn test_api_endpoints(
urls: Vec<String>,
#[allow(non_snake_case)] timeoutSecs: Option<u64>,
) -> Result<Vec<EndpointLatency>, String> {
SpeedtestService::test_endpoints(urls, timeoutSecs)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_custom_endpoints(
state: State<'_, AppState>,
app: String,
#[allow(non_snake_case)] providerId: String,
) -> Result<Vec<crate::settings::CustomEndpoint>, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::get_custom_endpoints(state.inner(), app_type, &providerId)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn add_custom_endpoint(
state: State<'_, AppState>,
app: String,
#[allow(non_snake_case)] providerId: String,
url: String,
) -> Result<(), String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::add_custom_endpoint(state.inner(), app_type, &providerId, url)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn remove_custom_endpoint(
state: State<'_, AppState>,
app: String,
#[allow(non_snake_case)] providerId: String,
url: String,
) -> Result<(), String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::remove_custom_endpoint(state.inner(), app_type, &providerId, url)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn update_endpoint_last_used(
state: State<'_, AppState>,
app: String,
#[allow(non_snake_case)] providerId: String,
url: String,
) -> Result<(), String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::update_endpoint_last_used(state.inner(), app_type, &providerId, url)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn update_providers_sort_order(
state: State<'_, AppState>,
app: String,
updates: Vec<ProviderSortUpdate>,
) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::update_sort_order(state.inner(), app_type, updates).map_err(|e| e.to_string())
}
use crate::provider::UniversalProvider;
use std::collections::HashMap;
use tauri::{AppHandle, Emitter};
#[derive(Clone, serde::Serialize)]
pub struct UniversalProviderSyncedEvent {
pub action: String,
pub id: String,
}
fn emit_universal_provider_synced(app: &AppHandle, action: &str, id: &str) {
let _ = app.emit(
"universal-provider-synced",
UniversalProviderSyncedEvent {
action: action.to_string(),
id: id.to_string(),
},
);
}
#[tauri::command]
pub fn get_universal_providers(
state: State<'_, AppState>,
) -> Result<HashMap<String, UniversalProvider>, String> {
ProviderService::list_universal(state.inner()).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_universal_provider(
state: State<'_, AppState>,
id: String,
) -> Result<Option<UniversalProvider>, String> {
ProviderService::get_universal(state.inner(), &id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn upsert_universal_provider(
app: AppHandle,
state: State<'_, AppState>,
provider: UniversalProvider,
) -> Result<bool, String> {
let id = provider.id.clone();
let result =
ProviderService::upsert_universal(state.inner(), provider).map_err(|e| e.to_string())?;
emit_universal_provider_synced(&app, "upsert", &id);
Ok(result)
}
#[tauri::command]
pub fn delete_universal_provider(
app: AppHandle,
state: State<'_, AppState>,
id: String,
) -> Result<bool, String> {
let result =
ProviderService::delete_universal(state.inner(), &id).map_err(|e| e.to_string())?;
emit_universal_provider_synced(&app, "delete", &id);
Ok(result)
}
#[tauri::command]
pub fn sync_universal_provider(
app: AppHandle,
state: State<'_, AppState>,
id: String,
) -> Result<bool, String> {
let result =
ProviderService::sync_universal_to_apps(state.inner(), &id).map_err(|e| e.to_string())?;
emit_universal_provider_synced(&app, "sync", &id);
Ok(result)
}
#[tauri::command]
pub fn import_opencode_providers_from_live(state: State<'_, AppState>) -> Result<usize, String> {
crate::services::provider::import_opencode_providers_from_live(state.inner())
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_opencode_live_provider_ids() -> Result<Vec<String>, String> {
crate::opencode_config::get_providers()
.map(|providers| providers.keys().cloned().collect())
.map_err(|e| e.to_string())
}
// ============================================================================
// OpenClaw 专属命令 → 已迁移至 commands/openclaw.rs
// ============================================================================