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:
Jason
2026-06-14 15:15:34 +08:00
parent b7ad1c4bf8
commit a5903d8600
18 changed files with 542 additions and 2402 deletions
+3 -3
View File
@@ -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>
+6 -7
View File
@@ -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.aicc-switch
// 不在请求路径上),没有可靠的探测目标,故隐藏其检测按钮。
onTest &&
!isOfficial &&
!isCopilot &&
!isCodexOauth &&
!isClaudeThirdParty
!(
appId === "claude-desktop" && provider.category === "official"
)
? () => onTest(provider)
: undefined
}
+3 -48
View File
@@ -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>
+22 -83
View File
@@ -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">