diff --git a/CHANGELOG.md b/CHANGELOG.md index e2b3d5eaf1..9156f3a298 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ ## [3.8.22] — TBD +### ♻️ Code Quality + +- **Provider-detail god-component decomposition — Phase 0** ([#3501]): introduced `ProviderDetailPageClient.tsx` and reduced `providers/[id]/page.tsx` to a thin 9-line route wrapper (was 12,882 LOC), following the repo's `*PageClient` convention. Added the first-ever smoke render test for the page (Hard Rule #8) as the safety net every later extraction phase is diffed against. Behavior unchanged; the `check-file-size` ratchet now tracks the extracted client. Foundation for Phases 1–6 (strangler-fig). Thanks @oyi77 for the parallel modularization effort in #3627. + --- ## [3.8.21] — 2026-06-11 diff --git a/file-size-baseline.json b/file-size-baseline.json index 0f69af4d03..32a7a8c4c2 100644 --- a/file-size-baseline.json +++ b/file-size-baseline.json @@ -48,7 +48,7 @@ "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]/page.tsx": 12883, + "src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": 12883, "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 new file mode 100644 index 0000000000..3b2e74cbbc --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx @@ -0,0 +1,12882 @@ +"use client"; + +import { useState, useEffect, useLayoutEffect, useCallback, useRef, useMemo } from "react"; +import { createPortal } from "react-dom"; +import { LlmChatCard } from "@/app/(dashboard)/dashboard/media-providers/components/LlmChatCard"; +import { ServiceKindTabs } from "@/app/(dashboard)/dashboard/media-providers/components/ServiceKindTabs"; +import { EmbeddingExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/EmbeddingExampleCard"; +import { ImageExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/ImageExampleCard"; +import { TtsExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/TtsExampleCard"; +import { SttExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/SttExampleCard"; +import { WebSearchExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/WebSearchExampleCard"; +import { WebFetchExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/WebFetchExampleCard"; +import { VideoExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/VideoExampleCard"; +import { MusicExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/MusicExampleCard"; +import type { ServiceKind } from "@/shared/constants/providers"; +import { useNotificationStore } from "@/store/notificationStore"; +import { useParams, useRouter } from "next/navigation"; +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import { + Card, + Button, + Badge, + Input, + Modal, + ConfirmModal, + CardSkeleton, + OAuthModal, + KiroOAuthWrapper, + CursorAuthModal, + TraeAuthModal, + Toggle, + Select, + ProxyConfigModal, + NoAuthProviderCard, +} from "@/shared/components"; +import { + LOCAL_PROVIDERS, + NOAUTH_PROVIDERS, + AI_PROVIDERS, + getProviderAlias, + isOpenAICompatibleProvider, + isAnthropicCompatibleProvider, + isClaudeCodeCompatibleProvider, + isSelfHostedChatProvider, + providerAllowsOptionalApiKey, + supportsApiKeyOnFreeProvider, + supportsBulkApiKey, +} from "@/shared/constants/providers"; +import { + ANTIGRAVITY_CLIENT_PROFILE_OPTIONS, + normalizeAntigravityClientProfileSetting, +} from "@/shared/constants/antigravityClientProfile"; +import { parseBulkApiKeys } from "@/shared/utils/bulkApiKeyParser"; +import { getModelsByProviderId } from "@/shared/constants/models"; +import { + compatibleProviderSupportsModelImport, + getCompatibleFallbackModels, +} from "@/lib/providers/managedAvailableModels"; +import { + getModelCatalogSourceLabel, + matchesModelCatalogQuery, + normalizeModelCatalogSource, +} from "@/shared/utils/modelCatalogSearch"; +import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +import { + MODEL_COMPAT_PROTOCOL_KEYS, + type ModelCompatProtocolKey, +} from "@/shared/constants/modelCompat"; +import { resolveManagedModelAlias } from "@/shared/utils/providerModelAliases"; +import { maskEmail, pickMaskedDisplayValue, pickDisplayValue } from "@/shared/utils/maskEmail"; +import useEmailPrivacyStore from "@/store/emailPrivacyStore"; +import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle"; +import ProviderIcon from "@/shared/components/ProviderIcon"; +import { + getClaudeCodeCompatibleRequestDefaults as _getClaudeCodeCompatibleRequestDefaults, + getCodexRequestDefaults as _getCodexRequestDefaults, + type CodexServiceTier, +} from "@/lib/providers/requestDefaults"; +import { + CODEX_FAST_TIER_DEFAULT_SUPPORTED_MODELS, + getCodexEffectiveServiceTier, + getCodexGlobalServiceMode, + resolveCodexGlobalFastServiceTier, + type CodexGlobalServiceMode, +} from "@/lib/providers/codexFastTier"; +import { isClaudeExtraUsageBlockEnabled } from "@/lib/providers/claudeExtraUsage"; +import { parseExtraApiKeys } from "@/shared/utils/parseApiKeys"; +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"; + +type CompatByProtocolMap = Partial< + Record< + ModelCompatProtocolKey, + { + normalizeToolCallId?: boolean; + preserveOpenAIDeveloperRole?: boolean; + upstreamHeaders?: Record; + } + > +>; + +/** PATCH fields for provider model compat (matches API + `ModelCompatPerProtocol` shape). */ +type ModelCompatSavePatch = { + normalizeToolCallId?: boolean; + preserveOpenAIDeveloperRole?: boolean; + upstreamHeaders?: Record; + compatByProtocol?: CompatByProtocolMap; + isHidden?: boolean; +}; + +type CompatModelRow = { + id?: string; + name?: string; + source?: string; + apiFormat?: string; + supportedEndpoints?: string[]; + normalizeToolCallId?: boolean; + preserveOpenAIDeveloperRole?: boolean; + isHidden?: boolean; + upstreamHeaders?: Record; + compatByProtocol?: CompatByProtocolMap; +}; + +type CompatModelMap = Map; +type LocalProviderMetadata = { + name?: string; + localDefault?: string; + [key: string]: unknown; +}; + +function buildCompatMap(rows: CompatModelRow[]): CompatModelMap { + const m = new Map(); + for (const r of rows) if (r.id) m.set(r.id, r); + return m; +} + +function getProtoSlice( + c: CompatModelRow | undefined, + o: CompatModelRow | undefined, + protocol: string +) { + return c?.compatByProtocol?.[protocol] ?? o?.compatByProtocol?.[protocol]; +} + +function isModelHidden( + modelId: string, + customMap: CompatModelMap, + overrideMap: CompatModelMap +): boolean { + const c = customMap.get(modelId); + if (c && Object.prototype.hasOwnProperty.call(c, "isHidden")) { + return Boolean(c.isHidden); + } + const o = overrideMap.get(modelId); + if (o && Object.prototype.hasOwnProperty.call(o, "isHidden")) { + return Boolean(o.isHidden); + } + return false; +} + +type ProviderMessageTranslator = ((key: string, values?: Record) => string) & { + has?: (key: string) => boolean; +}; + +function providerText( + t: ProviderMessageTranslator, + key: string, + fallback: string, + values?: Record +): string { + if (typeof t.has === "function" && t.has(key)) { + return t(key, values); + } + if (values) { + return Object.entries(values).reduce( + (acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)), + fallback + ); + } + return fallback; +} + +function providerCountText( + t: ProviderMessageTranslator, + key: string, + count: number, + singularFallback: string, + pluralFallback: string +): string { + return providerText(t, key, count === 1 ? singularFallback : pluralFallback, { count }); +} + +function readBooleanToggle(value: unknown, fallback: boolean): boolean { + if (typeof value === "boolean") return value; + if (typeof value === "number") return value === 1; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (normalized === "1" || normalized === "true") return true; + if (normalized === "0" || normalized === "false") return false; + } + return fallback; +} + +function getWebSessionCredentialLabel( + t: ProviderMessageTranslator, + requirement: WebSessionCredentialRequirement, + optional: boolean +): string { + if (requirement.kind === "none") { + return providerText(t, "webNoAuthCredentialLabel", "No credential required"); + } + const baseLabel = + requirement.kind === "token" + ? providerText(t, "webTokenCredentialLabel", "Web session token") + : t("sessionCookieLabel"); + return optional ? `${baseLabel} (${t("optional").toLowerCase()})` : baseLabel; +} + +function getWebSessionCredentialHint( + t: ProviderMessageTranslator, + requirement: WebSessionCredentialRequirement, + providerName: string, + editing: boolean +): string | undefined { + if (requirement.kind === "none") return undefined; + + const values = { provider: providerName, credential: requirement.credentialName }; + if (editing) { + return requirement.kind === "token" + ? providerText( + t, + "webTokenEditHint", + "Leave blank to keep the current web session token. Credential: {credential}.", + values + ) + : providerText( + t, + "webCookieEditHint", + "Leave blank to keep the current session cookie. Required cookie: {credential}.", + values + ); + } + + return requirement.kind === "token" + ? providerText( + t, + "webTokenCredentialHint", + "Credential: {credential}. Paste the token value from your own signed-in {provider} web session, or a DevTools HAR export if the provider supports it.", + values + ) + : providerText( + t, + "webCookieCredentialHint", + "Required cookie: {credential}. Paste the Cookie header value from your own signed-in {provider} web session. Do not include the Cookie: prefix.", + values + ); +} + +function getWebSessionCredentialCheckLabel( + t: ProviderMessageTranslator, + requirement: WebSessionCredentialRequirement +): string { + if (requirement.kind === "token") return providerText(t, "checkWebToken", "Check token"); + return providerText(t, "checkCookie", "Check cookie"); +} + +function getAddCredentialModalTitle( + t: ProviderMessageTranslator, + providerName: string, + requirement: WebSessionCredentialRequirement | null +): string { + if (!requirement) return t("addProviderApiKeyTitle", { provider: providerName }); + if (requirement.kind === "none") { + return providerText(t, "addProviderConnectionTitle", "Add {provider} connection", { + provider: providerName, + }); + } + if (requirement.kind === "token") { + return providerText(t, "addProviderWebTokenTitle", "Add {provider} web token", { + provider: providerName, + }); + } + return providerText(t, "addProviderSessionCookieTitle", "Add {provider} session cookie", { + provider: providerName, + }); +} + +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, + customMap: CompatModelMap, + overrideMap: CompatModelMap +): boolean { + const c = customMap.get(modelId); + const o = overrideMap.get(modelId); + const pc = getProtoSlice(c, o, protocol); + if (pc && Object.prototype.hasOwnProperty.call(pc, "normalizeToolCallId")) { + return Boolean(pc.normalizeToolCallId); + } + if (c?.normalizeToolCallId) return true; + return Boolean(o?.normalizeToolCallId); +} + +function effectivePreserveForProtocol( + modelId: string, + protocol: string, + customMap: CompatModelMap, + overrideMap: CompatModelMap +): boolean { + const c = customMap.get(modelId); + const o = overrideMap.get(modelId); + const pc = getProtoSlice(c, o, protocol); + if (pc && Object.prototype.hasOwnProperty.call(pc, "preserveOpenAIDeveloperRole")) { + return Boolean(pc.preserveOpenAIDeveloperRole); + } + if (c && Object.prototype.hasOwnProperty.call(c, "preserveOpenAIDeveloperRole")) { + return Boolean(c.preserveOpenAIDeveloperRole); + } + if (o && Object.prototype.hasOwnProperty.call(o, "preserveOpenAIDeveloperRole")) { + return Boolean(o.preserveOpenAIDeveloperRole); + } + return true; +} + +function anyNormalizeCompatBadge( + modelId: string, + customMap: CompatModelMap, + overrideMap: CompatModelMap +): boolean { + const c = customMap.get(modelId); + const o = overrideMap.get(modelId); + if (c?.normalizeToolCallId || o?.normalizeToolCallId) return true; + for (const p of MODEL_COMPAT_PROTOCOL_KEYS) { + const pc = getProtoSlice(c, o, p); + if (pc?.normalizeToolCallId) return true; + } + return false; +} + +function anyNoPreserveCompatBadge( + modelId: string, + customMap: CompatModelMap, + overrideMap: CompatModelMap +): boolean { + const c = customMap.get(modelId); + const o = overrideMap.get(modelId); + if ( + c && + Object.prototype.hasOwnProperty.call(c, "preserveOpenAIDeveloperRole") && + c.preserveOpenAIDeveloperRole === false + ) { + return true; + } + if ( + o && + Object.prototype.hasOwnProperty.call(o, "preserveOpenAIDeveloperRole") && + o.preserveOpenAIDeveloperRole === false + ) { + return true; + } + for (const p of MODEL_COMPAT_PROTOCOL_KEYS) { + const pc = getProtoSlice(c, o, p); + if ( + pc && + Object.prototype.hasOwnProperty.call(pc, "preserveOpenAIDeveloperRole") && + pc.preserveOpenAIDeveloperRole === false + ) { + return true; + } + } + return false; +} + +function upstreamHeadersRecordsEqual( + a: Record, + b: Record +): boolean { + const ka = Object.keys(a).sort(); + const kb = Object.keys(b).sort(); + if (ka.length !== kb.length) return false; + return ka.every((k, i) => k === kb[i] && a[k] === b[k]); +} + +type HeaderDraftRow = { id: string; name: string; value: string }; + +const UPSTREAM_HEADERS_UI_MAX = 16; + +function recordToHeaderRows(rec: Record, genId: () => string): HeaderDraftRow[] { + const entries = Object.entries(rec).filter(([k]) => k.trim()); + if (entries.length === 0) return [{ id: genId(), name: "", value: "" }]; + return entries.map(([name, value]) => ({ id: genId(), name, value })); +} + +function headerRowsToRecord(rows: HeaderDraftRow[]): Record { + const out: Record = {}; + for (const r of rows) { + const k = r.name.trim(); + if (!k) continue; + out[k] = r.value; + } + return out; +} + +type ProviderModelsApiErrorBody = { + error?: { + message?: string; + details?: Array<{ field?: string; message?: string }>; + }; +}; + +async function formatProviderModelsErrorResponse(res: Response): Promise { + try { + const data = (await res.json()) as ProviderModelsApiErrorBody; + const err = data?.error; + if (Array.isArray(err?.details) && err.details.length > 0) { + return err.details + .map((d) => { + const f = typeof d.field === "string" && d.field ? d.field : "?"; + const m = typeof d.message === "string" ? d.message : ""; + return m ? `${f}: ${m}` : f; + }) + .join("; "); + } + if (typeof err?.message === "string" && err.message.trim()) { + return err.message.trim(); + } + } catch { + /* ignore */ + } + const st = res.statusText?.trim(); + return st || `HTTP ${res.status}`; +} + +function effectiveUpstreamHeadersForProtocol( + modelId: string, + protocol: string, + customMap: CompatModelMap, + overrideMap: CompatModelMap +): Record { + const c = customMap.get(modelId); + const o = overrideMap.get(modelId); + const base: Record = {}; + if (c?.upstreamHeaders && typeof c.upstreamHeaders === "object") { + Object.assign(base, c.upstreamHeaders); + } else if (o?.upstreamHeaders && typeof o.upstreamHeaders === "object") { + Object.assign(base, o.upstreamHeaders); + } + const pc = getProtoSlice(c, o, protocol); + if (pc?.upstreamHeaders && typeof pc.upstreamHeaders === "object") { + Object.assign(base, pc.upstreamHeaders); + } + return base; +} + +function anyUpstreamHeadersBadge( + modelId: string, + customMap: CompatModelMap, + overrideMap: CompatModelMap +): boolean { + const c = customMap.get(modelId); + const o = overrideMap.get(modelId); + const nonempty = (u: unknown) => + u && typeof u === "object" && !Array.isArray(u) && Object.keys(u as object).length > 0; + if (nonempty(c?.upstreamHeaders) || nonempty(o?.upstreamHeaders)) return true; + for (const p of MODEL_COMPAT_PROTOCOL_KEYS) { + const pc = getProtoSlice(c, o, p); + if (nonempty(pc?.upstreamHeaders)) return true; + } + return false; +} + +interface ModelRowProps { + model: { id: string; name?: string; source?: string; isHidden?: boolean }; + fullModel: string; + provider: string; + copied?: string; + onCopy: (text: string, key: string) => void; + t: (key: string, values?: Record) => string; + showDeveloperToggle?: boolean; + effectiveModelNormalize: (modelId: string, protocol?: string) => boolean; + effectiveModelPreserveDeveloper: (modelId: string, protocol?: string) => boolean; + saveModelCompatFlags: (modelId: string, patch: ModelCompatSavePatch) => void; + getUpstreamHeadersRecord: (protocol: string) => Record; + compatDisabled?: boolean; + onToggleHidden?: (modelId: string, hidden: boolean) => Promise; + togglingHidden?: boolean; + onTestModel?: (modelId: string, fullModel: string) => Promise; + testStatus?: "ok" | "error" | null; + testingModel?: boolean; +} + +interface PassthroughModelRowProps { + modelId: string; + fullModel: string; + source?: string; + isFree?: boolean; + isHidden?: boolean; + copied?: string; + onCopy: (text: string, key: string) => void; + onDeleteAlias?: () => void; + t: (key: string, values?: Record) => string; + showDeveloperToggle?: boolean; + effectiveModelNormalize: (modelId: string, protocol?: string) => boolean; + effectiveModelPreserveDeveloper: (modelId: string, protocol?: string) => boolean; + saveModelCompatFlags: (modelId: string, patch: ModelCompatSavePatch) => void; + getUpstreamHeadersRecord: (protocol: string) => Record; + compatDisabled?: boolean; + onToggleHidden?: (modelId: string, hidden: boolean) => Promise; + togglingHidden?: boolean; + onTestModel?: (modelId: string, fullModel: string) => Promise; + testStatus?: "ok" | "error" | null; + testingModel?: boolean; +} + +interface PassthroughModelsSectionProps { + providerAlias: string; + modelAliases: Record; + availableModels?: CompatModelRow[]; + customModels?: CompatModelRow[]; + description: string; + inputLabel: string; + inputPlaceholder: string; + copied?: string; + onCopy: (text: string, key: string) => void; + onSetAlias: (modelId: string, alias: string) => Promise; + onDeleteAlias: (alias: string) => void; + t: (key: string, values?: Record) => string; + effectiveModelNormalize: (alias: string) => boolean; + effectiveModelPreserveDeveloper: (alias: string) => boolean; + getUpstreamHeadersRecord: (modelId: string, protocol: string) => Record; + saveModelCompatFlags: ( + modelId: string, + flags: { + normalizeToolCallId?: boolean; + preserveDeveloperRole?: boolean; + preserveOpenAIDeveloperRole?: boolean; + } + ) => Promise; + compatSavingModelId?: string; + isModelHidden: (modelId: string) => boolean; + onToggleHidden: (modelId: string, hidden: boolean) => Promise; + onBulkToggleHidden: (modelIds: string[], hidden: boolean) => Promise; + bulkTogglePending?: boolean; + togglingModelId?: string | null; + onTestModel?: (modelId: string, fullModel: string) => Promise; + modelTestStatus?: Record; + testingModelId?: string | null; + providerId: string; + connectionId: string; +} + +interface CustomModelsSectionProps { + providerId: string; + providerAlias: string; + copied?: string; + onCopy: (text: string, key: string) => void; + onModelsChanged?: () => void; +} + +interface CompatibleModelsSectionProps { + providerStorageAlias: string; + providerDisplayAlias: string; + modelAliases: Record; + availableModels?: CompatModelRow[]; + customModels?: CompatModelRow[]; + fallbackModels?: CompatModelRow[]; + allowImport: boolean; + description: string; + inputLabel: string; + inputPlaceholder: string; + copied?: string; + onCopy: (text: string, key: string) => void; + onSetAlias: (modelId: string, alias: string, providerStorageAlias?: string) => Promise; + onDeleteAlias: (alias: string) => void; + connections: { id?: string; isActive?: boolean }[]; + isAnthropic?: boolean; + onImportWithProgress: (connectionId: string) => Promise; + t: (key: string, values?: Record) => string; + effectiveModelNormalize: (alias: string) => boolean; + effectiveModelPreserveDeveloper: (alias: string) => boolean; + getUpstreamHeadersRecord: (modelId: string, protocol: string) => Record; + saveModelCompatFlags: ( + modelId: string, + flags: { + normalizeToolCallId?: boolean; + preserveDeveloperRole?: boolean; + preserveOpenAIDeveloperRole?: boolean; + isHidden?: boolean; + } + ) => Promise; + compatSavingModelId?: string; + onModelsChanged?: () => void; + isModelHidden: (modelId: string) => boolean; + onToggleHidden: (modelId: string, hidden: boolean) => Promise; + onBulkToggleHidden: (modelIds: string[], hidden: boolean) => Promise; + bulkTogglePending?: boolean; + togglingModelId?: string | null; + onTestModel?: (modelId: string, fullModel: string) => Promise; + modelTestStatus?: Record; + testingModelId?: string | null; + onTestAll?: (targets: Array<{ modelId: string; fullModel: string }>) => Promise; + testingAll?: boolean; + testProgress?: { done: number; total: number } | null; + autoHideFailed?: boolean; + onAutoHideFailedChange?: (v: boolean) => void; +} + +interface CooldownTimerProps { + until: string | number | Date; +} + +function getModelSourceBadgeClass(source?: string): string { + switch (normalizeModelCatalogSource(source)) { + case "imported": + return "border-sky-500/30 bg-sky-500/10 text-sky-300"; + case "custom": + return "border-emerald-500/30 bg-emerald-500/10 text-emerald-300"; + case "fallback": + return "border-amber-500/30 bg-amber-500/10 text-amber-300"; + case "alias": + return "border-violet-500/30 bg-violet-500/10 text-violet-300"; + case "system": + default: + return "border-border bg-sidebar/70 text-text-muted"; + } +} + +function ModelSourceBadge({ source }: { source?: string }) { + return ( + + {getModelCatalogSourceLabel(source)} + + ); +} + +interface ConnectionRowConnection { + id?: string; + name?: string; + email?: string; + displayName?: string; + rateLimitedUntil?: string; + rateLimitProtection?: boolean; + testStatus?: string; + isActive?: boolean; + priority?: number; + lastError?: string; + lastErrorType?: string; + lastErrorSource?: string; + errorCode?: string | number; + globalPriority?: number; + providerSpecificData?: Record; + expiresAt?: string; + tokenExpiresAt?: string; + maxConcurrent?: number | null; + authType?: string; + proxyEnabled?: boolean; + perKeyProxyEnabled?: boolean; +} + +interface ConnectionRowProps { + connection: ConnectionRowConnection; + isOAuth: boolean; + isClaude?: boolean; + isCodex?: boolean; + isGeminiCli?: boolean; + codexGlobalServiceMode?: CodexGlobalServiceMode; + isFirst: boolean; + isLast: boolean; + isSelected?: boolean; + onToggleSelect?: () => void; + onMoveUp: () => void; + onMoveDown: () => void; + onToggleActive: (isActive?: boolean) => void | Promise; + onToggleRateLimit: (enabled?: boolean) => void; + onToggleClaudeExtraUsage?: (enabled?: boolean) => void; + onToggleCodex5h?: (enabled?: boolean) => void; + onToggleCodexWeekly?: (enabled?: boolean) => void; + isCcCompatible?: boolean; + cliproxyapiEnabled?: boolean; + onToggleCliproxyapiMode?: (enabled?: boolean) => void; + onRetest: () => void; + isRetesting?: boolean; + onEdit: () => void; + onDelete: () => void; + onReauth?: () => void; + onProxy?: () => void; + hasProxy?: boolean; + proxySource?: string; + proxyHost?: string; + proxyEnabled?: boolean; + perKeyProxyEnabled?: boolean; + onToggleProxyEnabled?: (enabled: boolean) => void; + onTogglePerKeyProxyEnabled?: (enabled: boolean) => void; + onRefreshToken?: () => void; + isRefreshing?: boolean; + onApplyCodexAuthLocal?: () => void; + isApplyingCodexAuthLocal?: boolean; + onExportCodexAuthFile?: () => void; + isExportingCodexAuthFile?: boolean; + onApplyClaudeAuthLocal?: () => void; + isApplyingClaudeAuthLocal?: boolean; + onExportClaudeAuthFile?: () => void; + isExportingClaudeAuthFile?: boolean; + onApplyGeminiAuthLocal?: () => void; + isApplyingGeminiAuthLocal?: boolean; + onExportGeminiAuthFile?: () => void; + 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; +} + +type CommandCodeAuthFlowState = { + phase: + | "idle" + | "starting" + | "polling" + | "received" + | "applying" + | "applied" + | "expired" + | "error"; + state: string; + authUrl: string; + callbackUrl: string; + expiresAt: string | null; + message?: string; +}; + +const SILICONFLOW_ENDPOINTS = [ + { id: "siliconflow", label: "Global", baseUrl: "https://api.siliconflow.com/v1" }, + { id: "siliconflow-cn", label: "China", baseUrl: "https://api.siliconflow.cn/v1" }, +] as const; + +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; +} + +interface EditCompatibleNodeModalNode { + id?: string; + name?: string; + prefix?: string; + apiType?: string; + baseUrl?: string; + chatPath?: string; + modelsPath?: string; +} + +interface EditCompatibleNodeModalProps { + isOpen: boolean; + node: EditCompatibleNodeModalNode | null; + onSave: (data: unknown) => Promise; + onClose: () => void; + isAnthropic?: boolean; + isCcCompatible?: boolean; +} + +const CC_COMPATIBLE_DEFAULT_CHAT_PATH = "/v1/messages?beta=true"; +const CODEX_REASONING_STRENGTH_OPTIONS = [ + { value: "none", label: "None" }, + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, + { value: "xhigh", label: "XHigh" }, +]; + +const CODEX_ACCOUNT_SERVICE_TIER_VALUES: CodexServiceTier[] = ["default", "priority", "flex"]; +const CODEX_GLOBAL_SERVICE_MODE_VALUES: CodexGlobalServiceMode[] = [ + "none", + ...CODEX_ACCOUNT_SERVICE_TIER_VALUES, +]; + +function getCodexServiceTierLabel( + t: ProviderMessageTranslator, + value: CodexGlobalServiceMode +): string { + if (value === "none") { + return providerText(t, "codexServiceModeNone", "No global setting"); + } + if (value === "default") return providerText(t, "codexServiceTierDefault", "Default"); + if (value === "priority") return providerText(t, "codexServiceTierPriority", "Priority"); + return providerText(t, "codexServiceTierFlex", "Flex"); +} + +function normalizeCodexLimitPolicy(policy: unknown): { use5h: boolean; useWeekly: boolean } { + const record = + policy && typeof policy === "object" && !Array.isArray(policy) + ? (policy as Record) + : {}; + return { + use5h: typeof record.use5h === "boolean" ? record.use5h : true, + useWeekly: typeof record.useWeekly === "boolean" ? record.useWeekly : true, + }; +} + +/** + * UI adapter around the canonical getCodexRequestDefaults from requestDefaults.ts. + * Adds the "medium" fallback for reasoningEffort required by the connection form. + */ +function getCodexRequestDefaults(providerSpecificData: unknown): { + reasoningEffort: string; + serviceTier?: CodexServiceTier; +} { + const defaults = _getCodexRequestDefaults(providerSpecificData); + return { + reasoningEffort: defaults.reasoningEffort ?? "medium", + ...(defaults.serviceTier ? { serviceTier: defaults.serviceTier } : {}), + }; +} + +function getClaudeCodeCompatibleRequestDefaults(providerSpecificData: unknown): { + context1m: boolean; +} { + const defaults = _getClaudeCodeCompatibleRequestDefaults(providerSpecificData); + return { + context1m: defaults.context1m === true, + }; +} + +function compatProtocolLabelKey(protocol: string): string { + if (protocol === "openai") return "compatProtocolOpenAI"; + if (protocol === "openai-responses") return "compatProtocolOpenAIResponses"; + if (protocol === "claude") return "compatProtocolClaude"; + return "compatProtocolOpenAI"; +} + +function ModelCompatPopover({ + t, + effectiveModelNormalize, + effectiveModelPreserveDeveloper, + getUpstreamHeadersRecord, + onCompatPatch, + showDeveloperToggle = true, + compact = false, + disabled, +}: { + t: (key: string) => string; + effectiveModelNormalize: (protocol: string) => boolean; + effectiveModelPreserveDeveloper: (protocol: string) => boolean; + getUpstreamHeadersRecord: (protocol: string) => Record; + onCompatPatch: ( + protocol: string, + payload: { + normalizeToolCallId?: boolean; + preserveOpenAIDeveloperRole?: boolean; + upstreamHeaders?: Record; + } + ) => void; + showDeveloperToggle?: boolean; + compact?: boolean; + disabled?: boolean; +}) { + const [open, setOpen] = useState(false); + const [protocol, setProtocol] = useState(MODEL_COMPAT_PROTOCOL_KEYS[0]); + const [headerRows, setHeaderRows] = useState([]); + const [valuePeekRowId, setValuePeekRowId] = useState(null); + const [valueFocusRowId, setValueFocusRowId] = useState(null); + const ref = useRef(null); + const panelRef = useRef(null); + const [portalPanelRect, setPortalPanelRect] = useState<{ + top?: number; + bottom?: number; + left: number; + width: number; + } | null>(null); + const headerRowIdRef = useRef(0); + const headerRowsRef = useRef([]); + headerRowsRef.current = headerRows; + + const genHeaderRowId = () => { + headerRowIdRef.current += 1; + return `uh-${headerRowIdRef.current}`; + }; + + const normalizeToolCallId = effectiveModelNormalize(protocol); + const preserveDeveloperRole = effectiveModelPreserveDeveloper(protocol); + const devToggle = showDeveloperToggle && protocol !== "claude"; + + const tryCommitHeaderRows = useCallback( + (rows: HeaderDraftRow[]) => { + const parsed = headerRowsToRecord(rows); + const current = getUpstreamHeadersRecord(protocol); + if (upstreamHeadersRecordsEqual(parsed, current)) return; + onCompatPatch(protocol, { upstreamHeaders: parsed }); + }, + [getUpstreamHeadersRecord, onCompatPatch, protocol] + ); + + const onHeaderFieldBlur = useCallback(() => { + queueMicrotask(() => tryCommitHeaderRows(headerRowsRef.current)); + }, [tryCommitHeaderRows]); + + useEffect(() => { + if (!open) return; + return () => { + tryCommitHeaderRows(headerRowsRef.current); + }; + }, [open, tryCommitHeaderRows]); + + useEffect(() => { + if (!open) return; + const rec = getUpstreamHeadersRecord(protocol); + setHeaderRows(recordToHeaderRows(rec, genHeaderRowId)); + // Only re-load rows when opening or switching protocol — not when the parent passes a new + // inline callback every render (would wipe in-progress edits). + // eslint-disable-next-line react-hooks/exhaustive-deps -- see above + }, [open, protocol]); + + useEffect(() => { + setValuePeekRowId(null); + setValueFocusRowId(null); + }, [open, protocol]); + + const namedHeaderCount = headerRows.filter((r) => r.name.trim()).length; + const canAddHeaderRow = namedHeaderCount < UPSTREAM_HEADERS_UI_MAX; + + const updateHeaderRow = (id: string, patch: Partial>) => { + setHeaderRows((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r))); + }; + + const addHeaderRow = () => { + if (!canAddHeaderRow) return; + setHeaderRows((prev) => [...prev, { id: genHeaderRowId(), name: "", value: "" }]); + }; + + const removeHeaderRow = (id: string) => { + setHeaderRows((prev) => { + const next = prev.filter((r) => r.id !== id); + const normalized = next.length === 0 ? [{ id: genHeaderRowId(), name: "", value: "" }] : next; + queueMicrotask(() => tryCommitHeaderRows(normalized)); + return normalized; + }); + }; + + useEffect(() => { + if (!open) return; + const onDocClick = (e: MouseEvent) => { + const target = e.target as Node; + const insideTrigger = ref.current?.contains(target); + const insidePanel = panelRef.current?.contains(target); + if (!insideTrigger && !insidePanel) setOpen(false); + }; + document.addEventListener("mousedown", onDocClick); + return () => document.removeEventListener("mousedown", onDocClick); + }, [open]); + + const updatePortalPanelRect = useCallback(() => { + if (!open || !ref.current) return; + const rect = ref.current.getBoundingClientRect(); + const margin = 10; + const width = Math.min(window.innerWidth - 2 * margin, 24 * 16); + let left = rect.right - width; + left = Math.max(margin, Math.min(left, window.innerWidth - width - margin)); + // Estimated panel height: capped at min(82vh, 42rem=672px) + const estimatedPanelHeight = Math.min(window.innerHeight * 0.82, 672); + const spaceBelow = window.innerHeight - rect.bottom - margin; + const spaceAbove = rect.top - margin; + if (spaceBelow < estimatedPanelHeight && spaceAbove > spaceBelow) { + // Not enough space below — open upward + setPortalPanelRect({ bottom: window.innerHeight - rect.top + 8, left, width }); + } else { + setPortalPanelRect({ top: rect.bottom + 8, left, width }); + } + }, [open]); + + useLayoutEffect(() => { + if (!open) { + setPortalPanelRect(null); + return; + } + updatePortalPanelRect(); + window.addEventListener("resize", updatePortalPanelRect); + window.addEventListener("scroll", updatePortalPanelRect, true); + return () => { + window.removeEventListener("resize", updatePortalPanelRect); + window.removeEventListener("scroll", updatePortalPanelRect, true); + }; + }, [open, updatePortalPanelRect]); + + const panelChromeClass = + "flex max-h-[min(82vh,42rem)] flex-col overflow-hidden rounded-xl border-2 border-zinc-200 bg-white shadow-2xl dark:border-zinc-600 dark:bg-zinc-950"; + + return ( +
+ + {open && + typeof document !== "undefined" && + portalPanelRect && + createPortal( +
+
+

{t("compatAdjustmentsTitle")}

+

+ {t("compatProtocolHint")} +

+
+
+ + +
+ onCompatPatch(protocol, { normalizeToolCallId: v })} + disabled={disabled} + /> + {devToggle && ( + + onCompatPatch(protocol, { preserveOpenAIDeveloperRole: !checked }) + } + disabled={disabled} + /> + )} +
+ +
+ +

+ {t("compatUpstreamHeadersHint")} +

+
+
+ {t("compatUpstreamHeaderName")} + {t("compatUpstreamHeaderValue")} + +
+ {headerRows.map((row) => ( +
+ updateHeaderRow(row.id, { name: e.target.value })} + onBlur={onHeaderFieldBlur} + disabled={disabled} + placeholder={t("compatUpstreamHeaderNamePlaceholder")} + className="gap-0 min-w-0" + inputClassName="h-9 bg-white py-1.5 px-2 text-xs font-mono dark:bg-zinc-900" + autoComplete="off" + /> +
setValuePeekRowId(row.id)} + onMouseLeave={() => + setValuePeekRowId((cur) => (cur === row.id ? null : cur)) + } + > + updateHeaderRow(row.id, { value: e.target.value })} + onFocus={() => setValueFocusRowId(row.id)} + onBlur={() => { + setValueFocusRowId((cur) => (cur === row.id ? null : cur)); + onHeaderFieldBlur(); + }} + disabled={disabled} + placeholder={t("compatUpstreamHeaderValuePlaceholder")} + className="gap-0 min-w-0" + inputClassName="h-9 bg-white py-1.5 px-2 text-xs dark:bg-zinc-900" + autoComplete="off" + spellCheck={false} + /> +
+ +
+ ))} +
+ +
+
+
, + document.body + )} +
+ ); +} + +// ──── ProviderPlaygroundPanel ──────────────────────────────────────────────── +// Renders a playground section on the individual provider page. +// Shows ServiceKindTabs if the provider declares multiple kinds; falls back to +// a single-kind panel or the LlmChatCard for standard LLM providers. + +const MEDIA_SERVICE_KINDS: ServiceKind[] = [ + "embedding", + "image", + "tts", + "stt", + "webSearch", + "webFetch", + "video", + "music", +]; + +function renderKindPanel(kind: ServiceKind, providerId: string): JSX.Element | null { + switch (kind) { + case "llm": + return ; + case "embedding": + return ; + case "image": + return ; + case "tts": + return ; + case "stt": + return ; + case "webSearch": + return ; + case "webFetch": + return ; + case "video": + return ; + case "music": + return ; + default: + return null; + } +} + +function ProviderPlaygroundPanel({ providerId }: { providerId: string }) { + // Resolve serviceKinds from AI_PROVIDERS. + // For providers without explicit serviceKinds (most LLM providers), we infer + // "llm" as the default. + const providerEntry = AI_PROVIDERS[providerId as keyof typeof AI_PROVIDERS] as + | (Record & { serviceKinds?: string[] }) + | undefined; + + const rawKinds: string[] = providerEntry?.serviceKinds ?? []; + + const ALL_VALID_KINDS = [ + "llm", + "embedding", + "image", + "imageToText", + "tts", + "stt", + "webSearch", + "webFetch", + "video", + "music", + ] as const; + + const kinds: ServiceKind[] = + rawKinds.length > 0 + ? rawKinds.filter((k): k is ServiceKind => (ALL_VALID_KINDS as readonly string[]).includes(k)) + : ["llm"]; + + // Filter out kinds that have no playground implementation yet + const playgroundableKinds = kinds.filter((k) => k !== "imageToText"); + + // useState must be called unconditionally (Rules of Hooks) + const [activeKind, setActiveKind] = useState(playgroundableKinds[0] ?? "llm"); + + if (playgroundableKinds.length === 0) return null; + + return ( +
+

Playground

+ + {renderKindPanel(activeKind, providerId)} +
+ ); +} + +export default function ProviderDetailPageClient() { + const params = useParams(); + const router = useRouter(); + const providerId = params.id as string; + const [connections, setConnections] = useState([]); + const [loading, setLoading] = useState(true); + const [providerNode, setProviderNode] = useState(null); + const [showOAuthModal, _setShowOAuthModal] = useState(false); + const [reauthConnection, setReauthConnection] = useState(null); + const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false); + const [showSiliconFlowEndpointModal, setShowSiliconFlowEndpointModal] = useState(false); + const [siliconFlowInitialBaseUrl, setSiliconFlowInitialBaseUrl] = useState(); + const [showRiskNoticeModal, setShowRiskNoticeModal] = useState(false); + const [commandCodeAuthState, setCommandCodeAuthState] = useState({ + phase: "idle", + state: "", + authUrl: "", + callbackUrl: "", + expiresAt: null, + message: "", + }); + const [showEditModal, setShowEditModal] = useState(false); + const [showEditNodeModal, setShowEditNodeModal] = useState(false); + const [showTutorialModal, setShowTutorialModal] = useState(false); + const [selectedConnection, setSelectedConnection] = useState(null); + const [retestingId, setRetestingId] = useState(null); + const [batchTesting, setBatchTesting] = useState(false); + const [batchTestResults, setBatchTestResults] = useState(null); + const [modelAliases, setModelAliases] = useState({}); + const { copied, copy } = useCopyToClipboard(); + const t = useTranslations("providers"); + const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible); + const notify = useNotificationStore(); + const [proxyTarget, setProxyTarget] = useState(null); + const [distributingProxies, setDistributingProxies] = useState(false); + const [proxyConfig, setProxyConfig] = useState(null); + const [connProxyMap, setConnProxyMap] = useState< + Record + >({}); + const [importingModels, setImportingModels] = useState(false); + const [importingZed, setImportingZed] = useState(false); + const [showZedManual, setShowZedManual] = useState(false); + const [zedManualProvider, setZedManualProvider] = useState("openai"); + const [zedManualToken, setZedManualToken] = useState(""); + const [importingZedManual, setImportingZedManual] = useState(false); + const [showImportModal, setShowImportModal] = useState(false); + const [importProgress, setImportProgress] = useState({ + current: 0, + total: 0, + phase: "idle" as "idle" | "fetching" | "importing" | "done" | "error", + status: "", + logs: [] as string[], + error: "", + importedCount: 0, + }); + const [modelMeta, setModelMeta] = useState<{ + customModels: CompatModelRow[]; + modelCompatOverrides: Array; + }>({ customModels: [], modelCompatOverrides: [] }); + const [syncedAvailableModels, setSyncedAvailableModels] = useState([]); + const [compatSavingModelId, setCompatSavingModelId] = useState(null); + const [modelFilter, setModelFilter] = useState(""); + const [togglingModelId, setTogglingModelId] = useState(null); + const [testingModelId, setTestingModelId] = useState(null); + const [modelTestStatus, setModelTestStatus] = useState>({}); + const [testingAll, setTestingAll] = useState(false); + const [testProgress, setTestProgress] = useState<{ done: number; total: number } | null>(null); + const [autoHideFailed, setAutoHideFailed] = useState(true); + const [visibilityFilter, setVisibilityFilter] = useState<"all" | "visible" | "hidden">("all"); + const [bulkVisibilityAction, setBulkVisibilityAction] = useState<"select" | "deselect" | null>( + null + ); + const [applyingCodexAuthId, setApplyingCodexAuthId] = useState(null); + const [applyCodexModalConnectionId, setApplyCodexModalConnectionId] = useState( + null + ); + const [exportingCodexAuthId, setExportingCodexAuthId] = useState(null); + const [importCodexModalOpen, setImportCodexModalOpen] = useState(false); + const [codexCliGuideOpen, setCodexCliGuideOpen] = useState(false); + // "Adicionar Externo": public shareable device-flow link state. + const [externalLinkModalOpen, setExternalLinkModalOpen] = useState(false); + const [externalLinkUrl, setExternalLinkUrl] = useState(""); + const [externalLinkToken, setExternalLinkToken] = useState(null); + const [externalLinkLoading, setExternalLinkLoading] = useState(false); + const [externalLinkError, setExternalLinkError] = useState(null); + const { copied: externalLinkCopied, copy: externalLinkCopy } = useCopyToClipboard(); + const [applyingClaudeAuthId, setApplyingClaudeAuthId] = useState(null); + const [applyClaudeModalConnectionId, setApplyClaudeModalConnectionId] = useState( + null + ); + const [exportingClaudeAuthId, setExportingClaudeAuthId] = useState(null); + const [importClaudeModalOpen, setImportClaudeModalOpen] = useState(false); + const [applyingGeminiAuthId, setApplyingGeminiAuthId] = useState(null); + const [applyGeminiModalConnectionId, setApplyGeminiModalConnectionId] = useState( + null + ); + const [exportingGeminiAuthId, setExportingGeminiAuthId] = useState(null); + const [importGeminiModalOpen, setImportGeminiModalOpen] = useState(false); + const [codexGlobalServiceMode, setCodexGlobalServiceMode] = + useState("none"); + const [codexGlobalSupportedModels, setCodexGlobalSupportedModels] = useState([ + ...CODEX_FAST_TIER_DEFAULT_SUPPORTED_MODELS, + ]); + const [codexSettingsLoaded, setCodexSettingsLoaded] = useState(false); + const [codexSettingsLoadError, setCodexSettingsLoadError] = useState(null); + const [savingCodexGlobalServiceMode, setSavingCodexGlobalServiceMode] = useState(false); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [batchDeleting, setBatchDeleting] = useState(false); + const [batchUpdating, setBatchUpdating] = useState<"activate" | "deactivate" | null>(null); + const [batchRetesting, setBatchRetesting] = useState(false); + const [healthFilter, setHealthFilter] = useState("all"); + const [page, setPage] = useState(0); + const PAGE_SIZE = 50; + const [batchDeleteConfirmOpen, setBatchDeleteConfirmOpen] = useState(false); + const commandCodeAuthWindowRef = useRef(null); + const commandCodeAuthTimerRef = useRef(null); + const pendingRiskActionRef = useRef<(() => void) | null>(null); + const { acknowledged: riskAcknowledged, acknowledge: acknowledgeRisk } = + useRiskAcknowledged(providerId); + const codexSettingsRequestSeqRef = useRef(0); + const isOpenAICompatible = isOpenAICompatibleProvider(providerId); + const isCcCompatible = isClaudeCodeCompatibleProvider(providerId); + const isCommandCode = providerId === "command-code"; + const isAnthropicCompatible = + isAnthropicCompatibleProvider(providerId) && !isClaudeCodeCompatibleProvider(providerId); + const isCompatible = isOpenAICompatible || isAnthropicCompatible || isCcCompatible; + const isAnthropicProtocolCompatible = isAnthropicCompatible || isCcCompatible; + + const setShowOAuthModal = (show: boolean, connectionRow?: ConnectionRowConnection) => { + _setShowOAuthModal(show); + setReauthConnection(show && connectionRow ? connectionRow : null); + }; + + const codexGlobalServiceModeOptions = useMemo( + () => + CODEX_GLOBAL_SERVICE_MODE_VALUES.map((value) => ({ + value, + label: getCodexServiceTierLabel(t, value), + })), + [t] + ); + + const providerInfo = resolveDashboardProviderInfo(providerId, { + providerNode, + compatibleLabels: { + ccCompatibleName: t("ccCompatibleLabel"), + anthropicCompatibleName: t("anthropicCompatibleName"), + openAiCompatibleName: t("openaiCompatibleName"), + }, + }); + const providerSupportsOAuth = + providerInfo?.toggleAuthType === "oauth" || providerInfo?.toggleAuthType === "free"; + const subscriptionRisk = providerInfo?.subscriptionRisk === true; + const providerSupportsPat = supportsApiKeyOnFreeProvider(providerId); + const isOAuth = providerSupportsOAuth && !providerSupportsPat; + const isFreeNoAuth = NOAUTH_PROVIDERS[providerId]?.noAuth === true; + const registryModels = getModelsByProviderId(providerId); + // Prefer synced API-discovered models when available, then merge built-ins + // and user-managed custom models without duplicating IDs. + const models = useMemo(() => { + // Universal: merge built-in registry models with API-synced models and + // user-managed custom models for ALL providers (was previously Gemini-only). + // Synced models keep their full property spread so provider-specific fields + // (e.g. Gemini's `supportedGenerationMethods`) survive into the table. + const builtInModels = registryModels.map((model) => ({ + ...model, + source: "system", + })); + + const registryIds = new Set(builtInModels.map((m) => m.id)); + const syncedExtras = syncedAvailableModels + .filter((model: any) => model?.id && !registryIds.has(model.id)) + .map((model: any) => ({ + ...model, + id: model.id, + name: model.name || model.id, + source: "imported", + })); + const knownIds = new Set([...registryIds, ...syncedExtras.map((model: any) => model.id)]); + const customExtras = modelMeta.customModels + .filter((cm: any) => cm.id && !knownIds.has(cm.id)) + .map((cm: any) => ({ + id: cm.id, + name: cm.name || cm.id, + source: normalizeModelCatalogSource(cm.source) === "imported" ? "imported" : "custom", + })); + const allModels = [...builtInModels, ...syncedExtras, ...customExtras]; + const deduped = new Map(); + for (const m of allModels) { + if (m.id && !deduped.has(m.id)) deduped.set(m.id, m); + } + return Array.from(deduped.values()); + }, [providerId, registryModels, syncedAvailableModels, modelMeta.customModels]); + const providerAlias = getProviderAlias(providerId); + const isManagedAvailableModelsProvider = isCompatible || providerId === "openrouter"; + const isSearchProvider = providerId.endsWith("-search"); + const isUpstreamProxyProvider = providerInfo?.category === "upstream-proxy"; + const compatibleSupportsModelImport = compatibleProviderSupportsModelImport(providerId); + + const providerStorageAlias = isCompatible ? providerId : providerAlias; + const providerDisplayAlias = isCompatible ? providerNode?.prefix || providerId : providerAlias; + + const getApiLabel = () => { + if (isAnthropicProtocolCompatible) return t("messagesApi"); + const type = providerNode?.apiType; + switch (type) { + case "responses": + return t("responsesApi"); + case "embeddings": + return t("embeddings"); + case "audio-transcriptions": + return t("audioTranscriptions"); + case "audio-speech": + return t("audioSpeech"); + case "images-generations": + return t("imagesGenerations"); + default: + return t("chatCompletions"); + } + }; + + const getApiDefaultPath = () => { + if (isCcCompatible) return CC_COMPATIBLE_DEFAULT_CHAT_PATH; + if (isAnthropicCompatible) return "/messages"; + const type = providerNode?.apiType; + switch (type) { + case "responses": + return "/responses"; + case "embeddings": + return "/embeddings"; + case "audio-transcriptions": + return "/audio/transcriptions"; + case "audio-speech": + return "/audio/speech"; + case "images-generations": + return "/images/generations"; + default: + return "/chat/completions"; + } + }; + + const getApiPath = () => { + const defaultPath = getApiDefaultPath(); + return (providerNode?.chatPath || defaultPath).replace(/^\//, ""); + }; + + // Define callbacks BEFORE the useEffect that uses them + const fetchAliases = useCallback(async () => { + try { + const res = await fetch("/api/models/alias"); + const data = await res.json(); + if (res.ok) { + setModelAliases(data.aliases || {}); + } + } catch (error) { + console.log("Error fetching aliases:", error); + } + }, []); + + const handleSetAlias = useCallback( + async (modelId: string, alias: string, providerAlias?: string) => { + const qualifiedModel = providerAlias + ? modelId.includes("/") + ? `${providerAlias}/${modelId.split("/").slice(1).join("/")}` + : `${providerAlias}/${modelId}` + : modelId; + try { + const res = await fetch("/api/models/alias", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: qualifiedModel, alias }), + }); + if (res.ok) { + await fetchAliases(); + notify.success(t("setAliasSuccess", { alias })); + } else { + const data = await res.json().catch(() => ({})); + notify.error(data?.error?.message || "Failed to set alias"); + } + } catch (error) { + console.log("Error setting alias:", error); + notify.error("Network error setting alias"); + } + }, + [fetchAliases, t] + ); + + const handleDeleteAlias = useCallback( + async (alias: string) => { + try { + const res = await fetch( + `/api/models/alias?alias=${encodeURIComponent(alias)}`, + { method: "DELETE" } + ); + if (res.ok) { + await fetchAliases(); + notify.success(t("deleteAliasSuccess", { alias })); + } else { + const data = await res.json().catch(() => ({})); + notify.error(data?.error?.message || "Failed to delete alias"); + } + } catch (error) { + console.log("Error deleting alias:", error); + notify.error("Network error deleting alias"); + } + }, + [fetchAliases, t] + ); + + const fetchProviderModelMeta = useCallback(async () => { + if (isSearchProvider) return; + try { + const res = await fetch(`/api/provider-models?provider=${encodeURIComponent(providerId)}`, { + cache: "no-store", + }); + if (!res.ok) return; + const data = await res.json(); + setModelMeta({ + customModels: data.models || [], + modelCompatOverrides: data.modelCompatOverrides || [], + }); + try { + const syncRes = await fetch( + `/api/synced-available-models?provider=${encodeURIComponent(providerId)}`, + { + cache: "no-store", + } + ); + if (syncRes.ok) { + const syncData = await syncRes.json(); + setSyncedAvailableModels(syncData.models || []); + } else { + setSyncedAvailableModels([]); + } + } catch { + setSyncedAvailableModels([]); + } + } catch (e) { + console.error("fetchProviderModelMeta", e); + } + }, [providerId, isSearchProvider]); + + const fetchProxyConfig = useCallback(async () => { + try { + const res = await fetch("/api/settings/proxy", { cache: "no-store" }); + if (res.ok) { + setProxyConfig(await res.json()); + } else { + setProxyConfig(null); + } + } catch { + // Proxy indicators are best-effort. + } + }, []); + + const fetchConnections = useCallback(async () => { + try { + const [connectionsRes, nodesRes] = await Promise.all([ + fetch("/api/providers", { cache: "no-store" }), + fetch("/api/provider-nodes", { cache: "no-store" }), + ]); + const connectionsData = await connectionsRes.json(); + const nodesData = await nodesRes.json(); + if (connectionsRes.ok) { + const filtered = (connectionsData.connections || []).filter( + (c) => c.provider === providerId + ); + setConnections(filtered); + } + if (nodesRes.ok) { + let node = (nodesData.nodes || []).find((entry) => entry.id === providerId) || null; + + // Newly created compatible nodes can be briefly unavailable on one worker. + // Retry a few times before showing "Provider not found". + if (!node && isCompatible) { + for (let attempt = 0; attempt < 3; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 150)); + const retryRes = await fetch("/api/provider-nodes", { cache: "no-store" }); + if (!retryRes.ok) continue; + const retryData = await retryRes.json(); + node = (retryData.nodes || []).find((entry) => entry.id === providerId) || null; + if (node) break; + } + } + + setProviderNode(node); + } + } catch (error) { + console.log("Error fetching connections:", error); + } finally { + setLoading(false); + } + }, [providerId, isCompatible]); + + const handleUpdateNode = async (formData) => { + try { + const res = await fetch(`/api/provider-nodes/${providerId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(formData), + }); + const data = await res.json(); + if (res.ok) { + setProviderNode(data.node); + await fetchConnections(); + setShowEditNodeModal(false); + } + } catch (error) { + console.log("Error updating provider node:", error); + } + }; + + useEffect(() => { + fetchConnections(); + fetchAliases(); + // Load proxy config for visual indicators (provider-level button) + void fetchProxyConfig(); + }, [fetchConnections, fetchAliases, fetchProxyConfig]); + + const handleZedImport = useCallback(async () => { + if (importingZed) return; + setImportingZed(true); + try { + const res = await fetch("/api/providers/zed/import", { method: "POST" }); + const data = await res.json(); + if (!res.ok || !data.success) { + if (data.zedDockerEnvironment) { + setShowZedManual(true); + } + notify.error(data.error || "Zed import failed"); + } else if (!data.count) { + const found = data.credentials?.length ?? 0; + if (found === 0) { + notify.info("No Zed credentials found in keychain"); + } else { + notify.info( + `Found ${found} keychain credential(s), but none matched supported providers` + ); + } + } else { + notify.success( + `Imported ${data.count} credential(s) from Zed for ${data.providers?.length ?? 0} provider(s)` + ); + await fetchConnections(); + } + } catch (e: any) { + notify.error(e?.message || "Zed import failed"); + } finally { + setImportingZed(false); + } + }, [importingZed, notify, fetchConnections]); + + const handleZedManualImport = useCallback(async () => { + if (importingZedManual || !zedManualToken.trim()) return; + setImportingZedManual(true); + try { + const res = await fetch("/api/providers/zed/manual-import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: zedManualProvider, token: zedManualToken.trim() }), + }); + const data = await res.json(); + if (!res.ok || !data.success) { + notify.error(data.error?.message ?? data.error ?? "Manual import failed"); + } else { + notify.success(`Imported ${zedManualProvider} token from Zed`); + setZedManualToken(""); + await fetchConnections(); + } + } catch (e: any) { + notify.error(e?.message || "Manual import failed"); + } finally { + setImportingZedManual(false); + } + }, [importingZedManual, zedManualProvider, zedManualToken, notify, fetchConnections]); + + const loadCodexSettings = useCallback(async () => { + const requestSeq = codexSettingsRequestSeqRef.current + 1; + codexSettingsRequestSeqRef.current = requestSeq; + const isCurrentRequest = () => codexSettingsRequestSeqRef.current === requestSeq; + + if (providerId !== "codex") { + setCodexSettingsLoaded(false); + setCodexSettingsLoadError(null); + return; + } + + setCodexSettingsLoaded(false); + setCodexSettingsLoadError(null); + + try { + const response = await fetch("/api/settings", { cache: "no-store" }); + if (!response.ok) { + throw new Error(`Settings request failed with HTTP ${response.status}`); + } + const data = await response.json(); + if (!data || typeof data !== "object") { + throw new Error("Settings response was empty"); + } + if (!isCurrentRequest()) return; + const resolvedCodexServiceTier = resolveCodexGlobalFastServiceTier(data); + setCodexGlobalServiceMode(getCodexGlobalServiceMode(data)); + setCodexGlobalSupportedModels([...resolvedCodexServiceTier.supportedModels]); + setCodexSettingsLoaded(true); + } catch (error) { + if (!isCurrentRequest()) return; + setCodexSettingsLoaded(false); + setCodexSettingsLoadError(error instanceof Error ? error.message : "Failed to load settings"); + } + }, [providerId]); + + useEffect(() => { + void loadCodexSettings(); + }, [loadCodexSettings]); + + const loadConnProxies = useCallback(async (conns: { id?: string }[]) => { + if (!conns.length) return; + try { + const results = await Promise.all( + conns + .filter((c) => c.id) + .map((c) => + fetch(`/api/settings/proxy?resolve=${encodeURIComponent(c.id!)}`, { cache: "no-store" }) + .then((r) => (r.ok ? r.json() : null)) + .then((data) => [c.id!, data] as [string, any]) + .catch(() => [c.id!, null] as [string, any]) + ) + ); + const map: Record = {}; + for (const [id, data] of results) { + map[id] = data?.proxy ? data : null; + } + setConnProxyMap(map); + } catch { + // ignore + } + }, []); + + useEffect(() => { + if (loading || isSearchProvider) return; + fetchProviderModelMeta(); + }, [loading, isSearchProvider, fetchProviderModelMeta]); + + // Load per-connection effective proxy (handles registry assignments) + useEffect(() => { + if (!loading && connections.length > 0) { + void loadConnProxies(connections); + } + }, [loading, connections, loadConnProxies]); + + const onTestModel = async (modelId: string, fullModel: string) => { + setTestingModelId(modelId); + setModelTestStatus((prev) => ({ ...prev, [modelId]: undefined })); + try { + const res = await fetch("/api/models/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + providerId: selectedConnection?.provider || providerNode?.id || providerId, + modelId: fullModel, + connectionId: selectedConnection?.id, + }), + }); + const data = await res.json(); + if (res.ok && data.status === "ok") { + notify.success( + providerText( + t, + "testModelSuccess", + `Model ${modelId} is working. Latency: ${data.latencyMs}ms`, + { modelId, latencyMs: data.latencyMs } + ) + ); + setModelTestStatus((prev) => ({ ...prev, [modelId]: "ok" })); + } else { + notify.error(data.error || "Model test failed"); + setModelTestStatus((prev) => ({ ...prev, [modelId]: "error" })); + if (handleToggleModelHidden) { + await handleToggleModelHidden(providerStorageAlias, modelId, true); + } + } + } catch (err) { + notify.error("Network error testing model"); + setModelTestStatus((prev) => ({ ...prev, [modelId]: "error" })); + if (handleToggleModelHidden) { + await handleToggleModelHidden(providerStorageAlias, modelId, true); + } + } finally { + setTestingModelId(null); + } + }; + + const handleTestAll = async ( + targets: Array<{ modelId: string; fullModel: string }> + ): Promise => { + if (testingAll) return; + if (targets.length === 0) { + notify.error(providerText(t, "noModelsToTest", "No models to test")); + return; + } + setTestingAll(true); + setTestProgress({ done: 0, total: targets.length }); + + let ok = 0; + let error = 0; + let hiddenCount = 0; + + const CHUNK_SIZE = 3; + for (let i = 0; i < targets.length; i += CHUNK_SIZE) { + const chunk = targets.slice(i, i + CHUNK_SIZE); + await Promise.all( + chunk.map(async ({ modelId, fullModel }) => { + try { + const result: { + results?: Record< + string, + { + status?: "ok" | "error"; + rateLimited?: boolean; + isTimeout?: boolean; + error?: string; + } + >; + } = await fetch("/api/models/test-all", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + providerId: providerId, + connectionId: selectedConnection?.id, + modelIds: [fullModel], + }), + }).then((r) => r.json()); + + const entry = result.results?.[fullModel]; + if (entry?.status === "ok") { + ok++; + } else { + error++; + if (autoHideFailed && !entry?.rateLimited && !entry?.isTimeout) { + await handleToggleModelHidden(providerStorageAlias, modelId, true); + hiddenCount++; + } + } + } catch (e) { + error++; + } + setTestProgress((prev) => + prev ? { done: prev.done + 1, total: prev.total } : null + ); + }) + ); + } + + notify.info( + providerText(t, "testAllResults", "{ok} ok, {error} error", { ok, error }) + ); + if (hiddenCount > 0) { + notify.info( + providerText(t, "testAllFailedHidden", "{count} hidden", { count: hiddenCount }) + ); + } + setTestingAll(false); + setTestProgress(null); + }; + + const handleToggleSelectOne = useCallback((id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + const handleToggleSelectAll = useCallback(() => { + setSelectedIds((prev) => { + if (prev.size === connections.length && connections.length > 0) { + return new Set(); + } + return new Set(connections.map((c) => (c as { id: string }).id)); + }); + }, [connections]); + + const handleBatchDeleteOpenModal = () => { + if (selectedIds.size === 0) return; + setBatchDeleteConfirmOpen(true); + }; + + const handleBatchDeleteConfirm = async () => { + setBatchDeleteConfirmOpen(false); + setBatchDeleting(true); + try { + const res = await fetch("/api/providers", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids: Array.from(selectedIds) }), + }); + + if (res.ok) { + setSelectedIds(new Set()); + await fetchConnections(); + notify.success(t("batchDeleteSuccess", { count: selectedIds.size })); + await fetchProviderModelMeta(); + } else { + const data = await res.json(); + notify.error(data.error || "Batch delete failed"); + } + } catch { + notify.error("Network error during batch delete"); + } finally { + setBatchDeleting(false); + } + }; + + const handleDelete = useCallback( + async (connectionId: string) => { + if (!connectionId) return; + try { + const res = await fetch(`/api/providers/${connectionId}`, { method: "DELETE" }); + if (res.ok) { + notify.success("Connection deleted"); + await fetchConnections(); + await fetchProviderModelMeta(); + } else { + const data = await res.json().catch(() => ({})); + const message = + (typeof data?.error === "string" && data.error) || + data?.error?.message || + "Failed to delete connection"; + notify.error(message); + } + } catch (error) { + console.error("Error deleting connection:", error); + notify.error("Failed to delete connection"); + } + }, + [fetchConnections, fetchProviderModelMeta, notify] + ); + + const handleBatchSetActive = async (isActive: boolean) => { + if (selectedIds.size === 0 || batchUpdating) return; + setBatchUpdating(isActive ? "activate" : "deactivate"); + try { + const res = await fetch("/api/providers", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids: Array.from(selectedIds), isActive }), + }); + + if (res.ok) { + const data = await res.json(); + await fetchConnections(); + notify.success( + isActive + ? t("batchActivateSuccess", { count: data.updated }) + : t("batchDeactivateSuccess", { count: data.updated }) + ); + } else { + const data = await res.json().catch(() => ({})); + notify.error(data.error?.message || data.error || "Batch update failed"); + } + } catch { + notify.error("Network error during batch update"); + } finally { + setBatchUpdating(null); + } + }; + + const handleOAuthSuccess = useCallback(() => { + fetchConnections(); + setShowOAuthModal(false); + }, [fetchConnections]); + + const openApiKeyAddFlow = useCallback(() => { + if (providerId === "siliconflow") { + setShowSiliconFlowEndpointModal(true); + return; + } + setShowAddApiKeyModal(true); + }, [providerId]); + + const openPrimaryAddFlow = useCallback(() => { + if (isOAuth) { + setShowOAuthModal(true); + return; + } + openApiKeyAddFlow(); + }, [isOAuth, openApiKeyAddFlow]); + + // "Adicionar Externo": generate a single-use public link so a third party can + // complete the Codex device flow in their own browser. + const openExternalLinkFlow = useCallback(async () => { + setExternalLinkModalOpen(true); + setExternalLinkUrl(""); + setExternalLinkToken(null); + setExternalLinkError(null); + setExternalLinkLoading(true); + try { + const res = await fetch(`/api/oauth/${providerId}/public-link`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const data = await res.json().catch(() => ({})); + if (res.ok && data?.url) { + setExternalLinkUrl(data.url); + setExternalLinkToken(data.token || null); + } else { + setExternalLinkError(data?.error || "Falha ao gerar o link."); + } + } catch { + setExternalLinkError("Não foi possível contatar o servidor."); + } finally { + setExternalLinkLoading(false); + } + }, [providerId]); + + // While the share popup is open, poll the ticket status so the dashboard can + // notify + refresh the connections the moment the external visitor finishes. + useEffect(() => { + if (!externalLinkModalOpen || !externalLinkToken) return; + let active = true; + const interval = setInterval(async () => { + if (!active) return; + try { + const res = await fetch( + `/api/oauth/${providerId}/public-link-status?token=${encodeURIComponent(externalLinkToken)}` + ); + const data = await res.json().catch(() => ({})); + if (!active) return; + if (data?.status === "completed") { + active = false; + clearInterval(interval); + notify.success("Conta Codex conectada pelo link externo."); + fetchConnections(); + setExternalLinkModalOpen(false); + setExternalLinkToken(null); + } else if (data?.status === "expired") { + active = false; + clearInterval(interval); + setExternalLinkError("O link expirou sem ser concluído."); + } + } catch { + /* transient network error — keep polling */ + } + }, 3000); + return () => { + active = false; + clearInterval(interval); + }; + }, [externalLinkModalOpen, externalLinkToken, providerId, notify, fetchConnections]); + + const gateConnectionFlow = useCallback( + (callback: () => void) => { + if (subscriptionRisk && !riskAcknowledged && !isRiskAcknowledged(providerId)) { + pendingRiskActionRef.current = callback; + setShowRiskNoticeModal(true); + return; + } + callback(); + }, + [providerId, riskAcknowledged, subscriptionRisk] + ); + + const handleConfirmRiskNotice = useCallback(() => { + acknowledgeRisk(); + setShowRiskNoticeModal(false); + const pendingAction = pendingRiskActionRef.current; + pendingRiskActionRef.current = null; + pendingAction?.(); + }, [acknowledgeRisk]); + + const handleCancelRiskNotice = useCallback(() => { + pendingRiskActionRef.current = null; + setShowRiskNoticeModal(false); + }, []); + + const clearCommandCodeAuthTimer = useCallback(() => { + if (commandCodeAuthTimerRef.current !== null) { + window.clearTimeout(commandCodeAuthTimerRef.current); + commandCodeAuthTimerRef.current = null; + } + }, []); + + useEffect(() => { + return () => { + clearCommandCodeAuthTimer(); + commandCodeAuthWindowRef.current?.close?.(); + }; + }, [clearCommandCodeAuthTimer]); + + const handleCloseAddApiKeyModal = useCallback(() => { + clearCommandCodeAuthTimer(); + setSiliconFlowInitialBaseUrl(undefined); + commandCodeAuthWindowRef.current?.close?.(); + commandCodeAuthWindowRef.current = null; + setCommandCodeAuthState({ + phase: "idle", + state: "", + authUrl: "", + callbackUrl: "", + expiresAt: null, + message: "", + }); + setShowAddApiKeyModal(false); + }, [clearCommandCodeAuthTimer]); + + const handleCommandCodeAuthApply = useCallback( + async (state: string, connectionId?: string, name?: string, setDefault?: boolean) => { + setCommandCodeAuthState((current) => ({ + ...current, + phase: "applying", + message: "Applying browser-approved key…", + })); + + try { + const res = await fetch("/api/providers/command-code/auth/apply", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ state, connectionId, name, setDefault }), + }); + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + const errorMessage = data.error || "Failed to apply Command Code auth"; + setCommandCodeAuthState((current) => ({ + ...current, + phase: "error", + message: errorMessage, + })); + notify.error(errorMessage); + return false; + } + + setCommandCodeAuthState((current) => ({ + ...current, + phase: "applied", + message: "Command Code connected", + })); + commandCodeAuthWindowRef.current?.close?.(); + commandCodeAuthWindowRef.current = null; + await fetchConnections(); + handleCloseAddApiKeyModal(); + notify.success("Command Code connection added"); + return true; + } catch (error) { + console.error("Error applying Command Code auth:", error); + setCommandCodeAuthState((current) => ({ + ...current, + phase: "error", + message: "Failed to apply Command Code auth", + })); + notify.error("Failed to apply Command Code auth"); + return false; + } + }, + [fetchConnections, handleCloseAddApiKeyModal, notify] + ); + + const handleStartCommandCodeAuth = useCallback(async () => { + if (commandCodeAuthState.phase === "starting" || commandCodeAuthState.phase === "polling") { + return; + } + + clearCommandCodeAuthTimer(); + commandCodeAuthWindowRef.current?.close?.(); + + const popup = window.open("about:blank", "_blank"); + setCommandCodeAuthState({ + phase: "starting", + state: "", + authUrl: "", + callbackUrl: "", + expiresAt: null, + message: "Opening Command Code Studio…", + }); + + try { + const res = await fetch("/api/providers/command-code/auth/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + const data = await res.json().catch(() => ({})); + + if (!res.ok || !data.state || !data.authUrl) { + const errorMessage = data.error || "Failed to start Command Code auth"; + setCommandCodeAuthState((current) => ({ + ...current, + phase: "error", + message: errorMessage, + })); + notify.error(errorMessage); + popup?.close?.(); + return; + } + + setCommandCodeAuthState({ + phase: "polling", + state: data.state, + authUrl: data.authUrl, + callbackUrl: data.callbackUrl || "", + expiresAt: data.expiresAt || null, + message: "Open the auth URL, approve access, then paste the returned key/JSON/URL below…", + }); + + if (popup) { + try { + popup.opener = null; + } catch { + // Ignore opener cleanup failures. + } + popup.location.href = data.authUrl; + commandCodeAuthWindowRef.current = popup; + } else { + const fallbackPopup = window.open(data.authUrl, "_blank", "noopener,noreferrer"); + if (!fallbackPopup) { + setCommandCodeAuthState((current) => ({ + ...current, + phase: "error", + message: "Popup blocked. Please allow popups and try Command Code Connect again.", + })); + notify.error("Popup blocked. Please allow popups and try Command Code Connect again."); + return; + } + commandCodeAuthWindowRef.current = fallbackPopup; + } + + const deadline = data.expiresAt ? new Date(data.expiresAt).getTime() : Date.now() + 180000; + const poll = async () => { + if (Date.now() >= deadline) { + setCommandCodeAuthState((current) => ({ + ...current, + phase: "expired", + message: "Command Code link expired", + })); + commandCodeAuthWindowRef.current?.close?.(); + commandCodeAuthWindowRef.current = null; + notify.error("Command Code auth expired"); + clearCommandCodeAuthTimer(); + return; + } + + try { + const statusRes = await fetch( + `/api/providers/command-code/auth/status?state=${encodeURIComponent(data.state)}`, + { method: "GET", cache: "no-store" } + ); + const statusData = await statusRes.json().catch(() => ({})); + const status = String(statusData.status || statusData.state || statusData.phase || "") + .toLowerCase() + .trim(); + + if (status === "expired") { + setCommandCodeAuthState((current) => ({ + ...current, + phase: "expired", + message: "Command Code link expired", + })); + commandCodeAuthWindowRef.current?.close?.(); + commandCodeAuthWindowRef.current = null; + notify.error("Command Code auth expired"); + clearCommandCodeAuthTimer(); + return; + } + + if (status === "applied") { + setCommandCodeAuthState((current) => ({ + ...current, + phase: "applied", + message: "Command Code connected", + })); + commandCodeAuthWindowRef.current?.close?.(); + commandCodeAuthWindowRef.current = null; + await fetchConnections(); + handleCloseAddApiKeyModal(); + notify.success("Command Code connection added"); + clearCommandCodeAuthTimer(); + return; + } + + if (status === "received") { + setCommandCodeAuthState((current) => ({ + ...current, + phase: "received", + message: "Browser approved, applying…", + })); + clearCommandCodeAuthTimer(); + await handleCommandCodeAuthApply( + data.state, + statusData.connectionId, + statusData.name, + statusData.setDefault + ); + return; + } + } catch { + // Keep polling until the contract reports a terminal state or timeout. + } + + commandCodeAuthTimerRef.current = window.setTimeout(poll, 2000); + }; + + commandCodeAuthTimerRef.current = window.setTimeout(poll, 1000); + } catch (error) { + console.error("Error starting Command Code auth:", error); + setCommandCodeAuthState((current) => ({ + ...current, + phase: "error", + message: "Failed to start Command Code auth", + })); + notify.error("Failed to start Command Code auth"); + popup?.close?.(); + commandCodeAuthWindowRef.current = null; + clearCommandCodeAuthTimer(); + } + }, [ + clearCommandCodeAuthTimer, + handleCloseAddApiKeyModal, + commandCodeAuthState.phase, + fetchConnections, + handleCommandCodeAuthApply, + notify, + ]); + + const handleOpenCommandCodeConnect = useCallback(() => { + setShowAddApiKeyModal(true); + void handleStartCommandCodeAuth(); + }, [handleStartCommandCodeAuth]); + + const handleSaveApiKey = async (formData) => { + try { + const res = await fetch("/api/providers", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: providerId, ...formData }), + }); + if (res.ok) { + const connectionData = await res.json(); + const newConnection = connectionData?.connection; + await fetchConnections(); + setShowAddApiKeyModal(false); + setSiliconFlowInitialBaseUrl(undefined); + + // Universal: sync models from the provider endpoint on every new connection + // (was previously Gemini-only). Do NOT re-introduce a providerId guard here. + if (newConnection?.id) { + setShowImportModal(true); + setImportProgress({ + current: 0, + total: 0, + phase: "fetching", + status: t("fetchingModels"), + logs: [], + error: "", + importedCount: 0, + }); + + try { + const syncRes = await fetch(`/api/providers/${newConnection.id}/sync-models`, { + method: "POST", + signal: AbortSignal.timeout(30_000), // 30s timeout — model sync shouldn't hang + }); + const syncData = await syncRes.json(); + + if (!syncRes.ok || syncData.error) { + setImportProgress((prev) => ({ + ...prev, + phase: "error", + status: t("failedFetchModels"), + error: syncData.error?.message || syncData.error || t("failedImportModels"), + })); + return null; + } + + const syncedCount = syncData.syncedModels || 0; + const availableCount = + typeof syncData.availableModelsCount === "number" + ? syncData.availableModelsCount + : Array.isArray(syncData.models) + ? syncData.models.length + : syncedCount; + const syncedModelList: Array<{ id: string; name?: string }> = syncData.models || []; + const logs: string[] = []; + if (syncedModelList.length > 0) { + logs.push(`✓ ${availableCount} models available`); + logs.push(""); + for (const m of syncedModelList) { + logs.push(` ${m.name || m.id}`); + } + } + + setImportProgress((prev) => ({ + ...prev, + phase: "done", + status: t("modelsImported", { count: availableCount }), + total: availableCount, + current: availableCount, + importedCount: availableCount, + logs, + })); + + await fetchProviderModelMeta(); + } catch (syncError) { + setImportProgress((prev) => ({ + ...prev, + phase: "error", + status: t("failedFetchModels"), + error: String(syncError), + })); + } + } + return null; + } + const data = await res.json().catch(() => ({})); + const errorMsg = data.error?.message || data.error || t("failedSaveConnection"); + return errorMsg; + } catch (error) { + console.log("Error saving connection:", error); + return t("failedSaveConnectionRetry"); + } + }; + + const handleUpdateConnection = async (formData) => { + try { + const res = await fetch(`/api/providers/${selectedConnection.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(formData), + }); + if (res.ok) { + await fetchConnections(); + setShowEditModal(false); + return null; + } + const data = await res.json().catch(() => ({})); + return data.error?.message || data.error || t("failedSaveConnection"); + } catch (error) { + console.log("Error updating connection:", error); + return t("failedSaveConnectionRetry"); + } + }; + + const handleUpdateConnectionStatus = async (id, isActive) => { + try { + const res = await fetch(`/api/providers/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ isActive }), + }); + if (res.ok) { + setConnections((prev) => prev.map((c) => (c.id === id ? { ...c, isActive } : c))); + } + } catch (error) { + console.log("Error updating connection status:", error); + } + }; + + const handleToggleProxyEnabled = async (connectionId, proxyEnabled) => { + try { + const res = await fetch(`/api/providers/${connectionId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ proxyEnabled }), + }); + if (res.ok) { + setConnections((prev) => + prev.map((c) => (c.id === connectionId ? { ...c, proxyEnabled } : c)) + ); + } + } catch (error) { + console.error("Error toggling proxy enabled:", error); + } + }; + + const handleTogglePerKeyProxyEnabled = async (connectionId, perKeyProxyEnabled) => { + try { + const res = await fetch(`/api/providers/${connectionId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ perKeyProxyEnabled }), + }); + if (res.ok) { + setConnections((prev) => + prev.map((c) => (c.id === connectionId ? { ...c, perKeyProxyEnabled } : c)) + ); + } + } catch (error) { + console.error("Error toggling per-key proxy enabled:", error); + } + }; + + const handleDistributeProxies = async (tagFilter?: string) => { + const targetConnections = tagFilter + ? connections.filter( + (c: any) => + (c.providerSpecificData?.tag as string | undefined)?.trim() === tagFilter + ) + : connections; + if (targetConnections.length === 0) return; + setDistributingProxies(true); + try { + const proxiesRes = await fetch("/api/settings/proxies"); + if (!proxiesRes.ok) throw new Error("Failed to fetch proxies"); + const proxiesData = await proxiesRes.json(); + const savedProxies = (proxiesData?.items || []).filter( + (p: any) => p.status === "active" + ); + if (savedProxies.length === 0) { + notify.error("No saved proxies found. Add proxies in Settings → Proxy first."); + return; + } + + let assigned = 0; + const sorted = [...targetConnections].sort( + (a: any, b: any) => (a.priority || 0) - (b.priority || 0) + ); + + for (let i = 0; i < sorted.length; i++) { + const conn = sorted[i] as any; + const proxy = savedProxies[i % savedProxies.length]; + + try { + await fetch("/api/settings/proxies/assignments", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + scope: "account", + scopeId: conn.id, + proxyId: null, + }), + }); + } catch { + /* clear old assignment */ + } + + const patchRes = await fetch(`/api/providers/${conn.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ proxyEnabled: true, perKeyProxyEnabled: true }), + }); + + if (!patchRes.ok) { + console.error(`Failed to update connection ${conn.id}`); + continue; + } + + // Assign new proxy + const assignRes = await fetch("/api/settings/proxies/assignments", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + scope: "account", + scopeId: conn.id, + proxyId: proxy.id, + }), + }); + + if (!assignRes.ok) { + console.error(`Failed to assign proxy to ${conn.id}`); + continue; + } + + assigned++; + } + + await fetchConnections(); + const tagLabel = tagFilter ? `"${tagFilter}" ` : ""; + notify.success( + `Distributed ${assigned} proxy assignment(s) across ${tagLabel}${sorted.length} connection(s).` + ); + } catch (err) { + console.error("Error distributing proxies:", err); + notify.error("Failed to distribute proxies."); + } finally { + setDistributingProxies(false); + } + }; + + const handleToggleRateLimit = async (connectionId, enabled) => { + try { + const res = await fetch("/api/rate-limits", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ connectionId, enabled }), + }); + if (res.ok) { + setConnections((prev) => + prev.map((c) => (c.id === connectionId ? { ...c, rateLimitProtection: enabled } : c)) + ); + } + } catch (error) { + console.error("Error toggling rate limit:", error); + } + }; + + const handleToggleClaudeExtraUsage = async (connectionId, enabled) => { + try { + const target = connections.find((connection) => connection.id === connectionId); + if (!target) return; + + const providerSpecificData = + target.providerSpecificData && typeof target.providerSpecificData === "object" + ? target.providerSpecificData + : {}; + + const res = await fetch(`/api/providers/${connectionId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + providerSpecificData: { + ...providerSpecificData, + blockExtraUsage: enabled, + }, + }), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + notify.error(data.error || "Failed to update Claude extra-usage policy"); + return; + } + + setConnections((prev) => + prev.map((connection) => + connection.id === connectionId + ? { + ...connection, + providerSpecificData: { + ...(connection.providerSpecificData || {}), + blockExtraUsage: enabled, + }, + ...(!enabled && connection.lastErrorSource === "extra_usage" + ? { + testStatus: "active", + lastError: null, + lastErrorAt: null, + lastErrorType: null, + lastErrorSource: null, + errorCode: null, + rateLimitedUntil: null, + } + : {}), + } + : connection + ) + ); + notify.success( + enabled + ? "Claude extra-usage blocking enabled (extra usage will be blocked)" + : "Claude extra-usage blocking disabled (extra usage is allowed)" + ); + } catch (error) { + console.error("Error toggling Claude extra-usage policy:", error); + notify.error("Failed to update Claude extra-usage policy"); + } + }; + + const [cpaProviderEnabled, setCpaProviderEnabled] = useState(false); + + // Load upstream proxy config for this provider on mount + useEffect(() => { + if (!isCcCompatible) return; + fetch(`/api/settings`) + .then((r) => r.json()) + .then((data) => { + // Check if this provider has CLIProxyAPI routing enabled + // The upstream_proxy_config is synced via the settings API + }) + .catch(() => {}); + + // Also check via direct upstream proxy config lookup + fetch(`/api/upstream-proxy/${providerId}`) + .then((r) => { + if (!r.ok) return null; + return r.json(); + }) + .then((data) => { + if (data?.enabled && (data.mode === "cliproxyapi" || data.mode === "fallback")) { + setCpaProviderEnabled(true); + } + }) + .catch(() => {}); + }, [isCcCompatible, providerId]); + + const handleToggleCliproxyapiMode = async (_connectionId, enabled) => { + try { + // Write to upstream_proxy_config table which resolveExecutorWithProxy reads + const res = await fetch(`/api/upstream-proxy/${providerId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + mode: enabled ? "cliproxyapi" : "native", + enabled: enabled, + }), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + notify.error(data.error || "Failed to update CLIProxyAPI routing"); + return; + } + + setCpaProviderEnabled(enabled); + notify.success( + enabled + ? "Requests now route through CLIProxyAPI (deeper emulation)" + : "Requests now use native OmniRoute (direct)" + ); + } catch { + notify.error("Failed to update CLIProxyAPI routing"); + } + }; + + const handleToggleCodexLimit = async (connectionId, field, enabled) => { + try { + const target = connections.find((connection) => connection.id === connectionId); + if (!target) return; + + const providerSpecificData = + target.providerSpecificData && typeof target.providerSpecificData === "object" + ? target.providerSpecificData + : {}; + const existingPolicy = + providerSpecificData.codexLimitPolicy && + typeof providerSpecificData.codexLimitPolicy === "object" + ? providerSpecificData.codexLimitPolicy + : {}; + + const nextPolicy = { + ...normalizeCodexLimitPolicy(existingPolicy), + [field]: enabled, + }; + + const res = await fetch(`/api/providers/${connectionId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + providerSpecificData: { + ...providerSpecificData, + codexLimitPolicy: nextPolicy, + }, + }), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + notify.error(data.error || "Failed to update Codex limit policy"); + return; + } + + setConnections((prev) => + prev.map((connection) => + connection.id === connectionId + ? { + ...connection, + providerSpecificData: { + ...(connection.providerSpecificData || {}), + codexLimitPolicy: nextPolicy, + }, + } + : connection + ) + ); + notify.success("Codex limit policy updated"); + } catch (error) { + console.error("Error toggling Codex quota policy:", error); + notify.error("Failed to update Codex limit policy"); + } + }; + + const handleChangeCodexGlobalServiceMode = async (mode: CodexGlobalServiceMode) => { + if (savingCodexGlobalServiceMode || !codexSettingsLoaded) return; + setSavingCodexGlobalServiceMode(true); + const previousMode = codexGlobalServiceMode; + setCodexGlobalServiceMode(mode); + try { + const tier = mode === "none" ? (previousMode !== "none" ? previousMode : undefined) : mode; + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + codexServiceTier: { + enabled: mode !== "none", + ...(tier ? { tier } : {}), + supportedModels: codexGlobalSupportedModels, + }, + }), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setCodexGlobalServiceMode(previousMode); + notify.error(data.error || "Failed to update Codex service mode"); + return; + } + + notify.success("Codex service mode updated"); + } catch (error) { + setCodexGlobalServiceMode(previousMode); + console.error("Error updating Codex service mode:", error); + notify.error("Failed to update Codex service mode"); + } finally { + setSavingCodexGlobalServiceMode(false); + } + }; + + const handleRetestConnection = async (connectionId) => { + if (!connectionId || retestingId) return; + setRetestingId(connectionId); + try { + const res = await fetch(`/api/providers/${connectionId}/test`, { method: "POST" }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + alert(data.error || t("failedRetestConnection")); + return; + } + await fetchConnections(); + } catch (error) { + console.error("Error retesting connection:", error); + } finally { + setRetestingId(null); + } + }; + + // Shared runner for batch connection tests (all-for-provider or selected IDs) + const runBatchTest = async (payload: Record) => { + setBatchTestResults(null); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 120_000); // 2min max + try { + const res = await fetch("/api/providers/test-batch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + let data: any; + try { + data = await res.json(); + } catch { + data = { error: t("providerTestFailed"), results: [], summary: null }; + } + setBatchTestResults({ + ...data, + error: data.error + ? typeof data.error === "object" + ? data.error.message || data.error.error || JSON.stringify(data.error) + : String(data.error) + : null, + }); + if (data?.summary) { + const { passed, failed, total } = data.summary; + if (failed === 0) notify.success(t("allTestsPassed", { total })); + else notify.warning(t("testSummary", { passed, failed, total })); + } + // Refresh connections to update statuses + await fetchConnections(); + } catch (error: any) { + const isAbort = error?.name === "AbortError"; + const msg = isAbort ? t("providerTestTimeout") : t("providerTestFailed"); + setBatchTestResults({ error: msg, results: [], summary: null }); + notify.error(msg); + } finally { + clearTimeout(timeoutId); + } + }; + + // Batch test all connections for this provider + const handleBatchTestAll = async () => { + if (batchTesting || connections.length === 0) return; + setBatchTesting(true); + try { + await runBatchTest({ mode: "provider", providerId }); + } finally { + setBatchTesting(false); + } + }; + + // Batch retest only the selected connections + const handleBatchRetest = async () => { + if (batchRetesting || selectedIds.size === 0) return; + setBatchRetesting(true); + try { + await runBatchTest({ mode: "selected", connectionIds: Array.from(selectedIds) }); + } finally { + setBatchRetesting(false); + } + }; + + // T12: Manual token refresh + const [refreshingId, setRefreshingId] = useState(null); + + const parseApiErrorMessage = async (res: Response, fallback: string) => { + const contentType = res.headers.get("content-type") || ""; + + if (contentType.includes("application/json")) { + const data = await res.json().catch(() => ({})); + if (typeof data?.error === "string" && data.error.trim()) { + return data.error; + } + if (data?.error?.message) { + return data.error.message; + } + } + + const text = await res.text().catch(() => ""); + return text.trim() || fallback; + }; + + const getAttachmentFilename = (res: Response, fallback: string) => { + const disposition = res.headers.get("content-disposition") || ""; + const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i); + if (utf8Match?.[1]) { + return decodeURIComponent(utf8Match[1]); + } + + const plainMatch = disposition.match(/filename="([^"]+)"/i); + if (plainMatch?.[1]) { + return plainMatch[1]; + } + + return fallback; + }; + + const handleRefreshToken = async (connectionId: string) => { + if (refreshingId) return; + setRefreshingId(connectionId); + try { + const res = await fetch(`/api/providers/${connectionId}/refresh`, { method: "POST" }); + const data = await res.json().catch(() => ({})); + if (res.ok && data.success) { + notify.success(t("tokenRefreshed")); + await fetchConnections(); + } else { + notify.error(data.error || t("tokenRefreshFailed")); + } + } catch (error) { + console.error("Error refreshing token:", error); + notify.error(t("tokenRefreshFailed")); + } finally { + setRefreshingId(null); + } + }; + + const handleApplyCodexAuthLocal = async (connectionId: string) => { + if (applyingCodexAuthId) return; + setApplyingCodexAuthId(connectionId); + + const defaultSuccess = + typeof t.has === "function" && t.has("codexAuthAppliedLocal") + ? t("codexAuthAppliedLocal") + : "Codex auth.json applied locally"; + const defaultError = + typeof t.has === "function" && t.has("codexAuthApplyFailed") + ? t("codexAuthApplyFailed") + : "Failed to apply Codex auth.json locally"; + + try { + const res = await fetch(`/api/providers/${connectionId}/codex-auth/apply-local`, { + method: "POST", + }); + + if (!res.ok) { + notify.error(await parseApiErrorMessage(res, defaultError)); + return; + } + + notify.success(defaultSuccess); + setApplyCodexModalConnectionId(null); + } catch (error) { + console.error("Error applying Codex auth locally:", error); + notify.error(defaultError); + } finally { + setApplyingCodexAuthId(null); + } + }; + + const handleExportCodexAuthFile = async (connectionId: string) => { + if (exportingCodexAuthId) return; + setExportingCodexAuthId(connectionId); + + const defaultSuccess = + typeof t.has === "function" && t.has("codexAuthExported") + ? t("codexAuthExported") + : "Codex auth.json exported"; + const defaultError = + typeof t.has === "function" && t.has("codexAuthExportFailed") + ? t("codexAuthExportFailed") + : "Failed to export Codex auth.json"; + + try { + const res = await fetch(`/api/providers/${connectionId}/codex-auth/export`, { + method: "POST", + }); + + if (!res.ok) { + notify.error(await parseApiErrorMessage(res, defaultError)); + return; + } + + const blob = await res.blob(); + const filename = getAttachmentFilename(res, "codex-auth.json"); + const objectUrl = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = objectUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 1000); + + notify.success(defaultSuccess); + } catch (error) { + console.error("Error exporting Codex auth file:", error); + notify.error(defaultError); + } finally { + setExportingCodexAuthId(null); + } + }; + + const handleApplyClaudeAuthLocal = async (connectionId: string) => { + if (applyingClaudeAuthId) return; + setApplyingClaudeAuthId(connectionId); + + const defaultSuccess = + typeof t.has === "function" && t.has("claudeAuthAppliedLocal") + ? t("claudeAuthAppliedLocal") + : "Claude auth applied locally"; + const defaultError = + typeof t.has === "function" && t.has("claudeAuthApplyFailed") + ? t("claudeAuthApplyFailed") + : "Failed to apply Claude auth locally"; + + try { + const res = await fetch(`/api/providers/${connectionId}/claude-auth/apply-local`, { + method: "POST", + }); + + if (!res.ok) { + notify.error(await parseApiErrorMessage(res, defaultError)); + return; + } + + notify.success(defaultSuccess); + setApplyClaudeModalConnectionId(null); + } catch (error) { + console.error("Error applying Claude auth locally:", error); + notify.error(defaultError); + } finally { + setApplyingClaudeAuthId(null); + } + }; + + const handleExportClaudeAuthFile = async (connectionId: string) => { + if (exportingClaudeAuthId) return; + setExportingClaudeAuthId(connectionId); + + const defaultSuccess = + typeof t.has === "function" && t.has("claudeAuthExported") + ? t("claudeAuthExported") + : "Claude auth file exported"; + const defaultError = + typeof t.has === "function" && t.has("claudeAuthExportFailed") + ? t("claudeAuthExportFailed") + : "Failed to export Claude auth file"; + + try { + const res = await fetch(`/api/providers/${connectionId}/claude-auth/export`, { + method: "POST", + }); + + if (!res.ok) { + notify.error(await parseApiErrorMessage(res, defaultError)); + return; + } + + const blob = await res.blob(); + const filename = getAttachmentFilename(res, "claude-auth.json"); + const objectUrl = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = objectUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 1000); + + notify.success(defaultSuccess); + } catch (error) { + console.error("Error exporting Claude auth file:", error); + notify.error(defaultError); + } finally { + setExportingClaudeAuthId(null); + } + }; + + const handleApplyGeminiAuthLocal = async (connectionId: string) => { + if (applyingGeminiAuthId) return; + setApplyingGeminiAuthId(connectionId); + + const defaultSuccess = + typeof t.has === "function" && t.has("geminiAuthAppliedLocal") + ? t("geminiAuthAppliedLocal") + : "Gemini auth applied locally"; + const defaultError = + typeof t.has === "function" && t.has("geminiAuthApplyFailed") + ? t("geminiAuthApplyFailed") + : "Failed to apply Gemini auth locally"; + + try { + const res = await fetch(`/api/providers/${connectionId}/gemini-cli-auth/apply-local`, { + method: "POST", + }); + + if (!res.ok) { + notify.error(await parseApiErrorMessage(res, defaultError)); + return; + } + + notify.success(defaultSuccess); + setApplyGeminiModalConnectionId(null); + } catch (error) { + console.error("Error applying Gemini auth locally:", error); + notify.error(defaultError); + } finally { + setApplyingGeminiAuthId(null); + } + }; + + const handleExportGeminiAuthFile = async (connectionId: string) => { + if (exportingGeminiAuthId) return; + setExportingGeminiAuthId(connectionId); + + const defaultSuccess = + typeof t.has === "function" && t.has("geminiAuthExported") + ? t("geminiAuthExported") + : "Gemini auth file exported"; + const defaultError = + typeof t.has === "function" && t.has("geminiAuthExportFailed") + ? t("geminiAuthExportFailed") + : "Failed to export Gemini auth file"; + + try { + const res = await fetch(`/api/providers/${connectionId}/gemini-cli-auth/export`, { + method: "POST", + }); + + if (!res.ok) { + notify.error(await parseApiErrorMessage(res, defaultError)); + return; + } + + const blob = await res.blob(); + const filename = getAttachmentFilename(res, "gemini-auth.json"); + const objectUrl = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = objectUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 1000); + + notify.success(defaultSuccess); + } catch (error) { + console.error("Error exporting Gemini auth file:", error); + notify.error(defaultError); + } finally { + setExportingGeminiAuthId(null); + } + }; + + const handleSwapPriority = async (conn1, conn2) => { + if (!conn1 || !conn2) return; + try { + // If they have the same priority, we need to ensure the one moving up + // gets a lower value than the one moving down. + // We use a small offset which the backend re-indexing will fix. + let p1 = conn2.priority; + let p2 = conn1.priority; + + if (p1 === p2) { + // If moving conn1 "up" (index decreases) + const isConn1MovingUp = connections.indexOf(conn1) > connections.indexOf(conn2); + if (isConn1MovingUp) { + p1 = conn2.priority - 0.5; + } else { + p1 = conn2.priority + 0.5; + } + } + + await Promise.all([ + fetch(`/api/providers/${conn1.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ priority: p1 }), + }), + fetch(`/api/providers/${conn2.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ priority: p2 }), + }), + ]); + await fetchConnections(); + } catch (error) { + console.log("Error swapping priority:", error); + } + }; + + const handleImportModels = async () => { + if (importingModels) return; + const activeConnection = connections.find((conn) => conn.isActive !== false); + // #3047 — no-auth providers (e.g. OpenCode Free) have no connection rows; + // fall back to the provider id so the models route can serve the public + // catalog instead of the button silently doing nothing. + if (!activeConnection && !isFreeNoAuth) return; + const importTargetId = activeConnection?.id ?? providerId; + + setImportingModels(true); + setShowImportModal(true); + setImportProgress({ + current: 0, + total: 0, + phase: "fetching", + status: t("fetchingModels"), + logs: [], + error: "", + importedCount: 0, + }); + + try { + const res = await fetch(`/api/providers/${importTargetId}/models?refresh=true`); + const data = await res.json(); + if (!res.ok) { + setImportProgress((prev) => ({ + ...prev, + phase: "error", + status: t("failedFetchModels"), + error: data.error || t("failedImportModels"), + })); + return; + } + const fetchedModels = data.models || []; + if (fetchedModels.length === 0) { + setImportProgress((prev) => ({ + ...prev, + phase: "done", + status: t("noModelsFound"), + logs: [t("noModelsReturnedFromEndpoint")], + })); + return; + } + + const existingIds = new Set([ + ...(modelMeta.customModels || []).map((m: any) => m.id), + ...models.map((m: any) => m.id), + ]); + const newModels = fetchedModels.filter( + (model: any) => !existingIds.has(model.id || model.name || model.model) + ); + + if (newModels.length === 0) { + setImportProgress((prev) => ({ + ...prev, + phase: "done", + status: t("allModelsAlreadyImported") || "All models already imported", + logs: [t("noNewModelsToImport") || "No new models to import"], + importedCount: 0, + total: 0, + current: 0, + })); + return; + } + + setImportProgress((prev) => ({ + ...prev, + phase: "importing", + total: newModels.length, + current: 0, + status: t("importingModelsProgress", { current: 0, total: newModels.length }), + logs: [ + t("foundModelsStartingImport", { count: newModels.length }), + ...(newModels.length < fetchedModels.length + ? [ + t("skippingExistingModels", { count: fetchedModels.length - newModels.length }) || + `Skipping ${fetchedModels.length - newModels.length} existing models`, + ] + : []), + ], + })); + + let importedCount = 0; + for (let i = 0; i < newModels.length; i++) { + const model = newModels[i]; + const modelId = model.id || model.name || model.model; + if (!modelId) continue; + const parts = modelId.split("/"); + const baseAlias = parts[parts.length - 1]; + + setImportProgress((prev) => ({ + ...prev, + current: i + 1, + status: t("importingModelsProgress", { current: i + 1, total: newModels.length }), + logs: [...prev.logs, t("importingModelById", { modelId })], + })); + + // Save as imported (default) model in the DB + await fetch("/api/provider-models", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: providerId, + modelId, + modelName: model.name || modelId, + source: "imported", + ...(typeof model.apiFormat === "string" ? { apiFormat: model.apiFormat } : {}), + ...(Array.isArray(model.supportedEndpoints) + ? { supportedEndpoints: model.supportedEndpoints } + : {}), + }), + }); + // Also create an alias for routing + if (!modelAliases[baseAlias]) { + await handleSetAlias(modelId, baseAlias, providerStorageAlias); + } + importedCount += 1; + } + + await fetchAliases(); + + setImportProgress((prev) => ({ + ...prev, + phase: "done", + current: newModels.length, + status: + importedCount > 0 + ? t("importSuccessCount", { count: importedCount }) + : t("noNewModelsAddedExisting"), + logs: [ + ...prev.logs, + importedCount > 0 + ? t("importDoneCount", { count: importedCount }) + : t("noNewModelsAdded"), + ], + importedCount, + })); + + // Auto-reload after success + if (importedCount > 0) { + setTimeout(() => { + window.location.reload(); + }, 2000); + } + } catch (error) { + console.log("Error importing models:", error); + setImportProgress((prev) => ({ + ...prev, + phase: "error", + status: t("importFailed"), + error: error instanceof Error ? error.message : t("unexpectedErrorOccurred"), + })); + } finally { + setImportingModels(false); + } + }; + + // Shared import handler for CompatibleModelsSection + const handleCompatibleImportWithProgress = async (connectionId: string) => { + setShowImportModal(true); + setImportProgress({ + current: 0, + total: 0, + phase: "fetching", + status: t("fetchingModels"), + logs: [], + error: "", + importedCount: 0, + }); + + try { + const response = await fetch(`/api/providers/${connectionId}/sync-models?mode=import`, { + method: "POST", + signal: AbortSignal.timeout(60_000), + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || t("failedImportModels")); + } + + const importedModels = Array.isArray(data.importedModels) ? data.importedModels : []; + const importedCount = + typeof data.importedCount === "number" ? data.importedCount : importedModels.length; + const changedCount = + typeof data.importedChanges?.total === "number" + ? data.importedChanges.total + : importedCount; + const totalChangedCount = + changedCount + + (typeof data.customModelChanges?.total === "number" ? data.customModelChanges.total : 0); + + if (importedModels.length === 0) { + setImportProgress((prev) => ({ + ...prev, + phase: "done", + status: + importedCount > 0 + ? t("importSuccessCount", { count: importedCount }) + : t("noNewModelsAdded"), + logs: [ + importedCount > 0 + ? t("importDoneCount", { count: importedCount }) + : t("noNewModelsAdded"), + ], + importedCount, + })); + if (totalChangedCount > 0) { + setTimeout(() => { + window.location.reload(); + }, 2000); + } + return; + } + + setImportProgress((prev) => ({ + ...prev, + phase: "done", + total: importedModels.length, + current: importedModels.length, + status: + importedCount > 0 + ? t("importSuccessCount", { count: importedCount }) + : t("noNewModelsAdded"), + logs: [ + t("foundModelsStartingImport", { count: importedModels.length }), + ...importedModels.map((model: any) => + t("importingModelById", { modelId: model.id || model.name || model.model }) + ), + importedCount > 0 + ? t("importDoneCount", { count: importedCount }) + : t("noNewModelsAdded"), + ], + importedCount, + })); + + if (totalChangedCount > 0) { + setTimeout(() => { + window.location.reload(); + }, 2000); + } + } catch (error) { + console.log("Error importing models:", error); + setImportProgress((prev) => ({ + ...prev, + phase: "error", + status: t("importFailed"), + error: error instanceof Error ? error.message : t("unexpectedErrorOccurred"), + })); + } + }; + + const canImportModels = isFreeNoAuth || connections.some((conn) => conn.isActive !== false); + + // Auto-sync toggle state: read from first active connection's providerSpecificData + const autoSyncConnection = connections.find((conn: any) => conn.isActive !== false); + const isAutoSyncEnabled = !!(autoSyncConnection as any)?.providerSpecificData?.autoSync; + const [togglingAutoSync, setTogglingAutoSync] = useState(false); + + const handleToggleAutoSync = async () => { + if (!autoSyncConnection || togglingAutoSync) return; + setTogglingAutoSync(true); + try { + const newValue = !isAutoSyncEnabled; + await fetch(`/api/providers/${(autoSyncConnection as any).id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + providerSpecificData: { autoSync: newValue }, + }), + }); + await fetchConnections(); + notify[newValue ? "success" : "info"]( + newValue ? t("autoSyncEnabled") : t("autoSyncDisabled") + ); + } catch (error) { + console.log("Error toggling auto-sync:", error); + notify.error(t("autoSyncToggleFailed")); + } finally { + setTogglingAutoSync(false); + } + }; + + const [clearingModels, setClearingModels] = useState(false); + const providerAliasEntries = useMemo( + () => + Object.entries(modelAliases).filter( + ([, model]) => + typeof model === "string" && model.startsWith(`${providerStorageAlias}/`) + ), + [modelAliases, providerStorageAlias] + ); + + const handleClearAllModels = async () => { + if (clearingModels) return; + if (!confirm(t("clearAllModelsConfirm"))) return; + setClearingModels(true); + try { + const res = await fetch( + `/api/provider-models?provider=${encodeURIComponent(providerStorageAlias)}&all=true`, + { method: "DELETE" } + ); + if (res.ok) { + // Also delete all aliases that belong to this provider + await Promise.all( + providerAliasEntries.map(([alias]) => + fetch(`/api/models/alias?alias=${encodeURIComponent(alias)}`, { + method: "DELETE", + }).catch(() => {}) + ) + ); + await fetchProviderModelMeta(); + await fetchAliases(); + notify.success(t("clearAllModelsSuccess")); + } else { + notify.error(t("clearAllModelsFailed")); + } + } catch { + notify.error(t("clearAllModelsFailed")); + } finally { + setClearingModels(false); + } + }; + + const customMap = useMemo(() => buildCompatMap(modelMeta.customModels), [modelMeta.customModels]); + const overrideMap = useMemo( + () => buildCompatMap(modelMeta.modelCompatOverrides), + [modelMeta.modelCompatOverrides] + ); + const compatibleFallbackModels = useMemo( + () => getCompatibleFallbackModels(providerId, modelMeta.customModels), + [providerId, modelMeta.customModels] + ); + + const effectiveModelNormalize = (modelId: string, protocol = MODEL_COMPAT_PROTOCOL_KEYS[0]) => + effectiveNormalizeForProtocol(modelId, protocol, customMap, overrideMap); + + const effectiveModelPreserveDeveloper = ( + modelId: string, + protocol = MODEL_COMPAT_PROTOCOL_KEYS[0] + ) => effectivePreserveForProtocol(modelId, protocol, customMap, overrideMap); + + const effectiveModelHidden = useCallback( + (modelId: string) => isModelHidden(modelId, customMap, overrideMap), + [customMap, overrideMap] + ); + + const getUpstreamHeadersRecordForModel = useCallback( + (modelId: string, protocol: string) => + effectiveUpstreamHeadersForProtocol(modelId, protocol, customMap, overrideMap), + [customMap, overrideMap] + ); + + const saveModelCompatFlags = async (modelId: string, patch: ModelCompatSavePatch) => { + setCompatSavingModelId(modelId); + try { + const c = customMap.get(modelId) as Record | undefined; + let body: Record; + const onlyCompatByProtocol = + patch.compatByProtocol && + patch.normalizeToolCallId === undefined && + patch.preserveOpenAIDeveloperRole === undefined && + !("upstreamHeaders" in patch); + + if (c) { + if (onlyCompatByProtocol) { + body = { + provider: providerId, + modelId, + compatByProtocol: patch.compatByProtocol, + }; + } else { + body = { + provider: providerId, + modelId, + modelName: (c.name as string) || modelId, + source: (c.source as string) || "manual", + apiFormat: (c.apiFormat as string) || "chat-completions", + supportedEndpoints: + Array.isArray(c.supportedEndpoints) && (c.supportedEndpoints as unknown[]).length + ? c.supportedEndpoints + : ["chat"], + normalizeToolCallId: + patch.normalizeToolCallId !== undefined + ? patch.normalizeToolCallId + : Boolean(c.normalizeToolCallId), + preserveOpenAIDeveloperRole: + patch.preserveOpenAIDeveloperRole !== undefined + ? patch.preserveOpenAIDeveloperRole + : Object.prototype.hasOwnProperty.call(c, "preserveOpenAIDeveloperRole") + ? Boolean(c.preserveOpenAIDeveloperRole) + : true, + }; + if (patch.compatByProtocol) body.compatByProtocol = patch.compatByProtocol; + } + } else { + body = { provider: providerId, modelId, ...patch }; + } + const res = await fetch("/api/provider-models", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const detail = await formatProviderModelsErrorResponse(res); + notify.error( + detail ? `${t("failedSaveCustomModel")} — ${detail}` : t("failedSaveCustomModel") + ); + return; + } + } catch { + notify.error(t("failedSaveCustomModel")); + return; + } finally { + setCompatSavingModelId(null); + } + try { + await fetchProviderModelMeta(); + } catch { + /* refresh failure is non-critical — data was already saved */ + } + }; + + const handleToggleModelHidden = async ( + providerKey: string, + modelId: string, + hidden: boolean + ): Promise => { + setTogglingModelId(modelId); + try { + const res = await fetch( + `/api/provider-models?provider=${encodeURIComponent(providerKey)}&modelId=${encodeURIComponent(modelId)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ isHidden: hidden }), + } + ); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + notify.error(detail || t("failedSaveCustomModel")); + return; + } + await Promise.all([fetchProviderModelMeta().catch(() => {}), fetchAliases().catch(() => {})]); + } catch { + notify.error(t("failedSaveCustomModel")); + } finally { + setTogglingModelId(null); + } + }; + + const handleBulkToggleModelHidden = async ( + providerKey: string, + modelIds: string[], + hidden: boolean + ): Promise => { + if (modelIds.length === 0) return; + setBulkVisibilityAction(hidden ? "deselect" : "select"); + try { + const res = await fetch(`/api/provider-models?provider=${encodeURIComponent(providerKey)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ isHidden: hidden, modelIds }), + }); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + notify.error(detail || t("failedSaveCustomModel")); + return; + } + await Promise.all([fetchProviderModelMeta().catch(() => {}), fetchAliases().catch(() => {})]); + } catch { + notify.error(t("failedSaveCustomModel")); + } finally { + setBulkVisibilityAction(null); + } + }; + + const renderModelsSection = () => { + const autoSyncToggle = compatibleSupportsModelImport && canImportModels && ( + + ); + + const clearAllButton = (modelMeta.customModels.length > 0 || + providerAliasEntries.length > 0) && ( + + ); + + if (isManagedAvailableModelsProvider) { + const description = + providerId === "openrouter" + ? t("openRouterAnyModelHint") + : isCcCompatible + ? t("ccCompatibleModelsDescription") + : t("compatibleModelsDescription", { + type: isAnthropicCompatible ? t("anthropic") : t("openai"), + }); + const inputLabel = providerId === "openrouter" ? t("modelIdFromOpenRouter") : t("modelId"); + const inputPlaceholder = + providerId === "openrouter" + ? t("openRouterModelPlaceholder") + : isCcCompatible + ? "claude-sonnet-4-6" + : isAnthropicCompatible + ? t("anthropicCompatibleModelPlaceholder") + : t("openaiCompatibleModelPlaceholder"); + + return ( +
+
+ {autoSyncToggle} + {clearAllButton} +
+ + handleToggleModelHidden(providerStorageAlias, modelId, hidden) + } + onBulkToggleHidden={(modelIds, hidden) => + handleBulkToggleModelHidden(providerStorageAlias, modelIds, hidden) + } + bulkTogglePending={bulkVisibilityAction !== null} + togglingModelId={togglingModelId} + onTestModel={onTestModel} + modelTestStatus={modelTestStatus} + testingModelId={testingModelId} + onTestAll={handleTestAll} + testingAll={testingAll} + testProgress={testProgress} + autoHideFailed={autoHideFailed} + onAutoHideFailedChange={setAutoHideFailed} + /> +
+ ); + } + + if (providerInfo.passthroughModels) { + const passthroughDescription = + providerId === "openrouter" + ? t("openRouterAnyModelHint") + : providerId === "bedrock" + ? t("bedrockModelsDescription") + : t("passthroughModelsDescription", { provider: providerInfo?.name || providerId }); + const passthroughInputLabel = + providerId === "openrouter" ? t("modelIdFromOpenRouter") : t("modelId"); + const passthroughInputPlaceholder = + providerId === "openrouter" + ? t("openRouterModelPlaceholder") + : providerId === "bedrock" + ? t("bedrockModelPlaceholder") + : t("openaiCompatibleModelPlaceholder"); + + return ( +
+
+ + {autoSyncToggle} + {clearAllButton} + {!canImportModels && ( + {t("addConnectionToImport")} + )} +
+ + handleToggleModelHidden(providerStorageAlias, modelId, hidden) + } + onBulkToggleHidden={(modelIds, hidden) => + handleBulkToggleModelHidden(providerStorageAlias, modelIds, hidden) + } + bulkTogglePending={bulkVisibilityAction !== null} + togglingModelId={togglingModelId} + onTestModel={onTestModel} + modelTestStatus={modelTestStatus} + testingModelId={testingModelId} + providerId={providerId} + connectionId={selectedConnection?.id ?? ""} + /> +
+ ); + } + + const importButton = ( +
+ + {autoSyncToggle} + {!canImportModels && ( + {t("addConnectionToImport")} + )} +
+ ); + + if (models.length === 0) { + return ( +
+ {importButton} +

{t("noModelsConfigured")}

+
+ ); + } + const modelsWithVisibility = models.map((model) => ({ + ...model, + isHidden: effectiveModelHidden(model.id), + })); + const filteredModels = modelsWithVisibility.filter((model) => { + const matchesQuery = matchesModelCatalogQuery(modelFilter, { + modelId: model.id, + modelName: model.name, + source: model.source, + }); + const matchesVisibility = + visibilityFilter === "all" + ? true + : visibilityFilter === "visible" + ? !model.isHidden + : model.isHidden; + return matchesQuery && matchesVisibility; + }); + const activeCount = modelsWithVisibility.filter((m) => !m.isHidden).length; + const hiddenFilteredCount = filteredModels.filter((m) => m.isHidden).length; + const visibleFilteredCount = filteredModels.length - hiddenFilteredCount; + const testAllTargets = filteredModels + .filter((m) => !m.isHidden) + .map((m) => ({ modelId: m.id, fullModel: `${providerDisplayAlias}/${m.id}` })); + return ( +
+ {importButton} + {modelsWithVisibility.length > 0 && ( + + handleBulkToggleModelHidden( + providerId, + filteredModels.map((model) => model.id), + false + ) + } + onDeselectAll={() => + handleBulkToggleModelHidden( + providerId, + filteredModels.map((model) => model.id), + true + ) + } + selectAllDisabled={hiddenFilteredCount === 0 || bulkVisibilityAction !== null} + deselectAllDisabled={visibleFilteredCount === 0 || bulkVisibilityAction !== null} + onTestAll={() => handleTestAll(testAllTargets)} + testingAll={testingAll} + testProgress={testProgress} + visibilityFilter={visibilityFilter} + onVisibilityFilterChange={setVisibilityFilter} + autoHideFailed={autoHideFailed} + onAutoHideFailedChange={setAutoHideFailed} + /> + )} +
+ {filteredModels.map((model) => { + return ( + getUpstreamHeadersRecordForModel(model.id, p)} + saveModelCompatFlags={saveModelCompatFlags} + compatDisabled={compatSavingModelId === model.id} + onToggleHidden={(modelId, hidden) => + handleToggleModelHidden(providerId, modelId, hidden) + } + togglingHidden={togglingModelId === model.id} + onTestModel={onTestModel} + testStatus={modelTestStatus[model.id] || null} + testingModel={testingModelId === model.id} + /> + ); + })} + {filteredModels.length === 0 && modelFilter && ( +

+ {providerText(t, "noModelsMatch", `No models match "${modelFilter}"`, { + filter: modelFilter, + })} +

+ )} +
+
+ ); + }; + + if (loading) { + return ( +
+ + +
+ ); + } + + if (!providerInfo) { + return ( +
+

{t("providerNotFound")}

+ + {t("backToProviders")} + +
+ ); + } + + // OpenAI/Anthropic compatible providers use their specialized pseudo-provider icons. + const getHeaderIconProviderId = () => { + if (isOpenAICompatible && providerInfo.apiType) { + return providerInfo.apiType === "responses" ? "oai-r" : "oai-cc"; + } + if (isAnthropicProtocolCompatible) { + return "anthropic-m"; + } + return providerInfo.id; + }; + + return ( +
+ {/* Header */} +
+ + arrow_back + {t("backToProviders")} + +
+
+ +
+
+ {providerInfo.website ? ( + + {providerInfo.name} + open_in_new + + ) : ( +

{providerInfo.name}

+ )} +
+

+ {t("connectionCountLabel", { count: connections.length })} +

+ + {providerId === "adapta-web" && ( + + )} +
+
+
+
+ + {providerId === "zed" && ( + <> + +
+
+

+ download + Import from Zed Keychain +

+

+ Discover AI provider credentials (OpenAI, Anthropic, Google, Mistral, xAI) that + Zed IDE stored in the OS keychain and import them as connections. Requires Zed IDE + installed on this machine. +

+
+ +
+
+ +
+ + {showZedManual && ( +
+

+ Use this when OmniRoute runs in Docker or the keychain is unavailable. Paste the + API key that Zed stored under{" "} + ~/.config/zed/settings.json or copy + it from the Zed AI settings panel. +

+
+ + setZedManualToken(e.target.value)} + /> + +
+
+ )} +
+
+ + )} + + {isCompatible && providerNode && ( + +
+
+

+ {isCcCompatible + ? t("ccCompatibleDetailsTitle") + : isAnthropicCompatible + ? t("anthropicCompatibleDetails") + : t("openaiCompatibleDetails")} +

+

+ {getApiLabel()} · {(providerNode.baseUrl || "").replace(/\/$/, "")}/{getApiPath()} +

+
+
+ + + +
+
+ {isCcCompatible && ( +
+
+ + warning + +

{t("ccCompatibleValidationHint")}

+
+
+ )} +
+ )} + + {/* Connections */} + {!isUpstreamProxyProvider && isFreeNoAuth && } + {!isUpstreamProxyProvider && !isFreeNoAuth && ( + +
+
+

{t("connections")}

+ {providerId === "codex" && ( +
+ + {providerText(t, "providerDetailServiceModeLabel", "Global service mode:")} + + + {codexSettingsLoadError ? ( + + ) : null} +
+ )} + {/* Provider-level proxy indicator/button */} + +
+
+ {connections.length > 0 && ( + + )} + {connections.length > 1 && ( + + )} + {!isCompatible ? ( + <> + {isCommandCode ? ( + <> + + + + ) : ( + <> + + {providerId === "qoder" && ( + + )} + {providerId === "codex" && ( + + )} + {providerId === "codex" && ( + + )} + {providerId === "codex" && ( + + )} + {providerId === "claude" && ( + + )} + {providerId === "gemini-cli" && ( + + )} + + )} + + ) : ( + connections.length === 0 && ( + + ) + )} +
+
+ + {connections.length === 0 ? ( +
+
+ + {isOAuth ? "lock" : "key"} + +
+

{t("noConnectionsYet")}

+

{t("addFirstConnectionHint")}

+ {!isCompatible && ( +
+ {isCommandCode ? ( + <> + + + + ) : ( + <> + + {providerId === "qoder" && ( + + )} + {providerId === "codex" && ( + + )} + {providerId === "claude" && ( + + )} + {providerId === "gemini-cli" && ( + + )} + + )} +
+ )} +
+ ) : ( + (() => { + const sorted = [...connections].sort((a, b) => (a.priority || 0) - (b.priority || 0)); + const hasAnyTag = sorted.some( + (c) => c.providerSpecificData?.tag as string | undefined + ); + const allSelected = selectedIds.size === connections.length && connections.length > 0; + const someSelected = selectedIds.size > 0 && selectedIds.size < connections.length; + const bulkBusy = batchUpdating !== null || batchRetesting || batchDeleting; + const bulkActions = selectedIds.size > 0 && ( +
+ + + + +
+ ); + + const isHealthy = (c: ConnectionRowConnection): boolean => { + const s = c.testStatus; + return c.isActive !== false && (!s || s === "active" || s === "success"); + }; + const STATUS_FILTER_OPTIONS = [ + { value: "all", label: t("filterAll", "All") }, + { value: "active", label: t("filterActive", "Active") }, + { value: "error", label: t("filterError", "Error") }, + { value: "banned", label: t("filterBanned", "Banned") }, + { value: "credits_exhausted", label: t("filterCreditsExhausted", "Credits Exhausted") }, + ]; + const filtered = healthFilter === "all" + ? sorted + : sorted.filter((c) => { + if (healthFilter === "active") return isHealthy(c); + if (healthFilter === "error") return !isHealthy(c) && c.testStatus !== "banned" && c.testStatus !== "credits_exhausted"; + return c.testStatus === healthFilter; + }); + + const totalFilteredPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); + const clampedPage = Math.min(page, totalFilteredPages - 1); + const pageStart = clampedPage * PAGE_SIZE; + const pageEnd = pageStart + PAGE_SIZE; + + const filterPills = ( +
+ {STATUS_FILTER_OPTIONS.map((opt) => ( + + ))} +
+ ); + + const paginationBar = totalFilteredPages > 1 ? ( +
+ + {pageStart + 1}–{Math.min(pageEnd, filtered.length)} / {filtered.length} + +
+
+
+ ) : null; + + if (!hasAnyTag) { + const pageConnections = filtered.slice(pageStart, pageEnd); + const allSelected = pageConnections.length > 0 && pageConnections.every((c) => selectedIds.has(c.id)); + const someSelected = pageConnections.some((c) => selectedIds.has(c.id)); + return ( + <> +
+
+ + {filterPills} +
+ + {bulkActions} +
+
+ {pageConnections.length === 0 ? ( +
+ {t("noFilteredConnections", "No connections match the current filter.")} +
+ ) : ( + pageConnections.map((conn, index) => ( + handleToggleSelectOne(conn.id)} + onMoveUp={() => handleSwapPriority(conn, sorted[index - 1])} + onMoveDown={() => handleSwapPriority(conn, sorted[index + 1])} + onToggleActive={(isActive) => + handleUpdateConnectionStatus(conn.id, isActive) + } + onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)} + onToggleClaudeExtraUsage={(enabled) => + handleToggleClaudeExtraUsage(conn.id, enabled) + } + isCodex={providerId === "codex"} + isGeminiCli={providerId === "gemini-cli"} + isCcCompatible={isCcCompatible} + cliproxyapiEnabled={cpaProviderEnabled} + onToggleCliproxyapiMode={(enabled) => + handleToggleCliproxyapiMode(conn.id, enabled) + } + onToggleCodex5h={(enabled) => + handleToggleCodexLimit(conn.id, "use5h", enabled) + } + onToggleCodexWeekly={(enabled) => + handleToggleCodexLimit(conn.id, "useWeekly", enabled) + } + onRetest={() => handleRetestConnection(conn.id)} + isRetesting={retestingId === conn.id} + onEdit={() => { + setSelectedConnection(conn); + setShowEditModal(true); + }} + onDelete={() => handleDelete(conn.id)} + onReauth={ + conn.authType === "oauth" + ? () => gateConnectionFlow(() => setShowOAuthModal(true, conn)) + : undefined + } + onRefreshToken={ + conn.authType === "oauth" + ? () => handleRefreshToken(conn.id) + : undefined + } + isRefreshing={refreshingId === conn.id} + onApplyCodexAuthLocal={ + providerId === "codex" + ? () => setApplyCodexModalConnectionId(conn.id) + : undefined + } + isApplyingCodexAuthLocal={applyingCodexAuthId === conn.id} + onExportCodexAuthFile={ + providerId === "codex" + ? () => handleExportCodexAuthFile(conn.id) + : undefined + } + isExportingCodexAuthFile={exportingCodexAuthId === conn.id} + onApplyClaudeAuthLocal={ + providerId === "claude" + ? () => setApplyClaudeModalConnectionId(conn.id) + : undefined + } + isApplyingClaudeAuthLocal={applyingClaudeAuthId === conn.id} + onExportClaudeAuthFile={ + providerId === "claude" + ? () => handleExportClaudeAuthFile(conn.id) + : undefined + } + isExportingClaudeAuthFile={exportingClaudeAuthId === conn.id} + onApplyGeminiAuthLocal={ + providerId === "gemini-cli" + ? () => setApplyGeminiModalConnectionId(conn.id) + : undefined + } + isApplyingGeminiAuthLocal={applyingGeminiAuthId === conn.id} + onExportGeminiAuthFile={ + providerId === "gemini-cli" + ? () => handleExportGeminiAuthFile(conn.id) + : undefined + } + isExportingGeminiAuthFile={exportingGeminiAuthId === conn.id} + onProxy={() => + setProxyTarget({ + level: "key", + id: conn.id, + label: pickDisplayValue( + [conn.name, conn.email], + emailsVisible, + conn.id + ), + }) + } + hasProxy={!!connProxyMap[conn.id]?.proxy} + proxySource={connProxyMap[conn.id]?.level || null} + proxyHost={connProxyMap[conn.id]?.proxy?.host || null} + proxyEnabled={readBooleanToggle(conn.proxyEnabled, true)} + onToggleProxyEnabled={(enabled) => handleToggleProxyEnabled(conn.id, enabled)} + perKeyProxyEnabled={readBooleanToggle(conn.perKeyProxyEnabled, false)} + onTogglePerKeyProxyEnabled={(enabled) => handleTogglePerKeyProxyEnabled(conn.id, enabled)} + /> + ))) + } +
+ {paginationBar} + + ); + } + + // Build ordered tag groups: untagged first, then alphabetically + const groupMap = new Map(); + for (const conn of filtered) { + const tag = (conn.providerSpecificData?.tag as string | undefined)?.trim() || ""; + if (!groupMap.has(tag)) groupMap.set(tag, []); + groupMap.get(tag)!.push(conn); + } + const groupKeys = Array.from(groupMap.keys()).sort((a, b) => { + if (a === "") return -1; + if (b === "") return 1; + return compareTr(a, b); + }); + + return ( + <> + {selectedIds.size > 0 || connections.length > 0 ? ( +
+
+ + {filterPills} +
+ +
+ {/* Distribute Proxies lives in the provider toolbar (top action bar); + removed the duplicate here that rendered simultaneously when nothing + was selected. Per-tag groups keep their own scoped button. */} + {bulkActions} +
+
+ ) : null} +
+ {groupKeys.map((tag, gi) => { + const groupConns = groupMap.get(tag)!; + return ( +
0 + ? "border-t border-black/[0.06] dark:border-white/[0.06] mt-1 pt-1" + : "" + } + > + {tag && ( +
+ + label + + + {tag} + +
+ + + {groupConns.length} + +
+ )} +
+ {groupConns.map((conn, index) => ( + handleToggleSelectOne(conn.id)} + onMoveUp={() => + handleSwapPriority(conn, sorted[sorted.indexOf(conn) - 1]) + } + onMoveDown={() => + handleSwapPriority(conn, sorted[sorted.indexOf(conn) + 1]) + } + onToggleActive={(isActive) => + handleUpdateConnectionStatus(conn.id, isActive) + } + onToggleRateLimit={(enabled) => + handleToggleRateLimit(conn.id, enabled) + } + onToggleClaudeExtraUsage={(enabled) => + handleToggleClaudeExtraUsage(conn.id, enabled) + } + isCodex={providerId === "codex"} + isGeminiCli={providerId === "gemini-cli"} + isCcCompatible={isCcCompatible} + cliproxyapiEnabled={cpaProviderEnabled} + onToggleCodex5h={(enabled) => + handleToggleCodexLimit(conn.id, "use5h", enabled) + } + onToggleCodexWeekly={(enabled) => + handleToggleCodexLimit(conn.id, "useWeekly", enabled) + } + onRetest={() => handleRetestConnection(conn.id)} + isRetesting={retestingId === conn.id} + onEdit={() => { + setSelectedConnection(conn); + setShowEditModal(true); + }} + onDelete={() => handleDelete(conn.id)} + onReauth={ + conn.authType === "oauth" + ? () => gateConnectionFlow(() => setShowOAuthModal(true, conn)) + : undefined + } + onRefreshToken={ + conn.authType === "oauth" + ? () => handleRefreshToken(conn.id) + : undefined + } + isRefreshing={refreshingId === conn.id} + onApplyCodexAuthLocal={ + providerId === "codex" + ? () => setApplyCodexModalConnectionId(conn.id) + : undefined + } + isApplyingCodexAuthLocal={applyingCodexAuthId === conn.id} + onExportCodexAuthFile={ + providerId === "codex" + ? () => handleExportCodexAuthFile(conn.id) + : undefined + } + isExportingCodexAuthFile={exportingCodexAuthId === conn.id} + onApplyClaudeAuthLocal={ + providerId === "claude" + ? () => setApplyClaudeModalConnectionId(conn.id) + : undefined + } + isApplyingClaudeAuthLocal={applyingClaudeAuthId === conn.id} + onExportClaudeAuthFile={ + providerId === "claude" + ? () => handleExportClaudeAuthFile(conn.id) + : undefined + } + isExportingClaudeAuthFile={exportingClaudeAuthId === conn.id} + onApplyGeminiAuthLocal={ + providerId === "gemini-cli" + ? () => setApplyGeminiModalConnectionId(conn.id) + : undefined + } + isApplyingGeminiAuthLocal={applyingGeminiAuthId === conn.id} + onExportGeminiAuthFile={ + providerId === "gemini-cli" + ? () => handleExportGeminiAuthFile(conn.id) + : undefined + } + isExportingGeminiAuthFile={exportingGeminiAuthId === conn.id} + onProxy={() => + setProxyTarget({ + level: "key", + id: conn.id, + label: pickDisplayValue( + [conn.name, conn.email], + emailsVisible, + conn.id + ), + }) + } + hasProxy={!!connProxyMap[conn.id]?.proxy} + proxySource={connProxyMap[conn.id]?.level || null} + proxyHost={connProxyMap[conn.id]?.proxy?.host || null} + proxyEnabled={readBooleanToggle(conn.proxyEnabled, true)} + onToggleProxyEnabled={(enabled) => handleToggleProxyEnabled(conn.id, enabled)} + perKeyProxyEnabled={readBooleanToggle(conn.perKeyProxyEnabled, false)} + onTogglePerKeyProxyEnabled={(enabled) => handleTogglePerKeyProxyEnabled(conn.id, enabled)} + /> + ))} +
+
+ ); + })} +
+ + ); + })() + )} + + )} + + {isUpstreamProxyProvider && ( + +
+
+

+ {providerText( + t, + "upstreamProxyManagedTitle", + "Managed via Upstream Proxy Settings" + )} +

+

+ {providerText( + t, + "upstreamProxyManagedDescription", + "CLIProxyAPI is configured as an upstream proxy layer, not as a direct provider connection. Manage the binary/runtime in CLI Tools and enable proxy routing on each provider via the provider proxy controls." + )} +

+
+
+ + terminal + {t("openCliTools")} + + + settings + {t("openSettings")} + +
+
+
+ )} + + {/* Models — hidden for search providers (they don't have models) */} + {!isSearchProvider && !isUpstreamProxyProvider && ( + +

{t("availableModels")}

+ {renderModelsSection()} + + {/* Custom Models — available for all providers */} + +
+ )} + + {/* Search provider info */} + {isSearchProvider && ( + +

{t("searchProvider")}

+

{t("searchProviderDesc")}

+ {providerId === "perplexity-search" && ( +
+ link +

{t("perplexitySearchSharedKeyInfo")}

+
+ )} + {providerId === "google-pse-search" && ( +
+ tune +

{t("googlePseInfo")}

+
+ )} + {providerId === "searxng-search" && ( +
+ dns +

{t("searxngInfo")}

+
+ )} +
+ )} + + {/* Playground panel — rendered for providers that declare serviceKinds */} + + + {/* Modals */} + {showRiskNoticeModal && subscriptionRisk && ( + + )} + {!isUpstreamProxyProvider && + (providerId === "kiro" || providerId === "amazon-q" ? ( + { + setShowOAuthModal(false); + }} + /> + ) : providerId === "cursor" ? ( + { + setShowOAuthModal(false); + }} + /> + ) : providerId === "trae" ? ( + { + setShowOAuthModal(false); + }} + /> + ) : ( + { + setShowOAuthModal(false); + }} + /> + ))} + {providerId === "siliconflow" && ( + { + setSiliconFlowInitialBaseUrl(baseUrl); + setShowSiliconFlowEndpointModal(false); + setShowAddApiKeyModal(true); + }} + onClose={() => { + setShowSiliconFlowEndpointModal(false); + setSiliconFlowInitialBaseUrl(undefined); + }} + /> + )} + {!isUpstreamProxyProvider && ( + + )} + setBatchDeleteConfirmOpen(false)} + onConfirm={handleBatchDeleteConfirm} + title={t("batchDeleteConfirmTitle", "Delete connections")} + message={t("batchDeleteConfirm", { count: selectedIds.size })} + confirmText={t("batchDeleteConfirmButton", "Delete")} + cancelText={t("cancel", "Cancel")} + loading={batchDeleting} + /> + {providerId === "codex" && applyCodexModalConnectionId && ( + setApplyCodexModalConnectionId(null)} + /> + )} + {!isUpstreamProxyProvider && ( + setShowEditModal(false)} + /> + )} + {!isUpstreamProxyProvider && isCompatible && ( + setShowEditNodeModal(false)} + isAnthropic={isAnthropicProtocolCompatible} + isCcCompatible={isCcCompatible} + /> + )} + {/* Codex CLI Guide Modal */} + setCodexCliGuideOpen(false)} + /> + {/* Codex Import Auth Modal */} + {providerId === "codex" && importCodexModalOpen && ( + setImportCodexModalOpen(false)} + onSuccess={() => { + setImportCodexModalOpen(false); + void fetchConnections(); + }} + /> + )} + {providerId === "codex" && externalLinkModalOpen && ( + setExternalLinkModalOpen(false)} + title="Adicionar Externo — link do Codex" + > +
+

+ Compartilhe este link com quem vai autenticar a conta do Codex. A pessoa abre a + página, faz o login da OpenAI no próprio navegador e a conexão é cadastrada aqui. + Uso único, expira em 15 minutos. +

+ {externalLinkLoading ? ( +

Gerando link…

+ ) : externalLinkError ? ( +

{externalLinkError}

+ ) : externalLinkUrl ? ( + <> +
+ {externalLinkUrl} +
+
+ + +
+

+ sync + Aguardando a autenticação no navegador da pessoa… esta janela atualiza sozinha. +

+ + ) : null} +
+
+ )} + {/* Claude Apply Auth Modal */} + {providerId === "claude" && applyClaudeModalConnectionId && ( + setApplyClaudeModalConnectionId(null)} + /> + )} + {/* Claude Import Auth Modal */} + {providerId === "claude" && importClaudeModalOpen && ( + setImportClaudeModalOpen(false)} + onSuccess={() => { + setImportClaudeModalOpen(false); + void fetchConnections(); + }} + /> + )} + {/* Gemini Apply Auth Modal */} + {providerId === "gemini-cli" && applyGeminiModalConnectionId && ( + setApplyGeminiModalConnectionId(null)} + /> + )} + {/* Gemini Import Auth Modal */} + {providerId === "gemini-cli" && importGeminiModalOpen && ( + setImportGeminiModalOpen(false)} + onSuccess={() => { + setImportGeminiModalOpen(false); + void fetchConnections(); + }} + /> + )} + {/* Batch Test Results Modal */} + {batchTestResults && ( +
setBatchTestResults(null)} + > +
+
e.stopPropagation()} + > +
+

{t("testResults")}

+ +
+
+ {batchTestResults.error && + (!batchTestResults.results || batchTestResults.results.length === 0) ? ( +
+ + error + +

{String(batchTestResults.error)}

+
+ ) : ( +
+ {batchTestResults.summary && ( +
+ {providerInfo?.name || providerId} + + {t("passedCount", { count: batchTestResults.summary.passed })} + + {batchTestResults.summary.failed > 0 && ( + + {t("failedCount", { count: batchTestResults.summary.failed })} + + )} + + {t("testedCount", { count: batchTestResults.summary.total })} + +
+ )} + {(batchTestResults.results || []).map((r: any, i: number) => ( +
+ + {r.valid ? "check_circle" : "error"} + +
+ + {pickDisplayValue([r.connectionName], emailsVisible, r.connectionName)} + +
+ {r.latencyMs !== undefined && ( + + {t("millisecondsAbbr", { value: r.latencyMs })} + + )} + + {r.valid ? t("okShort") : r.diagnosis?.type || t("errorShort")} + +
+ ))} + {(!batchTestResults.results || batchTestResults.results.length === 0) && ( +
+ {t("noActiveConnectionsInGroup")} +
+ )} +
+ )} +
+
+
+ )} + {/* Proxy Config Modal */} + {proxyTarget && ( + setProxyTarget(null)} + level={proxyTarget.level} + levelId={proxyTarget.id} + levelLabel={proxyTarget.label} + onSaved={() => { + void fetchProxyConfig(); + void loadConnProxies(connections); + }} + /> + )} + {/* Import Progress Modal */} + { + if (importProgress.phase === "done" || importProgress.phase === "error") { + setShowImportModal(false); + } + }} + title={t("importingModelsTitle")} + size="md" + closeOnOverlay={false} + showCloseButton={importProgress.phase === "done" || importProgress.phase === "error"} + > +
+ {/* Status text */} +
+ {importProgress.phase === "fetching" && ( + + progress_activity + + )} + {importProgress.phase === "importing" && ( + + progress_activity + + )} + {importProgress.phase === "done" && ( + check_circle + )} + {importProgress.phase === "error" && ( + error + )} + {importProgress.status} +
+ + {/* Progress bar */} + {(importProgress.phase === "importing" || importProgress.phase === "done") && + importProgress.total > 0 && ( +
+
+ + {importProgress.current} / {importProgress.total} + + + {Math.round((importProgress.current / importProgress.total) * 100)}% + +
+
+
+
+
+ )} + + {/* Fetching indeterminate bar */} + {importProgress.phase === "fetching" && ( +
+
+
+ )} + + {/* Error message */} + {importProgress.phase === "error" && importProgress.error && ( +
+

{importProgress.error}

+
+ )} + + {/* Log list */} + {importProgress.logs.length > 0 && ( +
+
+ {importProgress.logs.map((log, i) => ( +

+ {log} +

+ ))} +
+
+ )} + + {/* Close button */} + {importProgress.phase === "done" && ( +
+ +
+ )} +
+ + + {/* Adapta Web — Tutorial Modal */} + {providerId === "adapta-web" && ( + setShowTutorialModal(false)} + title="Como conectar o Adapta Web" + size="md" + > +
+

+ O Adapta usa autenticação via Clerk. O token{" "} + __client é um JWT + de longa duração que permite renovar sessões automaticamente. +

+ +
    +
  1. + + 1 + +
    +

    Acesse o chat do Adapta

    +

    + Abra{" "} + + agent.adapta.one/agentic-chat + {" "} + e faça login com sua conta Gold ou Business. +

    +
    +
  2. + +
  3. + + 2 + +
    +

    Abra o DevTools

    +

    + Pressione{" "} + F12{" "} + ou{" "} + + Cmd+Option+I + {" "} + para abrir as Ferramentas do Desenvolvedor. +

    +
    +
  4. + +
  5. + + 3 + +
    +

    Vá em Application → Cookies

    +

    + Na aba Application (Chrome/Edge) ou Storage{" "} + (Firefox), expanda Cookies e clique em{" "} + + .clerk.agent.adapta.one + + . +

    +
    +
  6. + +
  7. + + 4 + +
    +

    + Copie o valor do cookie{" "} + __client +

    +

    + Localize o cookie chamado{" "} + __client na + lista. Clique nele e copie o conteúdo da coluna Value — começa + com eyJ…. +

    +
    +
  8. + +
  9. + + 5 + +
    +

    Cole aqui e salve

    +

    + Clique em Add Connection, cole o valor do{" "} + __client no + campo de API Key e salve. O OmniRoute renovará a sessão automaticamente. +

    +
    +
  10. +
+ +
+ Dica: O cookie __client tem + validade longa (meses). Só será necessário renová-lo se você sair da conta ou o Adapta + invalidar a sessão. +
+
+
+ )} +
+ ); +} + +function ModelRow({ + model, + fullModel, + provider, + copied, + onCopy, + t, + showDeveloperToggle = true, + effectiveModelNormalize, + effectiveModelPreserveDeveloper, + getUpstreamHeadersRecord, + saveModelCompatFlags, + compatDisabled, + onToggleHidden, + togglingHidden, + onTestModel, + testStatus, + testingModel, +}: ModelRowProps) { + const isHidden = Boolean(model.isHidden); + return ( +
+
+ + smart_toy + + + {fullModel} + + + +
+
+ {onTestModel && ( + + )} + {onToggleHidden && ( + + )} + effectiveModelNormalize(model.id, p)} + effectiveModelPreserveDeveloper={(p) => effectiveModelPreserveDeveloper(model.id, p)} + getUpstreamHeadersRecord={getUpstreamHeadersRecord} + onCompatPatch={(protocol, payload) => + saveModelCompatFlags(model.id, { compatByProtocol: { [protocol]: payload } }) + } + showDeveloperToggle={showDeveloperToggle} + disabled={compatDisabled} + /> +
+
+ ); +} + +function ModelVisibilityToolbar({ + t, + filterValue, + onFilterChange, + activeCount, + totalCount, + onSelectAll, + onDeselectAll, + selectAllDisabled, + deselectAllDisabled, + onTestAll, + testingAll, + testProgress, + visibilityFilter, + onVisibilityFilterChange, + autoHideFailed, + onAutoHideFailedChange, +}: { + t: ((key: string, values?: Record) => string) & { + has?: (key: string) => boolean; + }; + filterValue: string; + onFilterChange: (value: string) => void; + activeCount: number; + totalCount: number; + onSelectAll: () => void; + onDeselectAll: () => void; + selectAllDisabled?: boolean; + deselectAllDisabled?: boolean; + onTestAll?: () => void; + testingAll?: boolean; + testProgress?: { done: number; total: number } | null; + visibilityFilter?: "all" | "visible" | "hidden"; + onVisibilityFilterChange?: (filter: "all" | "visible" | "hidden") => void; + autoHideFailed?: boolean; + onAutoHideFailedChange?: (v: boolean) => void; +}) { + return ( +
+
+ + search + + onFilterChange(e.target.value)} + placeholder={providerText(t, "filterModels", "Filter models…")} + className="w-full rounded-lg border border-border bg-sidebar/50 py-1.5 pl-7 pr-3 text-xs text-text-main placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-primary" + /> +
+ {visibilityFilter !== undefined && onVisibilityFilterChange && ( +
+ {(["all", "visible", "hidden"] as const).map((f) => ( + + ))} +
+ )} + {onAutoHideFailedChange && ( + + )} + {onTestAll && ( + + )} + + +
+ ); +} + +function PassthroughModelsSection({ + providerAlias, + modelAliases, + availableModels = [], + customModels = [], + description, + inputLabel, + inputPlaceholder, + copied, + onCopy, + onSetAlias, + onDeleteAlias, + t, + effectiveModelNormalize, + effectiveModelPreserveDeveloper, + getUpstreamHeadersRecord, + saveModelCompatFlags, + compatSavingModelId, + isModelHidden, + onToggleHidden, + onBulkToggleHidden, + bulkTogglePending, + togglingModelId, + onTestModel, + modelTestStatus, + testingModelId, + providerId, + connectionId, +}: PassthroughModelsSectionProps) { + const [newModel, setNewModel] = useState(""); + const [adding, setAdding] = useState(false); + const [modelFilter, setModelFilter] = useState(""); + const [testingAll, setTestingAll] = useState(false); + const [testProgress, setTestProgress] = useState<{ done: number; total: number } | null>(null); + const [autoHideFailed, setAutoHideFailed] = useState(true); + const [visibilityFilter, setVisibilityFilter] = useState<"all" | "visible" | "hidden">("all"); + const notify = useNotificationStore(); + const customModelMap = useMemo(() => buildCompatMap(customModels), [customModels]); + + const handleTestAll = async () => { + const modelsToTest = filteredModels.filter((m) => !m.isHidden); + if (modelsToTest.length === 0) { + notify.error(providerText(t, "noModelsToTest", "No models to test")); + return; + } + setTestingAll(true); + setTestProgress({ done: 0, total: modelsToTest.length }); + + let ok = 0; + let error = 0; + let hiddenCount = 0; + + for (const model of modelsToTest) { + try { + const result: { + results?: Record< + string, + { + status?: "ok" | "error"; + rateLimited?: boolean; + isTimeout?: boolean; + error?: string; + } + >; + } = await fetch("/api/models/test-all", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + providerId, + connectionId, + modelIds: [model.modelId], + }), + }).then((r) => r.json()); + + const entry = result.results?.[model.modelId]; + if (entry?.status === "ok") { + ok++; + } else { + error++; + if (autoHideFailed && !entry?.rateLimited && !entry?.isTimeout) { + await onToggleHidden(model.modelId, true); + hiddenCount++; + } + } + } catch (e) { + error++; + } + setTestProgress((prev) => (prev ? { done: prev.done + 1, total: prev.total } : null)); + } + + notify.info(providerText(t, "testAllResults", "{ok} ok, {error} error", { ok, error })); + if (hiddenCount > 0) { + notify.info(providerText(t, "testAllFailedHidden", "{count} hidden", { count: hiddenCount })); + } + setTestingAll(false); + setTestProgress(null); + }; + + const providerAliases = useMemo( + () => + Object.entries(modelAliases).filter(([, model]: [string, any]) => + (model as string).startsWith(`${providerAlias}/`) + ), + [modelAliases, providerAlias] + ); + + const allModels = useMemo(() => { + const prefix = `${providerAlias}/`; + const aliasByModelId = new Map(); + const fullModelByModelId = new Map(); + const rows: Array<{ + modelId: string; + fullModel: string; + alias: string | null; + displayName: string; + source: string; + isFree: boolean; + isHidden: boolean; + }> = []; + const seenModelIds = new Set(); + + for (const [alias, fullModel] of providerAliases) { + const fmStr = fullModel as string; + const modelId = fmStr.startsWith(prefix) ? fmStr.slice(prefix.length) : fmStr; + aliasByModelId.set(modelId, alias as string); + fullModelByModelId.set(modelId, fmStr); + } + + const addModel = (model: CompatModelRow, source: string) => { + if (!model?.id || seenModelIds.has(model.id)) return; + const fullModel = fullModelByModelId.get(model.id) || `${providerAlias}/${model.id}`; + rows.push({ + modelId: model.id, + fullModel, + alias: aliasByModelId.get(model.id) || null, + displayName: model.name || model.id, + source, + isFree: + Boolean((model as any).free) || + model.id.endsWith(":free") || + /\bgr[aá]tis\b|\bfree\b/i.test(model.name || ""), + isHidden: isModelHidden(model.id), + }); + seenModelIds.add(model.id); + }; + + for (const model of availableModels) { + addModel(model, "imported"); + } + + for (const model of customModels) { + addModel( + model, + normalizeModelCatalogSource(model.source) === "imported" ? "imported" : "custom" + ); + } + + for (const [alias, fullModel] of providerAliases) { + const fmStr = fullModel as string; + const modelId = fmStr.startsWith(prefix) ? fmStr.slice(prefix.length) : fmStr; + if (!modelId || seenModelIds.has(modelId)) continue; + const customModel = customModelMap.get(modelId); + rows.push({ + modelId, + fullModel: fmStr, + alias: alias as string, + displayName: alias as string, + source: customModel ? customModel.source || "custom" : "alias", + isFree: + modelId.endsWith(":free") || + Boolean((customModel as any)?.free) || + /\bgr[aá]tis\b|\bfree\b/i.test(customModel?.name || alias || ""), + isHidden: isModelHidden(modelId), + }); + seenModelIds.add(modelId); + } + + return rows; + }, [ + availableModels, + customModelMap, + customModels, + isModelHidden, + providerAlias, + providerAliases, + ]); + const filteredModels = allModels.filter((model) => { + const matchesQuery = matchesModelCatalogQuery(modelFilter, { + modelId: model.modelId, + modelName: model.displayName, + alias: model.alias, + source: model.source, + }); + + const matchesVisibility = + visibilityFilter === "all" + ? true + : visibilityFilter === "visible" + ? !model.isHidden + : model.isHidden; + + return matchesQuery && matchesVisibility; + }); + const activeCount = allModels.filter((model) => !model.isHidden).length; + const hiddenFilteredCount = filteredModels.filter((model) => model.isHidden).length; + const visibleFilteredCount = filteredModels.length - hiddenFilteredCount; + + // Generate default alias from modelId (last part after /) + const generateDefaultAlias = (modelId) => { + const parts = modelId.split("/"); + return parts[parts.length - 1]; + }; + + const handleAdd = async () => { + if (!newModel.trim() || adding) return; + const modelId = newModel.trim(); + const defaultAlias = generateDefaultAlias(modelId); + + // Check if alias already exists + if (modelAliases[defaultAlias]) { + alert(t("aliasExistsAlert", { alias: defaultAlias })); + return; + } + + setAdding(true); + try { + await onSetAlias(modelId, defaultAlias); + setNewModel(""); + } catch (error) { + console.error("Error adding model:", error); + } finally { + setAdding(false); + } + }; + + return ( +
+

{description}

+ + {/* Add new model */} +
+
+ + setNewModel(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleAdd()} + placeholder={inputPlaceholder} + className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" + /> +
+ +
+ + {/* Models list */} + {allModels.length > 0 && ( +
+ + onBulkToggleHidden( + filteredModels.map((m) => m.modelId), + false + ) + } + onDeselectAll={() => + onBulkToggleHidden( + filteredModels.map((m) => m.modelId), + true + ) + } + selectAllDisabled={bulkTogglePending || filteredModels.length === 0} + deselectAllDisabled={bulkTogglePending || filteredModels.length === 0} + onTestAll={handleTestAll} + testingAll={testingAll} + visibilityFilter={visibilityFilter} + onVisibilityFilterChange={setVisibilityFilter} + autoHideFailed={autoHideFailed} + onAutoHideFailedChange={setAutoHideFailed} + /> +
+ {filteredModels.map(({ modelId, fullModel, alias, isHidden, source, isFree }) => ( + onDeleteAlias(alias) : undefined} + t={t} + showDeveloperToggle + effectiveModelNormalize={effectiveModelNormalize} + effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper} + getUpstreamHeadersRecord={(p) => getUpstreamHeadersRecord(modelId, p)} + saveModelCompatFlags={saveModelCompatFlags} + compatDisabled={compatSavingModelId === modelId} + onToggleHidden={onToggleHidden} + togglingHidden={togglingModelId === modelId} + onTestModel={onTestModel} + testStatus={modelTestStatus?.[modelId] || null} + testingModel={testingModelId === modelId} + /> + ))} +
+ {filteredModels.length === 0 && modelFilter && ( +

+ {providerText(t, "noModelsMatch", `No models match "${modelFilter}"`, { + filter: modelFilter, + })} +

+ )} +
+ )} +
+ ); +} + +function PassthroughModelRow({ + modelId, + fullModel, + source, + isFree, + isHidden, + copied, + onCopy, + onDeleteAlias, + t, + showDeveloperToggle = true, + effectiveModelNormalize, + effectiveModelPreserveDeveloper, + getUpstreamHeadersRecord, + saveModelCompatFlags, + compatDisabled, + onToggleHidden, + togglingHidden, + onTestModel, + testStatus, + testingModel, +}: PassthroughModelRowProps) { + return ( +
+
+ + smart_toy + + + {fullModel} + +
+
+
+ + {isFree && ( + + {providerText(t, "freeBadge", "Free")} + + )} +
+
+ + {onTestModel && ( + + )} + {onToggleHidden && ( + + )} + effectiveModelNormalize(modelId, p)} + effectiveModelPreserveDeveloper={(p) => effectiveModelPreserveDeveloper(modelId, p)} + getUpstreamHeadersRecord={getUpstreamHeadersRecord} + onCompatPatch={(protocol, payload) => + saveModelCompatFlags(modelId, { compatByProtocol: { [protocol]: payload } }) + } + showDeveloperToggle={showDeveloperToggle} + compact + disabled={compatDisabled} + /> + {onDeleteAlias && ( + + )} +
+
+
+ ); +} + +// ============ Custom Models Section (for ALL providers) ============ + +function CustomModelsSection({ + providerId, + providerAlias, + copied, + onCopy, + onModelsChanged, +}: CustomModelsSectionProps) { + const t = useTranslations("providers"); + const notify = useNotificationStore(); + const [customModels, setCustomModels] = useState([]); + const [modelCompatOverrides, setModelCompatOverrides] = useState< + Array + >([]); + const [newModelId, setNewModelId] = useState(""); + const [newModelName, setNewModelName] = useState(""); + const [newApiFormat, setNewApiFormat] = useState("chat-completions"); + const [newEndpoints, setNewEndpoints] = useState(["chat"]); + const [adding, setAdding] = useState(false); + const [loading, setLoading] = useState(true); + const [editingModelId, setEditingModelId] = useState(null); + const [editingApiFormat, setEditingApiFormat] = useState("chat-completions"); + const [editingEndpoints, setEditingEndpoints] = useState(["chat"]); + const [savingModelId, setSavingModelId] = useState(null); + const [togglingModelId, setTogglingModelId] = useState(null); + + const customMap = useMemo(() => buildCompatMap(customModels), [customModels]); + const overrideMap = useMemo(() => buildCompatMap(modelCompatOverrides), [modelCompatOverrides]); + + const fetchCustomModels = useCallback(async () => { + try { + const res = await fetch(`/api/provider-models?provider=${encodeURIComponent(providerId)}`); + if (res.ok) { + const data = await res.json(); + setCustomModels(data.models || []); + setModelCompatOverrides(data.modelCompatOverrides || []); + } + } catch (e) { + console.error("Failed to fetch custom models:", e); + } finally { + setLoading(false); + } + }, [providerId]); + + useEffect(() => { + fetchCustomModels(); + }, [fetchCustomModels]); + + const handleAdd = async () => { + if (!newModelId.trim() || adding) return; + setAdding(true); + try { + const res = await fetch("/api/provider-models", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: providerId, + modelId: newModelId.trim(), + modelName: newModelName.trim() || undefined, + apiFormat: newApiFormat, + supportedEndpoints: newEndpoints, + }), + }); + if (res.ok) { + setNewModelId(""); + setNewModelName(""); + setNewApiFormat("chat-completions"); + setNewEndpoints(["chat"]); + await fetchCustomModels(); + onModelsChanged?.(); + } + } catch (e) { + console.error("Failed to add custom model:", e); + } finally { + setAdding(false); + } + }; + + const handleRemove = async (modelId) => { + try { + await fetch( + `/api/provider-models?provider=${encodeURIComponent(providerId)}&model=${encodeURIComponent(modelId)}`, + { + method: "DELETE", + } + ); + await fetchCustomModels(); + onModelsChanged?.(); + } catch (e) { + console.error("Failed to remove custom model:", e); + } + }; + + const handleToggleHidden = async (modelId: string, hidden: boolean) => { + setTogglingModelId(modelId); + try { + const res = await fetch( + `/api/provider-models?provider=${encodeURIComponent(providerId)}&modelId=${encodeURIComponent(modelId)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ isHidden: hidden }), + } + ); + if (res.ok) { + await fetchCustomModels(); + onModelsChanged?.(); + } + } catch (e) { + console.error("Failed to toggle model visibility:", e); + } finally { + setTogglingModelId(null); + } + }; + + const beginEdit = (model) => { + setEditingModelId(model.id); + setEditingApiFormat(model.apiFormat || "chat-completions"); + setEditingEndpoints( + Array.isArray(model.supportedEndpoints) && model.supportedEndpoints.length + ? model.supportedEndpoints + : ["chat"] + ); + }; + + const cancelEdit = () => { + setEditingModelId(null); + setEditingApiFormat("chat-completions"); + setEditingEndpoints(["chat"]); + setSavingModelId(null); + }; + + const saveCustomCompat = async ( + modelId: string, + patch: { compatByProtocol?: CompatByProtocolMap } + ) => { + setSavingModelId(modelId); + try { + const res = await fetch("/api/provider-models", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: providerId, modelId, ...patch }), + }); + if (!res.ok) { + const detail = await formatProviderModelsErrorResponse(res); + notify.error( + detail ? `${t("failedSaveCustomModel")} — ${detail}` : t("failedSaveCustomModel") + ); + return; + } + } catch { + notify.error(t("failedSaveCustomModel")); + return; + } finally { + setSavingModelId(null); + } + try { + await fetchCustomModels(); + onModelsChanged?.(); + } catch { + /* refresh failure is non-critical — data was already saved */ + } + }; + + const saveEdit = async (modelId) => { + if (!editingModelId || editingModelId !== modelId) return; + if (!editingEndpoints.length) { + notify.error("Select at least one supported endpoint"); + return; + } + + setSavingModelId(modelId); + try { + const model = customModels.find((m) => m.id === modelId); + const res = await fetch("/api/provider-models", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: providerId, + modelId, + modelName: model?.name || modelId, + source: model?.source || "manual", + apiFormat: editingApiFormat, + supportedEndpoints: editingEndpoints, + }), + }); + + if (!res.ok) { + const detail = await formatProviderModelsErrorResponse(res); + throw new Error(detail || "Failed to save model endpoint settings"); + } + + await fetchCustomModels(); + onModelsChanged?.(); + notify.success("Saved model endpoint settings"); + cancelEdit(); + } catch (e) { + console.error("Failed to save custom model:", e); + notify.error( + e instanceof Error && e.message ? e.message : "Failed to save model endpoint settings" + ); + } finally { + setSavingModelId(null); + } + }; + + return ( +
+

+ tune + {t("customModels")} +

+

{t("customModelsHint")}

+ + {/* Add form */} +
+
+
+ + setNewModelId(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleAdd()} + placeholder={t("customModelPlaceholder")} + className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" + /> +
+
+ + setNewModelName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleAdd()} + placeholder={t("optional")} + className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" + /> +
+ +
+ + {/* API Format + Supported Endpoints */} +
+
+ + +
+
+ + {t("supportedEndpointsLabel")} + +
+ {["chat", "embeddings", "rerank", "images", "audio"].map((ep) => ( + + ))} +
+
+
+
+ + {/* List */} + {loading ? ( +

{t("loading")}

+ ) : customModels.length > 0 ? ( +
+ {customModels.map((model) => { + const fullModel = `${providerAlias}/${model.id}`; + const copyKey = `custom-${model.id}`; + return ( +
+ {editingModelId !== model.id && ( + + tune + + )} +
+

{model.name || model.id}

+
+ + {fullModel} + + + {model.apiFormat === "responses" && ( + + {t("responses")} + + )} + {model.supportedEndpoints?.includes("embeddings") && ( + + {`📐 ${t("supportedEndpointEmbeddings")}`} + + )} + {model.supportedEndpoints?.includes("images") && ( + + {`🖼️ ${t("imagesShortLabel")}`} + + )} + {model.supportedEndpoints?.includes("audio") && ( + + {`🔊 ${t("audioShortLabel")}`} + + )} + {anyNormalizeCompatBadge(model.id, customMap, overrideMap) && ( + + ID×9 + + )} + {anyNoPreserveCompatBadge(model.id, customMap, overrideMap) && ( + + {t("compatBadgeNoPreserve")} + + )} + {anyUpstreamHeadersBadge(model.id, customMap, overrideMap) && ( + + {t("compatBadgeUpstreamHeaders")} + + )} +
+ + {editingModelId === model.id && ( +
+
+
+ + +
+
+ + {t("supportedEndpointsLabel")} + +
+ {["chat", "embeddings", "rerank", "images", "audio"].map((ep) => ( + + ))} +
+
+
+ + +
+
+
+ )} +
+
+ + + effectiveNormalizeForProtocol(model.id, p, customMap, overrideMap) + } + effectiveModelPreserveDeveloper={(p) => + effectivePreserveForProtocol(model.id, p, customMap, overrideMap) + } + getUpstreamHeadersRecord={(p) => + effectiveUpstreamHeadersForProtocol(model.id, p, customMap, overrideMap) + } + onCompatPatch={(protocol, payload) => + saveCustomCompat(model.id, { + compatByProtocol: { [protocol]: payload }, + }) + } + showDeveloperToggle + disabled={savingModelId === model.id} + /> + + +
+
+ ); + })} +
+ ) : ( +

{t("noCustomModels")}

+ )} +
+ ); +} + +function CompatibleModelsSection({ + providerStorageAlias, + providerDisplayAlias, + modelAliases, + availableModels = [], + customModels = [], + fallbackModels = [], + description, + inputLabel, + inputPlaceholder, + copied, + onCopy, + onSetAlias, + onDeleteAlias, + connections, + isAnthropic, + onImportWithProgress, + t, + effectiveModelNormalize, + effectiveModelPreserveDeveloper, + getUpstreamHeadersRecord, + saveModelCompatFlags, + compatSavingModelId, + onModelsChanged, + allowImport, + isModelHidden, + onToggleHidden, + onBulkToggleHidden, + bulkTogglePending, + togglingModelId, + onTestModel, + modelTestStatus, + testingModelId, + onTestAll, + testingAll, + testProgress, + autoHideFailed, + onAutoHideFailedChange, +}: CompatibleModelsSectionProps) { + const [newModel, setNewModel] = useState(""); + const [adding, setAdding] = useState(false); + const [importing, setImporting] = useState(false); + const [modelFilter, setModelFilter] = useState(""); + const [visibilityFilter, setVisibilityFilter] = useState<"all" | "visible" | "hidden">("all"); + const notify = useNotificationStore(); + const customModelMap = useMemo(() => buildCompatMap(customModels), [customModels]); + + const providerAliases = useMemo( + () => + Object.entries(modelAliases).filter(([, model]: [string, any]) => + (model as string).startsWith(`${providerStorageAlias}/`) + ), + [modelAliases, providerStorageAlias] + ); + + const allModels = useMemo(() => { + const prefix = `${providerStorageAlias}/`; + const aliasByModelId = new Map(); + const rows: Array<{ + modelId: string; + alias: string | null; + displayName: string; + source: string; + isFree: boolean; + isHidden: boolean; + }> = []; + const seenModelIds = new Set(); + + for (const [alias, fullModel] of providerAliases) { + const fmStr = fullModel as string; + const modelId = fmStr.startsWith(prefix) ? fmStr.slice(prefix.length) : fmStr; + aliasByModelId.set(modelId, alias as string); + } + + const addModel = (model: CompatModelRow, source: string) => { + if (!model?.id || seenModelIds.has(model.id)) return; + rows.push({ + modelId: model.id, + alias: aliasByModelId.get(model.id) || null, + displayName: model.name || model.id, + source, + isFree: + Boolean((model as any).free) || + model.id.endsWith(":free") || + /\bgr[aá]tis\b|\bfree\b/i.test(model.name || ""), + isHidden: isModelHidden(model.id), + }); + seenModelIds.add(model.id); + }; + + for (const model of availableModels) { + addModel(model, "imported"); + } + + for (const model of customModels) { + addModel( + model, + normalizeModelCatalogSource(model.source) === "imported" ? "imported" : "custom" + ); + } + + for (const model of fallbackModels) { + addModel(model, "fallback"); + } + + for (const [alias, fullModel] of providerAliases) { + const fmStr = fullModel as string; + const modelId = fmStr.startsWith(prefix) ? fmStr.slice(prefix.length) : fmStr; + if (!modelId || seenModelIds.has(modelId)) continue; + const customModel = customModelMap.get(modelId); + rows.push({ + modelId, + alias: alias as string, + displayName: alias as string, + source: customModel ? customModel.source || "custom" : "alias", + isFree: + modelId.endsWith(":free") || + Boolean((customModel as any)?.free) || + /\bgr[aá]tis\b|\bfree\b/i.test(customModel?.name || alias || ""), + isHidden: isModelHidden(modelId), + }); + seenModelIds.add(modelId); + } + + return rows; + }, [ + availableModels, + customModelMap, + customModels, + fallbackModels, + isModelHidden, + providerAliases, + providerStorageAlias, + ]); + const filteredModels = allModels.filter((model) => { + const matchesQuery = matchesModelCatalogQuery(modelFilter, { + modelId: model.modelId, + modelName: model.displayName, + alias: model.alias, + source: model.source, + }); + const matchesVisibility = + visibilityFilter === "all" + ? true + : visibilityFilter === "visible" + ? !model.isHidden + : model.isHidden; + return matchesQuery && matchesVisibility; + }); + const activeCount = allModels.filter((model) => !model.isHidden).length; + const hiddenFilteredCount = filteredModels.filter((model) => model.isHidden).length; + const visibleFilteredCount = filteredModels.length - hiddenFilteredCount; + + const resolveAlias = useCallback( + (modelId: string, workingAliases: Record) => + resolveManagedModelAlias({ + modelId, + fullModel: `${providerStorageAlias}/${modelId}`, + providerDisplayAlias, + existingAliases: workingAliases, + }), + [providerDisplayAlias, providerStorageAlias] + ); + + const handleAdd = async () => { + if (!newModel.trim() || adding) return; + const modelId = newModel.trim(); + const resolvedAlias = resolveAlias(modelId, modelAliases); + if (!resolvedAlias) { + notify.error(t("allSuggestedAliasesExist")); + return; + } + + setAdding(true); + try { + // Save to customModels DB FIRST - only create alias if this succeeds + const customModelRes = await fetch("/api/provider-models", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: providerStorageAlias, + modelId, + modelName: modelId, + source: "manual", + }), + }); + + if (!customModelRes.ok) { + let errorData: { error?: { message?: string } } = {}; + try { + errorData = await customModelRes.json(); + } catch (jsonError) { + console.error("Failed to parse error response from custom model API:", jsonError); + } + throw new Error(errorData.error?.message || t("failedSaveCustomModel")); + } + + // Only create alias after customModel is saved successfully + await onSetAlias(modelId, resolvedAlias, providerStorageAlias); + setNewModel(""); + notify.success(t("modelAddedSuccess", { modelId })); + onModelsChanged?.(); + } catch (error) { + console.error("Error adding model:", error); + notify.error(error instanceof Error ? error.message : t("failedAddModelTryAgain")); + } finally { + setAdding(false); + } + }; + + const handleImport = async () => { + if (!allowImport || importing) return; + const activeConnection = connections.find((conn) => conn.isActive !== false); + if (!activeConnection?.id) return; + + setImporting(true); + try { + await onImportWithProgress(activeConnection.id); + } catch (error) { + console.error("Error importing models:", error); + notify.error(t("failedImportModelsTryAgain")); + } finally { + setImporting(false); + } + }; + + const canImport = connections.some((conn) => conn.isActive !== false); + + // Handle delete: remove from both alias and customModels DB + const handleDeleteModel = async (modelId: string, alias?: string | null) => { + try { + // Remove from customModels DB + const res = await fetch( + `/api/provider-models?provider=${encodeURIComponent(providerStorageAlias)}&model=${encodeURIComponent(modelId)}`, + { method: "DELETE" } + ); + if (!res.ok) { + throw new Error(t("failedRemoveModelFromDatabase")); + } + // Also delete the alias + if (alias) { + await onDeleteAlias(alias); + } + notify.success(t("modelRemovedSuccess")); + onModelsChanged?.(); + } catch (error) { + console.error("Error deleting model:", error); + notify.error(error instanceof Error ? error.message : t("failedDeleteModelTryAgain")); + } + }; + + return ( +
+

{description}

+ +
+
+ + setNewModel(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleAdd()} + placeholder={inputPlaceholder} + className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" + /> +
+ + {allowImport && ( + + )} +
+ + {allowImport && !canImport && ( +

{t("addConnectionToImport")}

+ )} + + {allModels.length > 0 && ( +
+ + onBulkToggleHidden( + filteredModels.map((model) => model.modelId), + false + ) + } + onDeselectAll={() => + onBulkToggleHidden( + filteredModels.map((model) => model.modelId), + true + ) + } + selectAllDisabled={hiddenFilteredCount === 0 || bulkTogglePending} + deselectAllDisabled={visibleFilteredCount === 0 || bulkTogglePending} + visibilityFilter={visibilityFilter} + onVisibilityFilterChange={setVisibilityFilter} + onTestAll={() => { + const targets = filteredModels + .filter((m) => !m.isHidden) + .map((m) => ({ + modelId: m.modelId, + fullModel: `${providerDisplayAlias}/${m.modelId}`, + })); + return onTestAll?.(targets); + }} + testingAll={testingAll} + testProgress={testProgress} + autoHideFailed={autoHideFailed} + onAutoHideFailedChange={onAutoHideFailedChange} + /> +
+ {filteredModels.map(({ modelId, alias, isHidden, source, isFree }) => { + const fullModel = `${providerDisplayAlias}/${modelId}`; + return ( + handleDeleteModel(modelId, alias) + : source === "alias" && alias + ? () => onDeleteAlias(alias) + : undefined + } + t={t} + showDeveloperToggle={!isAnthropic} + effectiveModelNormalize={effectiveModelNormalize} + effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper} + getUpstreamHeadersRecord={(p) => getUpstreamHeadersRecord(modelId, p)} + saveModelCompatFlags={saveModelCompatFlags} + compatDisabled={compatSavingModelId === modelId} + onToggleHidden={onToggleHidden} + togglingHidden={togglingModelId === modelId} + onTestModel={onTestModel} + testStatus={modelTestStatus?.[modelId] || null} + testingModel={testingModelId === modelId} + /> + ); + })} +
+ {filteredModels.length === 0 && modelFilter && ( +

+ {providerText(t, "noModelsMatch", `No models match "${modelFilter}"`, { + filter: modelFilter, + })} +

+ )} +
+ )} +
+ ); +} + +function CooldownTimer({ until }: CooldownTimerProps) { + const [remaining, setRemaining] = useState(""); + + useEffect(() => { + const updateRemaining = () => { + const diff = new Date(until).getTime() - Date.now(); + if (diff <= 0) { + setRemaining(""); + return; + } + const secs = Math.floor(diff / 1000); + if (secs < 60) { + setRemaining(`${secs}s`); + } else if (secs < 3600) { + setRemaining(`${Math.floor(secs / 60)}m ${secs % 60}s`); + } else { + const hrs = Math.floor(secs / 3600); + const mins = Math.floor((secs % 3600) / 60); + setRemaining(`${hrs}h ${mins}m`); + } + }; + + updateRemaining(); + const interval = setInterval(updateRemaining, 1000); + return () => clearInterval(interval); + }, [until]); + + if (!remaining) return null; + + 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"; + if (connection.testStatus === "credits_exhausted") return "credits_exhausted"; + if (connection.lastErrorType) return connection.lastErrorType; + + const code = Number(connection.errorCode); + if (code === 401 || code === 403) return "upstream_auth_error"; + if (code === 429) return "upstream_rate_limited"; + if (code >= 500) return "upstream_unavailable"; + + const msg = (connection.lastError || "").toLowerCase(); + if (!msg) return null; + if ( + msg.includes("runtime") || + msg.includes("not runnable") || + msg.includes("not installed") || + msg.includes("healthcheck") + ) + return "runtime_error"; + if (msg.includes("refresh failed")) return "token_refresh_failed"; + if (msg.includes("token expired") || msg.includes("expired")) return "token_expired"; + if ( + msg.includes("invalid api key") || + msg.includes("token invalid") || + msg.includes("revoked") || + msg.includes("access denied") || + msg.includes("unauthorized") + ) + return "upstream_auth_error"; + if ( + msg.includes("rate limit") || + msg.includes("quota") || + msg.includes("too many requests") || + msg.includes("429") + ) + return "upstream_rate_limited"; + if ( + msg.includes("fetch failed") || + msg.includes("network") || + msg.includes("timeout") || + msg.includes("econn") || + msg.includes("enotfound") + ) + return "network_error"; + if (msg.includes("not supported")) return "unsupported"; + return "upstream_error"; +} + +function getStatusPresentation(connection, effectiveStatus, isCooldown, t) { + if (connection.isActive === false) { + return { + statusVariant: "default", + statusLabel: t("statusDisabled"), + errorType: null, + errorBadge: null, + errorTextClass: "text-text-muted", + }; + } + + if (effectiveStatus === "active" || effectiveStatus === "success") { + return { + statusVariant: "success", + statusLabel: t("statusConnected"), + errorType: null, + errorBadge: null, + errorTextClass: "text-text-muted", + }; + } + + const errorType = inferErrorType(connection, isCooldown); + const errorBadge = errorType ? ERROR_TYPE_LABELS[errorType] || null : null; + + if (errorType === "runtime_error") { + return { + statusVariant: "warning", + statusLabel: t("statusRuntimeIssue"), + errorType, + errorBadge, + errorTextClass: "text-yellow-600 dark:text-yellow-400", + }; + } + + if (errorType === "account_deactivated") { + return { + statusVariant: "error", + statusLabel: t("statusDeactivated", "Deactivated"), + errorType, + errorBadge, + errorTextClass: "text-red-600 font-bold", + }; + } + + if ( + errorType === "upstream_auth_error" || + errorType === "auth_missing" || + errorType === "token_refresh_failed" || + errorType === "token_expired" + ) { + return { + statusVariant: "error", + statusLabel: t("statusAuthFailed"), + errorType, + errorBadge, + errorTextClass: "text-red-500", + }; + } + + if (errorType === "upstream_rate_limited") { + return { + statusVariant: "warning", + statusLabel: t("statusRateLimited"), + errorType, + errorBadge, + errorTextClass: "text-yellow-600 dark:text-yellow-400", + }; + } + + if (errorType === "network_error") { + return { + statusVariant: "warning", + statusLabel: t("statusNetworkIssue"), + errorType, + errorBadge, + errorTextClass: "text-yellow-600 dark:text-yellow-400", + }; + } + + if (errorType === "unsupported") { + return { + statusVariant: "default", + statusLabel: t("statusTestUnsupported"), + errorType, + errorBadge, + errorTextClass: "text-text-muted", + }; + } + + if (errorType === "banned") { + return { + statusVariant: "error", + statusLabel: t("statusBanned", "Banned (403)"), + errorType, + errorBadge, + errorTextClass: "text-red-600 font-bold", + }; + } + + if (errorType === "credits_exhausted") { + return { + statusVariant: "warning", + statusLabel: t("statusCreditsExhausted", "Out of Credits"), + errorType, + errorBadge, + errorTextClass: "text-amber-500", + }; + } + + const fallbackStatusMap = { + unavailable: t("statusUnavailable"), + failed: t("statusFailed"), + error: t("statusError"), + }; + + return { + statusVariant: "error", + statusLabel: fallbackStatusMap[effectiveStatus] || effectiveStatus || t("statusError"), + errorType, + errorBadge, + errorTextClass: "text-red-500", + }; +} + +function ConnectionRow({ + connection, + isOAuth, + isClaude, + isCodex, + isGeminiCli, + codexGlobalServiceMode, + isCcCompatible, + cliproxyapiEnabled, + isFirst, + isLast, + isSelected, + onToggleSelect, + onMoveUp, + onMoveDown, + onToggleActive, + onToggleRateLimit, + onToggleClaudeExtraUsage, + onToggleCodex5h, + onToggleCodexWeekly, + onToggleCliproxyapiMode, + onRetest, + isRetesting, + onEdit, + onDelete, + onReauth, + onProxy, + hasProxy, + proxySource, + proxyHost, + onRefreshToken, + isRefreshing, + onApplyCodexAuthLocal, + isApplyingCodexAuthLocal, + onExportCodexAuthFile, + isExportingCodexAuthFile, + onApplyClaudeAuthLocal, + isApplyingClaudeAuthLocal, + onExportClaudeAuthFile, + isExportingClaudeAuthFile, + onApplyGeminiAuthLocal, + isApplyingGeminiAuthLocal, + onExportGeminiAuthFile, + isExportingGeminiAuthFile, + perKeyProxyEnabled, + onTogglePerKeyProxyEnabled, + proxyEnabled, + onToggleProxyEnabled, +}: ConnectionRowProps) { + const t = useTranslations("providers"); + const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible); + const displayName = isOAuth + ? pickDisplayValue( + [connection.name, connection.email, connection.displayName], + emailsVisible, + t("oauthAccount") + ) + : connection.name; + const applyCodexAuthLabel = + typeof t.has === "function" && t.has("applyCodexAuthLocal") + ? t("applyCodexAuthLocal") + : "Apply auth"; + const exportCodexAuthLabel = + typeof t.has === "function" && t.has("exportCodexAuthFile") + ? t("exportCodexAuthFile") + : "Export auth"; + const applyClaudeAuthLabel = + typeof t.has === "function" && t.has("applyClaudeAuthLocal") + ? t("applyClaudeAuthLocal") + : "Apply auth"; + const exportClaudeAuthLabel = + typeof t.has === "function" && t.has("exportClaudeAuthFile") + ? t("exportClaudeAuthFile") + : "Export auth"; + const applyGeminiAuthLabel = + typeof t.has === "function" && t.has("applyGeminiAuthLocal") + ? t("applyGeminiAuthLocal") + : "Apply auth"; + const exportGeminiAuthLabel = + typeof t.has === "function" && t.has("exportGeminiAuthFile") + ? t("exportGeminiAuthFile") + : "Export auth"; + + // Use useState + useEffect for impure Date.now() to avoid calling during render + const [isCooldown, setIsCooldown] = useState(false); + // T12: token expiry status — lazy init avoids calling Date.now() during render; + // updates every 30s via interval only (no sync setState in effect body). + // Prefer tokenExpiresAt (updated on each refresh) over expiresAt (original grant date). + const effectiveExpiresAt = connection.tokenExpiresAt || connection.expiresAt; + const getTokenMinsLeft = () => { + if (!isOAuth || !effectiveExpiresAt) return null; + const expiresMs = new Date(effectiveExpiresAt).getTime(); + return Math.floor((expiresMs - Date.now()) / 60000); + }; + const [tokenMinsLeft, setTokenMinsLeft] = useState(getTokenMinsLeft); + + useEffect(() => { + if (!isOAuth || !effectiveExpiresAt) return; + const update = () => { + const expiresMs = new Date(effectiveExpiresAt).getTime(); + setTokenMinsLeft(Math.floor((expiresMs - Date.now()) / 60000)); + }; + update(); + const iv = setInterval(update, 30000); + return () => clearInterval(iv); + }, [isOAuth, effectiveExpiresAt]); + + useEffect(() => { + const checkCooldown = () => { + const cooldown = + connection.rateLimitedUntil && new Date(connection.rateLimitedUntil).getTime() > Date.now(); + setIsCooldown(cooldown); + }; + + checkCooldown(); + // Update every second while in cooldown + const interval = connection.rateLimitedUntil ? setInterval(checkCooldown, 1000) : null; + return () => { + if (interval) clearInterval(interval); + }; + }, [connection.rateLimitedUntil]); + + // Determine effective status (override unavailable if cooldown expired) + const effectiveStatus = + connection.testStatus === "unavailable" && !isCooldown + ? "active" // Cooldown expired → treat as active + : connection.testStatus; + + const statusPresentation = getStatusPresentation(connection, effectiveStatus, isCooldown, t); + const rateLimitEnabled = !!connection.rateLimitProtection; + const codexPolicy = + connection.providerSpecificData && + typeof connection.providerSpecificData === "object" && + connection.providerSpecificData.codexLimitPolicy && + typeof connection.providerSpecificData.codexLimitPolicy === "object" + ? connection.providerSpecificData.codexLimitPolicy + : {}; + const normalizedCodexPolicy = normalizeCodexLimitPolicy(codexPolicy); + const codex5hEnabled = normalizedCodexPolicy.use5h; + const codexWeeklyEnabled = normalizedCodexPolicy.useWeekly; + const codexServiceTier = isCodex + ? getCodexEffectiveServiceTier( + connection.providerSpecificData, + codexGlobalServiceMode ?? "none" + ) + : "default"; + const codexServiceTierIsGlobal = + isCodex && codexGlobalServiceMode !== undefined && codexGlobalServiceMode !== "none"; + const codexServiceTierBadge = + codexServiceTier === "priority" + ? { + label: providerText(t, "codexTierFastLabel", "Fast"), + icon: "bolt", + className: "bg-sky-500/15 text-sky-500", + title: codexServiceTierIsGlobal + ? providerText( + t, + "providerDetailGlobalPriorityActive", + "Global Codex priority service tier is active" + ) + : providerText( + t, + "providerDetailConnectionPriorityActive", + "Codex priority service tier is active for this connection" + ), + } + : codexServiceTier === "flex" + ? { + label: providerText(t, "codexTierFlexLabel", "Flex"), + icon: "speed", + className: "bg-cyan-500/15 text-cyan-500", + title: codexServiceTierIsGlobal + ? providerText( + t, + "providerDetailGlobalFlexActive", + "Global Codex flex service tier is active" + ) + : providerText( + t, + "providerDetailConnectionFlexActive", + "Codex flex service tier is active for this connection" + ), + } + : null; + const claudeBlockExtraUsageEnabled = isClaude + ? isClaudeExtraUsageBlockEnabled("claude", connection.providerSpecificData) + : false; + const cliproxyapiDeepMode = !!cliproxyapiEnabled; + + return ( +
+
+ {onToggleSelect && ( + + )} + {/* Priority arrows */} +
+ + +
+ + {isOAuth ? "lock" : "key"} + +
+

{displayName}

+
+ + {statusPresentation.statusLabel} + + {/* T12: Token expiry status indicator (state-driven, no Date.now in render) */} + {tokenMinsLeft !== null && + (tokenMinsLeft < 0 ? ( + + error + {t("tokenExpiredBadge")} + + ) : tokenMinsLeft < 30 ? ( + + warning + {`~${tokenMinsLeft}m`} + + ) : null)} + {isCooldown && connection.isActive !== false && ( + + )} + {statusPresentation.errorBadge && connection.isActive !== false && ( + + {t(statusPresentation.errorBadge.labelKey)} + + )} + {connection.lastError && connection.isActive !== false && ( + + {connection.lastError} + + )} + #{connection.priority} + {connection.globalPriority && ( + + {t("autoPriority", { priority: connection.globalPriority })} + + )} + {connection.maxConcurrent != null && connection.maxConcurrent > 0 && ( + + dynamic_feed + {connection.maxConcurrent} + + )} + {/* Rate Limit Protection — inline toggle with label */} + | + + {isClaude && ( + <> + | + + + )} + {isCcCompatible && ( + <> + | + + + )} + {isCodex && ( + <> + | + {codexServiceTierBadge && ( + + + {codexServiceTierBadge.icon} + + {codexServiceTierBadge.label} + + )} + + + + )} + {onToggleProxyEnabled && ( + <> + | + + + )} + {onTogglePerKeyProxyEnabled && ( + <> + | + + + )} + {hasProxy && + (() => { + const colorClass = + proxySource === "global" + ? "bg-emerald-500/15 text-emerald-500" + : proxySource === "provider" + ? "bg-amber-500/15 text-amber-500" + : "bg-blue-500/15 text-blue-500"; + const label = + proxySource === "global" + ? t("proxySourceGlobal") + : proxySource === "provider" + ? t("proxySourceProvider") + : t("proxySourceKey"); + return ( + <> + | + + vpn_lock + {proxyHost || t("proxy")} + + + ); + })()} +
+
+
+
+ + {/* T12: Manual token refresh for OAuth accounts */} + {onRefreshToken && ( + + )} + {isCodex && onApplyCodexAuthLocal && ( + + )} + {isCodex && onExportCodexAuthFile && ( + + )} + {isClaude && onApplyClaudeAuthLocal && ( + + )} + {isClaude && onExportClaudeAuthFile && ( + + )} + {isGeminiCli && onApplyGeminiAuthLocal && ( + + )} + {isGeminiCli && onExportGeminiAuthFile && ( + + )} + +
+ {onReauth && ( + + )} + + + +
+
+
+ ); +} + +const CONFIGURABLE_BASE_URL_PROVIDERS = new Set([ + "azure-openai", + "azure-ai", + "bailian-coding-plan", + "xiaomi-mimo", + "siliconflow", + "heroku", + "databricks", + "snowflake", + "searxng-search", + "petals", +]); + +const DEFAULT_PROVIDER_BASE_URLS: Record = { + "azure-openai": "https://example-resource.openai.azure.com", + "azure-ai": "https://example-resource.services.ai.azure.com/openai/v1", + "bailian-coding-plan": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1", + "xiaomi-mimo": "https://token-plan-sgp.xiaomimimo.com/v1", + siliconflow: "https://api.siliconflow.com/v1", + "searxng-search": "http://localhost:8888/search", + petals: "https://chat.petals.dev/api/v1/generate", +}; + +function getLocalProviderMetadata(providerId?: string | null) { + if (!providerId || !isSelfHostedChatProvider(providerId)) return null; + return (LOCAL_PROVIDERS as Record)[providerId] || null; +} + +function isBaseUrlConfigurableProvider(providerId?: string | null) { + return Boolean( + providerId && + (CONFIGURABLE_BASE_URL_PROVIDERS.has(providerId) || isSelfHostedChatProvider(providerId)) + ); +} + +function getProviderBaseUrlDefault(providerId?: string | null) { + const localProvider = getLocalProviderMetadata(providerId); + if (typeof localProvider?.localDefault === "string" && localProvider.localDefault.trim()) { + return localProvider.localDefault; + } + return providerId ? DEFAULT_PROVIDER_BASE_URLS[providerId] || "" : ""; +} + +function getProviderBaseUrlHint( + providerId?: string | null, + t?: ((key: string, values?: Record) => string) | null +) { + const localProvider = getLocalProviderMetadata(providerId); + if (localProvider && t) { + return t("localProviderBaseUrlHint", { + provider: localProvider.name || providerId, + baseUrl: getProviderBaseUrlDefault(providerId), + }); + } + switch (providerId) { + case "azure-openai": + return t ? t("azureOpenAiBaseUrlHint") : undefined; + case "bailian-coding-plan": + return t ? t("bailianBaseUrlHint") : undefined; + case "xiaomi-mimo": + return t ? t("xiaomiMimoBaseUrlHint") : undefined; + case "heroku": + return t ? t("herokuBaseUrlHint") : undefined; + case "databricks": + return t ? t("databricksBaseUrlHint") : undefined; + case "snowflake": + return t ? t("snowflakeBaseUrlHint") : undefined; + case "searxng-search": + return t ? t("searxngBaseUrlHint") : undefined; + default: + return undefined; + } +} + +function getProviderBaseUrlPlaceholder(providerId?: string | null) { + if (isSelfHostedChatProvider(providerId || "")) { + return getProviderBaseUrlDefault(providerId); + } + switch (providerId) { + case "azure-openai": + return "https://my-resource.openai.azure.com"; + case "bailian-coding-plan": + case "xiaomi-mimo": + return getProviderBaseUrlDefault(providerId); + case "siliconflow": + return "https://api.siliconflow.cn/v1"; + case "heroku": + return "https://us.inference.heroku.com"; + case "databricks": + return "https://adb-1234567890123456.7.azuredatabricks.net/serving-endpoints"; + case "snowflake": + return "https://example-account.snowflakecomputing.com"; + case "searxng-search": + return "http://localhost:8888/search"; + default: + return ""; + } +} + +function isGlmProvider(providerId?: string | null) { + return providerId === "glm" || providerId === "glm-cn" || providerId === "glmt"; +} + +function parseRoutingTagsInput(value: string): string[] | undefined { + const tags = Array.from( + new Set( + value + .split(",") + .map((tag) => tag.trim().toLowerCase()) + .filter(Boolean) + ) + ); + return tags.length > 0 ? tags : undefined; +} + +function parseExcludedModelsInput(value: string): string[] | undefined { + const patterns = Array.from( + new Set( + value + .split(",") + .map((pattern) => pattern.trim()) + .filter(Boolean) + ) + ); + return patterns.length > 0 ? patterns : undefined; +} + +function formatRoutingTagsInput(value: unknown): string { + if (!Array.isArray(value)) return ""; + return value + .filter((tag): tag is string => typeof tag === "string" && tag.trim().length > 0) + .join(", "); +} + +function formatExcludedModelsInput(value: unknown): string { + if (!Array.isArray(value)) return ""; + return value + .filter( + (pattern): pattern is string => typeof pattern === "string" && pattern.trim().length > 0 + ) + .join(", "); +} + +function extractCommandCodeCredentialInput(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return ""; + + try { + const parsed = JSON.parse(trimmed) as unknown; + if (parsed && typeof parsed === "object") { + const record = parsed as Record; + const direct = record.apiKey || record.api_key || record.key || record.token; + if (typeof direct === "string" && direct.trim()) return direct.trim(); + const nested = record.data; + if (nested && typeof nested === "object") { + const nestedRecord = nested as Record; + const nestedKey = nestedRecord.apiKey || nestedRecord.api_key || nestedRecord.key; + if (typeof nestedKey === "string" && nestedKey.trim()) return nestedKey.trim(); + } + } + } catch { + // Not JSON; continue with URL/raw parsing. + } + + try { + const url = new URL(trimmed); + const key = + url.searchParams.get("apiKey") || + url.searchParams.get("api_key") || + url.searchParams.get("key") || + url.searchParams.get("token"); + if (key?.trim()) return key.trim(); + const hash = url.hash.replace(/^#/, ""); + if (hash) { + const hashParams = new URLSearchParams(hash); + const hashKey = + hashParams.get("apiKey") || + hashParams.get("api_key") || + hashParams.get("key") || + hashParams.get("token"); + if (hashKey?.trim()) return hashKey.trim(); + } + } catch { + // Not a URL; use the raw value. + } + + return trimmed; +} + +function SiliconFlowEndpointModal({ + isOpen, + onSelect, + onClose, +}: { + isOpen: boolean; + onSelect: (baseUrl: string) => void; + onClose: () => void; +}) { + const t = useTranslations("providers"); + + return ( + +
+

+ {providerText(t, "chooseSiliconFlowEndpoint", "Choose your SiliconFlow endpoint:")} +

+ {SILICONFLOW_ENDPOINTS.map((endpoint) => ( + + ))} +
+
+ ); +} + +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")}

+