diff --git a/src-tauri/src/commands/stream_check.rs b/src-tauri/src/commands/stream_check.rs index 9fc605775..71759ce01 100644 --- a/src-tauri/src/commands/stream_check.rs +++ b/src-tauri/src/commands/stream_check.rs @@ -1,4 +1,7 @@ -//! 流式健康检查命令 +//! 供应商连通性检查命令 +//! +//! 注意:本检查只探测 base_url 是否可达,不发真实大模型请求,也不触碰故障转移 +//! 熔断器(熔断器由真实转发流量驱动)。详见 `services::stream_check`。 use crate::app_config::AppType; use crate::commands::copilot::CopilotAuthState; @@ -10,7 +13,7 @@ use crate::store::AppState; use std::collections::HashSet; use tauri::State; -/// 流式健康检查(单个供应商) +/// 连通性检查(单个供应商) #[tauri::command] pub async fn stream_check_provider( state: State<'_, AppState>, @@ -25,25 +28,12 @@ pub async fn stream_check_provider( .get(&provider_id) .ok_or_else(|| AppError::Message(format!("供应商 {provider_id} 不存在")))?; - let auth_override = resolve_copilot_auth_override(provider, &copilot_state).await?; + // Copilot 端点是动态的(随 OAuth token 解析),需预先取出 host 再探测; + // 其余供应商传 None,由服务层从 settings_config 提取 base_url。无需鉴权。 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 result = + StreamCheckService::check_with_retry(&app_type, provider, &config, base_url_override) + .await?; // 记录日志 let _ = @@ -54,7 +44,7 @@ pub async fn stream_check_provider( Ok(result) } -/// 批量流式健康检查 +/// 批量连通性检查 #[tauri::command] pub async fn stream_check_all_providers( state: State<'_, AppState>, @@ -65,7 +55,6 @@ pub async fn stream_check_all_providers( 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> = if proxy_targets_only { let mut ids = HashSet::new(); if let Ok(Some(current_id)) = state.db.get_current_provider(app_type.as_str()) { @@ -81,6 +70,7 @@ pub async fn stream_check_all_providers( None }; + let mut results = Vec::new(); for (id, provider) in providers { if let Some(ids) = &allowed_ids { if !ids.contains(&id) { @@ -88,54 +78,22 @@ pub async fn stream_check_all_providers( } } - 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, - error_category: None, - } - }); + 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 @@ -147,13 +105,13 @@ pub async fn stream_check_all_providers( Ok(results) } -/// 获取流式检查配置 +/// 获取连通性检查配置 #[tauri::command] pub fn get_stream_check_config(state: State<'_, AppState>) -> Result { state.db.get_stream_check_config() } -/// 保存流式检查配置 +/// 保存连通性检查配置 #[tauri::command] pub fn save_stream_check_config( state: State<'_, AppState>, @@ -162,39 +120,8 @@ pub fn save_stream_check_config( state.db.save_stream_check_config(&config) } -async fn resolve_copilot_auth_override( - provider: &crate::provider::Provider, - copilot_state: &State<'_, CopilotAuthState>, -) -> Result, 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, - ))) -} - +/// Copilot 供应商的 base_url 需要从 OAuth 管理器动态解析(按账号或默认端点)。 +/// `is_full_url` 的供应商已是完整地址,无需解析。 async fn resolve_copilot_base_url_override( provider: &crate::provider::Provider, copilot_state: &State<'_, CopilotAuthState>, @@ -238,54 +165,6 @@ fn is_copilot_provider(provider: &crate::provider::Provider) -> bool { .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, 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; diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index c99758ebe..648575de0 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -287,21 +287,15 @@ pub struct UsageResult { pub error: Option, } -/// 供应商单独的模型测试配置 +/// 供应商单独的连通检测配置 #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ProviderTestConfig { /// 是否启用单独配置(false 时使用全局配置) #[serde(default)] pub enabled: bool, - /// 测试用的模型名称(覆盖全局配置) - #[serde(rename = "testModel", skip_serializing_if = "Option::is_none")] - pub test_model: Option, /// 超时时间(秒) #[serde(rename = "timeoutSecs", skip_serializing_if = "Option::is_none")] pub timeout_secs: Option, - /// 测试提示词 - #[serde(rename = "testPrompt", skip_serializing_if = "Option::is_none")] - pub test_prompt: Option, /// 降级阈值(毫秒) #[serde( rename = "degradedThresholdMs", diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index e9dae2487..7f3d8e1ac 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -48,8 +48,7 @@ pub use claude::{ pub use codex::CodexAdapter; pub use codex::{ apply_codex_chat_upstream_model, codex_provider_upstream_model, - codex_provider_uses_chat_completions, is_origin_only_url, resolve_codex_chat_reasoning_config, - should_convert_codex_responses_to_chat, + resolve_codex_chat_reasoning_config, should_convert_codex_responses_to_chat, }; pub use gemini::GeminiAdapter; diff --git a/src-tauri/src/proxy/providers/transform.rs b/src-tauri/src/proxy/providers/transform.rs index 9b0dd8f72..c13b26cd1 100644 --- a/src-tauri/src/proxy/providers/transform.rs +++ b/src-tauri/src/proxy/providers/transform.rs @@ -112,6 +112,10 @@ pub fn resolve_reasoning_effort(body: &Value) -> Option<&'static str> { } /// Anthropic 请求 → OpenAI Chat Completions 请求 +/// +/// 转换工具库 API:当前无生产调用方(连通性检查不再发真实请求,曾是其唯一 crate 内 +/// 消费者),但保留其转换逻辑与下方测试套件,供代理转换路径复用 / 未来接线。 +#[allow(dead_code)] pub fn anthropic_to_openai(body: Value) -> Result { anthropic_to_openai_with_reasoning_content(body, false) } diff --git a/src-tauri/src/proxy/providers/transform_gemini.rs b/src-tauri/src/proxy/providers/transform_gemini.rs index d386d57a5..76601c3e1 100644 --- a/src-tauri/src/proxy/providers/transform_gemini.rs +++ b/src-tauri/src/proxy/providers/transform_gemini.rs @@ -39,6 +39,11 @@ pub(crate) fn is_synthesized_tool_call_id(id: &str) -> bool { id.starts_with(SYNTHESIZED_ID_PREFIX) } +/// Anthropic 请求 → Gemini 原生请求。 +/// +/// 转换工具库 API:当前无生产调用方(连通性检查不再发真实请求,曾是其唯一 crate 内 +/// 消费者),但保留其转换逻辑与下方测试套件,供代理转换路径复用 / 未来接线。 +#[allow(dead_code)] pub fn anthropic_to_gemini(body: Value) -> Result { anthropic_to_gemini_with_shadow(body, None, None, None) } diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index c7a1a0bb2..8fa55a2ff 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -1,24 +1,30 @@ -//! 流式健康检查服务 +//! 供应商连通性检查服务(reachability) //! -//! 使用流式 API 进行快速健康检查,只需接收首个 chunk 即判定成功。 +//! 仅探测供应商 `base_url` 是否可达,**不发送真实大模型请求**: +//! - 收到任意 HTTP 响应(200/4xx/5xx)即判定"可达"(端口通、网关存活); +//! - 仅 DNS / 连接被拒 / TLS / 超时等网络级错误判定"不可达"; +//! - 延迟 = 收到响应头的耗时(TTFB,真实往返)。 +//! +//! ## 设计取舍:可达 ≠ 配置正确 +//! +//! 本检查刻意不验证鉴权或模型,因此不会被第三方供应商的鉴权拦截 / 模型校验 +//! 误判为"不可用"。代价是它无法告诉你鉴权对不对、模型存不存在。 +//! +//! ## 与故障转移的关系(重要不变量) +//! +//! 连通性检查 **绝不** 触碰故障转移熔断器:一个返回 403/401 的供应商在本检查里 +//! 算"可达",但它对真实流量是坏的。熔断器只由 `proxy/forwarder.rs` 转发真实流量 +//! 的成败驱动(被动)。两者职责分离——可达性回答"能不能到",真实流量回答"能不能用"。 -use futures::StreamExt; +use reqwest::header::HeaderValue; use reqwest::Client; use serde::{Deserialize, Serialize}; -use serde_json::json; use std::time::Instant; use crate::app_config::AppType; use crate::error::AppError; use crate::provider::Provider; -use crate::proxy::gemini_url::{normalize_gemini_model_id, resolve_gemini_native_url}; -use crate::proxy::providers::copilot_auth; -use crate::proxy::providers::transform::anthropic_to_openai; -use crate::proxy::providers::transform_gemini::anthropic_to_gemini; -use crate::proxy::providers::transform_responses::anthropic_to_responses; -use crate::proxy::providers::{ - get_adapter, AuthInfo, AuthStrategy, ClaudeAdapter, ProviderAdapter, -}; +use crate::proxy::providers::{get_adapter, ClaudeAdapter, ProviderAdapter}; /// 健康状态枚举 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -29,43 +35,31 @@ pub enum HealthStatus { Failed, } -/// 流式检查配置 +/// 连通性检查配置 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct StreamCheckConfig { + /// 单次探测超时(秒) pub timeout_secs: u64, + /// 超时类失败的最大重试次数 pub max_retries: u32, + /// 降级阈值(毫秒):可达但 TTFB 超过该值判定为"较慢" pub degraded_threshold_ms: u64, - /// Claude 测试模型 - pub claude_model: String, - /// Codex 测试模型 - pub codex_model: String, - /// Gemini 测试模型 - pub gemini_model: String, - /// 检查提示词 - #[serde(default = "default_test_prompt")] - pub test_prompt: String, -} - -fn default_test_prompt() -> String { - "Who are you?".to_string() } impl Default for StreamCheckConfig { fn default() -> Self { + // 可达性探测打的是 base_url 的小请求(仅读响应头),不等待模型生成, + // 因此超时与阈值都远小于旧的真实请求检查(45s / 6000ms)。 Self { - timeout_secs: 45, - max_retries: 2, - degraded_threshold_ms: 6000, - claude_model: "claude-haiku-4-5-20251001".to_string(), - codex_model: "gpt-5.5@low".to_string(), - gemini_model: "gemini-3.5-flash".to_string(), - test_prompt: default_test_prompt(), + timeout_secs: 8, + max_retries: 1, + degraded_threshold_ms: 1500, } } } -/// 流式检查结果 +/// 连通性检查结果 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct StreamCheckResult { @@ -74,70 +68,59 @@ pub struct StreamCheckResult { pub message: String, pub response_time_ms: Option, pub http_status: Option, + /// 保留字段以兼容 `stream_check_logs` 表结构;连通性检查恒为空串。 pub model_used: String, pub tested_at: i64, pub retry_count: u32, - /// 细粒度错误分类(如 "modelNotFound"),前端据此渲染专门的文案 + /// 细粒度错误分类;连通性检查不再细分,恒为 None。 #[serde(skip_serializing_if = "Option::is_none")] pub error_category: Option, } -/// 流式健康检查服务 +/// 连通性检查服务 pub struct StreamCheckService; impl StreamCheckService { - /// 执行流式健康检查(带重试) + /// 执行连通性检查(仅对超时类失败重试)。 /// - /// 如果 Provider 配置了单独的测试配置(meta.testConfig),则使用该配置覆盖全局配置 + /// `base_url_override`:用于 Copilot 等需要从 OAuth 管理器动态解析端点的供应商, + /// 由命令层预先解析后传入;其余供应商传 `None`,由本服务从 `settings_config` 提取。 pub async fn check_with_retry( app_type: &AppType, provider: &Provider, config: &StreamCheckConfig, - auth_override: Option, base_url_override: Option, - claude_api_format_override: Option, ) -> Result { - // 合并供应商单独配置和全局配置 - let effective_config = Self::merge_provider_config(provider, config); - let mut last_result = None; + let effective = Self::merge_provider_config(provider, config); - for attempt in 0..=effective_config.max_retries { + let mut last_result: Option = None; + for attempt in 0..=effective.max_retries { + let start = Instant::now(); let result = Self::check_once( app_type, provider, - &effective_config, - auth_override.clone(), + &effective, base_url_override.clone(), - claude_api_format_override.clone(), + start, ) - .await; + .await?; - match &result { - Ok(r) if r.success => { - return Ok(StreamCheckResult { - retry_count: attempt, - ..r.clone() - }); - } - Ok(r) => { - // 失败但非异常,判断是否重试 - if Self::should_retry(&r.message) && attempt < effective_config.max_retries { - last_result = Some(r.clone()); - continue; - } - return Ok(StreamCheckResult { - retry_count: attempt, - ..r.clone() - }); - } - Err(e) => { - if Self::should_retry(&e.to_string()) && attempt < effective_config.max_retries - { - continue; - } - return Err(AppError::Message(e.to_string())); - } + if result.success { + return Ok(StreamCheckResult { + retry_count: attempt, + ..result + }); } + + // 仅超时 / abort 类网络抖动值得重试;连接被拒、DNS 失败等立即返回。 + if Self::should_retry(&result.message) && attempt < effective.max_retries { + last_result = Some(result); + continue; + } + return Ok(StreamCheckResult { + retry_count: attempt, + ..result + }); } Ok(last_result.unwrap_or_else(|| StreamCheckResult { @@ -148,872 +131,241 @@ impl StreamCheckService { http_status: None, model_used: String::new(), tested_at: chrono::Utc::now().timestamp(), - retry_count: effective_config.max_retries, + retry_count: effective.max_retries, error_category: None, })) } - /// 合并供应商单独配置和全局配置 - /// - /// 如果供应商配置了 meta.testConfig 且 enabled 为 true,则使用供应商配置覆盖全局配置 - fn merge_provider_config( - provider: &Provider, - global_config: &StreamCheckConfig, - ) -> StreamCheckConfig { - let test_config = provider + /// 合并供应商单独配置(`meta.testConfig`,仅当 `enabled`)与全局配置。 + fn merge_provider_config(provider: &Provider, global: &StreamCheckConfig) -> StreamCheckConfig { + let tc = provider .meta .as_ref() .and_then(|m| m.test_config.as_ref()) .filter(|tc| tc.enabled); - match test_config { + match tc { Some(tc) => StreamCheckConfig { - timeout_secs: tc.timeout_secs.unwrap_or(global_config.timeout_secs), - max_retries: tc.max_retries.unwrap_or(global_config.max_retries), + timeout_secs: tc.timeout_secs.unwrap_or(global.timeout_secs), + max_retries: tc.max_retries.unwrap_or(global.max_retries), degraded_threshold_ms: tc .degraded_threshold_ms - .unwrap_or(global_config.degraded_threshold_ms), - claude_model: tc - .test_model - .clone() - .unwrap_or_else(|| global_config.claude_model.clone()), - codex_model: tc - .test_model - .clone() - .unwrap_or_else(|| global_config.codex_model.clone()), - gemini_model: tc - .test_model - .clone() - .unwrap_or_else(|| global_config.gemini_model.clone()), - test_prompt: tc - .test_prompt - .clone() - .unwrap_or_else(|| global_config.test_prompt.clone()), + .unwrap_or(global.degraded_threshold_ms), }, - None => global_config.clone(), + None => global.clone(), } } - /// 单次流式检查 + /// 单次连通性探测。 async fn check_once( app_type: &AppType, provider: &Provider, config: &StreamCheckConfig, - auth_override: Option, base_url_override: Option, - claude_api_format_override: Option, + start: Instant, ) -> Result { - let start = Instant::now(); - - // OpenCode / OpenClaw 的 settings_config 结构与 Claude/Codex/Gemini 不同 - // (baseUrl / apiKey 直接作为根字段而非嵌套在 env),并且协议由 `api` - // 或 `npm` 字段显式指定。它们不走 get_adapter 路径,而是直接分发。 - if matches!( - app_type, - AppType::OpenCode | AppType::OpenClaw | AppType::Hermes - ) { - return Self::check_once_without_adapter(app_type, provider, config, start).await; - } - - let adapter: Box = if matches!(app_type, AppType::ClaudeDesktop) { - Box::new(ClaudeAdapter::new()) - } else { - get_adapter(app_type) - }; - let base_url = match base_url_override { - Some(base_url) => base_url, - None => adapter - .extract_base_url(provider) - .map_err(|e| AppError::Message(format!("Failed to extract base_url: {e}")))?, + Some(b) => b, + None => Self::resolve_base_url(app_type, provider)?, }; - let auth = auth_override - .or_else(|| adapter.extract_auth(provider)) - .ok_or_else(|| AppError::Message("API Key not found".to_string()))?; - - // 获取 HTTP 客户端 let client = crate::proxy::http_client::get(); - let request_timeout = std::time::Duration::from_secs(config.timeout_secs); - - let model_to_test = Self::resolve_test_model(app_type, provider, config); - let test_prompt = &config.test_prompt; - - let result = match app_type { - AppType::Claude | AppType::ClaudeDesktop => { - Self::check_claude_stream( - &client, - &base_url, - &auth, - &model_to_test, - test_prompt, - request_timeout, - provider, - claude_api_format_override.as_deref(), - None, - ) - .await - } - AppType::Codex => { - Self::check_codex_stream( - &client, - &base_url, - &auth, - &model_to_test, - test_prompt, - request_timeout, - provider, - ) - .await - } - AppType::Gemini => { - Self::check_gemini_stream( - &client, - &base_url, - &auth, - &model_to_test, - test_prompt, - request_timeout, - None, - ) - .await - } - AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { - // Already handled via early dispatch above - unreachable!("OpenCode/OpenClaw/Hermes 已通过 check_once_without_adapter 处理") - } - }; + let timeout = std::time::Duration::from_secs(config.timeout_secs); + let ua = Self::custom_user_agent(provider); + let result = Self::probe_reachability(&client, &base_url, timeout, ua).await; let response_time = start.elapsed().as_millis() as u64; - Ok(Self::build_stream_check_result( + Ok(Self::build_result( result, response_time, config.degraded_threshold_ms, - &model_to_test, )) } - /// Provider 级自定义 User-Agent(`meta.customUserAgent`),经 `parse_custom_user_agent` 校验。 + /// 解析供应商 `base_url`。 /// - /// 与 forwarder 转发路径(`RequestForwarder::forward`)、model_fetch 共用单一口径:trim、 - /// 空串视为未设置、**非法值静默忽略**(返回 `None`,不报错)。Stream Check 必须复用同一个 - /// UA 去探测,否则会与真实流量用不同的 User-Agent(例如 Kimi Coding Plan 的 UA 白名单), - /// 导致"检测失败但代理可用"或反之的分歧——非法 UA 时尤甚(转发静默丢弃、检测却会因 - /// reqwest 非法头在 `.send()` 报错)。 - fn custom_user_agent(provider: &Provider) -> Option { + /// 连通性探测只需打到 base(origin 或用户配置的 base 路径)即可——任何 HTTP + /// 响应都证明端口可达,因此无需像旧的真实请求检查那样解析具体 API 路径 + /// (`/v1/messages` vs `/chat/completions` vs `:streamGenerateContent`)。 + fn resolve_base_url(app_type: &AppType, provider: &Provider) -> Result { + match app_type { + // 累加模式应用的 settings_config 结构与 Claude/Codex/Gemini 不同, + // 不走 adapter,直接按各自约定提取 base_url。 + AppType::OpenCode => { + let npm = Self::extract_opencode_npm(provider); + Self::resolve_opencode_base_url(provider, npm.as_deref()) + } + AppType::OpenClaw => Self::extract_openclaw_base_url(provider), + AppType::Hermes => Self::extract_hermes_base_url(provider), + AppType::ClaudeDesktop => Self::base_url_or_official_fallback( + ClaudeAdapter::new().extract_base_url(provider), + app_type, + provider, + ), + _ => Self::base_url_or_official_fallback( + get_adapter(app_type).extract_base_url(provider), + app_type, + provider, + ), + } + } + + /// adapter 提取成功(且非空)→ 直接用;否则: + /// - 官方供应商(`category == "official"`)base_url 故意留空——客户端在 base_url + /// 未设时默认走官方端点,因此回退到官方默认端点,使可达性探测探的就是客户端 + /// 实际会连的地址; + /// - 非官方且提取失败 → 保留"缺 base_url"错误(真实配置缺失;若也回退到官方端点, + /// 会给忘填 base_url 的第三方中转误显绿灯)。 + fn base_url_or_official_fallback( + extracted: Result, + app_type: &AppType, + provider: &Provider, + ) -> Result { + if let Ok(url) = &extracted { + if !url.trim().is_empty() { + return Ok(url.clone()); + } + } + + if Self::is_official(provider) { + if let Some(endpoint) = Self::official_fallback_endpoint(app_type) { + return Ok(endpoint.to_string()); + } + } + + match extracted { + Ok(_) => Err(AppError::Message("缺少 base_url".to_string())), + Err(e) => Err(AppError::Message(format!( + "Failed to extract base_url: {e}" + ))), + } + } + + /// 官方供应商判定:单一事实源是 `category == "official"`(与前端一致); + /// 内置官方种子入库时由 `init_default_official_providers` 标注该值。 + fn is_official(provider: &Provider) -> bool { + provider.category.as_deref() == Some("official") + } + + /// 官方模式的默认探测端点——即客户端在 base_url 未设时实际会连的官方地址。 + /// Codex 官方走 ChatGPT Plus/Pro OAuth 后端,故指向 chatgpt.com 而非 api.openai.com。 + /// + /// **ClaudeDesktop 故意返回 None**:Claude Desktop 官方 = 原生 1P 模式(切换时写 + /// `deploymentMode=1p`、删 3P profile,见 `claude_desktop_config.rs`),应用走 claude.ai + /// 消费端后端而非 api.anthropic.com 开发者 API,cc-switch 不在请求路径上,也没有可靠的 + /// 1P 端点常量可探。回退到 api.anthropic.com 会探错目标(误报可达/不可达),故不回退 + /// (前端同步隐藏其检测按钮)。 + fn official_fallback_endpoint(app_type: &AppType) -> Option<&'static str> { + match app_type { + AppType::Claude => Some("https://api.anthropic.com"), + AppType::Codex => Some("https://chatgpt.com/backend-api/codex"), + AppType::Gemini => Some("https://generativelanguage.googleapis.com"), + AppType::ClaudeDesktop | AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { + None + } + } + } + + /// 轻量可达性探测:GET `base_url`,收到任意 HTTP 响应即可达。 + /// + /// - `send()` 在收到响应头时即返回,故计时天然是 TTFB;不读 body。 + /// - reqwest 对任何 HTTP 状态码都返回 `Ok`,只有网络级错误进 `Err`—— + /// 这正是"任何响应都算可达、只有连不上才算失败"的语义。 + async fn probe_reachability( + client: &Client, + base_url: &str, + timeout: std::time::Duration, + custom_ua: Option, + ) -> Result { + let url = base_url.trim(); + if url.is_empty() { + return Err(AppError::Message("base_url 为空".to_string())); + } + + let mut req = client + .get(url) + .timeout(timeout) + .header("accept", "*/*") + .header("accept-encoding", "identity"); + // 复用供应商自定义 UA(部分网关按 UA 白名单放行),与转发路径口径一致。 + if let Some(ua) = custom_ua { + req = req.header("user-agent", ua); + } + + match req.send().await { + Ok(resp) => Ok(resp.status().as_u16()), + Err(e) => Err(Self::map_request_error(e)), + } + } + + /// 将探测原始结果包装成 `StreamCheckResult`。 + fn build_result( + result: Result, + response_time: u64, + degraded_threshold_ms: u64, + ) -> StreamCheckResult { + let tested_at = chrono::Utc::now().timestamp(); + match result { + Ok(status) => StreamCheckResult { + status: Self::determine_status(response_time, degraded_threshold_ms), + success: true, + message: "Reachable".to_string(), + response_time_ms: Some(response_time), + http_status: Some(status), + model_used: String::new(), + tested_at, + retry_count: 0, + error_category: None, + }, + Err(e) => StreamCheckResult { + status: HealthStatus::Failed, + success: false, + message: e.to_string(), + response_time_ms: Some(response_time), + http_status: None, + model_used: String::new(), + tested_at, + retry_count: 0, + error_category: None, + }, + } + } + + fn determine_status(latency_ms: u64, threshold: u64) -> HealthStatus { + if latency_ms <= threshold { + HealthStatus::Operational + } else { + HealthStatus::Degraded + } + } + + fn should_retry(msg: &str) -> bool { + let lower = msg.to_lowercase(); + lower.contains("timeout") || lower.contains("abort") || lower.contains("timed out") + } + + fn map_request_error(e: reqwest::Error) -> AppError { + if e.is_timeout() { + AppError::Message("Request timeout".to_string()) + } else if e.is_connect() { + AppError::Message(format!("Connection failed: {e}")) + } else { + AppError::Message(e.to_string()) + } + } + + /// Provider 级自定义 User-Agent(`meta.customUserAgent`),与转发路径共用单一口径: + /// trim、空串视为未设置、非法值静默忽略(返回 `None`)。 + fn custom_user_agent(provider: &Provider) -> Option { provider .meta .as_ref() .and_then(|meta| meta.custom_user_agent_header().ok().flatten()) } - /// Claude 流式检查 - /// - /// 根据供应商的 api_format 选择请求格式: - /// - "anthropic" (默认): Anthropic Messages API (/v1/messages) - /// - "openai_chat": OpenAI Chat Completions API (/v1/chat/completions) - /// - "openai_responses": OpenAI Responses API (/v1/responses) - /// - "gemini_native": Gemini Native streamGenerateContent - /// - /// `extra_headers` 是一个可选的供应商级自定义 header 集合(从 OpenClaw - /// 的 `settings_config.headers` 或 OpenCode 的 `settings_config.options.headers` - /// 读取),在所有内置 header 之后追加,用于覆盖或补充(例如自定义 User-Agent)。 - #[allow(clippy::too_many_arguments)] - async fn check_claude_stream( - client: &Client, - base_url: &str, - auth: &AuthInfo, - model: &str, - test_prompt: &str, - timeout: std::time::Duration, - provider: &Provider, - claude_api_format_override: Option<&str>, - extra_headers: Option<&serde_json::Map>, - ) -> Result<(u16, String), AppError> { - let base = base_url.trim_end_matches('/'); - let is_github_copilot = auth.strategy == AuthStrategy::GitHubCopilot; - - // Detect api_format: meta.api_format > settings_config.api_format > default "anthropic" - let api_format = provider - .meta - .as_ref() - .and_then(|m| m.api_format.as_deref()) - .or_else(|| { - provider - .settings_config - .get("api_format") - .and_then(|v| v.as_str()) - }) - .unwrap_or("anthropic"); - - let effective_api_format = claude_api_format_override.unwrap_or(api_format); - - let is_full_url = provider - .meta - .as_ref() - .and_then(|meta| meta.is_full_url) - .unwrap_or(false); - let is_openai_chat = effective_api_format == "openai_chat"; - let is_openai_responses = effective_api_format == "openai_responses"; - let is_gemini_native = effective_api_format == "gemini_native"; - let url = Self::resolve_claude_stream_url( - base, - auth.strategy, - effective_api_format, - is_full_url, - model, - ); - - let max_tokens = if is_openai_responses { 16 } else { 1 }; - - // Build from Anthropic-native shape first, then convert for configured targets. - let anthropic_body = json!({ - "model": model, - "max_tokens": max_tokens, - "messages": [{ "role": "user", "content": test_prompt }], - "stream": true - }); - // Codex OAuth (ChatGPT Plus/Pro 反代) 需要 store:false + include 标记, - // 否则 Stream Check 会和生产路径一样被服务端 400 拒绝。 - let is_codex_oauth = provider.is_codex_oauth(); - let codex_fast_mode = provider.codex_fast_mode_enabled(); - - let body = if is_openai_responses { - anthropic_to_responses( - anthropic_body, - Some(&provider.id), - is_codex_oauth, - codex_fast_mode, - ) - .map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))? - } else if is_gemini_native { - anthropic_to_gemini(anthropic_body) - .map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))? - } else if is_openai_chat { - anthropic_to_openai(anthropic_body) - .map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))? - } else { - anthropic_body - }; - - let mut request_builder = client.post(&url); - - if is_github_copilot { - // 生成请求追踪 ID - let request_id = uuid::Uuid::new_v4().to_string(); - request_builder = request_builder - .header("authorization", format!("Bearer {}", auth.api_key)) - .header("content-type", "application/json") - .header("accept", "text/event-stream") - .header("accept-encoding", "identity") - .header("user-agent", copilot_auth::COPILOT_USER_AGENT) - .header("editor-version", copilot_auth::COPILOT_EDITOR_VERSION) - .header( - "editor-plugin-version", - copilot_auth::COPILOT_PLUGIN_VERSION, - ) - .header( - "copilot-integration-id", - copilot_auth::COPILOT_INTEGRATION_ID, - ) - .header("x-github-api-version", copilot_auth::COPILOT_API_VERSION) - // 260401 新增copilot 的关键 headers - .header("openai-intent", "conversation-agent") - .header("x-initiator", "user") - .header("x-interaction-type", "conversation-agent") - .header("x-vscode-user-agent-library-version", "electron-fetch") - .header("x-request-id", &request_id) - .header("x-agent-task-id", &request_id); - } else if is_gemini_native { - request_builder = match auth.strategy { - AuthStrategy::GoogleOAuth => { - let token = auth.access_token.as_ref().unwrap_or(&auth.api_key); - request_builder - .header("authorization", format!("Bearer {token}")) - .header("x-goog-api-client", "GeminiCLI/1.0") - .header("content-type", "application/json") - .header("accept", "text/event-stream") - .header("accept-encoding", "identity") - } - _ => request_builder - .header("x-goog-api-key", &auth.api_key) - .header("content-type", "application/json") - .header("accept", "text/event-stream") - .header("accept-encoding", "identity"), - }; - } else if is_openai_chat || is_openai_responses { - // OpenAI-compatible targets: Bearer auth + SSE headers only - request_builder = request_builder - .header("authorization", format!("Bearer {}", auth.api_key)) - .header("content-type", "application/json") - .header("accept", "text/event-stream") - .header("accept-encoding", "identity"); - } else { - // Anthropic native: full Claude CLI headers - let os_name = Self::get_os_name(); - let arch_name = Self::get_arch_name(); - - // 鉴权头复用 ClaudeAdapter::get_auth_headers,与代理路径(forwarder)保持单一真理来源。 - // - AuthStrategy::Anthropic → x-api-key - // - AuthStrategy::ClaudeAuth → Authorization: Bearer - // - AuthStrategy::Bearer → Authorization: Bearer - // 避免之前"无条件 Bearer + 条件 x-api-key 双发"导致的假阴性 / auth conflict。 - let auth_headers = ClaudeAdapter::new() - .get_auth_headers(auth) - .map_err(|e| AppError::Message(format!("stream check 构造鉴权头失败: {e}")))?; - for (name, value) in auth_headers { - request_builder = request_builder.header(name, value); - } - - request_builder = request_builder - // Anthropic required headers - .header("anthropic-version", "2023-06-01") - .header( - "anthropic-beta", - "claude-code-20250219,interleaved-thinking-2025-05-14", - ) - .header("anthropic-dangerous-direct-browser-access", "true") - // Content type headers - .header("content-type", "application/json") - .header("accept", "application/json") - .header("accept-encoding", "identity") - .header("accept-language", "*") - // Client identification headers - .header("user-agent", "claude-cli/2.1.2 (external, cli)") - .header("x-app", "cli") - // x-stainless SDK headers (dynamic local system info) - .header("x-stainless-lang", "js") - .header("x-stainless-package-version", "0.70.0") - .header("x-stainless-os", os_name) - .header("x-stainless-arch", arch_name) - .header("x-stainless-runtime", "node") - .header("x-stainless-runtime-version", "v22.20.0") - .header("x-stainless-retry-count", "0") - .header("x-stainless-timeout", "600") - // Other headers - .header("sec-fetch-mode", "cors"); - } - - // 供应商自定义 headers 最后追加,允许覆盖内置默认值(例如 user-agent) - if let Some(headers) = extra_headers { - for (key, value) in headers { - if let Some(v) = value.as_str() { - request_builder = request_builder.header(key.as_str(), v); - } - } - } - - // Provider 级自定义 User-Agent(meta.customUserAgent)覆盖默认 UA,与 forwarder - // 转发路径口径一致;Copilot 指纹 UA 不可被覆盖。 - if !is_github_copilot { - if let Some(ua) = Self::custom_user_agent(provider) { - request_builder = request_builder.header("user-agent", ua); - } - } - - let response = request_builder - .timeout(timeout) - .json(&body) - .send() - .await - .map_err(Self::map_request_error)?; - - let status = response.status().as_u16(); - - if !response.status().is_success() { - let error_text = response.text().await.unwrap_or_default(); - return Err(Self::http_status_error(status, error_text)); - } - - // 流式读取:只需首个 chunk - let mut stream = response.bytes_stream(); - if let Some(chunk) = stream.next().await { - match chunk { - Ok(_) => Ok((status, model.to_string())), - Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))), - } - } else { - Err(AppError::Message("No response data received".to_string())) - } - } - - /// Codex 流式检查 - /// - /// 严格按照 Codex CLI 真实请求格式构建请求 (Responses API) - async fn check_codex_stream( - client: &Client, - base_url: &str, - auth: &AuthInfo, - model: &str, - test_prompt: &str, - timeout: std::time::Duration, - provider: &Provider, - ) -> Result<(u16, String), AppError> { - let is_full_url = provider - .meta - .as_ref() - .and_then(|meta| meta.is_full_url) - .unwrap_or(false); - // 当 provider 的 api_format 标记为 openai_chat 时,上游不接受 Responses API; - // 必须改打 /chat/completions 并发送 Chat 格式 body,否则 Stream Check 与代理路径不一致, - // 会把"实际可用"的供应商误报为不可用(典型如 DeepSeek、MiniMax、Kimi 等 Chat 兼容厂商)。 - let uses_chat = crate::proxy::providers::codex_provider_uses_chat_completions(provider); - let urls = if uses_chat { - Self::resolve_codex_chat_stream_urls(base_url, is_full_url) - } else { - Self::resolve_codex_stream_urls(base_url, is_full_url) - }; - - // 解析模型名和推理等级 (支持 model@level 或 model#level 格式) - let (actual_model, reasoning_effort) = Self::parse_model_with_effort(model); - - // 获取本地系统信息 - let os_name = Self::get_os_name(); - let arch_name = Self::get_arch_name(); - - // Provider 级自定义 User-Agent(meta.customUserAgent)覆盖默认 codex UA,与 forwarder - // 转发路径口径一致——否则 Stream Check 会用与真实流量不同的 UA 探测(如 Kimi UA 白名单)。 - let user_agent = Self::custom_user_agent(provider).unwrap_or_else(|| { - reqwest::header::HeaderValue::from_str(&format!( - "codex_cli_rs/0.80.0 ({os_name} 15.7.2; {arch_name}) Terminal" - )) - .unwrap_or_else(|_| reqwest::header::HeaderValue::from_static("codex_cli_rs/0.80.0")) - }); - - let mut body = if uses_chat { - // Chat Completions 请求体(与 transform_codex_chat::responses_to_chat_completions 对齐) - json!({ - "model": actual_model, - "messages": [{ "role": "user", "content": test_prompt }], - "max_tokens": 1, - "stream": true - }) - } else { - // Responses API 请求体格式 (input 必须是数组) - json!({ - "model": actual_model, - "input": [{ "role": "user", "content": test_prompt }], - "stream": true - }) - }; - - // Chat 路径只对 OpenAI o-series 透传 reasoning_effort,与 transform_codex_chat - // 一致;非 o-series(DeepSeek、Kimi 等)收到未知字段会 400。 - if let Some(effort) = reasoning_effort { - if uses_chat - && crate::proxy::providers::transform::supports_reasoning_effort(&actual_model) - { - body["reasoning_effort"] = json!(effort); - } else if !uses_chat { - body["reasoning"] = json!({ "effort": effort }); - } - } - - for (i, url) in urls.iter().enumerate() { - // 严格按照 Codex CLI 请求格式设置 headers - let response = client - .post(url) - .header("authorization", format!("Bearer {}", auth.api_key)) - .header("content-type", "application/json") - .header("accept", "text/event-stream") - .header("accept-encoding", "identity") - .header("user-agent", user_agent.clone()) - .header("originator", "codex_cli_rs") - .timeout(timeout) - .json(&body) - .send() - .await - .map_err(Self::map_request_error)?; - - let status = response.status().as_u16(); - - if !response.status().is_success() { - let error_text = response.text().await.unwrap_or_default(); - // 回退策略:仅当首选 URL 返回 404 时尝试下一个 - if i == 0 && status == 404 && urls.len() > 1 { - continue; - } - return Err(Self::http_status_error(status, error_text)); - } - - let mut stream = response.bytes_stream(); - if let Some(chunk) = stream.next().await { - match chunk { - Ok(_) => return Ok((status, actual_model)), - Err(e) => return Err(AppError::Message(format!("Stream read failed: {e}"))), - } - } - - return Err(AppError::Message("No response data received".to_string())); - } - - Err(AppError::Message( - "No valid Codex responses endpoint found".to_string(), - )) - } - - /// Gemini 流式检查 - /// - /// 使用 Gemini 原生 API 格式 (streamGenerateContent) - async fn check_gemini_stream( - client: &Client, - base_url: &str, - auth: &AuthInfo, - model: &str, - test_prompt: &str, - timeout: std::time::Duration, - extra_headers: Option<&serde_json::Map>, - ) -> Result<(u16, String), AppError> { - let base = base_url.trim_end_matches('/'); - // Strip `models/` resource-name prefix from the model id — see - // `normalize_gemini_model_id` for rationale. - let normalized_model = normalize_gemini_model_id(model); - // Gemini 原生 API: /v1beta/models/{model}:streamGenerateContent?alt=sse - // 智能处理 /v1beta 路径:如果 base_url 不包含版本路径,则添加 /v1beta - // alt=sse 参数使 API 返回 SSE 格式(text/event-stream)而非 JSON 数组 - let url = if base.contains("/v1beta") || base.contains("/v1/") { - format!("{base}/models/{normalized_model}:streamGenerateContent?alt=sse") - } else { - format!("{base}/v1beta/models/{normalized_model}:streamGenerateContent?alt=sse") - }; - - // Gemini 原生请求体格式 - let body = json!({ - "contents": [{ - "role": "user", - "parts": [{ "text": test_prompt }] - }] - }); - - let mut request_builder = client - .post(&url) - .header("x-goog-api-key", &auth.api_key) - .header("Content-Type", "application/json") - .header("Accept", "text/event-stream"); - - // 供应商自定义 headers 最后追加 - if let Some(headers) = extra_headers { - for (key, value) in headers { - if let Some(v) = value.as_str() { - request_builder = request_builder.header(key.as_str(), v); - } - } - } - - let response = request_builder - .timeout(timeout) - .json(&body) - .send() - .await - .map_err(Self::map_request_error)?; - - let status = response.status().as_u16(); - - if !response.status().is_success() { - let error_text = response.text().await.unwrap_or_default(); - return Err(Self::http_status_error(status, error_text)); - } - - let mut stream = response.bytes_stream(); - if let Some(chunk) = stream.next().await { - match chunk { - Ok(_) => Ok((status, model.to_string())), - Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))), - } - } else { - Err(AppError::Message("No response data received".to_string())) - } - } - - /// OpenCode / OpenClaw 的独立分发入口(绕过 `get_adapter`) - /// - /// 这两个应用的 `settings_config` 与 Claude/Codex/Gemini 完全不同: - /// - OpenClaw: `{ baseUrl, apiKey, api, models: [...] }`,`api` 字段标识协议 - /// - OpenCode: `{ npm, options: { baseURL, apiKey }, models: {...} }`,`npm` 字段标识协议 - /// - /// 因此不能复用 `get_adapter`(会 fallback 到 CodexAdapter 而提取失败), - /// 改为独立解析 base_url/api_key/协议,再分发到现有的 check_*_stream 函数。 - async fn check_once_without_adapter( - app_type: &AppType, - provider: &Provider, - config: &StreamCheckConfig, - start: Instant, - ) -> Result { - // 获取 HTTP 客户端 - let client = crate::proxy::http_client::get(); - let request_timeout = std::time::Duration::from_secs(config.timeout_secs); - - let model_to_test = Self::resolve_test_model(app_type, provider, config); - let test_prompt = &config.test_prompt; - - let result = match app_type { - AppType::OpenClaw => { - Self::check_additive_app_stream( - &client, - provider, - &model_to_test, - test_prompt, - request_timeout, - ) - .await - } - AppType::OpenCode => { - Self::check_opencode_stream( - &client, - provider, - &model_to_test, - test_prompt, - request_timeout, - ) - .await - } - AppType::Hermes => { - Self::check_hermes_stream( - &client, - provider, - &model_to_test, - test_prompt, - request_timeout, - ) - .await - } - _ => unreachable!("check_once_without_adapter 只处理 OpenCode/OpenClaw/Hermes"), - }; - - let response_time = start.elapsed().as_millis() as u64; - Ok(Self::build_stream_check_result( - result, - response_time, - config.degraded_threshold_ms, - &model_to_test, - )) - } - - /// 将 check_*_stream 的原始结果包装成 StreamCheckResult - /// - /// 抽取自 check_once 的末尾逻辑,以便 OpenCode/OpenClaw 的独立分支复用。 - /// - /// `model_tested` 是本次探测使用的模型名,用于在失败场景下仍能把模型信息透传给前端, - /// 方便针对"模型不存在 / 已下架"这类错误渲染专门的提示。 - fn build_stream_check_result( - result: Result<(u16, String), AppError>, - response_time: u64, - degraded_threshold_ms: u64, - model_tested: &str, - ) -> StreamCheckResult { - let tested_at = chrono::Utc::now().timestamp(); - match result { - Ok((status_code, model)) => StreamCheckResult { - status: Self::determine_status(response_time, degraded_threshold_ms), - success: true, - message: "Check succeeded".to_string(), - response_time_ms: Some(response_time), - http_status: Some(status_code), - model_used: model, - tested_at, - retry_count: 0, - error_category: None, - }, - Err(e) => { - let (http_status, message, error_category) = match &e { - AppError::HttpStatus { status, body } => { - let category = Self::detect_error_category(*status, body); - ( - Some(*status), - Self::classify_http_status(*status).to_string(), - category.map(|s| s.to_string()), - ) - } - _ => (None, e.to_string(), None), - }; - StreamCheckResult { - status: HealthStatus::Failed, - success: false, - message, - response_time_ms: Some(response_time), - http_status, - model_used: model_tested.to_string(), - tested_at, - retry_count: 0, - error_category, - } - } - } - } - - /// 基于 HTTP 状态码和响应体识别细粒度错误分类。 - /// - /// 目前仅识别"模型不存在 / 已下架":各厂商该类错误通常返回 4xx,body 中会包含 - /// 如 `model_not_found`(OpenAI)、`does not exist`、`invalid model`、`not_found_error` - /// + `model` 字样(Anthropic)等标记。 - pub(crate) fn detect_error_category(status: u16, body: &str) -> Option<&'static str> { - // 只检查 4xx;5xx 的错误信息里可能巧合出现"model"之类的词,容易误判 - if !(400..500).contains(&status) { - return None; - } - let lower = body.to_lowercase(); - let qianfan_quota_indicators = [ - "coding_plan_hour_quota_exceeded", - "coding_plan_week_quota_exceeded", - "coding_plan_month_quota_exceeded", - ]; - if qianfan_quota_indicators.iter().any(|s| lower.contains(s)) { - return Some("quotaExceeded"); - } - - // 必须提到 "model",避免通用 404 / 400 被误判 - if !lower.contains("model") { - return None; - } - let indicators = [ - "model_not_found", - "model not found", - "does not exist", - "invalid_model", - "invalid model", - "unknown_model", - "unknown model", - "is not a valid model", - "not_found_error", // Anthropic 的 type 字段 - ]; - if indicators.iter().any(|s| lower.contains(s)) { - return Some("modelNotFound"); - } - None - } - - /// OpenClaw 流式检查分发器 - /// - /// 根据 `settings_config.api` 字段分发到对应协议的检查器。 - /// 取值参见 `openclawApiProtocols` (前端 openclawProviderPresets.ts): - /// - `openai-completions` → check_claude_stream + api_format="openai_chat" - /// - `openai-responses` → check_claude_stream + api_format="openai_responses" - /// - `anthropic-messages` → check_claude_stream + api_format="anthropic" (ClaudeAuth 策略) - /// - `google-generative-ai` → check_gemini_stream (Google API Key 策略) - /// - `bedrock-converse-stream` → 不支持(需要 AWS SigV4 签名) - async fn check_additive_app_stream( - client: &Client, - provider: &Provider, - model: &str, - test_prompt: &str, - timeout: std::time::Duration, - ) -> Result<(u16, String), AppError> { - // 自定义认证头(如 Longcat 的 `apikey` 头)不走标准 Bearer, - // 具体头名由 OpenClaw 网关内部决定,cc-switch 无法准确构造, - // 因此直接返回友好错误而不是让用户看到一个误导性的 401。 - if Self::additive_app_uses_auth_header(provider) { - return Err(AppError::localized( - "openclaw_auth_header_not_supported", - "该供应商使用自定义认证头,暂不支持流式健康检查。建议直接通过 OpenClaw 测试。", - "This provider uses a custom auth header; stream health check is not supported. Please test it directly via OpenClaw.", - )); - } - - let base_url = Self::extract_openclaw_base_url(provider)?; - let api_key = Self::extract_openclaw_api_key(provider)?; - let api = Self::extract_openclaw_protocol(provider); - let extra_headers = Self::extract_openclaw_headers(provider); - - match api.as_deref() { - Some("openai-completions") => { - let auth = AuthInfo::new(api_key, AuthStrategy::Bearer); - Self::check_claude_stream( - client, - &base_url, - &auth, - model, - test_prompt, - timeout, - provider, - Some("openai_chat"), - extra_headers, - ) - .await - } - Some("openai-responses") => { - let auth = AuthInfo::new(api_key, AuthStrategy::Bearer); - Self::check_claude_stream( - client, - &base_url, - &auth, - model, - test_prompt, - timeout, - provider, - Some("openai_responses"), - extra_headers, - ) - .await - } - Some("anthropic-messages") => { - // 使用 ClaudeAuth(Bearer-only)以兼容 Claude 中转服务。 - // 某些中转同时收到 Authorization 和 x-api-key 会报错,ClaudeAuth - // 策略保证只下发 Bearer。官方 Anthropic 也接受纯 Bearer。 - let auth = AuthInfo::new(api_key, AuthStrategy::ClaudeAuth); - Self::check_claude_stream( - client, - &base_url, - &auth, - model, - test_prompt, - timeout, - provider, - Some("anthropic"), - extra_headers, - ) - .await - } - Some("google-generative-ai") => { - let auth = AuthInfo::new(api_key, AuthStrategy::Google); - Self::check_gemini_stream( - client, - &base_url, - &auth, - model, - test_prompt, - timeout, - extra_headers, - ) - .await - } - Some("bedrock-converse-stream") => Err(AppError::localized( - "openclaw_bedrock_not_supported", - "AWS Bedrock 需要 SigV4 签名,当前不支持健康检查。请通过 AWS 控制台或 OpenClaw 验证连通性。", - "AWS Bedrock requires SigV4 signing and is not supported by stream health check. Please verify connectivity via AWS console or OpenClaw.", - )), - Some(other) => Err(AppError::localized( - "openclaw_protocol_not_yet_supported", - format!("OpenClaw 暂不支持协议: {other}"), - format!("OpenClaw protocol not yet supported: {other}"), - )), - None => Err(AppError::localized( - "openclaw_protocol_missing", - "OpenClaw 供应商缺少 api 字段", - "OpenClaw provider is missing the `api` field", - )), - } - } - - /// 判断 additive-mode 供应商是否使用自定义认证头(`authHeader: true`) - fn additive_app_uses_auth_header(provider: &Provider) -> bool { - provider - .settings_config - .get("authHeader") - .and_then(|v| v.as_bool()) - .unwrap_or(false) - } - - /// 提取 OpenClaw 供应商的自定义 headers(来自 `settings_config.headers`) - fn extract_openclaw_headers( - provider: &Provider, - ) -> Option<&serde_json::Map> { - provider - .settings_config - .get("headers") - .and_then(|v| v.as_object()) - .filter(|m| !m.is_empty()) - } + // ===== 各应用 base_url 提取(settings_config 结构互不相同)===== + /// OpenClaw: `{ baseUrl, apiKey, api, ... }`(camelCase) fn extract_openclaw_base_url(provider: &Provider) -> Result { provider .settings_config @@ -1030,34 +382,7 @@ impl StreamCheckService { }) } - fn extract_openclaw_api_key(provider: &Provider) -> Result { - provider - .settings_config - .get("apiKey") - .and_then(|v| v.as_str()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .ok_or_else(|| { - AppError::localized( - "openclaw_api_key_missing", - "OpenClaw 供应商缺少 apiKey", - "OpenClaw provider is missing `apiKey`", - ) - }) - } - - fn extract_openclaw_protocol(provider: &Provider) -> Option { - provider - .settings_config - .get("api") - .and_then(|v| v.as_str()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - } - - // Hermes 的 settings_config 用 snake_case(base_url / api_key / api_mode), - // 与 OpenClaw 的 camelCase(baseUrl / apiKey / api)是两套独立命名。 - // 见 src/config/hermesProviderPresets.ts 的 HermesProviderSettingsConfig。 + /// Hermes: `{ base_url, api_key, api_mode }`(snake_case) fn extract_hermes_base_url(provider: &Provider) -> Result { provider .settings_config @@ -1074,206 +399,10 @@ impl StreamCheckService { }) } - fn extract_hermes_api_key(provider: &Provider) -> Result { - provider - .settings_config - .get("api_key") - .and_then(|v| v.as_str()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .ok_or_else(|| { - AppError::localized( - "hermes_api_key_missing", - "Hermes 供应商缺少 api_key", - "Hermes provider is missing `api_key`", - ) - }) - } - - fn extract_hermes_api_mode(provider: &Provider) -> Option { - provider - .settings_config - .get("api_mode") - .and_then(|v| v.as_str()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - } - - /// Hermes 流式检查分发器 + /// OpenCode: `{ npm, options: { baseURL, apiKey }, ... }` /// - /// Hermes 以 `api_mode` 字段显式指定协议,取值来自 - /// `HermesApiMode`(hermesProviderPresets.ts): - /// - `chat_completions` → check_claude_stream + api_format="openai_chat"(Bearer) - /// - `anthropic_messages` → check_claude_stream + api_format="anthropic"(ClaudeAuth,与 OpenClaw 的 anthropic-messages 同策略) - /// - `codex_responses` → check_claude_stream + api_format="openai_responses"(Bearer) - /// - `bedrock_converse` → 不支持(需要 AWS SigV4 签名) - async fn check_hermes_stream( - client: &Client, - provider: &Provider, - model: &str, - test_prompt: &str, - timeout: std::time::Duration, - ) -> Result<(u16, String), AppError> { - // 先把 api_mode 路由出协议格式与认证策略。 - // 纯错误路径(bedrock / 未知 / 缺失)直接 return,避免在用户 - // 选了 bedrock_converse 时被"缺 base_url"的二级错误盖住真正原因。 - let (api_format, auth_strategy) = match Self::extract_hermes_api_mode(provider).as_deref() { - Some("chat_completions") => ("openai_chat", AuthStrategy::Bearer), - Some("anthropic_messages") => ("anthropic", AuthStrategy::ClaudeAuth), - Some("codex_responses") => ("openai_responses", AuthStrategy::Bearer), - Some("bedrock_converse") => { - return Err(AppError::localized( - "hermes_bedrock_not_supported", - "AWS Bedrock 需要 SigV4 签名,当前不支持健康检查。", - "AWS Bedrock requires SigV4 signing and is not supported by stream health check.", - )); - } - Some(other) => { - return Err(AppError::localized( - "hermes_protocol_not_yet_supported", - format!("Hermes 暂不支持协议: {other}"), - format!("Hermes protocol not yet supported: {other}"), - )); - } - None => { - return Err(AppError::localized( - "hermes_api_mode_missing", - "Hermes 供应商缺少 api_mode 字段", - "Hermes provider is missing the `api_mode` field", - )); - } - }; - - let base_url = Self::extract_hermes_base_url(provider)?; - let api_key = Self::extract_hermes_api_key(provider)?; - let auth = AuthInfo::new(api_key, auth_strategy); - Self::check_claude_stream( - client, - &base_url, - &auth, - model, - test_prompt, - timeout, - provider, - Some(api_format), - None, - ) - .await - } - - /// OpenCode 流式检查分发器 - /// - /// OpenCode 用 `npm` 字段(AI SDK 包名)隐式指定协议。映射关系参见 - /// `opencodeNpmPackages` (前端 opencodeProviderPresets.ts): - /// - `@ai-sdk/openai-compatible` → check_claude_stream + api_format="openai_chat" - /// - `@ai-sdk/openai` → check_claude_stream + api_format="openai_responses" - /// - `@ai-sdk/anthropic` → check_claude_stream + api_format="anthropic" - /// - `@ai-sdk/google` → check_gemini_stream (Google API Key 策略) - /// - `@ai-sdk/amazon-bedrock` → 不支持(需要 AWS SigV4 签名) - /// - /// URL/API Key 存放在 `settings_config.options.{baseURL,apiKey}`,注意 - /// `baseURL` 大写 L(与 OpenClaw 的 `baseUrl` 首字母小写 u 不同)。 - async fn check_opencode_stream( - client: &Client, - provider: &Provider, - model: &str, - test_prompt: &str, - timeout: std::time::Duration, - ) -> Result<(u16, String), AppError> { - let npm = Self::extract_opencode_npm(provider); - // 若用户未显式填 baseURL,则根据 npm 回退到 AI SDK 包自带的默认端点 - let base_url = Self::resolve_opencode_base_url(provider, npm.as_deref())?; - let api_key = Self::extract_opencode_api_key(provider)?; - let extra_headers = Self::extract_opencode_headers(provider); - - match npm.as_deref() { - Some("@ai-sdk/openai-compatible") => { - let auth = AuthInfo::new(api_key, AuthStrategy::Bearer); - Self::check_claude_stream( - client, - &base_url, - &auth, - model, - test_prompt, - timeout, - provider, - Some("openai_chat"), - extra_headers, - ) - .await - } - Some("@ai-sdk/openai") => { - let auth = AuthInfo::new(api_key, AuthStrategy::Bearer); - Self::check_claude_stream( - client, - &base_url, - &auth, - model, - test_prompt, - timeout, - provider, - Some("openai_responses"), - extra_headers, - ) - .await - } - Some("@ai-sdk/anthropic") => { - // 见 check_additive_app_stream 对 anthropic-messages 的处理: - // 用 ClaudeAuth(Bearer-only)兼容中转服务。 - let auth = AuthInfo::new(api_key, AuthStrategy::ClaudeAuth); - Self::check_claude_stream( - client, - &base_url, - &auth, - model, - test_prompt, - timeout, - provider, - Some("anthropic"), - extra_headers, - ) - .await - } - Some("@ai-sdk/google") => { - let auth = AuthInfo::new(api_key, AuthStrategy::Google); - Self::check_gemini_stream( - client, - &base_url, - &auth, - model, - test_prompt, - timeout, - extra_headers, - ) - .await - } - Some("@ai-sdk/amazon-bedrock") => Err(AppError::localized( - "opencode_bedrock_not_supported", - "AWS Bedrock 需要 SigV4 签名,当前不支持健康检查。请通过 AWS 控制台或 OpenCode 验证连通性。", - "AWS Bedrock requires SigV4 signing and is not supported by stream health check. Please verify connectivity via AWS console or OpenCode.", - )), - Some(other) => Err(AppError::localized( - "opencode_npm_not_yet_supported", - format!("OpenCode 暂不支持 SDK 包: {other}"), - format!("OpenCode SDK package not yet supported: {other}"), - )), - None => Err(AppError::localized( - "opencode_npm_missing", - "OpenCode 供应商缺少 npm 字段", - "OpenCode provider is missing the `npm` field", - )), - } - } - - /// 按 OpenCode 的实际 SDK 包特性确定 baseURL: - /// - 用户显式填写的 `options.baseURL` 总是优先 - /// - 否则根据 `npm` 返回 AI SDK 包自带的默认端点 - /// - `@ai-sdk/openai-compatible` 没有默认端点,必须显式填 - /// - /// 注意:这里的默认端点对应 AI SDK 包的行为(例如 `@ai-sdk/openai` - /// 自带 `/v1` 路径后缀),与 `proxy/providers/mod.rs` 里的 - /// `ProviderType::default_endpoint()` 语义不同——后者是代理层的上游 - /// 默认值,不带 `/v1`。两者维护的是不同系统的默认值,不能简单共享。 + /// 用户未显式填 `options.baseURL` 时,按 `npm`(AI SDK 包)回退到包自带默认端点。 + /// `@ai-sdk/openai-compatible` 无默认端点,必须显式填。 fn resolve_opencode_base_url( provider: &Provider, npm: Option<&str>, @@ -1308,35 +437,6 @@ impl StreamCheckService { .filter(|s| !s.is_empty()) } - /// 提取 OpenCode 供应商的自定义 headers(来自 `settings_config.options.headers`) - fn extract_opencode_headers( - provider: &Provider, - ) -> Option<&serde_json::Map> { - provider - .settings_config - .get("options") - .and_then(|v| v.get("headers")) - .and_then(|v| v.as_object()) - .filter(|m| !m.is_empty()) - } - - fn extract_opencode_api_key(provider: &Provider) -> Result { - provider - .settings_config - .get("options") - .and_then(|v| v.get("apiKey")) - .and_then(|v| v.as_str()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .ok_or_else(|| { - AppError::localized( - "opencode_api_key_missing", - "OpenCode 供应商缺少 options.apiKey", - "OpenCode provider is missing `options.apiKey`", - ) - }) - } - fn extract_opencode_npm(provider: &Provider) -> Option { provider .settings_config @@ -1345,283 +445,6 @@ impl StreamCheckService { .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) } - - fn determine_status(latency_ms: u64, threshold: u64) -> HealthStatus { - if latency_ms <= threshold { - HealthStatus::Operational - } else { - HealthStatus::Degraded - } - } - - /// 解析模型名和推理等级 (支持 model@level 或 model#level 格式) - /// 返回 (实际模型名, Option<推理等级>) - fn parse_model_with_effort(model: &str) -> (String, Option) { - if let Some(pos) = model.find('@').or_else(|| model.find('#')) { - let actual_model = model[..pos].to_string(); - let effort = model[pos + 1..].to_string(); - if !effort.is_empty() { - return (actual_model, Some(effort)); - } - } - (model.to_string(), None) - } - - fn should_retry(msg: &str) -> bool { - let lower = msg.to_lowercase(); - lower.contains("timeout") || lower.contains("abort") || lower.contains("timed out") - } - - fn map_request_error(e: reqwest::Error) -> AppError { - if e.is_timeout() { - AppError::Message("Request timeout".to_string()) - } else if e.is_connect() { - AppError::Message(format!("Connection failed: {e}")) - } else { - AppError::Message(e.to_string()) - } - } - - /// 构造 HTTP 状态码错误,截断过长的响应体 - fn http_status_error(status: u16, body: String) -> AppError { - let body = if body.len() > 200 { - // 安全截断:找到 200 字节内最近的 char 边界 - let mut end = 200; - while end > 0 && !body.is_char_boundary(end) { - end -= 1; - } - format!("{}…", &body[..end]) - } else { - body - }; - AppError::HttpStatus { status, body } - } - - /// 将 HTTP 状态码映射为简短的分类标签 - pub(crate) fn classify_http_status(status: u16) -> &'static str { - match status { - 400 => "Bad request (400)", - 401 => "Auth rejected (401)", - 402 => "Payment required (402)", - 403 => "Access denied (403)", - 404 => "Not found (404)", - 429 => "Rate limited (429)", - 500 => "Internal server error (500)", - 502 => "Bad gateway (502)", - 503 => "Service unavailable (503)", - 504 => "Gateway timeout (504)", - s if (500..600).contains(&s) => "Server error", - _ => "HTTP error", - } - } - - fn resolve_test_model( - app_type: &AppType, - provider: &Provider, - config: &StreamCheckConfig, - ) -> String { - match app_type { - AppType::Claude | AppType::ClaudeDesktop => { - Self::extract_env_model(provider, "ANTHROPIC_MODEL") - .unwrap_or_else(|| config.claude_model.clone()) - } - AppType::Codex => { - Self::extract_codex_model(provider).unwrap_or_else(|| config.codex_model.clone()) - } - AppType::Gemini => Self::extract_env_model(provider, "GEMINI_MODEL") - .unwrap_or_else(|| config.gemini_model.clone()), - AppType::OpenCode => { - // OpenCode uses models map in settings_config - // Try to extract first model from the models object - Self::extract_opencode_model(provider).unwrap_or_else(|| "gpt-4o".to_string()) - } - AppType::OpenClaw | AppType::Hermes => { - // OpenClaw/Hermes use models array in settings_config - // Try to extract first model from the models array - Self::extract_openclaw_model(provider).unwrap_or_else(|| "gpt-4o".to_string()) - } - } - } - - fn extract_opencode_model(provider: &Provider) -> Option { - let models = provider - .settings_config - .get("models") - .and_then(|m| m.as_object())?; - - // Return the first model ID from the models map - models.keys().next().map(|s| s.to_string()) - } - - fn extract_openclaw_model(provider: &Provider) -> Option { - // OpenClaw uses models array: [{ "id": "model-id", "name": "Model Name" }] - let models = provider - .settings_config - .get("models") - .and_then(|m| m.as_array())?; - - // Return the first model ID from the models array - models - .first() - .and_then(|m| m.get("id")) - .and_then(|id| id.as_str()) - .map(|s| s.to_string()) - } - - fn extract_env_model(provider: &Provider, key: &str) -> Option { - provider - .settings_config - .get("env") - .and_then(|env| env.get(key)) - .and_then(|value| value.as_str()) - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - } - - fn extract_codex_model(provider: &Provider) -> Option { - let config_text = provider - .settings_config - .get("config") - .and_then(|value| value.as_str())?; - if config_text.trim().is_empty() { - return None; - } - - let table = toml::from_str::(config_text).ok()?; - table - .get("model") - .and_then(|v| v.as_str()) - .map(|s| s.trim().to_string()) - .filter(|value| !value.is_empty()) - } - - /// 获取操作系统名称(映射为 Claude CLI 使用的格式) - fn get_os_name() -> &'static str { - match std::env::consts::OS { - "macos" => "MacOS", - "linux" => "Linux", - "windows" => "Windows", - other => other, - } - } - - /// 获取 CPU 架构名称(映射为 Claude CLI 使用的格式) - fn get_arch_name() -> &'static str { - match std::env::consts::ARCH { - "aarch64" => "arm64", - "x86_64" => "x86_64", - "x86" => "x86", - other => other, - } - } - - fn resolve_claude_stream_url( - base_url: &str, - auth_strategy: AuthStrategy, - api_format: &str, - is_full_url: bool, - model: &str, - ) -> String { - if api_format == "gemini_native" { - // Strip an optional `models/` resource-name prefix so that model - // identifiers copied from Gemini SDK outputs (e.g. - // `models/gemini-2.5-pro`) don't produce a doubled - // `/v1beta/models/models/...` URL. - let normalized_model = normalize_gemini_model_id(model); - let endpoint = - format!("/v1beta/models/{normalized_model}:streamGenerateContent?alt=sse"); - return resolve_gemini_native_url(base_url, &endpoint, is_full_url); - } - - if is_full_url { - return base_url.to_string(); - } - - let base = base_url.trim_end_matches('/'); - let is_github_copilot = auth_strategy == AuthStrategy::GitHubCopilot; - - if is_github_copilot && api_format == "openai_responses" { - format!("{base}/v1/responses") - } else if is_github_copilot { - format!("{base}/chat/completions") - } else if api_format == "openai_responses" { - if base.ends_with("/v1") { - format!("{base}/responses") - } else { - format!("{base}/v1/responses") - } - } else if api_format == "openai_chat" { - if base.ends_with("/v1") { - format!("{base}/chat/completions") - } else { - format!("{base}/v1/chat/completions") - } - } else if base.ends_with("/v1") { - format!("{base}/messages") - } else { - format!("{base}/v1/messages") - } - } - - /// Codex Responses 流式 URL 构造(薄包装,详见 `resolve_codex_endpoint_urls`)。 - fn resolve_codex_stream_urls(base_url: &str, is_full_url: bool) -> Vec { - Self::resolve_codex_endpoint_urls(base_url, is_full_url, "responses") - } - - /// Codex Chat Completions 流式 URL 构造(薄包装,详见 `resolve_codex_endpoint_urls`)。 - fn resolve_codex_chat_stream_urls(base_url: &str, is_full_url: bool) -> Vec { - Self::resolve_codex_endpoint_urls(base_url, is_full_url, "chat/completions") - } - - /// 与 `CodexAdapter::build_url` 优先级对齐的 stream check URL 列表构造。 - /// - /// 纯 origin 在生产路径上会自动补 `/v1`,所以 Stream Check 必须优先走 - /// `/v1/`。否则上游对 bare 路径返回 401/400/405(非 404) - /// 时不会触发循环里的 fallback,会把可用供应商误判为不可用。 - fn resolve_codex_endpoint_urls( - base_url: &str, - is_full_url: bool, - endpoint: &str, - ) -> Vec { - if is_full_url { - return vec![base_url.to_string()]; - } - - let base = base_url.trim_end_matches('/'); - let lower = base.to_ascii_lowercase(); - let endpoint_suffix = format!("/{endpoint}"); - let endpoint_lower = endpoint_suffix.to_ascii_lowercase(); - - // 用户在 base_url 里写了完整 endpoint 但忘开 is_full_url 的兜底 - if lower.ends_with(&endpoint_lower) { - return vec![base.to_string()]; - } - - if base.ends_with("/v1") { - return vec![format!("{base}{endpoint_suffix}")]; - } - - if crate::proxy::providers::is_origin_only_url(base) { - vec![ - format!("{base}/v1{endpoint_suffix}"), - format!("{base}{endpoint_suffix}"), - ] - } else { - vec![ - format!("{base}{endpoint_suffix}"), - format!("{base}/v1{endpoint_suffix}"), - ] - } - } - - pub(crate) fn resolve_effective_test_model( - app_type: &AppType, - provider: &Provider, - config: &StreamCheckConfig, - ) -> String { - let effective_config = Self::merge_provider_config(provider, config); - Self::resolve_test_model(app_type, provider, &effective_config) - } } #[cfg(test)] @@ -1638,71 +461,113 @@ mod tests { } #[test] - fn test_additive_app_uses_auth_header_true() { - let p = make_provider(serde_json::json!({ - "baseUrl": "https://api.longcat.chat/v1", - "apiKey": "k", - "api": "openai-completions", - "authHeader": true, - })); - assert!(StreamCheckService::additive_app_uses_auth_header(&p)); + fn test_default_config_uses_reachability_friendly_values() { + let config = StreamCheckConfig::default(); + assert_eq!(config.timeout_secs, 8); + assert_eq!(config.max_retries, 1); + assert_eq!(config.degraded_threshold_ms, 1500); } #[test] - fn test_additive_app_uses_auth_header_default_false() { - let p = make_provider(serde_json::json!({ - "baseUrl": "https://api.deepseek.com/v1", - "apiKey": "k", - "api": "openai-completions", - })); - assert!(!StreamCheckService::additive_app_uses_auth_header(&p)); - } - - #[test] - fn test_custom_user_agent_trims_and_filters_empty() { - use crate::provider::ProviderMeta; - - let mut p = make_provider(serde_json::json!({ - "baseUrl": "https://api.kimi.com/coding", - "apiKey": "k", - })); - - // 未设置 meta → None - assert!(StreamCheckService::custom_user_agent(&p).is_none()); - - // 带首尾空格的 UA → 去空格后返回合法 HeaderValue - p.meta = Some(ProviderMeta { - custom_user_agent: Some(" claude-cli/2.1.161 ".to_string()), - ..Default::default() - }); + fn test_determine_status() { assert_eq!( - StreamCheckService::custom_user_agent(&p) - .unwrap() - .to_str() - .unwrap(), - "claude-cli/2.1.161" + StreamCheckService::determine_status(1000, 1500), + HealthStatus::Operational ); + assert_eq!( + StreamCheckService::determine_status(1500, 1500), + HealthStatus::Operational + ); + assert_eq!( + StreamCheckService::determine_status(1501, 1500), + HealthStatus::Degraded + ); + } - // 纯空白 → 视为未设置(与 forwarder 路径口径一致) - p.meta = Some(ProviderMeta { - custom_user_agent: Some(" ".to_string()), + #[test] + fn test_should_retry_only_on_timeout_like_errors() { + assert!(StreamCheckService::should_retry("Request timeout")); + assert!(StreamCheckService::should_retry("request timed out")); + assert!(StreamCheckService::should_retry("connection abort")); + // 连接被拒 / DNS 失败不重试 + assert!(!StreamCheckService::should_retry( + "Connection failed: dns error" + )); + assert!(!StreamCheckService::should_retry("Reachable")); + } + + #[test] + fn test_build_result_any_http_status_is_reachable() { + // 任何 HTTP 状态码都算可达(success=true) + for status in [200u16, 401, 403, 404, 429, 500, 503] { + let r = StreamCheckService::build_result(Ok(status), 100, 1500); + assert!(r.success, "status {status} should be reachable"); + assert_eq!(r.status, HealthStatus::Operational); + assert_eq!(r.http_status, Some(status)); + assert!(r.model_used.is_empty()); + assert!(r.error_category.is_none()); + } + } + + #[test] + fn test_build_result_network_error_is_unreachable() { + let r = StreamCheckService::build_result( + Err(AppError::Message("Connection failed: refused".to_string())), + 5, + 1500, + ); + assert!(!r.success); + assert_eq!(r.status, HealthStatus::Failed); + assert!(r.http_status.is_none()); + } + + #[test] + fn test_build_result_slow_response_is_degraded() { + let r = StreamCheckService::build_result(Ok(200), 3000, 1500); + assert!(r.success); + assert_eq!(r.status, HealthStatus::Degraded); + } + + #[test] + fn test_merge_provider_config_override_and_default() { + use crate::provider::{ProviderMeta, ProviderTestConfig}; + + let global = StreamCheckConfig::default(); + + // 无 testConfig → 用全局 + let p = make_provider(serde_json::json!({})); + let merged = StreamCheckService::merge_provider_config(&p, &global); + assert_eq!(merged.timeout_secs, global.timeout_secs); + + // testConfig 启用并覆盖部分字段 + let mut p2 = make_provider(serde_json::json!({})); + p2.meta = Some(ProviderMeta { + test_config: Some(ProviderTestConfig { + enabled: true, + timeout_secs: Some(20), + degraded_threshold_ms: Some(3000), + max_retries: None, + }), ..Default::default() }); - assert!(StreamCheckService::custom_user_agent(&p).is_none()); + let merged2 = StreamCheckService::merge_provider_config(&p2, &global); + assert_eq!(merged2.timeout_secs, 20); + assert_eq!(merged2.degraded_threshold_ms, 3000); + assert_eq!(merged2.max_retries, global.max_retries); // 未覆盖 → 全局 - // 非 ASCII 字符其实合法(UTF-8 字节均 ≥ 0x80,HeaderValue 按字节放行)→ 应返回 Some - p.meta = Some(ProviderMeta { - custom_user_agent: Some("claude-cli/2.1.161 \u{4e2d}".to_string()), + // testConfig 存在但未启用 → 忽略,用全局 + let mut p3 = make_provider(serde_json::json!({})); + p3.meta = Some(ProviderMeta { + test_config: Some(ProviderTestConfig { + enabled: false, + timeout_secs: Some(99), + degraded_threshold_ms: None, + max_retries: None, + }), ..Default::default() }); - assert!(StreamCheckService::custom_user_agent(&p).is_some()); - - // 含控制字符(内嵌换行)才非法 → None(静默忽略,与 forwarder 一致,不让 Stream Check 报错) - p.meta = Some(ProviderMeta { - custom_user_agent: Some("claude-cli/2.1.161\nX".to_string()), - ..Default::default() - }); - assert!(StreamCheckService::custom_user_agent(&p).is_none()); + let merged3 = StreamCheckService::merge_provider_config(&p3, &global); + assert_eq!(merged3.timeout_secs, global.timeout_secs); } #[test] @@ -1720,27 +585,17 @@ mod tests { #[test] fn test_resolve_opencode_base_url_falls_back_for_known_npm() { let p = make_provider(serde_json::json!({ - "npm": "@ai-sdk/openai", - "options": { "apiKey": "k" }, - "models": {}, - })); - let resolved = - StreamCheckService::resolve_opencode_base_url(&p, Some("@ai-sdk/openai")).unwrap(); - assert_eq!(resolved, "https://api.openai.com/v1"); - - let p2 = make_provider(serde_json::json!({ "npm": "@ai-sdk/anthropic", "options": { "apiKey": "k" }, "models": {}, })); - let resolved2 = - StreamCheckService::resolve_opencode_base_url(&p2, Some("@ai-sdk/anthropic")).unwrap(); - assert_eq!(resolved2, "https://api.anthropic.com"); + let resolved = + StreamCheckService::resolve_opencode_base_url(&p, Some("@ai-sdk/anthropic")).unwrap(); + assert_eq!(resolved, "https://api.anthropic.com"); } #[test] fn test_resolve_opencode_base_url_errors_for_openai_compatible_without_url() { - // @ai-sdk/openai-compatible 没有默认端点,必须显式填 let p = make_provider(serde_json::json!({ "npm": "@ai-sdk/openai-compatible", "options": { "apiKey": "k" }, @@ -1752,490 +607,70 @@ mod tests { } #[test] - fn test_extract_openclaw_headers_preserves_map() { - let p = make_provider(serde_json::json!({ - "baseUrl": "https://example.com/v1", - "apiKey": "k", - "api": "openai-completions", - "headers": { "User-Agent": "MyBot/1.0", "X-Trace": "abc" }, - })); - let headers = StreamCheckService::extract_openclaw_headers(&p).unwrap(); + fn test_extract_openclaw_base_url_missing_errors() { + let p = make_provider(serde_json::json!({ "apiKey": "k", "api": "openai-completions" })); + assert!(StreamCheckService::extract_openclaw_base_url(&p).is_err()); + + let p2 = make_provider(serde_json::json!({ "baseUrl": "https://api.deepseek.com/v1" })); assert_eq!( - headers.get("User-Agent").and_then(|v| v.as_str()), - Some("MyBot/1.0") + StreamCheckService::extract_openclaw_base_url(&p2).unwrap(), + "https://api.deepseek.com/v1" ); - assert_eq!(headers.get("X-Trace").and_then(|v| v.as_str()), Some("abc")); } #[test] - fn test_extract_openclaw_headers_ignores_empty_map() { - let p = make_provider(serde_json::json!({ - "baseUrl": "https://example.com/v1", - "apiKey": "k", - "api": "openai-completions", - "headers": {}, - })); - assert!(StreamCheckService::extract_openclaw_headers(&p).is_none()); - } - - #[test] - fn test_extract_opencode_headers_from_options() { - let p = make_provider(serde_json::json!({ - "npm": "@ai-sdk/openai-compatible", - "options": { - "baseURL": "https://example.com/v1", - "apiKey": "k", - "headers": { "X-Custom": "yes" }, - }, - "models": {}, - })); - let headers = StreamCheckService::extract_opencode_headers(&p).unwrap(); + fn test_resolve_base_url_official_falls_back_to_default_endpoint() { + // 官方 Claude(category=official,空 env)→ 回退到 api.anthropic.com + let mut claude = make_provider(serde_json::json!({ "env": {} })); + claude.category = Some("official".to_string()); assert_eq!( - headers.get("X-Custom").and_then(|v| v.as_str()), - Some("yes") + StreamCheckService::resolve_base_url(&AppType::Claude, &claude).unwrap(), + "https://api.anthropic.com" ); - } - #[test] - fn test_determine_status() { + // 官方 Codex(空 auth+config)→ 回退到 ChatGPT 后端 + let mut codex = make_provider(serde_json::json!({ "auth": {}, "config": "" })); + codex.category = Some("official".to_string()); assert_eq!( - StreamCheckService::determine_status(3000, 6000), - HealthStatus::Operational + StreamCheckService::resolve_base_url(&AppType::Codex, &codex).unwrap(), + "https://chatgpt.com/backend-api/codex" ); + + // 官方 Gemini(空 env+config)→ 回退到 generativelanguage + let mut gemini = make_provider(serde_json::json!({ "env": {}, "config": {} })); + gemini.category = Some("official".to_string()); assert_eq!( - StreamCheckService::determine_status(6000, 6000), - HealthStatus::Operational + StreamCheckService::resolve_base_url(&AppType::Gemini, &gemini).unwrap(), + "https://generativelanguage.googleapis.com" ); + } + + #[test] + fn test_resolve_base_url_non_official_missing_base_url_still_errors() { + // 非官方且无 base_url → 仍报错,不误回退到官方端点给"绿灯" + let p = make_provider(serde_json::json!({ "env": {} })); + assert!(StreamCheckService::resolve_base_url(&AppType::Claude, &p).is_err()); + } + + #[test] + fn test_resolve_base_url_explicit_wins_over_official_fallback() { + // 官方但显式配了 base_url(少见)→ 用显式值,不回退 + let mut p = make_provider( + serde_json::json!({ "env": { "ANTHROPIC_BASE_URL": "https://relay.example/v1" } }), + ); + p.category = Some("official".to_string()); assert_eq!( - StreamCheckService::determine_status(6001, 6000), - HealthStatus::Degraded + StreamCheckService::resolve_base_url(&AppType::Claude, &p).unwrap(), + "https://relay.example/v1" ); } #[test] - fn test_should_retry() { - assert!(StreamCheckService::should_retry("Request timeout")); - assert!(StreamCheckService::should_retry("request timed out")); - assert!(StreamCheckService::should_retry("connection abort")); - assert!(!StreamCheckService::should_retry("API Key invalid")); - } - - #[test] - fn test_default_config() { - let config = StreamCheckConfig::default(); - assert_eq!(config.timeout_secs, 45); - assert_eq!(config.max_retries, 2); - assert_eq!(config.degraded_threshold_ms, 6000); - } - - #[test] - fn test_parse_model_with_effort() { - // 带 @ 分隔符 - let (model, effort) = StreamCheckService::parse_model_with_effort("gpt-5.1-codex@low"); - assert_eq!(model, "gpt-5.1-codex"); - assert_eq!(effort, Some("low".to_string())); - - // 带 # 分隔符 - let (model, effort) = StreamCheckService::parse_model_with_effort("o1-preview#high"); - assert_eq!(model, "o1-preview"); - assert_eq!(effort, Some("high".to_string())); - - // 无分隔符 - let (model, effort) = StreamCheckService::parse_model_with_effort("gpt-4o-mini"); - assert_eq!(model, "gpt-4o-mini"); - assert_eq!(effort, None); - } - - #[test] - fn test_detect_model_not_found() { - // OpenAI 典型响应:404 + model_not_found 错误码 - let openai_404 = r#"{"error":{"message":"The model `gpt-5.1-codex` does not exist or you do not have access to it","type":"invalid_request_error","param":null,"code":"model_not_found"}}"#; - assert_eq!( - StreamCheckService::detect_error_category(404, openai_404), - Some("modelNotFound") - ); - - // Anthropic 典型响应:404 + not_found_error + 提到 model - let anthropic_404 = r#"{"type":"error","error":{"type":"not_found_error","message":"model: claude-deprecated"}}"#; - assert_eq!( - StreamCheckService::detect_error_category(404, anthropic_404), - Some("modelNotFound") - ); - - // 400 + invalid model 也算 - let bad_req = r#"{"error":{"message":"invalid model specified"}}"#; - assert_eq!( - StreamCheckService::detect_error_category(400, bad_req), - Some("modelNotFound") - ); - - // 通用 404(比如 Base URL 错误),body 里没有 model 字样 → 不应误判 - let generic_404 = r#"{"error":"Not Found"}"#; - assert_eq!( - StreamCheckService::detect_error_category(404, generic_404), - None - ); - - // 5xx 就算 body 里有 "model does not exist" 也不分类(避免误判) - let server_error = r#"{"error":"model does not exist"}"#; - assert_eq!( - StreamCheckService::detect_error_category(500, server_error), - None - ); - - // 401 鉴权错误(body 里没有 model 字样) - let auth_err = r#"{"error":"Invalid API key"}"#; - assert_eq!( - StreamCheckService::detect_error_category(401, auth_err), - None - ); - } - - #[test] - fn test_detect_qianfan_coding_plan_quota_errors() { - let cases = [ - r#"{"error":{"code":"coding_plan_hour_quota_exceeded","message":"hour quota exceeded"}}"#, - r#"{"error":{"code":"coding_plan_week_quota_exceeded","message":"week quota exceeded"}}"#, - r#"{"error":{"code":"coding_plan_month_quota_exceeded","message":"month quota exceeded"}}"#, - ]; - - for body in cases { - assert_eq!( - StreamCheckService::detect_error_category(429, body), - Some("quotaExceeded") - ); - } - } - - #[test] - fn test_get_os_name() { - let os_name = StreamCheckService::get_os_name(); - // 确保返回非空字符串 - assert!(!os_name.is_empty()); - // 在 macOS 上应该返回 "MacOS" - #[cfg(target_os = "macos")] - assert_eq!(os_name, "MacOS"); - // 在 Linux 上应该返回 "Linux" - #[cfg(target_os = "linux")] - assert_eq!(os_name, "Linux"); - // 在 Windows 上应该返回 "Windows" - #[cfg(target_os = "windows")] - assert_eq!(os_name, "Windows"); - } - - #[test] - fn test_get_arch_name() { - let arch_name = StreamCheckService::get_arch_name(); - // 确保返回非空字符串 - assert!(!arch_name.is_empty()); - // 在 ARM64 上应该返回 "arm64" - #[cfg(target_arch = "aarch64")] - assert_eq!(arch_name, "arm64"); - // 在 x86_64 上应该返回 "x86_64" - #[cfg(target_arch = "x86_64")] - assert_eq!(arch_name, "x86_64"); - } - - #[test] - fn test_auth_strategy_imports() { - // 验证 AuthStrategy 枚举可以正常使用 - let anthropic = AuthStrategy::Anthropic; - let claude_auth = AuthStrategy::ClaudeAuth; - let bearer = AuthStrategy::Bearer; - - // 验证不同的策略是不相等的 - assert_ne!(anthropic, claude_auth); - assert_ne!(anthropic, bearer); - assert_ne!(claude_auth, bearer); - - // 验证相同策略是相等的 - assert_eq!(anthropic, AuthStrategy::Anthropic); - assert_eq!(claude_auth, AuthStrategy::ClaudeAuth); - assert_eq!(bearer, AuthStrategy::Bearer); - } - - #[test] - fn test_resolve_claude_stream_url_for_full_url_mode() { - let url = StreamCheckService::resolve_claude_stream_url( - "https://relay.example/v1/chat/completions", - AuthStrategy::Bearer, - "openai_chat", - true, - "gpt-5.5", - ); - - assert_eq!(url, "https://relay.example/v1/chat/completions"); - } - - #[test] - fn test_resolve_claude_stream_url_for_github_copilot() { - let url = StreamCheckService::resolve_claude_stream_url( - "https://api.githubcopilot.com", - AuthStrategy::GitHubCopilot, - "openai_chat", - false, - "gpt-5.5", - ); - - assert_eq!(url, "https://api.githubcopilot.com/chat/completions"); - } - - #[test] - fn test_resolve_claude_stream_url_for_github_copilot_responses() { - let url = StreamCheckService::resolve_claude_stream_url( - "https://api.githubcopilot.com", - AuthStrategy::GitHubCopilot, - "openai_responses", - false, - "gpt-5.5", - ); - - assert_eq!(url, "https://api.githubcopilot.com/v1/responses"); - } - - #[test] - fn test_resolve_claude_stream_url_for_openai_chat() { - let url = StreamCheckService::resolve_claude_stream_url( - "https://example.com/v1", - AuthStrategy::Bearer, - "openai_chat", - false, - "gpt-5.5", - ); - - assert_eq!(url, "https://example.com/v1/chat/completions"); - } - - #[test] - fn test_resolve_claude_stream_url_for_openai_responses() { - let url = StreamCheckService::resolve_claude_stream_url( - "https://example.com/v1", - AuthStrategy::Bearer, - "openai_responses", - false, - "gpt-5.5", - ); - - assert_eq!(url, "https://example.com/v1/responses"); - } - - #[test] - fn test_resolve_claude_stream_url_for_anthropic() { - let url = StreamCheckService::resolve_claude_stream_url( - "https://api.anthropic.com", - AuthStrategy::Anthropic, - "anthropic", - false, - "claude-sonnet-4-6", - ); - - assert_eq!(url, "https://api.anthropic.com/v1/messages"); - } - - #[test] - fn test_resolve_claude_stream_url_for_gemini_native() { - let url = StreamCheckService::resolve_claude_stream_url( - "https://generativelanguage.googleapis.com", - AuthStrategy::Google, - "gemini_native", - false, - "gemini-2.5-flash", - ); - - assert_eq!( - url, - "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse" - ); - } - - #[test] - fn test_resolve_claude_stream_url_for_gemini_native_full_url_openai_compat_base() { - let url = StreamCheckService::resolve_claude_stream_url( - "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions", - AuthStrategy::Google, - "gemini_native", - true, - "gemini-2.5-flash", - ); - - assert_eq!( - url, - "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse" - ); - } - - #[test] - fn test_resolve_claude_stream_url_for_gemini_native_opaque_full_url() { - let url = StreamCheckService::resolve_claude_stream_url( - "https://relay.example/custom/generate-content", - AuthStrategy::Google, - "gemini_native", - true, - "gemini-2.5-flash", - ); - - assert_eq!(url, "https://relay.example/custom/generate-content?alt=sse"); - } - - #[test] - fn test_resolve_claude_stream_url_for_gemini_native_cloudflare_vertex_full_url() { - let url = StreamCheckService::resolve_claude_stream_url( - "https://gateway.ai.cloudflare.com/v1/account/gateway/google-vertex-ai/v1/projects/project/locations/us-central1/publishers/google/models/gemini-3.5-flash:streamGenerateContent", - AuthStrategy::Google, - "gemini_native", - true, - "gemini-2.5-flash", - ); - - assert_eq!( - url, - "https://gateway.ai.cloudflare.com/v1/account/gateway/google-vertex-ai/v1/projects/project/locations/us-central1/publishers/google/models/gemini-3.5-flash:streamGenerateContent?alt=sse" - ); - } - - /// Regression: Gemini SDK outputs commonly surface model ids as the - /// resource-name form `models/gemini-2.5-pro`. Interpolating that raw - /// value used to produce `/v1beta/models/models/gemini-2.5-pro:...` - /// which the upstream rejects and the health check records as a - /// false-negative for an otherwise valid provider. - #[test] - fn test_resolve_claude_stream_url_for_gemini_native_strips_models_prefix() { - let url = StreamCheckService::resolve_claude_stream_url( - "https://generativelanguage.googleapis.com", - AuthStrategy::Google, - "gemini_native", - false, - "models/gemini-2.5-pro", - ); - - assert_eq!( - url, - "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse" - ); - } - - #[test] - fn test_resolve_codex_stream_urls_for_full_url_mode() { - let urls = StreamCheckService::resolve_codex_stream_urls( - "https://relay.example/custom/responses", - true, - ); - - assert_eq!(urls, vec!["https://relay.example/custom/responses"]); - } - - #[test] - fn test_resolve_codex_stream_urls_for_v1_base() { - let urls = - StreamCheckService::resolve_codex_stream_urls("https://api.openai.com/v1", false); - - assert_eq!(urls, vec!["https://api.openai.com/v1/responses"]); - } - - /// 纯 origin 必须优先 /v1/responses(与 CodexAdapter::build_url 一致)。 - /// OpenAI 官方 /responses 实际挂在 /v1 下,bare 路径只在用户配置错误的 - /// 反代上才可能命中,作为 fallback 保留即可。 - #[test] - fn test_resolve_codex_stream_urls_for_origin_base_prioritizes_v1() { - let urls = StreamCheckService::resolve_codex_stream_urls("https://api.openai.com", false); - - assert_eq!( - urls, - vec![ - "https://api.openai.com/v1/responses", - "https://api.openai.com/responses", - ] - ); - } - - /// 自定义前缀(如 /openai)生产路径不会自动补 /v1,Stream Check 应先打 - /// 不带 /v1 的版本与之对齐;/v1 作为 misconfigured 兜底。 - #[test] - fn test_resolve_codex_stream_urls_for_custom_prefix() { - let urls = - StreamCheckService::resolve_codex_stream_urls("https://relay.example/openai", false); - - assert_eq!( - urls, - vec![ - "https://relay.example/openai/responses", - "https://relay.example/openai/v1/responses", - ] - ); - } - - #[test] - fn test_resolve_codex_stream_urls_recognizes_full_endpoint_without_flag() { - let urls = StreamCheckService::resolve_codex_stream_urls( - "https://api.openai.com/v1/responses", - false, - ); - - assert_eq!(urls, vec!["https://api.openai.com/v1/responses"]); - } - - #[test] - fn test_resolve_codex_chat_stream_urls_for_v1_base() { - let urls = StreamCheckService::resolve_codex_chat_stream_urls( - "https://api.deepseek.com/v1", - false, - ); - - assert_eq!(urls, vec!["https://api.deepseek.com/v1/chat/completions"]); - } - - /// 纯 origin 必须优先 /v1/chat/completions,与 CodexAdapter::build_url 一致; - /// bare /chat/completions 仅作为 fallback。如果颠倒了优先级,上游对 bare - /// 路径返回 401/400/405 时(非 404)fallback 不会触发,会误判为不可用。 - #[test] - fn test_resolve_codex_chat_stream_urls_for_origin_base_prioritizes_v1() { - let urls = - StreamCheckService::resolve_codex_chat_stream_urls("https://api.deepseek.com", false); - - assert_eq!( - urls, - vec![ - "https://api.deepseek.com/v1/chat/completions", - "https://api.deepseek.com/chat/completions", - ] - ); - } - - /// 自定义前缀(路径中已经包含段如 `/openai`)生产路径不会自动补 /v1。 - /// Stream Check 应先打不带 /v1 的版本,与 build_url 行为一致。 - #[test] - fn test_resolve_codex_chat_stream_urls_for_custom_prefix() { - let urls = - StreamCheckService::resolve_codex_chat_stream_urls("https://example.com/openai", false); - - assert_eq!( - urls, - vec![ - "https://example.com/openai/chat/completions", - "https://example.com/openai/v1/chat/completions", - ] - ); - } - - #[test] - fn test_resolve_codex_chat_stream_urls_for_full_url_mode() { - let urls = StreamCheckService::resolve_codex_chat_stream_urls( - "https://relay.example/custom/chat/completions", - true, - ); - - assert_eq!(urls, vec!["https://relay.example/custom/chat/completions"]); - } - - /// 用户在 base_url 里直接写完整 chat/completions endpoint 但忘开 is_full_url 时, - /// 不应该再追加 `/chat/completions` 后缀。 - #[test] - fn test_resolve_codex_chat_stream_urls_recognizes_full_endpoint_without_flag() { - let urls = StreamCheckService::resolve_codex_chat_stream_urls( - "https://api.deepseek.com/v1/chat/completions", - false, - ); - - assert_eq!(urls, vec!["https://api.deepseek.com/v1/chat/completions"]); + fn test_resolve_base_url_claude_desktop_official_does_not_fall_back() { + // Claude Desktop 官方 = 原生 1P 模式,走 claude.ai 而非 api.anthropic.com, + // 无可靠探测目标 → 不回退(提取失败即报错;前端也隐藏其检测按钮)。 + let mut desktop = make_provider(serde_json::json!({ "env": {} })); + desktop.category = Some("official".to_string()); + assert!(StreamCheckService::resolve_base_url(&AppType::ClaudeDesktop, &desktop).is_err()); } } diff --git a/src/components/providers/ProviderActions.tsx b/src/components/providers/ProviderActions.tsx index 4e9911450..e62d1584f 100644 --- a/src/components/providers/ProviderActions.tsx +++ b/src/components/providers/ProviderActions.tsx @@ -1,4 +1,5 @@ import { + Activity, BarChart3, Check, Copy, @@ -8,7 +9,6 @@ import { Play, Plus, Terminal, - TestTube2, Trash2, Zap, } from "lucide-react"; @@ -310,7 +310,7 @@ export function ProviderActions({ variant="ghost" onClick={onTest || undefined} disabled={isTesting} - title={t("modelTest.testProvider", "测试模型")} + title={t("provider.connectivityCheck", "检测连通")} className={cn( iconButtonClass, !onTest && "opacity-40 cursor-not-allowed text-muted-foreground", @@ -319,7 +319,7 @@ export function ProviderActions({ {isTesting ? ( ) : ( - + )} diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index 827134cbf..fd53b797b 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -232,9 +232,6 @@ export function ProviderCard({ provider.meta?.apiFormat, (provider.settingsConfig as Record)?.config, ]); - const isClaudeThirdParty = - appId === "claude" && provider.category === "third_party"; - // 获取用量数据以判断是否有多套餐 // 累加模式应用(OpenCode/OpenClaw/Hermes):使用 isInConfig 代替 isCurrent const shouldAutoQuery = @@ -551,11 +548,13 @@ export function ProviderCard({ onEdit={() => onEdit(provider)} onDuplicate={() => onDuplicate(provider)} onTest={ + // 连通检测对所有供应商开放(官方会回退到客户端实际会连的官方端点), + // 唯独 Claude Desktop 官方除外:它是原生 1P 模式(走 claude.ai,cc-switch + // 不在请求路径上),没有可靠的探测目标,故隐藏其检测按钮。 onTest && - !isOfficial && - !isCopilot && - !isCodexOauth && - !isClaudeThirdParty + !( + appId === "claude-desktop" && provider.category === "official" + ) ? () => onTest(provider) : undefined } diff --git a/src/components/providers/ProviderList.tsx b/src/components/providers/ProviderList.tsx index e312d063b..a991a5564 100644 --- a/src/components/providers/ProviderList.tsx +++ b/src/components/providers/ProviderList.tsx @@ -45,8 +45,6 @@ import { import { useCallback } from "react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; -import { ConfirmDialog } from "@/components/ConfirmDialog"; -import { settingsApi } from "@/lib/api/settings"; interface ProviderListProps { providers: Record; @@ -192,9 +190,6 @@ export function ProviderList({ const [searchTerm, setSearchTerm] = useState(""); const [isSearchOpen, setIsSearchOpen] = useState(false); const searchInputRef = useRef(null); - const [showStreamCheckConfirm, setShowStreamCheckConfirm] = useState(false); - const [pendingTestProvider, setPendingTestProvider] = - useState(null); const { data: claudeDesktopStatus } = useQuery({ queryKey: ["claudeDesktopStatus"], queryFn: () => providersApi.getClaudeDesktopStatus(), @@ -202,41 +197,14 @@ export function ProviderList({ refetchInterval: appId === "claude-desktop" ? 5000 : false, }); - // Query settings for streamCheckConfirmed flag - const { data: settings } = useQuery({ - queryKey: ["settings"], - queryFn: () => settingsApi.get(), - }); - + // 连通性检查不发真实请求、无封号/计费风险,直接执行(无需确认弹窗)。 const handleTest = useCallback( (provider: Provider) => { - if (!settings?.streamCheckConfirmed) { - setPendingTestProvider(provider); - setShowStreamCheckConfirm(true); - } else { - checkProvider(provider.id, provider.name); - } + checkProvider(provider.id, provider.name); }, - [checkProvider, settings?.streamCheckConfirmed], + [checkProvider], ); - const handleStreamCheckConfirm = async () => { - setShowStreamCheckConfirm(false); - try { - if (settings) { - const { webdavSync: _, ...rest } = settings; - await settingsApi.save({ ...rest, streamCheckConfirmed: true }); - await queryClient.invalidateQueries({ queryKey: ["settings"] }); - } - } catch (error) { - console.error("Failed to save stream check confirmed:", error); - } - if (pendingTestProvider) { - checkProvider(pendingTestProvider.id, pendingTestProvider.name); - setPendingTestProvider(null); - } - }; - // Import current live config as default provider const queryClient = useQueryClient(); const importMutation = useMutation({ @@ -558,19 +526,6 @@ export function ProviderList({ ) : ( renderProviderList() )} - - void handleStreamCheckConfirm()} - onCancel={() => { - setShowStreamCheckConfirm(false); - setPendingTestProvider(null); - }} - /> ); } diff --git a/src/components/providers/forms/ProviderAdvancedConfig.tsx b/src/components/providers/forms/ProviderAdvancedConfig.tsx index 8801ae873..cccc208f8 100644 --- a/src/components/providers/forms/ProviderAdvancedConfig.tsx +++ b/src/components/providers/forms/ProviderAdvancedConfig.tsx @@ -61,7 +61,7 @@ export function ProviderAdvancedConfig({ {t("providerAdvanced.testConfig", { - defaultValue: "模型测试配置", + defaultValue: "连通检测配置", })} @@ -106,31 +106,10 @@ export function ProviderAdvancedConfig({

{t("providerAdvanced.testConfigDesc", { defaultValue: - "为此供应商配置单独的模型测试参数,不启用时使用全局配置。", + "为此供应商配置单独的连通检测参数(超时/阈值/重试),不启用时使用全局配置。", })}

-
- - - onTestConfigChange({ - ...testConfig, - testModel: e.target.value || undefined, - }) - } - placeholder={t("providerAdvanced.testModelPlaceholder", { - defaultValue: "留空使用全局配置", - })} - disabled={!testConfig.enabled} - /> -
-
- - - onTestConfigChange({ - ...testConfig, - testPrompt: e.target.value || undefined, - }) - } - placeholder="Who are you?" + placeholder="8" disabled={!testConfig.enabled} />
@@ -184,7 +144,7 @@ export function ProviderAdvancedConfig({ id="degraded-threshold" type="number" min={100} - max={60000} + max={10000} value={testConfig.degradedThresholdMs || ""} onChange={(e) => onTestConfigChange({ @@ -194,7 +154,7 @@ export function ProviderAdvancedConfig({ : undefined, }) } - placeholder="6000" + placeholder="1500" disabled={!testConfig.enabled} />
@@ -208,7 +168,7 @@ export function ProviderAdvancedConfig({ id="max-retries" type="number" min={0} - max={10} + max={5} value={testConfig.maxRetries ?? ""} onChange={(e) => onTestConfigChange({ @@ -218,7 +178,7 @@ export function ProviderAdvancedConfig({ : undefined, }) } - placeholder="2" + placeholder="1" disabled={!testConfig.enabled} /> diff --git a/src/components/usage/ModelTestConfigPanel.tsx b/src/components/usage/ModelTestConfigPanel.tsx index 39981a948..4d5c46bdf 100644 --- a/src/components/usage/ModelTestConfigPanel.tsx +++ b/src/components/usage/ModelTestConfigPanel.tsx @@ -2,10 +2,9 @@ import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Save, Loader2 } from "lucide-react"; +import { Save, Loader2, Info } from "lucide-react"; import { toast } from "sonner"; import { getStreamCheckConfig, @@ -20,13 +19,9 @@ export function ModelTestConfigPanel() { const [error, setError] = useState(null); // 使用字符串状态以支持完全清空数字输入框 const [config, setConfig] = useState({ - timeoutSecs: "45", - maxRetries: "2", - degradedThresholdMs: "6000", - claudeModel: "claude-haiku-4-5-20251001", - codexModel: "gpt-5.5@low", - geminiModel: "gemini-3.5-flash", - testPrompt: "Who are you?", + timeoutSecs: "8", + maxRetries: "1", + degradedThresholdMs: "1500", }); useEffect(() => { @@ -42,10 +37,6 @@ export function ModelTestConfigPanel() { timeoutSecs: String(data.timeoutSecs), maxRetries: String(data.maxRetries), degradedThresholdMs: String(data.degradedThresholdMs), - claudeModel: data.claudeModel, - codexModel: data.codexModel, - geminiModel: data.geminiModel, - testPrompt: data.testPrompt || "Who are you?", }); } catch (e) { setError(String(e)); @@ -63,13 +54,9 @@ export function ModelTestConfigPanel() { try { setIsSaving(true); const parsed: StreamCheckConfig = { - timeoutSecs: parseNum(config.timeoutSecs, 45), - maxRetries: parseNum(config.maxRetries, 2), - degradedThresholdMs: parseNum(config.degradedThresholdMs, 6000), - claudeModel: config.claudeModel, - codexModel: config.codexModel, - geminiModel: config.geminiModel, - testPrompt: config.testPrompt || "Who are you?", + timeoutSecs: parseNum(config.timeoutSecs, 8), + maxRetries: parseNum(config.maxRetries, 1), + degradedThresholdMs: parseNum(config.degradedThresholdMs, 1500), }; await saveStreamCheckConfig(parsed); toast.success(t("streamCheck.configSaved"), { @@ -98,49 +85,16 @@ export function ModelTestConfigPanel() { )} - {/* 测试模型配置 */} -
-

- {t("streamCheck.testModels")} -

-
-
- - - setConfig({ ...config, claudeModel: e.target.value }) - } - placeholder="claude-3-5-haiku-latest" - /> -
- -
- - - setConfig({ ...config, codexModel: e.target.value }) - } - placeholder="gpt-4o-mini" - /> -
- -
- - - setConfig({ ...config, geminiModel: e.target.value }) - } - placeholder="gemini-1.5-flash" - /> -
-
-
+ {/* 连通检测语义说明:可达 ≠ 配置正确 */} + + + + {t("streamCheck.connectivityNote", { + defaultValue: + "连通检测仅探测供应商地址是否可达,不发送真实模型请求。收到任意响应即视为“可达”——这不代表鉴权或模型配置一定正确。", + })} + + {/* 检查参数配置 */}
@@ -153,8 +107,8 @@ export function ModelTestConfigPanel() { setConfig({ ...config, timeoutSecs: e.target.value }) @@ -183,9 +137,9 @@ export function ModelTestConfigPanel() { setConfig({ ...config, degradedThresholdMs: e.target.value }) @@ -193,21 +147,6 @@ export function ModelTestConfigPanel() { />
- - {/* 检查提示词配置 */} -
- -