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

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

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

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

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

372 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
Activity,
BarChart3,
Check,
Copy,
Edit,
Loader2,
Minus,
Play,
Plus,
Terminal,
Trash2,
Zap,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { AppId } from "@/lib/api";
interface ProviderActionsProps {
appId?: AppId;
isCurrent: boolean;
isInConfig?: boolean;
isTesting?: boolean;
isProxyTakeover?: boolean;
isOmo?: boolean;
onSwitch: () => void;
onEdit: () => void;
onDuplicate: () => void;
onTest?: () => void;
onConfigureUsage?: () => void;
onDelete: () => void;
onRemoveFromConfig?: () => void;
onDisableOmo?: () => void;
onOpenTerminal?: () => void;
isAutoFailoverEnabled?: boolean;
isInFailoverQueue?: boolean;
onToggleFailover?: (enabled: boolean) => void;
isOfficialBlockedByProxy?: boolean;
// Hermes v12+ providers: dict overlay — edit/delete must go through Web UI
isReadOnly?: boolean;
// OpenClaw: default model
isDefaultModel?: boolean;
onSetAsDefault?: () => void;
}
// 主按钮的呈现状态。title 用于 disabled 态向用户解释为何不可点击;
// 因 Button 基类带 disabled:pointer-events-nonetitle 必须挂在外层非禁用
// 的 wrapper 上才会在 hover 时显示(见下方 <span> 包裹)。
interface MainButtonState {
disabled: boolean;
variant: "default" | "secondary";
className: string;
icon: JSX.Element;
text: string;
title?: string;
}
export function ProviderActions({
appId,
isCurrent,
isInConfig = false,
isTesting,
isProxyTakeover = false,
isOmo = false,
onSwitch,
onEdit,
onDuplicate,
onTest,
onConfigureUsage,
onDelete,
onRemoveFromConfig,
onDisableOmo,
onOpenTerminal,
isAutoFailoverEnabled = false,
isInFailoverQueue = false,
onToggleFailover,
isOfficialBlockedByProxy = false,
isReadOnly = false,
// OpenClaw: default model
isDefaultModel = false,
onSetAsDefault,
}: ProviderActionsProps) {
const { t } = useTranslation();
const iconButtonClass = "h-8 w-8 p-1";
// 累加模式应用(OpenCode 非 OMO / OpenClaw / Hermes
const isAdditiveMode =
(appId === "opencode" && !isOmo) ||
appId === "openclaw" ||
appId === "hermes";
// 故障转移模式下的按钮逻辑(累加模式和 OMO 应用不支持故障转移)
const isFailoverMode =
!isAdditiveMode && !isOmo && isAutoFailoverEnabled && onToggleFailover;
const handleMainButtonClick = () => {
if (isOmo) {
if (isCurrent) {
onDisableOmo?.();
} else {
onSwitch();
}
} else if (isAdditiveMode) {
// 累加模式:切换配置状态(添加/移除)
if (isInConfig) {
if (onRemoveFromConfig) {
onRemoveFromConfig();
} else {
onDelete();
}
} else {
onSwitch(); // 添加到配置
}
} else if (isFailoverMode) {
onToggleFailover(!isInFailoverQueue);
} else {
onSwitch();
}
};
const getMainButtonState = (): MainButtonState => {
if (isOmo) {
if (isCurrent) {
return {
disabled: false,
variant: "secondary" as const,
className:
"bg-gray-200 text-muted-foreground hover:bg-gray-200 hover:text-muted-foreground dark:bg-gray-700 dark:hover:bg-gray-700",
icon: <Check className="h-4 w-4" />,
text: t("provider.inUse"),
};
}
return {
disabled: false,
variant: "default" as const,
className: "",
icon: <Play className="h-4 w-4" />,
text: t("provider.enable"),
};
}
// 累加模式(OpenCode 非 OMO / OpenClaw
if (isAdditiveMode) {
if (isInConfig) {
return {
disabled: isDefaultModel === true,
variant: "secondary" as const,
className: cn(
"bg-orange-100 text-orange-600 hover:bg-orange-200 dark:bg-orange-900/50 dark:text-orange-400 dark:hover:bg-orange-900/70",
isDefaultModel && "opacity-40 cursor-not-allowed",
),
icon: <Minus className="h-4 w-4" />,
text: t("provider.removeFromConfig", { defaultValue: "移除" }),
};
}
return {
disabled: false,
variant: "default" as const,
className:
"bg-emerald-500 hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700",
icon: <Plus className="h-4 w-4" />,
text: t("provider.addToConfig", { defaultValue: "添加" }),
};
}
if (isFailoverMode) {
if (isInFailoverQueue) {
return {
disabled: false,
variant: "secondary" as const,
className:
"bg-blue-100 text-blue-600 hover:bg-blue-200 dark:bg-blue-900/50 dark:text-blue-400 dark:hover:bg-blue-900/70",
icon: <Check className="h-4 w-4" />,
text: t("failover.inQueue", { defaultValue: "已加入" }),
};
}
return {
disabled: false,
variant: "default" as const,
className:
"bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
icon: <Plus className="h-4 w-4" />,
text: t("failover.addQueue", { defaultValue: "加入" }),
};
}
if (isCurrent) {
return {
disabled: true,
variant: "secondary" as const,
className:
"bg-gray-200 text-muted-foreground hover:bg-gray-200 hover:text-muted-foreground dark:bg-gray-700 dark:hover:bg-gray-700",
icon: <Check className="h-4 w-4" />,
text: t("provider.inUse"),
};
}
if (isOfficialBlockedByProxy) {
return {
disabled: true,
variant: "default" as const,
className: "",
icon: <Play className="h-4 w-4" />,
text: t("provider.enable"),
title: t("provider.blockedByProxyHint"),
};
}
return {
disabled: false,
variant: "default" as const,
className: isProxyTakeover
? "bg-emerald-500 hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700"
: "",
icon: <Play className="h-4 w-4" />,
text: t("provider.enable"),
};
};
const buttonState = getMainButtonState();
const canDelete =
!isReadOnly && (isOmo || isAdditiveMode ? true : !isCurrent);
const readOnlyHint = t("provider.managedByHermesHint", {
defaultValue: "由 Hermes 管理,请在 Hermes Web UI 中编辑",
});
return (
<div className="flex items-center gap-1.5">
{(appId === "openclaw" || appId === "hermes") &&
isInConfig &&
onSetAsDefault &&
(() => {
const activeLabel =
appId === "hermes"
? t("provider.inUse", { defaultValue: "已在用" })
: t("provider.isDefault", { defaultValue: "当前默认" });
const inactiveLabel =
appId === "hermes"
? t("provider.enable", { defaultValue: "启用" })
: t("provider.setAsDefault", { defaultValue: "设为默认" });
return (
<Button
size="sm"
variant={isDefaultModel ? "secondary" : "default"}
onClick={isDefaultModel ? undefined : onSetAsDefault}
disabled={isDefaultModel}
className={cn(
"w-fit px-2.5",
isDefaultModel
? "bg-gray-200 text-muted-foreground dark:bg-gray-700 opacity-60 cursor-not-allowed"
: "bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
)}
>
<Zap className="h-4 w-4" />
{isDefaultModel ? activeLabel : inactiveLabel}
</Button>
);
})()}
{/* wrapper span 承接 hoverdisabled 按钮自身 pointer-events:none
原生 title 与 cursor 都必须挂在未禁用的外层元素上才会生效 */}
<span
title={buttonState.title}
className={cn(
"inline-flex",
buttonState.disabled && "cursor-not-allowed",
)}
>
<Button
size="sm"
variant={buttonState.variant}
onClick={handleMainButtonClick}
disabled={buttonState.disabled}
className={cn("w-[4.5rem] px-2.5", buttonState.className)}
>
{buttonState.icon}
{buttonState.text}
</Button>
</span>
<div className="flex items-center gap-1">
<Button
size="icon"
variant="ghost"
onClick={isReadOnly ? undefined : onEdit}
disabled={isReadOnly}
title={isReadOnly ? readOnlyHint : t("common.edit")}
className={cn(
iconButtonClass,
isReadOnly && "opacity-40 cursor-not-allowed text-muted-foreground",
)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
onClick={onDuplicate}
title={t("provider.duplicate")}
className={iconButtonClass}
>
<Copy className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
onClick={onTest || undefined}
disabled={isTesting}
title={t("provider.connectivityCheck", "检测连通")}
className={cn(
iconButtonClass,
!onTest && "opacity-40 cursor-not-allowed text-muted-foreground",
)}
>
{isTesting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Activity className="h-4 w-4" />
)}
</Button>
<Button
size="icon"
variant="ghost"
onClick={onConfigureUsage || undefined}
title={t("provider.configureUsage")}
className={cn(
iconButtonClass,
!onConfigureUsage &&
"opacity-40 cursor-not-allowed text-muted-foreground",
)}
>
<BarChart3 className="h-4 w-4" />
</Button>
{onOpenTerminal && (
<Button
size="icon"
variant="ghost"
onClick={onOpenTerminal}
title={t("provider.openTerminal", "打开终端")}
className={cn(
iconButtonClass,
"hover:text-emerald-600 dark:hover:text-emerald-400",
)}
>
<Terminal className="h-4 w-4" />
</Button>
)}
<Button
size="icon"
variant="ghost"
onClick={canDelete ? onDelete : undefined}
title={isReadOnly ? readOnlyHint : t("common.delete")}
className={cn(
iconButtonClass,
canDelete && "hover:text-red-500 dark:hover:text-red-400",
!canDelete && "opacity-40 cursor-not-allowed text-muted-foreground",
)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
);
}