feat(claude-desktop): add 3P provider switching with proxy gateway

Adds a new ClaudeDesktop AppType that writes Claude Desktop's third-party
inference profile under configLibrary/, sharing _meta.json with other
launchers (Ollama-compatible) so cc-switch can coexist with them.

Two switch modes:
- direct: provider already exposes claude-* / anthropic/claude-* model
  ids on Anthropic Messages, Claude Desktop connects to it directly.
- proxy: cc-switch's local proxy acts as the inference gateway,
  presenting only claude-* route names to Claude Desktop and mapping
  them to real upstream models. Required after Anthropic restricted
  Claude Desktop to claude-family ids.

Backend:
- New module claude_desktop_config with snapshot/rollback, official seed
  bypass, /claude-desktop/v1/{models,messages} routes, and a single
  source of truth for default proxy routes.
- Gateway token persisted in SQLite, validated on every proxied request.
- get_claude_desktop_status surfaces drift signals (stale models,
  missing routes, proxy stopped, base URL mismatch, missing token).

Frontend:
- Slim ClaudeDesktopProviderForm independent from ProviderForm,
  controlled by a top-level appId guard.
- ProviderList banner consumes the status query (5s polling) and
  renders actionable diagnostics.
- ClaudeDesktopRouteToggle in the header to start/stop the local
  gateway without touching takeover state.
- Three-locale i18n synchronised.
This commit is contained in:
Jason
2026-05-08 11:50:01 +08:00
parent e15bfbfe7a
commit 8b3ad9caf9
64 changed files with 3328 additions and 109 deletions
+23 -2
View File
@@ -1,6 +1,6 @@
#![allow(non_snake_case)]
use tauri::AppHandle;
use tauri::{AppHandle, State};
use tauri_plugin_dialog::DialogExt;
use tauri_plugin_opener::OpenerExt;
@@ -8,6 +8,7 @@ use crate::app_config::AppType;
use crate::codex_config;
use crate::config::{self, get_claude_settings_path, ConfigStatus};
use crate::settings;
use crate::store::AppState;
#[tauri::command]
pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
@@ -62,9 +63,23 @@ fn validate_common_config_snippet(app_type: &str, snippet: &str) -> Result<(), S
}
#[tauri::command]
pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
pub async fn get_config_status(
state: State<'_, AppState>,
app: String,
) -> Result<ConfigStatus, String> {
match AppType::from_str(&app).map_err(|e| e.to_string())? {
AppType::Claude => Ok(config::get_claude_config_status()),
AppType::ClaudeDesktop => {
let status = crate::claude_desktop_config::get_status(
state.db.as_ref(),
state.proxy_service.is_running().await,
)
.map_err(|e| e.to_string())?;
Ok(ConfigStatus {
exists: status.configured,
path: status.config_library_path.unwrap_or_default(),
})
}
AppType::Codex => {
let auth_path = codex_config::get_codex_auth_path();
let exists = auth_path.exists();
@@ -122,6 +137,9 @@ pub async fn get_claude_code_config_path() -> Result<String, String> {
pub async fn get_config_dir(app: String) -> Result<String, String> {
let dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
AppType::Claude => config::get_claude_config_dir(),
AppType::ClaudeDesktop => {
crate::claude_desktop_config::get_config_library_path().map_err(|e| e.to_string())?
}
AppType::Codex => codex_config::get_codex_config_dir(),
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
@@ -136,6 +154,9 @@ pub async fn get_config_dir(app: String) -> Result<String, String> {
pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool, String> {
let config_dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
AppType::Claude => config::get_claude_config_dir(),
AppType::ClaudeDesktop => {
crate::claude_desktop_config::get_config_library_path().map_err(|e| e.to_string())?
}
AppType::Codex => codex_config::get_codex_config_dir(),
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
+1 -1
View File
@@ -784,7 +784,7 @@ fn extract_env_vars_from_config(
// 处理 base_url: 根据应用类型添加对应的环境变量
let base_url_key = match app_type {
AppType::Claude => Some("ANTHROPIC_BASE_URL"),
AppType::Claude | AppType::ClaudeDesktop => Some("ANTHROPIC_BASE_URL"),
AppType::Gemini => Some("GOOGLE_GEMINI_BASE_URL"),
_ => None,
};
+149 -1
View File
@@ -4,7 +4,7 @@ use tauri::{Emitter, State};
use crate::app_config::AppType;
use crate::commands::copilot::CopilotAuthState;
use crate::error::AppError;
use crate::provider::Provider;
use crate::provider::{ClaudeDesktopMode, Provider};
use crate::services::{
EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService, SwitchResult,
};
@@ -150,6 +150,154 @@ pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result<
import_default_config_internal(&state, app_type).map_err(Into::into)
}
#[tauri::command]
pub async fn get_claude_desktop_status(
state: State<'_, AppState>,
) -> Result<crate::claude_desktop_config::ClaudeDesktopStatus, String> {
let proxy_running = state.proxy_service.is_running().await;
crate::claude_desktop_config::get_status(state.db.as_ref(), proxy_running)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_claude_desktop_default_routes(
) -> Vec<crate::claude_desktop_config::ClaudeDesktopDefaultRoute> {
crate::claude_desktop_config::default_proxy_routes()
}
#[tauri::command]
pub fn import_claude_desktop_providers_from_claude(
state: State<'_, AppState>,
) -> Result<usize, String> {
let claude_providers = state
.db
.get_all_providers(AppType::Claude.as_str())
.map_err(|e| e.to_string())?;
let existing_ids = state
.db
.get_provider_ids(AppType::ClaudeDesktop.as_str())
.map_err(|e| e.to_string())?;
let mut imported = 0usize;
for provider in claude_providers.values() {
if existing_ids.contains(&provider.id) {
continue;
}
if matches!(
provider
.meta
.as_ref()
.and_then(|meta| meta.provider_type.as_deref()),
Some("github_copilot") | Some("codex_oauth")
) {
continue;
}
let mut desktop_provider = provider.clone();
desktop_provider.in_failover_queue = false;
let meta = desktop_provider.meta.get_or_insert_with(Default::default);
if crate::claude_desktop_config::is_compatible_direct_provider(provider)
&& claude_provider_models_are_claude_safe(provider)
{
meta.claude_desktop_mode = Some(ClaudeDesktopMode::Direct);
} else if let Some(routes) = suggested_claude_desktop_routes(provider) {
meta.claude_desktop_mode = Some(ClaudeDesktopMode::Proxy);
meta.claude_desktop_model_routes = routes;
} else {
continue;
}
state
.db
.save_provider(AppType::ClaudeDesktop.as_str(), &desktop_provider)
.map_err(|e| e.to_string())?;
imported += 1;
}
Ok(imported)
}
fn claude_provider_models_are_claude_safe(provider: &Provider) -> bool {
let Some(env) = provider
.settings_config
.get("env")
.and_then(|value| value.as_object())
else {
return true;
};
[
"ANTHROPIC_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
]
.into_iter()
.filter_map(|key| env.get(key).and_then(|value| value.as_str()))
.map(str::trim)
.filter(|value| !value.is_empty())
.all(crate::claude_desktop_config::is_claude_safe_model_id)
}
fn suggested_claude_desktop_routes(
provider: &Provider,
) -> Option<std::collections::HashMap<String, crate::provider::ClaudeDesktopModelRoute>> {
let env = provider
.settings_config
.get("env")
.and_then(|value| value.as_object())?;
let mut routes = std::collections::HashMap::new();
fn add_route(
routes: &mut std::collections::HashMap<String, crate::provider::ClaudeDesktopModelRoute>,
env: &serde_json::Map<String, serde_json::Value>,
route_id: &str,
env_key: &str,
display_name: &str,
) {
if let Some(model) = env
.get(env_key)
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
{
routes.insert(
route_id.to_string(),
crate::provider::ClaudeDesktopModelRoute {
model: model.to_string(),
display_name: Some(display_name.to_string()),
supports_1m: Some(true),
},
);
}
}
for spec in crate::claude_desktop_config::DEFAULT_PROXY_ROUTES {
add_route(
&mut routes,
env,
spec.route_id,
spec.env_key,
spec.display_name,
);
}
let primary_route = crate::claude_desktop_config::DEFAULT_PROXY_ROUTES[0];
if !routes.contains_key(primary_route.route_id) {
add_route(
&mut routes,
env,
primary_route.route_id,
"ANTHROPIC_MODEL",
primary_route.display_name,
);
}
(!routes.is_empty()).then_some(routes)
}
#[allow(non_snake_case)]
#[tauri::command]
pub async fn queryProviderUsage(
+18
View File
@@ -15,6 +15,24 @@ pub async fn start_proxy_server(
state.proxy_service.start().await
}
/// 停止代理服务器(仅停止服务,不恢复/清理 Live 接管状态)
#[tauri::command]
pub async fn stop_proxy_server(state: tauri::State<'_, AppState>) -> Result<(), String> {
let takeover = state.proxy_service.get_takeover_status().await?;
if takeover.claude
|| takeover.codex
|| takeover.gemini
|| takeover.opencode
|| takeover.openclaw
{
return Err(
"仍有应用处于代理接管状态,请先在设置中关闭对应应用接管后再停止本地路由。".to_string(),
);
}
state.proxy_service.stop().await
}
/// 停止代理服务器(恢复 Live 配置)
#[tauri::command]
pub async fn stop_proxy_with_restore(state: tauri::State<'_, AppState>) -> Result<(), String> {
+2 -8
View File
@@ -12,6 +12,7 @@ use crate::services::skill::{
SkillsShSearchResult,
};
use crate::store::AppState;
use std::str::FromStr;
use std::sync::Arc;
use tauri::State;
@@ -20,14 +21,7 @@ pub struct SkillServiceState(pub Arc<SkillService>);
/// 解析 app 参数为 AppType
fn parse_app_type(app: &str) -> Result<AppType, String> {
match app.to_lowercase().as_str() {
"claude" => Ok(AppType::Claude),
"codex" => Ok(AppType::Codex),
"gemini" => Ok(AppType::Gemini),
"opencode" => Ok(AppType::OpenCode),
"hermes" => Ok(AppType::Hermes),
_ => Err(format!("不支持的 app 类型: {app}")),
}
AppType::from_str(app).map_err(|e| e.to_string())
}
// ========== 统一管理命令 ==========