import { useMemo, useState, useEffect } from "react"; import { GripVertical, ChevronDown, ChevronUp } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { DraggableAttributes, DraggableSyntheticListeners, } from "@dnd-kit/core"; import type { Provider } from "@/types"; import type { AppId } from "@/lib/api"; import { cn } from "@/lib/utils"; import { ProviderActions } from "@/components/providers/ProviderActions"; import { ProviderIcon } from "@/components/ProviderIcon"; import UsageFooter from "@/components/UsageFooter"; import SubscriptionQuotaFooter from "@/components/SubscriptionQuotaFooter"; import CopilotQuotaFooter from "@/components/CopilotQuotaFooter"; import CodexOauthQuotaFooter from "@/components/CodexOauthQuotaFooter"; import XaiOauthQuotaFooter from "@/components/XaiOauthQuotaFooter"; import { PROVIDER_TYPES, TEMPLATE_TYPES } from "@/config/constants"; import { isHermesReadOnlyProvider } from "@/config/hermesProviderPresets"; import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge"; import { FailoverPriorityBadge } from "@/components/providers/FailoverPriorityBadge"; import { extractCodexBaseUrl, extractCodexExperimentalBearerToken, } from "@/utils/providerConfigUtils"; import { supportsOfficialProxyTakeover, providerNeedsRouting, } from "@/utils/providerCapabilities"; import { useProviderHealth } from "@/lib/query/failover"; import { useUsageQuery } from "@/lib/query/queries"; import { resolveProviderIcon } from "@/utils/providerIcon"; interface DragHandleProps { attributes: DraggableAttributes; listeners: DraggableSyntheticListeners; isDragging: boolean; } interface ProviderCardProps { provider: Provider; isCurrent: boolean; appId: AppId; isInConfig?: boolean; // OpenCode: 是否已添加到 opencode.json isOmo?: boolean; isOmoSlim?: boolean; onSwitch: (provider: Provider) => void; onEdit: (provider: Provider) => void; onDelete: (provider: Provider) => void; onRemoveFromConfig?: (provider: Provider) => void; onDisableOmo?: () => void; onDisableOmoSlim?: () => void; onConfigureUsage: (provider: Provider) => void; onOpenWebsite: (url: string) => void; onDuplicate: (provider: Provider) => void; onTest?: (provider: Provider) => void; onOpenTerminal?: (provider: Provider) => void; isTesting?: boolean; isProxyRunning: boolean; isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换) dragHandleProps?: DragHandleProps; isAutoFailoverEnabled?: boolean; // 是否开启自动故障转移 failoverPriority?: number; // 故障转移优先级(1 = P1, 2 = P2, ...) isInFailoverQueue?: boolean; // 是否在故障转移队列中 onToggleFailover?: (enabled: boolean) => void; // 切换故障转移队列 activeProviderId?: string; // 代理当前实际使用的供应商 ID(用于故障转移模式下标注绿色边框) // OpenClaw: default model isDefaultModel?: boolean; onSetAsDefault?: () => void; } /** 判断是否为官方供应商(无自定义 base URL / API key,直连官方 API) */ function isOfficialProvider(provider: Provider, appId: AppId): boolean { if (provider.category === "official") { return true; } const config = provider.settingsConfig as Record; if (appId === "claude") { const baseUrl = config?.env?.ANTHROPIC_BASE_URL; return !baseUrl || (typeof baseUrl === "string" && baseUrl.trim() === ""); } if (appId === "codex") { // 无 OPENAI_API_KEY → 使用 Codex CLI 内置 OAuth(官方) const apiKey = config?.auth?.OPENAI_API_KEY; const bearerToken = typeof config?.config === "string" ? extractCodexExperimentalBearerToken(config.config) : undefined; return ( !bearerToken && (!apiKey || (typeof apiKey === "string" && apiKey.trim() === "")) ); } if (appId === "gemini") { // 无 GEMINI_API_KEY 且无 GOOGLE_GEMINI_BASE_URL → Google OAuth 官方模式 const apiKey = config?.env?.GEMINI_API_KEY; const baseUrl = config?.env?.GOOGLE_GEMINI_BASE_URL; return ( (!apiKey || (typeof apiKey === "string" && apiKey.trim() === "")) && (!baseUrl || (typeof baseUrl === "string" && baseUrl.trim() === "")) ); } return false; } const extractApiUrl = (provider: Provider, fallbackText: string) => { if (provider.notes?.trim()) { return provider.notes.trim(); } if (provider.websiteUrl) { return provider.websiteUrl; } const config = provider.settingsConfig; if (config && typeof config === "object") { const envBase = (config as Record)?.env?.ANTHROPIC_BASE_URL || (config as Record)?.env?.GOOGLE_GEMINI_BASE_URL; if (typeof envBase === "string" && envBase.trim()) { return envBase; } const baseUrl = (config as Record)?.config; if (typeof baseUrl === "string" && baseUrl.includes("base_url")) { const extractedBaseUrl = extractCodexBaseUrl(baseUrl); if (extractedBaseUrl) { return extractedBaseUrl; } } } return fallbackText; }; export function ProviderCard({ provider, isCurrent, appId, isInConfig = true, isOmo = false, isOmoSlim = false, onSwitch, onEdit, onDelete, onRemoveFromConfig, onDisableOmo, onDisableOmoSlim, onConfigureUsage, onOpenWebsite, onDuplicate, onTest, onOpenTerminal, isTesting, isProxyRunning, isProxyTakeover = false, dragHandleProps, isAutoFailoverEnabled = false, failoverPriority, isInFailoverQueue = false, onToggleFailover, activeProviderId, // OpenClaw: default model isDefaultModel, onSetAsDefault, }: ProviderCardProps) { const { t } = useTranslation(); // OMO and OMO Slim share the same card behavior const isAnyOmo = isOmo || isOmoSlim; const handleDisableAnyOmo = isOmoSlim ? onDisableOmoSlim : onDisableOmo; const isAdditiveMode = appId === "opencode" && !isAnyOmo; const { data: health } = useProviderHealth(provider.id, appId); const fallbackUrlText = t("provider.notConfigured", { defaultValue: "未配置接口地址", }); const displayUrl = useMemo(() => { return extractApiUrl(provider, fallbackUrlText); }, [provider, fallbackUrlText]); const isClickableUrl = useMemo(() => { if (provider.notes?.trim()) { return false; } if (displayUrl === fallbackUrlText) { return false; } return true; }, [provider.notes, displayUrl, fallbackUrlText]); const usageEnabled = provider.meta?.usage_script?.enabled ?? false; const isOfficial = isOfficialProvider(provider, appId); const supportsOfficialSubscription = isOfficial && ["claude", "codex", "gemini", "grokbuild"].includes(appId); const isOfficialSubscriptionUsage = provider.meta?.usage_script?.templateType === TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION; const officialSubscriptionEnabled = supportsOfficialSubscription && usageEnabled && isOfficialSubscriptionUsage; // 官方判定只认显式 category === "official"(SSOT),不回退 isOfficial 的空字段启发式。 // 理由(此判定曾在「纯 category ↔ category+isOfficial 回退」间反复,结论钉死于此): // 1) 封号保护是高代价决策,不该建立在「base_url/key 缺失」这种脆弱信号上——它无法区分 // 「想直连官方」与「自定义但还没填完」,两者都表现为字段为空,必然误伤后者。 // 2) 启发式在 UI 多拦的部分,执行层 useProviderActions.ts 也只认 category === "official"、 // 并不兑现(绕过 UI 即可切换)→ 属虚保护,却以误伤 category 缺失的自定义供应商为代价。 // 3) 预设导入的官方一定带 category="official",category 缺失的「真官方」现实中≈不存在。 // 真官方就该有显式 category;手动新建官方应引导标注,而不是靠空字段猜。 const supportsOfficialRouting = supportsOfficialProxyTakeover( appId, provider, ); const isOfficialBlockedByProxy = isProxyTakeover && provider.category === "official" && !supportsOfficialRouting; const isCopilot = provider.meta?.providerType === PROVIDER_TYPES.GITHUB_COPILOT || provider.meta?.usage_script?.templateType === "github_copilot"; // Hermes v12+ overlay entries live under the `providers:` dict and are // read-only here — writes have to go through Hermes Web UI. const isHermesReadOnly = appId === "hermes" && isHermesReadOnlyProvider(provider.settingsConfig); const isCodexOauth = provider.meta?.providerType === PROVIDER_TYPES.CODEX_OAUTH; // xAI OAuth (SuperGrok 反代):额度经自管 OAuth token 自动显示,与 codex_oauth 同构 const isXaiOauth = provider.meta?.providerType === PROVIDER_TYPES.XAI_OAUTH; // 统一权威谓词(详见 providerNeedsRouting):以 providerType 为准,不受 // apiFormat 被改动/缺省影响。此 badge 仅在 Codex 视图渲染,故加 appId 守卫。 const codexNeedsRouting = appId === "codex" && providerNeedsRouting(appId, provider); // 获取用量数据以判断是否有多套餐 // 累加模式应用(OpenCode/OpenClaw/Hermes):使用 isInConfig 代替 isCurrent const shouldAutoQuery = appId === "opencode" || appId === "openclaw" || appId === "hermes" ? isInConfig : isCurrent; const autoQueryInterval = shouldAutoQuery ? provider.meta?.usage_script?.autoQueryInterval || 0 : 0; const { data: usage } = useUsageQuery(provider.id, appId, { enabled: usageEnabled && !isOfficial && !isOfficialSubscriptionUsage, autoQueryInterval, }); const isTokenPlan = provider.meta?.usage_script?.templateType === "token_plan"; const hasMultiplePlans = usage?.success && usage.data && usage.data.length > 1 && !isTokenPlan; const [isExpanded, setIsExpanded] = useState(false); useEffect(() => { if (hasMultiplePlans) { setIsExpanded(true); } }, [hasMultiplePlans]); const handleOpenWebsite = () => { if (!isClickableUrl) { return; } onOpenWebsite(displayUrl); }; // 判断是否是"当前使用中"的供应商 // - OMO/OMO Slim 供应商:使用 isCurrent // - OpenClaw:使用默认模型归属的 provider 作为当前项(蓝色边框) // - OpenCode(非 OMO):不存在"当前"概念,返回 false // - 故障转移模式:代理实际使用的供应商(activeProviderId) // - 普通模式:isCurrent const isActiveProvider = isAnyOmo ? isCurrent : appId === "openclaw" ? Boolean(isDefaultModel) : appId === "opencode" ? false : isAutoFailoverEnabled ? activeProviderId === provider.id : isCurrent; const shouldUseGreen = !isAnyOmo && isProxyTakeover && isActiveProvider; const hasPersistentConfigHighlight = isAdditiveMode && isInConfig; const shouldUseBlue = (isAnyOmo && isActiveProvider) || (!isAnyOmo && !isProxyTakeover && (isActiveProvider || hasPersistentConfigHighlight)); return (

{provider.name}

{isOmo && ( OMO )} {isOmoSlim && ( Slim )} {appId === "claude-desktop" && providerNeedsRouting(appId, provider) && ( {t("claudeDesktop.modeProxy", { defaultValue: "需要路由", })} )} {appId === "claude" && providerNeedsRouting(appId, provider) && ( {t("claudeCode.needsRouting", { defaultValue: "需要路由", })} )} {codexNeedsRouting && ( {t("codex.needsRouting", { defaultValue: "需要路由", })} )} {appId === "claude" && provider.category === "official" && ( {t("claudeCode.noRoutingSupport", { defaultValue: "不支持路由", })} )} {appId === "codex" && supportsOfficialRouting && ( {isProxyTakeover ? t("codex.officialRouting", { defaultValue: "官方账号路由", }) : t("codex.nativeLogin", { defaultValue: "Codex 登录", })} )} {appId === "codex" && provider.category === "official" && !supportsOfficialRouting && ( {t("codex.noRoutingSupport", { defaultValue: "不支持路由", })} )} {isProxyRunning && isInFailoverQueue && health && ( )} {isAutoFailoverEnabled && isInFailoverQueue && failoverPriority && ( )} {provider.category === "third_party" && provider.meta?.isPartner && ( )} {isHermesReadOnly && ( {t("provider.managedByHermes", { defaultValue: "Hermes Managed", })} )}
{displayUrl && ( )}
{isCopilot ? ( ) : isCodexOauth ? ( ) : isXaiOauth ? ( ) : isOfficial ? ( officialSubscriptionEnabled ? ( ) : null ) : hasMultiplePlans ? (
{t("usage.multiplePlans", { count: usage?.data?.length || 0, defaultValue: `${usage?.data?.length || 0} 个套餐`, })}
) : ( )} {hasMultiplePlans && ( )}
onSwitch(provider)} onEdit={() => onEdit(provider)} onDuplicate={() => onDuplicate(provider)} onTest={ // 连通检测对第三方/自定义/Copilot/Codex-OAuth 供应商开放(这些正是旧的 // 真实请求探测会误报、而可达性探测能正确处理的对象)。官方供应商 // (category === "official") 一律隐藏:它们 base_url 故意留空、走客户端 // 默认/OAuth 端点,cc-switch 没有可靠的探测目标(尤其 Claude Desktop // 官方是原生 1P 模式,根本不在请求路径上)。 onTest && provider.category !== "official" ? () => onTest(provider) : undefined } onConfigureUsage={ (isOfficial && !supportsOfficialSubscription) || isCopilot || isCodexOauth || isXaiOauth ? undefined : () => onConfigureUsage(provider) } onDelete={() => onDelete(provider)} onRemoveFromConfig={ onRemoveFromConfig ? () => onRemoveFromConfig(provider) : undefined } onDisableOmo={handleDisableAnyOmo} onOpenTerminal={ onOpenTerminal ? () => onOpenTerminal(provider) : undefined } isAutoFailoverEnabled={isAutoFailoverEnabled} isInFailoverQueue={isInFailoverQueue} onToggleFailover={onToggleFailover} // OpenClaw: default model isDefaultModel={isDefaultModel} onSetAsDefault={onSetAsDefault} />
{isExpanded && hasMultiplePlans && (
)}
); }