Files
CC-Switch/src-tauri/src/commands/stream_check.rs
T
Jason a5903d8600 feat(health-check): replace real-LLM probe with HTTP reachability check
The provider panel health check sent a real streaming model request, which many third-party providers block (401/403/WAF), causing false negatives while only stable official endpoints passed. Replace it with a lightweight reachability probe: GET the provider base_url and treat any HTTP response (200/4xx/5xx) as reachable; only DNS/connect/TLS/timeout count as failure. Latency is the probe's TTFB.

Backend (services/stream_check.rs): rewrite ~2200 -> ~350 lines, dropping real-request building, format conversion, auth and API-path resolution while keeping per-app base_url extraction. Defaults: 8s timeout, 1 retry, 1500ms degraded threshold.

Failover invariant: the reachability check must never reset the circuit breaker (reachable != usable; a 403 host is reachable but broken for real traffic). Remove the resetCircuitBreaker call from useStreamCheck; failover failure detection stays driven solely by real proxy traffic (forwarder/circuit_breaker untouched). useResetCircuitBreaker is kept dormant for a future manual-recovery entry.

Open the check to all providers: drop the official/copilot/codex-oauth/third-party gating and the 'sends a real request' confirm dialog. For official providers whose base_url is intentionally empty, fall back to the endpoint the client actually uses (Claude -> api.anthropic.com, Codex -> chatgpt.com/backend-api/codex, Gemini -> generativelanguage). Non-official providers with a missing base_url still error to avoid a false green light. Claude Desktop Official is native 1P mode (talks to claude.ai, cc-switch not in the request path, no reliable endpoint) so its button stays hidden.

Slim StreamCheckConfig and per-provider testConfig to timeout/threshold/retries (drop test model + prompt); sync zh/en/ja/zh-TW. Retain the now-unused anthropic_to_openai/anthropic_to_gemini transform utilities and their test suites.
2026-06-14 21:21:45 +08:00

244 lines
7.6 KiB
Rust

//! 供应商连通性检查命令
//!
//! 注意:本检查只探测 base_url 是否可达,不发真实大模型请求,也不触碰故障转移
//! 熔断器(熔断器由真实转发流量驱动)。详见 `services::stream_check`。
use crate::app_config::AppType;
use crate::commands::copilot::CopilotAuthState;
use crate::error::AppError;
use crate::services::stream_check::{
HealthStatus, StreamCheckConfig, StreamCheckResult, StreamCheckService,
};
use crate::store::AppState;
use std::collections::HashSet;
use tauri::State;
/// 连通性检查(单个供应商)
#[tauri::command]
pub async fn stream_check_provider(
state: State<'_, AppState>,
copilot_state: State<'_, CopilotAuthState>,
app_type: AppType,
provider_id: String,
) -> Result<StreamCheckResult, AppError> {
let config = state.db.get_stream_check_config()?;
let providers = state.db.get_all_providers(app_type.as_str())?;
let provider = providers
.get(&provider_id)
.ok_or_else(|| AppError::Message(format!("供应商 {provider_id} 不存在")))?;
// Copilot 端点是动态的(随 OAuth token 解析),需预先取出 host 再探测;
// 其余供应商传 None,由服务层从 settings_config 提取 base_url。无需鉴权。
let base_url_override = resolve_copilot_base_url_override(provider, &copilot_state).await?;
let result =
StreamCheckService::check_with_retry(&app_type, provider, &config, base_url_override)
.await?;
// 记录日志
let _ =
state
.db
.save_stream_check_log(&provider_id, &provider.name, app_type.as_str(), &result);
Ok(result)
}
/// 批量连通性检查
#[tauri::command]
pub async fn stream_check_all_providers(
state: State<'_, AppState>,
copilot_state: State<'_, CopilotAuthState>,
app_type: AppType,
proxy_targets_only: bool,
) -> Result<Vec<(String, StreamCheckResult)>, AppError> {
let config = state.db.get_stream_check_config()?;
let providers = state.db.get_all_providers(app_type.as_str())?;
let allowed_ids: Option<HashSet<String>> = if proxy_targets_only {
let mut ids = HashSet::new();
if let Ok(Some(current_id)) = state.db.get_current_provider(app_type.as_str()) {
ids.insert(current_id);
}
if let Ok(queue) = state.db.get_failover_queue(app_type.as_str()) {
for item in queue {
ids.insert(item.provider_id);
}
}
Some(ids)
} else {
None
};
let mut results = Vec::new();
for (id, provider) in providers {
if let Some(ids) = &allowed_ids {
if !ids.contains(&id) {
continue;
}
}
let base_url_override =
resolve_copilot_base_url_override(&provider, &copilot_state).await?;
let result =
StreamCheckService::check_with_retry(&app_type, &provider, &config, base_url_override)
.await
.unwrap_or_else(|e| StreamCheckResult {
status: HealthStatus::Failed,
success: false,
message: e.to_string(),
response_time_ms: None,
http_status: None,
model_used: String::new(),
tested_at: chrono::Utc::now().timestamp(),
retry_count: 0,
error_category: None,
});
let _ = state
.db
.save_stream_check_log(&id, &provider.name, app_type.as_str(), &result);
results.push((id, result));
}
Ok(results)
}
/// 获取连通性检查配置
#[tauri::command]
pub fn get_stream_check_config(state: State<'_, AppState>) -> Result<StreamCheckConfig, AppError> {
state.db.get_stream_check_config()
}
/// 保存连通性检查配置
#[tauri::command]
pub fn save_stream_check_config(
state: State<'_, AppState>,
config: StreamCheckConfig,
) -> Result<(), AppError> {
state.db.save_stream_check_config(&config)
}
/// Copilot 供应商的 base_url 需要从 OAuth 管理器动态解析(按账号或默认端点)。
/// `is_full_url` 的供应商已是完整地址,无需解析。
async fn resolve_copilot_base_url_override(
provider: &crate::provider::Provider,
copilot_state: &State<'_, CopilotAuthState>,
) -> Result<Option<String>, AppError> {
let is_copilot = is_copilot_provider(provider);
let is_full_url = provider
.meta
.as_ref()
.and_then(|meta| meta.is_full_url)
.unwrap_or(false);
if !is_copilot || is_full_url {
return Ok(None);
}
let auth_manager = copilot_state.0.read().await;
let account_id = provider
.meta
.as_ref()
.and_then(|meta| meta.managed_account_id_for("github_copilot"));
let endpoint = match account_id.as_deref() {
Some(id) => auth_manager.get_api_endpoint(id).await,
None => auth_manager.get_default_api_endpoint().await,
};
Ok(Some(endpoint))
}
fn is_copilot_provider(provider: &crate::provider::Provider) -> bool {
provider
.meta
.as_ref()
.and_then(|meta| meta.provider_type.as_deref())
== Some("github_copilot")
|| provider
.settings_config
.pointer("/env/ANTHROPIC_BASE_URL")
.and_then(|value| value.as_str())
.map(|url| url.contains("githubcopilot.com"))
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::is_copilot_provider;
use crate::provider::{Provider, ProviderMeta};
use serde_json::json;
#[test]
fn copilot_provider_detection_accepts_provider_type_or_base_url() {
let typed_provider = Provider {
id: "p1".to_string(),
name: "typed".to_string(),
settings_config: json!({}),
website_url: None,
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: Some(ProviderMeta {
provider_type: Some("github_copilot".to_string()),
..Default::default()
}),
icon: None,
icon_color: None,
in_failover_queue: false,
};
assert!(is_copilot_provider(&typed_provider));
let url_provider = Provider {
id: "p2".to_string(),
name: "url".to_string(),
settings_config: json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.githubcopilot.com"
}
}),
website_url: None,
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
};
assert!(is_copilot_provider(&url_provider));
}
#[test]
fn copilot_full_url_metadata_is_available_for_override_guard() {
let provider = Provider {
id: "p3".to_string(),
name: "relay".to_string(),
settings_config: json!({}),
website_url: None,
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: Some(ProviderMeta {
provider_type: Some("github_copilot".to_string()),
is_full_url: Some(true),
..Default::default()
}),
icon: None,
icon_color: None,
in_failover_queue: false,
};
assert!(is_copilot_provider(&provider));
assert_eq!(
provider.meta.as_ref().and_then(|meta| meta.is_full_url),
Some(true)
);
}
}