mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
689ca08409
Distinguish between "provider rejects probe" (yellow warning) and "genuinely broken" (red error) in health check results. Backend: add AppError::HttpStatus variant to carry structured HTTP status codes, populate http_status on error results, classify codes into short labels (e.g. "Auth rejected (401)"), and truncate overly long response bodies. Frontend: route 401/403/400/429/5xx to toast.warning with localized hints explaining the error may not indicate actual unusability; route 404/402/connection errors to toast.error. Add i18n keys for all three locales (zh/en/ja). Also deduplicate check_once by reusing build_stream_check_result.
364 lines
11 KiB
Rust
364 lines
11 KiB
Rust
//! 流式健康检查命令
|
|
|
|
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} 不存在")))?;
|
|
|
|
let auth_override = resolve_copilot_auth_override(provider, &copilot_state).await?;
|
|
let base_url_override = resolve_copilot_base_url_override(provider, &copilot_state).await?;
|
|
let claude_api_format_override = resolve_claude_api_format_override(
|
|
&app_type,
|
|
provider,
|
|
&config,
|
|
&copilot_state,
|
|
auth_override.as_ref(),
|
|
)
|
|
.await?;
|
|
let result = StreamCheckService::check_with_retry(
|
|
&app_type,
|
|
provider,
|
|
&config,
|
|
auth_override,
|
|
base_url_override,
|
|
claude_api_format_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 mut results = Vec::new();
|
|
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
|
|
};
|
|
|
|
for (id, provider) in providers {
|
|
if let Some(ids) = &allowed_ids {
|
|
if !ids.contains(&id) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
let auth_override = resolve_copilot_auth_override(&provider, &copilot_state).await?;
|
|
let base_url_override =
|
|
resolve_copilot_base_url_override(&provider, &copilot_state).await?;
|
|
let claude_api_format_override = resolve_claude_api_format_override(
|
|
&app_type,
|
|
&provider,
|
|
&config,
|
|
&copilot_state,
|
|
auth_override.as_ref(),
|
|
)
|
|
.await
|
|
.unwrap_or_else(|e| {
|
|
log::warn!(
|
|
"[StreamCheck] Failed to resolve Claude API format override for {}: {}",
|
|
provider.id,
|
|
e
|
|
);
|
|
None
|
|
});
|
|
let result = StreamCheckService::check_with_retry(
|
|
&app_type,
|
|
&provider,
|
|
&config,
|
|
auth_override,
|
|
base_url_override,
|
|
claude_api_format_override,
|
|
)
|
|
.await
|
|
.unwrap_or_else(|e| {
|
|
let (http_status, message) = match &e {
|
|
crate::error::AppError::HttpStatus { status, .. } => (
|
|
Some(*status),
|
|
StreamCheckService::classify_http_status(*status).to_string(),
|
|
),
|
|
_ => (None, e.to_string()),
|
|
};
|
|
StreamCheckResult {
|
|
status: HealthStatus::Failed,
|
|
success: false,
|
|
message,
|
|
response_time_ms: None,
|
|
http_status,
|
|
model_used: String::new(),
|
|
tested_at: chrono::Utc::now().timestamp(),
|
|
retry_count: 0,
|
|
}
|
|
});
|
|
|
|
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)
|
|
}
|
|
|
|
async fn resolve_copilot_auth_override(
|
|
provider: &crate::provider::Provider,
|
|
copilot_state: &State<'_, CopilotAuthState>,
|
|
) -> Result<Option<crate::proxy::providers::AuthInfo>, AppError> {
|
|
let is_copilot = is_copilot_provider(provider);
|
|
|
|
if !is_copilot {
|
|
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 token = match account_id.as_deref() {
|
|
Some(id) => auth_manager
|
|
.get_valid_token_for_account(id)
|
|
.await
|
|
.map_err(|e| AppError::Message(format!("GitHub Copilot 认证失败: {e}")))?,
|
|
None => auth_manager
|
|
.get_valid_token()
|
|
.await
|
|
.map_err(|e| AppError::Message(format!("GitHub Copilot 认证失败: {e}")))?,
|
|
};
|
|
|
|
Ok(Some(crate::proxy::providers::AuthInfo::new(
|
|
token,
|
|
crate::proxy::providers::AuthStrategy::GitHubCopilot,
|
|
)))
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
async fn resolve_claude_api_format_override(
|
|
app_type: &AppType,
|
|
provider: &crate::provider::Provider,
|
|
config: &StreamCheckConfig,
|
|
copilot_state: &State<'_, CopilotAuthState>,
|
|
auth_override: Option<&crate::proxy::providers::AuthInfo>,
|
|
) -> Result<Option<String>, AppError> {
|
|
if *app_type != AppType::Claude {
|
|
return Ok(None);
|
|
}
|
|
|
|
let is_copilot = auth_override
|
|
.map(|auth| auth.strategy == crate::proxy::providers::AuthStrategy::GitHubCopilot)
|
|
.unwrap_or(false);
|
|
if !is_copilot {
|
|
return Ok(None);
|
|
}
|
|
|
|
let model_id = StreamCheckService::resolve_effective_test_model(app_type, provider, config);
|
|
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 vendor_result = match account_id.as_deref() {
|
|
Some(id) => {
|
|
auth_manager
|
|
.get_model_vendor_for_account(id, &model_id)
|
|
.await
|
|
}
|
|
None => auth_manager.get_model_vendor(&model_id).await,
|
|
};
|
|
|
|
let api_format = match vendor_result {
|
|
Ok(Some(vendor)) if vendor.eq_ignore_ascii_case("openai") => "openai_responses",
|
|
Ok(Some(_)) | Ok(None) => "openai_chat",
|
|
Err(err) => {
|
|
log::warn!(
|
|
"[StreamCheck] Failed to resolve Copilot model vendor for {model_id}: {err}. Falling back to chat/completions"
|
|
);
|
|
"openai_chat"
|
|
}
|
|
};
|
|
|
|
Ok(Some(api_format.to_string()))
|
|
}
|
|
|
|
#[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)
|
|
);
|
|
}
|
|
}
|