mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
feat(health-check): replace real-LLM probe with HTTP reachability check
The provider panel health check sent a real streaming model request, which many third-party providers block (401/403/WAF), causing false negatives while only stable official endpoints passed. Replace it with a lightweight reachability probe: GET the provider base_url and treat any HTTP response (200/4xx/5xx) as reachable; only DNS/connect/TLS/timeout count as failure. Latency is the probe's TTFB. Backend (services/stream_check.rs): rewrite ~2200 -> ~350 lines, dropping real-request building, format conversion, auth and API-path resolution while keeping per-app base_url extraction. Defaults: 8s timeout, 1 retry, 1500ms degraded threshold. Failover invariant: the reachability check must never reset the circuit breaker (reachable != usable; a 403 host is reachable but broken for real traffic). Remove the resetCircuitBreaker call from useStreamCheck; failover failure detection stays driven solely by real proxy traffic (forwarder/circuit_breaker untouched). useResetCircuitBreaker is kept dormant for a future manual-recovery entry. Open the check to all providers: drop the official/copilot/codex-oauth/third-party gating and the 'sends a real request' confirm dialog. For official providers whose base_url is intentionally empty, fall back to the endpoint the client actually uses (Claude -> api.anthropic.com, Codex -> chatgpt.com/backend-api/codex, Gemini -> generativelanguage). Non-official providers with a missing base_url still error to avoid a false green light. Claude Desktop Official is native 1P mode (talks to claude.ai, cc-switch not in the request path, no reliable endpoint) so its button stays hidden. Slim StreamCheckConfig and per-provider testConfig to timeout/threshold/retries (drop test model + prompt); sync zh/en/ja/zh-TW. Retain the now-unused anthropic_to_openai/anthropic_to_gemini transform utilities and their test suites.
This commit is contained in:
@@ -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<HashSet<String>> = if proxy_targets_only {
|
||||
let mut ids = HashSet::new();
|
||||
if let Ok(Some(current_id)) = state.db.get_current_provider(app_type.as_str()) {
|
||||
@@ -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<StreamCheckConfig, AppError> {
|
||||
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<Option<crate::proxy::providers::AuthInfo>, AppError> {
|
||||
let is_copilot = is_copilot_provider(provider);
|
||||
|
||||
if !is_copilot {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let auth_manager = copilot_state.0.read().await;
|
||||
let account_id = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.managed_account_id_for("github_copilot"));
|
||||
|
||||
let token = match account_id.as_deref() {
|
||||
Some(id) => auth_manager
|
||||
.get_valid_token_for_account(id)
|
||||
.await
|
||||
.map_err(|e| AppError::Message(format!("GitHub Copilot 认证失败: {e}")))?,
|
||||
None => auth_manager
|
||||
.get_valid_token()
|
||||
.await
|
||||
.map_err(|e| AppError::Message(format!("GitHub Copilot 认证失败: {e}")))?,
|
||||
};
|
||||
|
||||
Ok(Some(crate::proxy::providers::AuthInfo::new(
|
||||
token,
|
||||
crate::proxy::providers::AuthStrategy::GitHubCopilot,
|
||||
)))
|
||||
}
|
||||
|
||||
/// 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<Option<String>, AppError> {
|
||||
if *app_type != AppType::Claude {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let is_copilot = auth_override
|
||||
.map(|auth| auth.strategy == crate::proxy::providers::AuthStrategy::GitHubCopilot)
|
||||
.unwrap_or(false);
|
||||
if !is_copilot {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let model_id = StreamCheckService::resolve_effective_test_model(app_type, provider, config);
|
||||
let auth_manager = copilot_state.0.read().await;
|
||||
let account_id = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.managed_account_id_for("github_copilot"));
|
||||
|
||||
let vendor_result = match account_id.as_deref() {
|
||||
Some(id) => {
|
||||
auth_manager
|
||||
.get_model_vendor_for_account(id, &model_id)
|
||||
.await
|
||||
}
|
||||
None => auth_manager.get_model_vendor(&model_id).await,
|
||||
};
|
||||
|
||||
let api_format = match vendor_result {
|
||||
Ok(Some(vendor)) if vendor.eq_ignore_ascii_case("openai") => "openai_responses",
|
||||
Ok(Some(_)) | Ok(None) => "openai_chat",
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"[StreamCheck] Failed to resolve Copilot model vendor for {model_id}: {err}. Falling back to chat/completions"
|
||||
);
|
||||
"openai_chat"
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Some(api_format.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_copilot_provider;
|
||||
|
||||
@@ -287,21 +287,15 @@ pub struct UsageResult {
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// 供应商单独的模型测试配置
|
||||
/// 供应商单独的连通检测配置
|
||||
#[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<String>,
|
||||
/// 超时时间(秒)
|
||||
#[serde(rename = "timeoutSecs", skip_serializing_if = "Option::is_none")]
|
||||
pub timeout_secs: Option<u64>,
|
||||
/// 测试提示词
|
||||
#[serde(rename = "testPrompt", skip_serializing_if = "Option::is_none")]
|
||||
pub test_prompt: Option<String>,
|
||||
/// 降级阈值(毫秒)
|
||||
#[serde(
|
||||
rename = "degradedThresholdMs",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<Value, ProxyError> {
|
||||
anthropic_to_openai_with_reasoning_content(body, false)
|
||||
}
|
||||
|
||||
@@ -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<Value, ProxyError> {
|
||||
anthropic_to_gemini_with_shadow(body, None, None, None)
|
||||
}
|
||||
|
||||
+400
-1965
File diff suppressed because it is too large
Load Diff
@@ -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 ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<TestTube2 className="h-4 w-4" />
|
||||
<Activity className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -232,9 +232,6 @@ export function ProviderCard({
|
||||
provider.meta?.apiFormat,
|
||||
(provider.settingsConfig as Record<string, any>)?.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
|
||||
}
|
||||
|
||||
@@ -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<string, Provider>;
|
||||
@@ -192,9 +190,6 @@ export function ProviderList({
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showStreamCheckConfirm, setShowStreamCheckConfirm] = useState(false);
|
||||
const [pendingTestProvider, setPendingTestProvider] =
|
||||
useState<Provider | null>(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()
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={showStreamCheckConfirm}
|
||||
variant="info"
|
||||
title={t("confirm.streamCheck.title")}
|
||||
message={t("confirm.streamCheck.message")}
|
||||
confirmText={t("confirm.streamCheck.confirm")}
|
||||
onConfirm={() => void handleStreamCheckConfirm()}
|
||||
onCancel={() => {
|
||||
setShowStreamCheckConfirm(false);
|
||||
setPendingTestProvider(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export function ProviderAdvancedConfig({
|
||||
<FlaskConical className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">
|
||||
{t("providerAdvanced.testConfig", {
|
||||
defaultValue: "模型测试配置",
|
||||
defaultValue: "连通检测配置",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
@@ -106,31 +106,10 @@ export function ProviderAdvancedConfig({
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("providerAdvanced.testConfigDesc", {
|
||||
defaultValue:
|
||||
"为此供应商配置单独的模型测试参数,不启用时使用全局配置。",
|
||||
"为此供应商配置单独的连通检测参数(超时/阈值/重试),不启用时使用全局配置。",
|
||||
})}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="test-model">
|
||||
{t("providerAdvanced.testModel", {
|
||||
defaultValue: "测试模型",
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
id="test-model"
|
||||
value={testConfig.testModel || ""}
|
||||
onChange={(e) =>
|
||||
onTestConfigChange({
|
||||
...testConfig,
|
||||
testModel: e.target.value || undefined,
|
||||
})
|
||||
}
|
||||
placeholder={t("providerAdvanced.testModelPlaceholder", {
|
||||
defaultValue: "留空使用全局配置",
|
||||
})}
|
||||
disabled={!testConfig.enabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="test-timeout">
|
||||
{t("providerAdvanced.timeoutSecs", {
|
||||
@@ -141,7 +120,7 @@ export function ProviderAdvancedConfig({
|
||||
id="test-timeout"
|
||||
type="number"
|
||||
min={1}
|
||||
max={300}
|
||||
max={60}
|
||||
value={testConfig.timeoutSecs || ""}
|
||||
onChange={(e) =>
|
||||
onTestConfigChange({
|
||||
@@ -151,26 +130,7 @@ export function ProviderAdvancedConfig({
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
placeholder="45"
|
||||
disabled={!testConfig.enabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="test-prompt">
|
||||
{t("providerAdvanced.testPrompt", {
|
||||
defaultValue: "测试提示词",
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
id="test-prompt"
|
||||
value={testConfig.testPrompt || ""}
|
||||
onChange={(e) =>
|
||||
onTestConfigChange({
|
||||
...testConfig,
|
||||
testPrompt: e.target.value || undefined,
|
||||
})
|
||||
}
|
||||
placeholder="Who are you?"
|
||||
placeholder="8"
|
||||
disabled={!testConfig.enabled}
|
||||
/>
|
||||
</div>
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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<string | null>(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() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 测试模型配置 */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">
|
||||
{t("streamCheck.testModels")}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="claudeModel">{t("streamCheck.claudeModel")}</Label>
|
||||
<Input
|
||||
id="claudeModel"
|
||||
value={config.claudeModel}
|
||||
onChange={(e) =>
|
||||
setConfig({ ...config, claudeModel: e.target.value })
|
||||
}
|
||||
placeholder="claude-3-5-haiku-latest"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="codexModel">{t("streamCheck.codexModel")}</Label>
|
||||
<Input
|
||||
id="codexModel"
|
||||
value={config.codexModel}
|
||||
onChange={(e) =>
|
||||
setConfig({ ...config, codexModel: e.target.value })
|
||||
}
|
||||
placeholder="gpt-4o-mini"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="geminiModel">{t("streamCheck.geminiModel")}</Label>
|
||||
<Input
|
||||
id="geminiModel"
|
||||
value={config.geminiModel}
|
||||
onChange={(e) =>
|
||||
setConfig({ ...config, geminiModel: e.target.value })
|
||||
}
|
||||
placeholder="gemini-1.5-flash"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 连通检测语义说明:可达 ≠ 配置正确 */}
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{t("streamCheck.connectivityNote", {
|
||||
defaultValue:
|
||||
"连通检测仅探测供应商地址是否可达,不发送真实模型请求。收到任意响应即视为“可达”——这不代表鉴权或模型配置一定正确。",
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* 检查参数配置 */}
|
||||
<div className="space-y-4">
|
||||
@@ -153,8 +107,8 @@ export function ModelTestConfigPanel() {
|
||||
<Input
|
||||
id="timeoutSecs"
|
||||
type="number"
|
||||
min={10}
|
||||
max={120}
|
||||
min={2}
|
||||
max={60}
|
||||
value={config.timeoutSecs}
|
||||
onChange={(e) =>
|
||||
setConfig({ ...config, timeoutSecs: e.target.value })
|
||||
@@ -183,9 +137,9 @@ export function ModelTestConfigPanel() {
|
||||
<Input
|
||||
id="degradedThresholdMs"
|
||||
type="number"
|
||||
min={1000}
|
||||
max={30000}
|
||||
step={1000}
|
||||
min={200}
|
||||
max={10000}
|
||||
step={100}
|
||||
value={config.degradedThresholdMs}
|
||||
onChange={(e) =>
|
||||
setConfig({ ...config, degradedThresholdMs: e.target.value })
|
||||
@@ -193,21 +147,6 @@ export function ModelTestConfigPanel() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 检查提示词配置 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="testPrompt">{t("streamCheck.testPrompt")}</Label>
|
||||
<Textarea
|
||||
id="testPrompt"
|
||||
value={config.testPrompt}
|
||||
onChange={(e) =>
|
||||
setConfig({ ...config, testPrompt: e.target.value })
|
||||
}
|
||||
placeholder="Who are you?"
|
||||
rows={2}
|
||||
className="min-h-[60px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
|
||||
+27
-73
@@ -6,12 +6,17 @@ import {
|
||||
type StreamCheckResult,
|
||||
} from "@/lib/api/model-test";
|
||||
import type { AppId } from "@/lib/api";
|
||||
import { useResetCircuitBreaker } from "@/lib/query/failover";
|
||||
|
||||
/**
|
||||
* 供应商连通性检查。
|
||||
*
|
||||
* 只探测 base_url 是否可达(任何 HTTP 响应都算可达),不发真实大模型请求。
|
||||
* 刻意 **不** 重置故障转移熔断器——可达 ≠ 配置正确,一个端口通但鉴权废的供应商
|
||||
* 不应被误判为"健康"而切回线上。熔断器只由真实转发流量驱动(见 proxy/forwarder.rs)。
|
||||
*/
|
||||
export function useStreamCheck(appId: AppId) {
|
||||
const { t } = useTranslation();
|
||||
const [checkingIds, setCheckingIds] = useState<Set<string>>(new Set());
|
||||
const resetCircuitBreaker = useResetCircuitBreaker();
|
||||
|
||||
const checkProvider = useCallback(
|
||||
async (
|
||||
@@ -25,89 +30,38 @@ export function useStreamCheck(appId: AppId) {
|
||||
|
||||
if (result.status === "operational") {
|
||||
toast.success(
|
||||
t("streamCheck.operational", {
|
||||
t("streamCheck.reachable", {
|
||||
providerName: providerName,
|
||||
responseTimeMs: result.responseTimeMs,
|
||||
defaultValue: `${providerName} 运行正常 (${result.responseTimeMs}ms)`,
|
||||
defaultValue: `${providerName} 连通正常 (${result.responseTimeMs}ms)`,
|
||||
}),
|
||||
{ closeButton: true },
|
||||
);
|
||||
|
||||
// 测试通过后重置熔断器状态
|
||||
resetCircuitBreaker.mutate({ providerId, appType: appId });
|
||||
} else if (result.status === "degraded") {
|
||||
toast.warning(
|
||||
t("streamCheck.degraded", {
|
||||
t("streamCheck.reachableSlow", {
|
||||
providerName: providerName,
|
||||
responseTimeMs: result.responseTimeMs,
|
||||
defaultValue: `${providerName} 响应较慢 (${result.responseTimeMs}ms)`,
|
||||
defaultValue: `${providerName} 连通但较慢 (${result.responseTimeMs}ms)`,
|
||||
}),
|
||||
);
|
||||
|
||||
// 降级状态也重置熔断器,因为至少能通信
|
||||
resetCircuitBreaker.mutate({ providerId, appType: appId });
|
||||
} else if (result.errorCategory === "modelNotFound") {
|
||||
// 专门处理"模型不存在/已下架":指向配置入口,比通用 404 文案更有指导性
|
||||
toast.error(
|
||||
t("streamCheck.modelNotFound", {
|
||||
providerName: providerName,
|
||||
model: result.modelUsed,
|
||||
defaultValue: `${providerName} 测试模型 ${result.modelUsed} 不存在或已下架`,
|
||||
}),
|
||||
{
|
||||
description: t("streamCheck.modelNotFoundHint", {
|
||||
defaultValue: "",
|
||||
}),
|
||||
duration: 10000,
|
||||
closeButton: true,
|
||||
},
|
||||
);
|
||||
} else if (result.errorCategory === "quotaExceeded") {
|
||||
toast.warning(
|
||||
t("streamCheck.quotaExceeded", {
|
||||
providerName: providerName,
|
||||
defaultValue: `${providerName} Coding Plan quota has been exceeded`,
|
||||
}),
|
||||
{
|
||||
description: t("streamCheck.quotaExceededHint", {
|
||||
defaultValue: "",
|
||||
}),
|
||||
duration: 10000,
|
||||
closeButton: true,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const httpStatus = result.httpStatus;
|
||||
const hintKey = httpStatus
|
||||
? `streamCheck.httpHint.${httpStatus >= 500 ? "5xx" : httpStatus}`
|
||||
: null;
|
||||
const description =
|
||||
(hintKey ? t(hintKey, { defaultValue: "" }) : "") || undefined;
|
||||
|
||||
// 401/403/400 = 检查被拒(供应商可能正常);429/5xx = 临时问题
|
||||
const isProbeRejection =
|
||||
httpStatus != null &&
|
||||
([401, 403, 400, 429].includes(httpStatus) || httpStatus >= 500);
|
||||
|
||||
if (isProbeRejection) {
|
||||
toast.warning(
|
||||
t("streamCheck.rejected", {
|
||||
providerName: providerName,
|
||||
message: result.message,
|
||||
defaultValue: `${providerName} 检查被拒: ${result.message}`,
|
||||
// 仅当无法建立连接(DNS / 连接被拒 / TLS / 超时)才会到这里
|
||||
toast.error(
|
||||
t("streamCheck.unreachable", {
|
||||
providerName: providerName,
|
||||
message: result.message,
|
||||
defaultValue: `${providerName} 无法连通: ${result.message}`,
|
||||
}),
|
||||
{
|
||||
description: t("streamCheck.unreachableHint", {
|
||||
defaultValue:
|
||||
"无法建立连接(DNS / 连接 / TLS / 超时)。请检查 base_url 与网络。",
|
||||
}),
|
||||
{ description, duration: 8000, closeButton: true },
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
t("streamCheck.failed", {
|
||||
providerName: providerName,
|
||||
message: result.message,
|
||||
defaultValue: `${providerName} 检查失败: ${result.message}`,
|
||||
}),
|
||||
{ description, duration: 8000, closeButton: true },
|
||||
);
|
||||
}
|
||||
duration: 8000,
|
||||
closeButton: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -128,7 +82,7 @@ export function useStreamCheck(appId: AppId) {
|
||||
});
|
||||
}
|
||||
},
|
||||
[appId, t, resetCircuitBreaker],
|
||||
[appId, t],
|
||||
);
|
||||
|
||||
const isChecking = useCallback(
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"windowClose": "Close window"
|
||||
},
|
||||
"provider": {
|
||||
"connectivityCheck": "Connectivity check",
|
||||
"tabProvider": "Provider",
|
||||
"tabUniversal": "Universal",
|
||||
"noProviders": "No providers added yet",
|
||||
@@ -2515,6 +2516,11 @@
|
||||
"stopWithRestoreFailed": "Stop failed: {{detail}}"
|
||||
},
|
||||
"streamCheck": {
|
||||
"reachable": "{{providerName}} is reachable ({{responseTimeMs}}ms)",
|
||||
"reachableSlow": "{{providerName}} reachable but slow ({{responseTimeMs}}ms)",
|
||||
"unreachable": "{{providerName}} unreachable: {{message}}",
|
||||
"unreachableHint": "Could not establish a connection (DNS / connect / TLS / timeout). Check the base_url and your network.",
|
||||
"connectivityNote": "Connectivity check only probes whether the provider address is reachable; it does not send a real model request. Any response counts as \"reachable\" — which does not guarantee that auth or model settings are correct.",
|
||||
"configSaved": "Health check config saved",
|
||||
"configSaveFailed": "Save failed",
|
||||
"testModels": "Test Models",
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"windowClose": "ウィンドウを閉じる"
|
||||
},
|
||||
"provider": {
|
||||
"connectivityCheck": "接続チェック",
|
||||
"tabProvider": "プロバイダー",
|
||||
"tabUniversal": "統一プロバイダー",
|
||||
"noProviders": "まだプロバイダーがありません",
|
||||
@@ -2515,6 +2516,11 @@
|
||||
"stopWithRestoreFailed": "停止に失敗しました: {{detail}}"
|
||||
},
|
||||
"streamCheck": {
|
||||
"reachable": "{{providerName}} は接続可能 ({{responseTimeMs}}ms)",
|
||||
"reachableSlow": "{{providerName}} は接続可能だが低速 ({{responseTimeMs}}ms)",
|
||||
"unreachable": "{{providerName}} に接続できません: {{message}}",
|
||||
"unreachableHint": "接続を確立できませんでした(DNS / 接続 / TLS / タイムアウト)。base_url とネットワークを確認してください。",
|
||||
"connectivityNote": "接続チェックはプロバイダーのアドレスに到達できるかを確認するだけで、実際のモデルリクエストは送信しません。何らかの応答があれば「到達可能」とみなされますが、認証やモデル設定が正しいことを保証するものではありません。",
|
||||
"configSaved": "ヘルスチェック設定を保存しました",
|
||||
"configSaveFailed": "保存に失敗しました",
|
||||
"testModels": "テストモデル",
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"windowClose": "關閉視窗"
|
||||
},
|
||||
"provider": {
|
||||
"connectivityCheck": "檢測連通",
|
||||
"tabProvider": "供應商",
|
||||
"tabUniversal": "通用供應商",
|
||||
"noProviders": "尚未新增任何供應商",
|
||||
@@ -2487,6 +2488,11 @@
|
||||
"stopWithRestoreFailed": "停止失敗:{{detail}}"
|
||||
},
|
||||
"streamCheck": {
|
||||
"reachable": "{{providerName}} 連通正常 ({{responseTimeMs}}ms)",
|
||||
"reachableSlow": "{{providerName}} 連通但較慢 ({{responseTimeMs}}ms)",
|
||||
"unreachable": "{{providerName}} 無法連通: {{message}}",
|
||||
"unreachableHint": "無法建立連線(DNS / 連線 / TLS / 逾時)。請檢查 base_url 與網路。",
|
||||
"connectivityNote": "連通檢測僅探測供應商位址是否可達,不發送真實模型請求。收到任意回應即視為「可達」——這不代表驗證或模型設定一定正確。",
|
||||
"configSaved": "健康檢測設定已儲存",
|
||||
"configSaveFailed": "儲存失敗",
|
||||
"testModels": "測試模型",
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"windowClose": "关闭窗口"
|
||||
},
|
||||
"provider": {
|
||||
"connectivityCheck": "检测连通",
|
||||
"tabProvider": "供应商",
|
||||
"tabUniversal": "统一供应商",
|
||||
"noProviders": "还没有添加任何供应商",
|
||||
@@ -2515,6 +2516,11 @@
|
||||
"stopWithRestoreFailed": "停止失败: {{detail}}"
|
||||
},
|
||||
"streamCheck": {
|
||||
"reachable": "{{providerName}} 连通正常 ({{responseTimeMs}}ms)",
|
||||
"reachableSlow": "{{providerName}} 连通但较慢 ({{responseTimeMs}}ms)",
|
||||
"unreachable": "{{providerName}} 无法连通: {{message}}",
|
||||
"unreachableHint": "无法建立连接(DNS / 连接 / TLS / 超时)。请检查 base_url 与网络。",
|
||||
"connectivityNote": "连通检测仅探测供应商地址是否可达,不发送真实模型请求。收到任意响应即视为“可达”——这不代表鉴权或模型配置一定正确。",
|
||||
"configSaved": "健康检查配置已保存",
|
||||
"configSaveFailed": "保存失败",
|
||||
"testModels": "测试模型",
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { AppId } from "./types";
|
||||
|
||||
// ===== 流式健康检查类型 =====
|
||||
// ===== 连通性检查类型 =====
|
||||
// 注意:本检查只探测 base_url 是否可达,不发真实大模型请求,也不触碰故障转移熔断器。
|
||||
|
||||
export type HealthStatus = "operational" | "degraded" | "failed";
|
||||
|
||||
export interface StreamCheckConfig {
|
||||
/** 单次探测超时(秒) */
|
||||
timeoutSecs: number;
|
||||
/** 超时类失败的最大重试次数 */
|
||||
maxRetries: number;
|
||||
/** 降级阈值(毫秒):可达但 TTFB 超过该值判定为"较慢" */
|
||||
degradedThresholdMs: number;
|
||||
claudeModel: string;
|
||||
codexModel: string;
|
||||
geminiModel: string;
|
||||
testPrompt: string;
|
||||
}
|
||||
|
||||
export interface StreamCheckResult {
|
||||
@@ -21,17 +21,14 @@ export interface StreamCheckResult {
|
||||
message: string;
|
||||
responseTimeMs?: number;
|
||||
httpStatus?: number;
|
||||
modelUsed: string;
|
||||
testedAt: number;
|
||||
retryCount: number;
|
||||
/** 细粒度错误分类,如 "modelNotFound" */
|
||||
errorCategory?: string;
|
||||
}
|
||||
|
||||
// ===== 流式健康检查 API =====
|
||||
// ===== 连通性检查 API =====
|
||||
|
||||
/**
|
||||
* 流式健康检查(单个供应商)
|
||||
* 连通性检查(单个供应商)
|
||||
*/
|
||||
export async function streamCheckProvider(
|
||||
appType: AppId,
|
||||
|
||||
+1
-5
@@ -107,16 +107,12 @@ export interface UsageResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 供应商单独的模型测试配置
|
||||
// 供应商单独的连通检测配置(覆盖全局配置)
|
||||
export interface ProviderTestConfig {
|
||||
// 是否启用单独配置(false 时使用全局配置)
|
||||
enabled: boolean;
|
||||
// 测试用的模型名称(覆盖全局配置)
|
||||
testModel?: string;
|
||||
// 超时时间(秒)
|
||||
timeoutSecs?: number;
|
||||
// 测试提示词
|
||||
testPrompt?: string;
|
||||
// 降级阈值(毫秒)
|
||||
degradedThresholdMs?: number;
|
||||
// 最大重试次数
|
||||
|
||||
Reference in New Issue
Block a user