diff --git a/CHANGELOG.md b/CHANGELOG.md index ba6b4c68c9..730291ffba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ ### ♻️ Code Quality +- **providers/[id]: extract AddApiKeyModal + EditConnectionModal (+ WebSessionCredentialGuide) from the god-component into components/** ([#3501] Phase 1c): extracted the two heaviest inline modals — `AddApiKeyModal` (~787-LOC body) and `EditConnectionModal` (~1091-LOC body) — plus shared `WebSessionCredentialGuide` (~103 LOC) into standalone files under `providers/[id]/components/modals/` and `providers/[id]/components/` respectively. Added `ERROR_TYPE_LABELS` and `formatTimeAgo` to `providerPageHelpers.ts` (leaf) so `EditConnectionModal` and `ConnectionRow` share them without cycles. Pruned 14 now-unused imports from the god-component. `ProviderDetailPageClient.tsx`: 9,981 → 8,092 LOC (−1,889 lines). Frozen baselines: `AddApiKeyModal.tsx: 842`, `EditConnectionModal.tsx: 1170`. 6 new Phase-1c smoke tests; all 21 vitest modal tests pass; typecheck/cycles/lint green. - **refactor: small db/utils cleanup** ([#3523] — thanks @androw): table-driven `compression_analytics` column migration (replaces 17 repeated `ALTER TABLE` calls), a single merged `serializeJsonField` helper in `db/providers.ts` (folded two byte-identical serializers), and removal of the dead no-op `syncProviderDataToCloud`/`getProvidersNeedingRefresh` stubs from `shared/utils/machine.ts` (no remaining callers). Pure refactor; behavior unchanged. - **Provider-detail god-component decomposition — Phase 2b (remaining shared helpers→leaf)** ([#3501]): extended `providers/[id]/providerPageHelpers.ts` with all remaining pure helpers needed by the heavy modals (`AddApiKeyModal`/`EditConnectionModal`) before they can be extracted. Moved 22 symbols: web-session credential label/hint/check/title helpers; upstream-headers helpers (`upstreamHeadersRecordsEqual`, `headerRowsToRecord`, `effectiveUpstreamHeadersForProtocol`, `anyUpstreamHeadersBadge`, `getProtoSlice`) plus their `HeaderDraftRow`/`CompatModelRow`/`CompatModelMap`/`CompatByProtocolMap` types; Codex consts and helpers (`CODEX_REASONING_STRENGTH_OPTIONS`, `CODEX_ACCOUNT_SERVICE_TIER_VALUES`, `CODEX_GLOBAL_SERVICE_MODE_VALUES`, `getCodexServiceTierLabel`, `normalizeCodexLimitPolicy`, `getCodexRequestDefaults`, `getClaudeCodeCompatibleRequestDefaults`); misc helpers (`compatProtocolLabelKey`, `extractCommandCodeCredentialInput`, `normalizeAndValidateHttpBaseUrl`, `SILICONFLOW_ENDPOINTS`, `CommandCodeAuthFlowState`). New transitive imports wired into the leaf: `MODEL_COMPAT_PROTOCOL_KEYS` (`@/shared/constants/modelCompat`), `CodexServiceTier`/`getCodexRequestDefaults`/`getClaudeCodeCompatibleRequestDefaults` (`@/lib/providers/requestDefaults`), `CodexGlobalServiceMode` (`@/lib/providers/codexFastTier`), `WebSessionCredentialRequirement` (`./webSessionCredentials`). `ProviderDetailPageClient.tsx`: 10,288 → 9,980 LOC. Leaf module: 589 LOC (acyclic). 25-assertion unit test suite passes; smoke test 3/3; no import cycles. Co-authored with @oyi77. - **Provider-detail god-component decomposition — Phase 2 (helpers→lib)** ([#3501]): extracted the pure shared helpers — `ProviderMessageTranslator`/`LocalProviderMetadata` types, `providerText`/`providerCountText`/`readBooleanToggle`, and the provider base-URL + routing-tag/excluded-model parse/format block — into a new leaf `providers/[id]/providerPageHelpers.ts` (imports only `@/shared`, so the client and modals share them with no import cycle). `ProviderDetailPageClient.tsx`: 10,435 → 10,288 LOC. Unblocks extracting the heavier `AddApiKeyModal`/`EditConnectionModal` (which depend on these helpers) without cycling. The Phase 0 smoke test caught a missing transitive import (`isSelfHostedChatProvider`) at mount — now wired + locked by a new helpers unit test (12 assertions). Co-authored with @oyi77. diff --git a/file-size-baseline.json b/file-size-baseline.json index 6cf62ccde3..44dddee110 100644 --- a/file-size-baseline.json +++ b/file-size-baseline.json @@ -48,7 +48,9 @@ "src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2570, "src/app/(dashboard)/dashboard/health/page.tsx": 1091, "src/app/(dashboard)/dashboard/playground/components/tabs/ApiTab.tsx": 847, - "src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": 9981, + "src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": 8093, + "src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 843, + "src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1171, "src/app/(dashboard)/dashboard/providers/components/onboarding/ProviderOnboardingWizard.tsx": 906, "src/app/(dashboard)/dashboard/providers/page.tsx": 1925, "src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1127, diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx index 70559febe5..1c639641ba 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx @@ -44,15 +44,10 @@ import { isAnthropicCompatibleProvider, isClaudeCodeCompatibleProvider, isSelfHostedChatProvider, - providerAllowsOptionalApiKey, supportsApiKeyOnFreeProvider, - supportsBulkApiKey, + // providerAllowsOptionalApiKey + supportsBulkApiKey used by extracted AddApiKeyModal } from "@/shared/constants/providers"; -import { - ANTIGRAVITY_CLIENT_PROFILE_OPTIONS, - normalizeAntigravityClientProfileSetting, -} from "@/shared/constants/antigravityClientProfile"; -import { parseBulkApiKeys } from "@/shared/utils/bulkApiKeyParser"; +// antigravityClientProfile + parseBulkApiKeys used by extracted modals (AddApiKeyModal, EditConnectionModal) import { getModelsByProviderId } from "@/shared/constants/models"; import { compatibleProviderSupportsModelImport, @@ -69,7 +64,7 @@ import { type ModelCompatProtocolKey, } from "@/shared/constants/modelCompat"; import { resolveManagedModelAlias } from "@/shared/utils/providerModelAliases"; -import { maskEmail, pickMaskedDisplayValue, pickDisplayValue } from "@/shared/utils/maskEmail"; +import { pickDisplayValue } from "@/shared/utils/maskEmail"; import useEmailPrivacyStore from "@/store/emailPrivacyStore"; import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle"; import ProviderIcon from "@/shared/components/ProviderIcon"; @@ -82,16 +77,13 @@ import { type CodexGlobalServiceMode, } from "@/lib/providers/codexFastTier"; import { isClaudeExtraUsageBlockEnabled } from "@/lib/providers/claudeExtraUsage"; -import { parseExtraApiKeys } from "@/shared/utils/parseApiKeys"; +// parseExtraApiKeys used by extracted EditConnectionModal import { compareTr } from "@/shared/utils/turkishText"; import RiskNoticeModal from "../components/RiskNoticeModal"; import CodexCliGuideModal from "../components/CodexCliGuideModal"; import { isRiskAcknowledged, useRiskAcknowledged } from "../hooks/useRiskAcknowledged"; import { resolveDashboardProviderInfo } from "../providerPageUtils"; -import { - getWebSessionCredentialRequirement, - type WebSessionCredentialRequirement, -} from "./webSessionCredentials"; +// webSessionCredentials used by extracted modals (AddApiKeyModal, EditConnectionModal) import { ImportCodexAuthModal, ApplyCodexAuthModal, @@ -106,43 +98,33 @@ import { } from "./components/modals/ImportGeminiAuthModal"; import EditCompatibleNodeModal from "./components/modals/EditCompatibleNodeModal"; +import AddApiKeyModal from "./components/modals/AddApiKeyModal"; +import EditConnectionModal from "./components/modals/EditConnectionModal"; +import WebSessionCredentialGuide from "./components/WebSessionCredentialGuide"; import { CC_COMPATIBLE_DEFAULT_CHAT_PATH } from "./providerDetailConstants"; import { - CONFIGURABLE_BASE_URL_PROVIDERS, - DEFAULT_PROVIDER_BASE_URLS, - getLocalProviderMetadata, - isBaseUrlConfigurableProvider, - getProviderBaseUrlDefault, - getProviderBaseUrlHint, - getProviderBaseUrlPlaceholder, - isGlmProvider, - parseRoutingTagsInput, - parseExcludedModelsInput, - formatRoutingTagsInput, - formatExcludedModelsInput, + // CONFIGURABLE_BASE_URL_PROVIDERS, DEFAULT_PROVIDER_BASE_URLS, getLocalProviderMetadata, + // isBaseUrlConfigurableProvider, getProviderBaseUrlDefault, getProviderBaseUrlHint, + // getProviderBaseUrlPlaceholder, isGlmProvider, parseRoutingTagsInput, parseExcludedModelsInput, + // formatRoutingTagsInput, formatExcludedModelsInput, getWebSessionCredentialLabel, + // getWebSessionCredentialHint, getWebSessionCredentialCheckLabel, getAddCredentialModalTitle, + // CODEX_REASONING_STRENGTH_OPTIONS, CODEX_ACCOUNT_SERVICE_TIER_VALUES, getCodexRequestDefaults, + // getClaudeCodeCompatibleRequestDefaults, extractCommandCodeCredentialInput, + // normalizeAndValidateHttpBaseUrl, formatTimeAgo + // — all moved to extracted modals (AddApiKeyModal, EditConnectionModal, WebSessionCredentialGuide) providerText, providerCountText, readBooleanToggle, - getWebSessionCredentialLabel, - getWebSessionCredentialHint, - getWebSessionCredentialCheckLabel, - getAddCredentialModalTitle, upstreamHeadersRecordsEqual, UPSTREAM_HEADERS_UI_MAX, headerRowsToRecord, effectiveUpstreamHeadersForProtocol, anyUpstreamHeadersBadge, getProtoSlice, - CODEX_REASONING_STRENGTH_OPTIONS, - CODEX_ACCOUNT_SERVICE_TIER_VALUES, CODEX_GLOBAL_SERVICE_MODE_VALUES, getCodexServiceTierLabel, normalizeCodexLimitPolicy, - getCodexRequestDefaults, - getClaudeCodeCompatibleRequestDefaults, compatProtocolLabelKey, - extractCommandCodeCredentialInput, - normalizeAndValidateHttpBaseUrl, SILICONFLOW_ENDPOINTS, type ProviderMessageTranslator, type LocalProviderMetadata, @@ -153,6 +135,7 @@ import { type CompatModelMap, buildPassthroughTestBody, shouldSwitchToVisibleFilter, + ERROR_TYPE_LABELS, } from "./providerPageHelpers"; /** PATCH fields for provider model compat (matches API + `ModelCompatPerProtocol` shape). */ type ModelCompatSavePatch = { @@ -185,110 +168,6 @@ function isModelHidden( return false; } -function WebSessionCredentialGuide({ - requirement, - providerName, - t, -}: { - requirement: WebSessionCredentialRequirement; - providerName: string; - t: ProviderMessageTranslator; -}) { - if (requirement.kind === "none") { - return ( -
-
- - check_circle - -
-

- {providerText(t, "webNoAuthGuideTitle", "No credential required")} -

-

- {providerText( - t, - "webNoAuthGuideBody", - "{provider} does not need an API key or cookie. Save the connection to use its free web endpoint.", - { provider: providerName } - )} -

-
-
-
- ); - } - - const requiredCredentialKey = - requirement.kind === "token" ? "webTokenRequiredCredential" : "webCookieRequiredCredential"; - const requiredCredentialFallback = - requirement.kind === "token" ? "Required token: {credential}" : "Required cookie: {credential}"; - - return ( -
-
- cookie -
-
-

- {providerText(t, "webSessionGuideTitle", "How to get the session credential")} -

-

- {providerText( - t, - "webSessionGuideIntro", - "{provider} uses a browser web session instead of an API key.", - { provider: providerName } - )} -

-
-

- {providerText(t, requiredCredentialKey, requiredCredentialFallback, { - credential: requirement.credentialName, - })} -

-
    -
  1. - {providerText(t, "webSessionGuideStep1", "Sign in to {provider} in your browser.", { - provider: providerName, - })} -
  2. -
  3. - {providerText( - t, - "webSessionGuideStep2", - "Open the browser developer tools and inspect a request made by the web app." - )} -
  4. -
  5. - {providerText( - t, - "webSessionGuideStep3", - "Copy the required credential from the provider's own domain. For cookies, copy only the Cookie header value and omit Cookie:.", - { credential: requirement.credentialName } - )} -
  6. -
  7. - {providerText( - t, - "webSessionGuideStep4", - "Paste it here and check the connection. If it stops working, sign in again and replace it with a fresh value." - )} -
  8. -
-

- {providerText( - t, - "webSessionSecurityHint", - "Treat this like a password: it may access your signed-in web account until it expires or is revoked." - )} -

-
-
-
- ); -} - function effectiveNormalizeForProtocol( modelId: string, protocol: string, @@ -656,63 +535,6 @@ interface ConnectionRowProps { isExportingGeminiAuthFile?: boolean; } -interface AddApiKeyModalProps { - isOpen: boolean; - provider?: string; - providerName?: string; - initialBaseUrl?: string; - isCompatible?: boolean; - isAnthropic?: boolean; - isCcCompatible?: boolean; - isCommandCode?: boolean; - commandCodeAuthState?: CommandCodeAuthFlowState; - onStartCommandCodeAuth?: () => void; - onSave: (data: { - name: string; - apiKey?: string; - priority: number; - baseUrl?: string; - providerSpecificData?: Record; - }) => Promise; - onClose: () => void; -} - -interface EditConnectionModalConnection { - id?: string; - name?: string; - email?: string; - priority?: number; - maxConcurrent?: number | null; - rateLimitOverrides?: Record | null; - authType?: string; - provider?: string; - apiKey?: string; - providerSpecificData?: Record; - healthCheckInterval?: number; - projectId?: string | null; -} - -const formatTimeAgo = (dateStr: string): string => { - const now = Date.now(); - const date = new Date(dateStr).getTime(); - const diff = now - date; - if (diff < 0) return "just now"; - const minutes = Math.floor(diff / 60000); - if (minutes < 1) return "just now"; - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - if (days < 30) return `${days}d ago`; - return new Date(dateStr).toLocaleDateString(); -}; - -interface EditConnectionModalProps { - isOpen: boolean; - connection: EditConnectionModalConnection | null; - onSave: (data: unknown) => Promise; - onClose: () => void; -} function ModelCompatPopover({ t, effectiveModelNormalize, @@ -7442,22 +7264,6 @@ function CooldownTimer({ until }: CooldownTimerProps) { return ⏱ {remaining}; } -const ERROR_TYPE_LABELS = { - runtime_error: { labelKey: "errorTypeRuntime", variant: "warning" }, - upstream_auth_error: { labelKey: "errorTypeUpstreamAuth", variant: "error" }, - account_deactivated: { labelKey: "Account Deactivated", variant: "error" }, - auth_missing: { labelKey: "errorTypeMissingCredential", variant: "warning" }, - token_refresh_failed: { labelKey: "errorTypeRefreshFailed", variant: "warning" }, - token_expired: { labelKey: "errorTypeTokenExpired", variant: "warning" }, - upstream_rate_limited: { labelKey: "errorTypeRateLimited", variant: "warning" }, - upstream_unavailable: { labelKey: "errorTypeUpstreamUnavailable", variant: "error" }, - network_error: { labelKey: "errorTypeNetworkError", variant: "warning" }, - unsupported: { labelKey: "errorTypeTestUnsupported", variant: "default" }, - upstream_error: { labelKey: "errorTypeUpstreamError", variant: "error" }, - banned: { labelKey: "errorTypeBanned", variant: "error" }, - credits_exhausted: { labelKey: "errorTypeCreditsExhausted", variant: "warning" }, -}; - function inferErrorType(connection, isCooldown) { if (isCooldown) return "upstream_rate_limited"; if (connection.testStatus === "banned") return "banned"; @@ -8284,1883 +8090,3 @@ function SiliconFlowEndpointModal({ ); } - -function AddApiKeyModal({ - isOpen, - provider, - providerName, - initialBaseUrl, - isCompatible, - isAnthropic, - isCcCompatible, - isCommandCode, - commandCodeAuthState, - onStartCommandCodeAuth, - onSave, - onClose, -}: AddApiKeyModalProps) { - const t = useTranslations("providers"); - const usesBaseUrl = isBaseUrlConfigurableProvider(provider); - const defaultBaseUrl = getProviderBaseUrlDefault(provider); - const isVertex = provider === "vertex" || provider === "vertex-partner"; - const isBedrock = provider === "bedrock"; - const showsRegion = isVertex || isBedrock; - const defaultRegion = isBedrock ? "eu-west-2" : "us-central1"; - const isGlm = isGlmProvider(provider); - const isQoder = provider === "qoder"; - const isCloudflare = provider === "cloudflare-ai"; - const localProviderMetadata = getLocalProviderMetadata(provider); - const isLocalSelfHostedProvider = !!localProviderMetadata; - const isGooglePse = provider === "google-pse-search"; - const webSessionCredential = getWebSessionCredentialRequirement(provider); - const isNoAuthWebSessionCredential = webSessionCredential?.kind === "none"; - const isWebSessionCredential = !!webSessionCredential && webSessionCredential.kind !== "none"; - const providerDisplayName = providerName || provider || ""; - const apiKeyOptional = - providerAllowsOptionalApiKey(provider) || Boolean(isNoAuthWebSessionCredential); - const commandCodeAuthPhaseLabel = commandCodeAuthState - ? { - idle: "Ready", - starting: "Starting…", - polling: "Waiting for browser…", - received: "Browser approved", - applying: "Applying key…", - applied: "Connected", - expired: "Link expired", - error: "Connection failed", - }[commandCodeAuthState.phase] - : null; - - const [formData, setFormData] = useState({ - name: "", - apiKey: "", - priority: 1, - baseUrl: initialBaseUrl || defaultBaseUrl, - cx: "", - region: showsRegion ? defaultRegion : "", - apiRegion: "international", - validationModelId: "", - routingTags: "", - excludedModels: "", - customUserAgent: "", - accountId: "", - consoleApiKey: "", - ccCompatibleContext1m: false, - passthroughModels: false, - }); - const [validating, setValidating] = useState(false); - const [validationResult, setValidationResult] = useState(null); - const [saving, setSaving] = useState(false); - const [saveError, setSaveError] = useState(null); - const [showAdvanced, setShowAdvanced] = useState(false); - const [copiedCommandCodeField, setCopiedCommandCodeField] = useState(null); - const wasOpenRef = useRef(false); - - useEffect(() => { - const wasOpen = wasOpenRef.current; - wasOpenRef.current = isOpen; - if (!isOpen || wasOpen) return; - setFormData((current) => ({ - ...current, - baseUrl: initialBaseUrl || defaultBaseUrl, - })); - }, [defaultBaseUrl, initialBaseUrl, isOpen]); - - const bulkSupported = supportsBulkApiKey(provider); - const [mode, setMode] = useState<"single" | "bulk">("single"); - const [bulkText, setBulkText] = useState(""); - const [bulkValidateKeys, setBulkValidateKeys] = useState(false); - const [bulkResult, setBulkResult] = useState<{ - success: number; - failed: number; - total: number; - errors: Array<{ index: number; name: string; message: string }>; - } | null>(null); - const [bulkWarnings, setBulkWarnings] = useState([]); - const apiCredentialLabel = isQoder - ? t("personalAccessTokenLabel") - : webSessionCredential - ? getWebSessionCredentialLabel(t, webSessionCredential, apiKeyOptional) - : apiKeyOptional - ? `${t("apiKeyLabel")} (${t("optional").toLowerCase()})` - : t("apiKeyLabel"); - const apiCredentialPlaceholder = isVertex - ? t("vertexServiceAccountPlaceholder") - : isWebSessionCredential - ? webSessionCredential.placeholder - : isQoder - ? t("qoderPatPlaceholder") - : apiKeyOptional - ? t("optional") - : undefined; - const apiCredentialHint = isQoder - ? t("qoderPatHint") - : isWebSessionCredential - ? getWebSessionCredentialHint(t, webSessionCredential, providerDisplayName, false) - : isLocalSelfHostedProvider - ? t("localProviderApiKeyOptionalHint", { - provider: localProviderMetadata?.name || providerName || provider || "", - }) - : apiKeyOptional - ? t("apiKeyOptionalHint") - : undefined; - const credentialValidationFailedMessage = isWebSessionCredential - ? providerText( - t, - "webSessionCredentialValidationFailed", - "Session credential validation failed. Sign in again, copy a fresh credential, and try again." - ) - : t("apiKeyValidationFailed"); - - const handleValidate = async () => { - setValidating(true); - setSaveError(null); - try { - const credentialInput = isCommandCode - ? extractCommandCodeCredentialInput(formData.apiKey) - : formData.apiKey; - const res = await fetch("/api/providers/validate", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - provider, - apiKey: credentialInput, - validationModelId: formData.validationModelId || undefined, - customUserAgent: formData.customUserAgent.trim() || undefined, - baseUrl: formData.baseUrl.trim() || undefined, - region: showsRegion ? formData.region.trim() || defaultRegion : undefined, - cx: formData.cx.trim() || undefined, - }), - }); - const data = await res.json(); - setValidationResult(data.valid ? "success" : "failed"); - } catch { - setValidationResult("failed"); - } finally { - setValidating(false); - } - }; - - const copyCommandCodeValue = async (value: string | undefined, key: string) => { - if (!value) return; - try { - await navigator.clipboard.writeText(value); - setCopiedCommandCodeField(key); - window.setTimeout(() => setCopiedCommandCodeField(null), 1500); - } catch { - setSaveError("Copy failed. Select the text and copy it manually."); - } - }; - - const handleSubmit = async () => { - const credentialInput = isCommandCode - ? extractCommandCodeCredentialInput(formData.apiKey) - : formData.apiKey; - if (!provider || (!isCompatible && !apiKeyOptional && !credentialInput)) return; - - setSaving(true); - setSaveError(null); - try { - if (isGooglePse && !formData.cx.trim()) { - setSaveError(t("searchEngineIdRequired")); - return; - } - - let validatedBaseUrl = null; - if (usesBaseUrl) { - const checked = normalizeAndValidateHttpBaseUrl(formData.baseUrl, defaultBaseUrl); - if (checked.error) { - setSaveError(checked.error); - return; - } - validatedBaseUrl = checked.value; - } - - let isValid = Boolean(isNoAuthWebSessionCredential && !credentialInput); - let validationError: string | null = null; - if (!isValid) { - try { - setValidating(true); - setValidationResult(null); - const res = await fetch("/api/providers/validate", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - provider, - apiKey: credentialInput, - validationModelId: formData.validationModelId || undefined, - customUserAgent: formData.customUserAgent.trim() || undefined, - baseUrl: formData.baseUrl.trim() || undefined, - region: showsRegion ? formData.region.trim() || defaultRegion : undefined, - cx: formData.cx.trim() || undefined, - }), - }); - const data = await res.json(); - isValid = !!data.valid; - if (!isValid && data.error) { - validationError = data.error; - } - setValidationResult(isValid ? "success" : "failed"); - } catch { - setValidationResult("failed"); - } finally { - setValidating(false); - } - } - - if (!isValid) { - if (apiKeyOptional && !credentialInput) { - // Bypass validation block for local/optional providers when no key is provided - console.debug("Validation failed but apiKey is optional; proceeding to save."); - } else { - setSaveError(validationError || credentialValidationFailedMessage); - return; - } - } - - const providerSpecificData: Record = {}; - if (formData.customUserAgent.trim()) { - providerSpecificData.customUserAgent = formData.customUserAgent.trim(); - } - if (formData.routingTags.trim()) { - providerSpecificData.tags = parseRoutingTagsInput(formData.routingTags); - } - if (formData.excludedModels.trim()) { - providerSpecificData.excludedModels = parseExcludedModelsInput(formData.excludedModels); - } - if (formData.passthroughModels) { - providerSpecificData.passthroughModels = true; - } - if (provider === "bailian-coding-plan" && formData.consoleApiKey.trim()) { - providerSpecificData.consoleApiKey = formData.consoleApiKey.trim(); - } - if (isGooglePse && formData.cx.trim()) { - providerSpecificData.cx = formData.cx.trim(); - } - if (usesBaseUrl) { - providerSpecificData.baseUrl = validatedBaseUrl; - } else if (showsRegion) { - providerSpecificData.region = formData.region.trim() || defaultRegion; - } else if (isGlm) { - providerSpecificData.apiRegion = formData.apiRegion; - } else if (isCloudflare && formData.accountId.trim()) { - providerSpecificData.accountId = formData.accountId.trim(); - } - if (isCcCompatible && formData.ccCompatibleContext1m) { - providerSpecificData.requestDefaults = { context1m: true }; - } - - const payload = { - name: formData.name, - apiKey: credentialInput.trim() || undefined, - priority: formData.priority, - testStatus: "active", - providerSpecificData: - Object.keys(providerSpecificData).length > 0 ? providerSpecificData : undefined, - }; - - const error = await onSave(payload); - if (error) { - setSaveError(typeof error === "string" ? error : t("failedSaveConnection")); - } - } finally { - setSaving(false); - } - }; - - const handleBulkSubmit = async () => { - if (!provider) return; - const parsed = parseBulkApiKeys(bulkText); - setBulkWarnings(parsed.warnings); - if (parsed.entries.length === 0) return; - - setSaving(true); - setBulkResult(null); - setSaveError(null); - - try { - let providerSpecificData: Record | undefined; - if (usesBaseUrl) { - const checked = normalizeAndValidateHttpBaseUrl(formData.baseUrl, defaultBaseUrl); - if (checked.error) { - setSaveError(checked.error); - return; - } - providerSpecificData = { baseUrl: checked.value }; - } - - const res = await fetch("/api/providers/bulk", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - provider, - entries: parsed.entries.map((e) => ({ name: e.name, apiKey: e.apiKey })), - priority: formData.priority || 1, - providerSpecificData, - validateKeys: bulkValidateKeys, - }), - }); - const data = await res.json(); - if (!res.ok) { - setSaveError(typeof data?.error === "string" ? data.error : t("failedSaveConnection")); - return; - } - setBulkResult({ - success: data.success || 0, - failed: data.failed || 0, - total: data.total || 0, - errors: Array.isArray(data.errors) ? data.errors : [], - }); - } catch (err) { - setSaveError(err instanceof Error ? err.message : t("failedSaveConnection")); - } finally { - setSaving(false); - } - }; - - if (!provider) return null; - - return ( - -
- {bulkSupported && ( -
- - -
- )} - - {bulkSupported && mode === "bulk" && ( -
-

{t("bulkAddFormatHint")}

-