mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-30 02:14:43 +08:00
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:
@@ -195,6 +195,7 @@ impl RequestForwarder {
|
||||
// 转发请求(每个 Provider 只尝试一次,重试由客户端控制)
|
||||
match self
|
||||
.forward(
|
||||
app_type,
|
||||
provider,
|
||||
endpoint,
|
||||
&provider_body,
|
||||
@@ -325,6 +326,7 @@ impl RequestForwarder {
|
||||
// 使用同一供应商重试(不计入熔断器)
|
||||
match self
|
||||
.forward(
|
||||
app_type,
|
||||
provider,
|
||||
endpoint,
|
||||
&provider_body,
|
||||
@@ -524,6 +526,7 @@ impl RequestForwarder {
|
||||
// 使用同一供应商重试(不计入熔断器)
|
||||
match self
|
||||
.forward(
|
||||
app_type,
|
||||
provider,
|
||||
endpoint,
|
||||
&provider_body,
|
||||
@@ -763,6 +766,7 @@ impl RequestForwarder {
|
||||
/// 转发单个请求(使用适配器)
|
||||
async fn forward(
|
||||
&self,
|
||||
app_type: &AppType,
|
||||
provider: &Provider,
|
||||
endpoint: &str,
|
||||
body: &Value,
|
||||
@@ -780,8 +784,16 @@ impl RequestForwarder {
|
||||
.unwrap_or(false);
|
||||
|
||||
// 应用模型映射(独立于格式转换)
|
||||
let (mapped_body, _original_model, _mapped_model) =
|
||||
super::model_mapper::apply_model_mapping(body.clone(), provider);
|
||||
// Claude Desktop proxy 模式必须先把 Desktop 可见的 claude-* route
|
||||
// 映射成真实上游模型名,并且未知 route 要直接报错,不能使用默认模型兜底。
|
||||
let mapped_body = if matches!(app_type, AppType::ClaudeDesktop) {
|
||||
crate::claude_desktop_config::map_proxy_request_model(body.clone(), provider)
|
||||
.map_err(|e| ProxyError::InvalidRequest(e.to_string()))?
|
||||
} else {
|
||||
let (mapped_body, _original_model, _mapped_model) =
|
||||
super::model_mapper::apply_model_mapping(body.clone(), provider);
|
||||
mapped_body
|
||||
};
|
||||
|
||||
// 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口)
|
||||
let mut mapped_body = normalize_thinking_type(mapped_body);
|
||||
|
||||
@@ -69,6 +69,49 @@ pub async fn get_status(State(state): State<ProxyState>) -> Result<Json<ProxySta
|
||||
pub async fn handle_messages(
|
||||
State(state): State<ProxyState>,
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
handle_messages_for_app(state, request, AppType::Claude, "Claude", "claude", None).await
|
||||
}
|
||||
|
||||
pub async fn handle_claude_desktop_messages(
|
||||
State(state): State<ProxyState>,
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
validate_claude_desktop_gateway_auth(&state, request.headers())?;
|
||||
handle_messages_for_app(
|
||||
state,
|
||||
request,
|
||||
AppType::ClaudeDesktop,
|
||||
"Claude Desktop",
|
||||
"claude-desktop",
|
||||
Some("/claude-desktop"),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn handle_claude_desktop_models(
|
||||
State(state): State<ProxyState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Result<Json<Value>, ProxyError> {
|
||||
validate_claude_desktop_gateway_auth(&state, &headers)?;
|
||||
let providers = state
|
||||
.provider_router
|
||||
.select_providers("claude-desktop")
|
||||
.await
|
||||
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
|
||||
let provider = providers.first().ok_or(ProxyError::NoAvailableProvider)?;
|
||||
let response = crate::claude_desktop_config::model_list_response(provider)
|
||||
.map_err(|e| ProxyError::ConfigError(e.to_string()))?;
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
async fn handle_messages_for_app(
|
||||
state: ProxyState,
|
||||
request: axum::extract::Request,
|
||||
app_type: AppType,
|
||||
tag: &'static str,
|
||||
app_type_str: &'static str,
|
||||
strip_prefix: Option<&'static str>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let (parts, body) = request.into_parts();
|
||||
let uri = parts.uri;
|
||||
@@ -83,12 +126,15 @@ pub async fn handle_messages(
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||||
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Claude, "Claude", "claude").await?;
|
||||
RequestContext::new(&state, &body, &headers, app_type.clone(), tag, app_type_str).await?;
|
||||
|
||||
let endpoint = uri
|
||||
let raw_endpoint = uri
|
||||
.path_and_query()
|
||||
.map(|path_and_query| path_and_query.as_str())
|
||||
.unwrap_or(uri.path());
|
||||
let endpoint = strip_prefix
|
||||
.and_then(|prefix| raw_endpoint.strip_prefix(prefix))
|
||||
.unwrap_or(raw_endpoint);
|
||||
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
@@ -99,7 +145,7 @@ pub async fn handle_messages(
|
||||
let forwarder = ctx.create_forwarder(&state);
|
||||
let result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Claude,
|
||||
&app_type,
|
||||
endpoint,
|
||||
body.clone(),
|
||||
headers,
|
||||
@@ -127,7 +173,7 @@ pub async fn handle_messages(
|
||||
let response = result.response;
|
||||
|
||||
// 检查是否需要格式转换(OpenRouter 等中转服务)
|
||||
let adapter = get_adapter(&AppType::Claude);
|
||||
let adapter = get_adapter(&app_type);
|
||||
let needs_transform = adapter.needs_transform(&ctx.provider);
|
||||
|
||||
// Claude 特有:格式转换处理
|
||||
@@ -140,6 +186,33 @@ pub async fn handle_messages(
|
||||
process_response(response, &ctx, &state, &CLAUDE_PARSER_CONFIG).await
|
||||
}
|
||||
|
||||
fn validate_claude_desktop_gateway_auth(
|
||||
state: &ProxyState,
|
||||
headers: &axum::http::HeaderMap,
|
||||
) -> Result<(), ProxyError> {
|
||||
let expected = crate::claude_desktop_config::get_or_create_gateway_token(state.db.as_ref())
|
||||
.map_err(|e| ProxyError::AuthError(e.to_string()))?;
|
||||
let Some(value) = headers.get(axum::http::header::AUTHORIZATION) else {
|
||||
return Err(ProxyError::AuthError(
|
||||
"Claude Desktop gateway 缺少 Authorization 头".to_string(),
|
||||
));
|
||||
};
|
||||
let value = value
|
||||
.to_str()
|
||||
.map_err(|_| ProxyError::AuthError("Authorization 头格式无效".to_string()))?;
|
||||
let token = value
|
||||
.strip_prefix("Bearer ")
|
||||
.or_else(|| value.strip_prefix("bearer "))
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
if token != expected {
|
||||
return Err(ProxyError::AuthError(
|
||||
"Claude Desktop gateway token 无效".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Claude 格式转换处理(独有逻辑)
|
||||
///
|
||||
/// 支持 OpenAI Chat Completions 和 Responses API 两种格式的转换
|
||||
|
||||
@@ -380,6 +380,16 @@ impl ClaudeAdapter {
|
||||
log::debug!("[Claude] 使用 OPENAI_API_KEY");
|
||||
return Some(key.to_string());
|
||||
}
|
||||
// Gemini Native key
|
||||
if let Some(key) = env
|
||||
.get("GEMINI_API_KEY")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
log::debug!("[Claude] 使用 GEMINI_API_KEY");
|
||||
return Some(key.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试直接获取
|
||||
@@ -904,6 +914,27 @@ mod tests {
|
||||
assert_eq!(auth.strategy, AuthStrategy::Bearer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_auth_gemini_api_key() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let provider = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com/v1beta",
|
||||
"GEMINI_API_KEY": "gemini-test-key"
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
api_format: Some("gemini_native".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.api_key, "gemini-test-key");
|
||||
assert_eq!(auth.strategy, AuthStrategy::Google);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_auth_claude_auth_mode() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
|
||||
@@ -105,7 +105,7 @@ impl ProviderType {
|
||||
#[allow(dead_code)]
|
||||
pub fn from_app_type_and_config(app_type: &AppType, provider: &Provider) -> Self {
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
AppType::Claude | AppType::ClaudeDesktop => {
|
||||
if get_claude_api_format(provider) == "gemini_native" {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
return match adapter.extract_auth(provider).map(|auth| auth.strategy) {
|
||||
@@ -226,7 +226,7 @@ impl std::str::FromStr for ProviderType {
|
||||
/// 根据 AppType 获取对应的适配器
|
||||
pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
|
||||
match app_type {
|
||||
AppType::Claude => Box::new(ClaudeAdapter::new()),
|
||||
AppType::Claude | AppType::ClaudeDesktop => Box::new(ClaudeAdapter::new()),
|
||||
AppType::Codex => Box::new(CodexAdapter::new()),
|
||||
AppType::Gemini => Box::new(GeminiAdapter::new()),
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
|
||||
|
||||
@@ -285,6 +285,15 @@ impl ProxyServer {
|
||||
// Claude API (支持带前缀和不带前缀两种格式)
|
||||
.route("/v1/messages", post(handlers::handle_messages))
|
||||
.route("/claude/v1/messages", post(handlers::handle_messages))
|
||||
// Claude Desktop 3P 本地 gateway(独立 provider namespace)
|
||||
.route(
|
||||
"/claude-desktop/v1/models",
|
||||
get(handlers::handle_claude_desktop_models),
|
||||
)
|
||||
.route(
|
||||
"/claude-desktop/v1/messages",
|
||||
post(handlers::handle_claude_desktop_messages),
|
||||
)
|
||||
// OpenAI Chat Completions API (Codex CLI,支持带前缀和不带前缀)
|
||||
.route("/chat/completions", post(handlers::handle_chat_completions))
|
||||
.route(
|
||||
|
||||
Reference in New Issue
Block a user