From 2db9a3aa36752c9e73b2b32b9b275f3c65d4633f Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 12 Jun 2026 08:50:16 -0300 Subject: [PATCH] =?UTF-8?q?refactor(#3501):=20god-component=20Phase=201k-1?= =?UTF-8?q?m=20=E2=80=94=20client=203408=E2=86=922553=20LOC=20(-855)=20(#3?= =?UTF-8?q?721)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1k-1m of #3501: client 3408→2553 LOC. Pure extraction (useModelImportHandlers+ImportProgressModal, useModelVisibilityHandlers, ProviderModelsSection). Co-authored-by: oyi77 <14921983+oyi77@users.noreply.github.com> --- file-size-baseline.json | 8 +- .../[id]/ProviderDetailPageClient.tsx | 1143 +++-------------- .../[id]/components/ImportProgressModal.tsx | 142 ++ .../[id]/components/ProviderModelsSection.tsx | 466 +++++++ .../[id]/hooks/useModelImportHandlers.ts | 382 ++++++ .../[id]/hooks/useModelVisibilityHandlers.ts | 411 ++++++ 6 files changed, 1549 insertions(+), 1003 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/components/ImportProgressModal.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModelsSection.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelImportHandlers.ts create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelVisibilityHandlers.ts diff --git a/file-size-baseline.json b/file-size-baseline.json index 645f733dfc..e45c4ad9d0 100644 --- a/file-size-baseline.json +++ b/file-size-baseline.json @@ -15,7 +15,7 @@ "open-sse/executors/muse-spark-web.ts": 1284, "open-sse/executors/perplexity-web.ts": 868, "open-sse/handlers/audioSpeech.ts": 952, - "open-sse/handlers/chatCore.ts": 6023, + "open-sse/handlers/chatCore.ts": 5808, "open-sse/handlers/imageGeneration.ts": 3777, "open-sse/handlers/responseSanitizer.ts": 1103, "open-sse/handlers/search.ts": 1442, @@ -35,7 +35,7 @@ "open-sse/translator/response/openai-responses.ts": 873, "open-sse/utils/cursorAgentProtobuf.ts": 1499, "open-sse/utils/stream.ts": 2710, - "src/app/(dashboard)/dashboard/HomePageClient.tsx": 1417, + "src/app/(dashboard)/dashboard/HomePageClient.tsx": 1385, "src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx": 1020, "src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 2680, "src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx": 1105, @@ -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]/ProviderDetailPageClient.tsx": 3409, + "src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": 2554, "src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx": 941, "src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 843, "src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1171, @@ -73,7 +73,7 @@ "src/app/api/oauth/[provider]/[action]/route.ts": 897, "src/app/api/providers/[id]/models/route.ts": 2426, "src/app/api/providers/[id]/test/route.ts": 842, - "src/app/api/usage/analytics/route.ts": 1355, + "src/app/api/usage/analytics/route.ts": 941, "src/app/api/v1/models/catalog.ts": 1435, "src/lib/cloudflaredTunnel.ts": 934, "src/lib/db/apiKeys.ts": 1490, diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx index a6ef97eacf..51657ff1db 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx @@ -94,6 +94,13 @@ import ConnectionRow, { import ModelCompatPopover from "./components/ModelCompatPopover"; import SiliconFlowEndpointModal from "./components/SiliconFlowEndpointModal"; import { CC_COMPATIBLE_DEFAULT_CHAT_PATH } from "./providerDetailConstants"; +// Phase 1k extractions — Issue #3501 +import { useModelImportHandlers } from "./hooks/useModelImportHandlers"; +import ImportProgressModal from "./components/ImportProgressModal"; +// Phase 1l extractions — Issue #3501 +import { useModelVisibilityHandlers } from "./hooks/useModelVisibilityHandlers"; +// Phase 1m extractions — Issue #3501 +import ProviderModelsSection from "./components/ProviderModelsSection"; import { // CONFIGURABLE_BASE_URL_PROVIDERS, DEFAULT_PROVIDER_BASE_URLS, getLocalProviderMetadata, // isBaseUrlConfigurableProvider, getProviderBaseUrlDefault, getProviderBaseUrlHint, @@ -107,13 +114,11 @@ import { providerText, providerCountText, readBooleanToggle, - formatProviderModelsErrorResponse, + // formatProviderModelsErrorResponse → hooks/useModelVisibilityHandlers.ts (Phase 1l) type ProviderMessageTranslator, type LocalProviderMetadata, // CommandCodeAuthFlowState moved to hooks/useCommandCodeAuth.ts (Phase 1h) - type CompatByProtocolMap, - type CompatModelRow, - type CompatModelMap, + // CompatByProtocolMap, CompatModelRow, CompatModelMap → hooks/useModelVisibilityHandlers.ts (Phase 1l) } from "./providerPageHelpers"; // CODEX_GLOBAL_SERVICE_MODE_VALUES, getCodexServiceTierLabel, normalizeCodexLimitPolicy // moved to hooks/useProviderSettings.ts + hooks/useProviderConnections.ts (Phase 1f) @@ -128,15 +133,7 @@ import CompatibleModelsSection from "./components/CompatibleModelsSection"; // moved to providerPageHelpers.ts + hook useModelCompatState (Phase 1e) // formatProviderModelsErrorResponse moved to providerPageHelpers.ts (Phase 1e) -/** PATCH fields for provider model compat (matches API + `ModelCompatPerProtocol` shape). */ -type ModelCompatSavePatch = { - normalizeToolCallId?: boolean; - preserveOpenAIDeveloperRole?: boolean; - upstreamHeaders?: Record; - compatByProtocol?: CompatByProtocolMap; - isHidden?: boolean; -}; - +// ModelCompatSavePatch → hooks/useModelVisibilityHandlers.ts (Phase 1l) // MAX_BULK_IDS moved to hooks/useProviderConnections.ts (Phase 1f) // ModelRowProps, PassthroughModelRowProps → components/ModelRow.tsx, PassthroughModelRow.tsx (Phase 1e) // PassthroughModelsSectionProps → components/PassthroughModelsSection.tsx (Phase 1e) @@ -168,34 +165,11 @@ export default function ProviderDetailPageClient() { const [showTutorialModal, setShowTutorialModal] = useState(false); const [selectedConnection, setSelectedConnection] = useState(null); const [proxyTarget, setProxyTarget] = useState(null); - 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 [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 [importCodexModalOpen, setImportCodexModalOpen] = useState(false); const [codexCliGuideOpen, setCodexCliGuideOpen] = useState(false); const [importClaudeModalOpen, setImportClaudeModalOpen] = useState(false); @@ -375,6 +349,36 @@ export default function ProviderDetailPageClient() { const providerStorageAlias = isCompatible ? providerId : providerAlias; const providerDisplayAlias = isCompatible ? providerNode?.prefix || providerId : providerAlias; + // ── Phase 1k: model import handlers ───────────────────────────────────── + const { + importingModels, + showImportModal, + importProgress, + togglingAutoSync, + canImportModels, + isAutoSyncEnabled, + autoSyncConnection, + setShowImportModal, + setImportProgress, + handleImportModels, + handleCompatibleImportWithProgress, + handleToggleAutoSync, + } = useModelImportHandlers({ + providerId, + models, + modelMeta, + modelAliases, + connections, + isFreeNoAuth, + handleSetAlias, + fetchAliases, + fetchProviderModelMeta, + fetchConnections, + notify, + t, + providerStorageAlias, + }); + const getApiLabel = () => { if (isAnthropicProtocolCompatible) return t("messagesApi"); const type = providerNode?.apiType; @@ -514,115 +518,9 @@ export default function ProviderDetailPageClient() { // loadCodexSettings, loadClaudeRoutingSettings → hooks/useProviderSettings.ts (Phase 1f) // loadConnProxies → hooks/useProviderConnections.ts (Phase 1f) - - 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); - }; + // onTestModel, handleTestAll, saveModelCompatFlags, handleToggleModelHidden, + // handleBulkToggleModelHidden, handleClearAllModels, providerAliasEntries + // → hooks/useModelVisibilityHandlers.ts (Phase 1l) // handleToggleSelectOne/All, handleBatchDeleteOpenModal/Confirm, handleDelete, // handleBatchSetActive → hooks/useProviderConnections.ts (Phase 1f) @@ -852,328 +750,8 @@ export default function ProviderDetailPageClient() { } = useAuthFileHandlers({ parseApiErrorMessage, getAttachmentFilename, notify, t }); // handleSwapPriority → useProviderConnections (Phase 1f) - - 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); - } - }; + // handleImportModels, handleCompatibleImportWithProgress, handleToggleAutoSync, + // canImportModels, isAutoSyncEnabled, autoSyncConnection → hooks/useModelImportHandlers.ts (Phase 1k) // Phase 1e: compat-state derivations moved to useModelCompatState hook. const compat = useModelCompatState( @@ -1191,429 +769,44 @@ export default function ProviderDetailPageClient() { [providerId, modelMeta.customModels] ); - 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); + // ── Phase 1l: model visibility handlers ───────────────────────────────── + const { + compatSavingModelId, + togglingModelId, + bulkVisibilityAction, + clearingModels, + modelFilter, + testingModelId, + modelTestStatus, + testingAll, + testProgress, + autoHideFailed, + visibilityFilter, + providerAliasEntries, + setModelFilter, + setAutoHideFailed, + setVisibilityFilter, + saveModelCompatFlags, + handleToggleModelHidden, + handleBulkToggleModelHidden, + handleClearAllModels, + onTestModel, + handleTestAll, + } = useModelVisibilityHandlers({ + providerId, + modelAliases, + customMap, + providerStorageAlias, + fetchProviderModelMeta, + fetchAliases, + notify, + t, + selectedConnection, + providerNode, + }); - 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 */ - } - }; + // renderModelsSection → components/ProviderModelsSection.tsx (Phase 1m) - 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 ?? ""} - autoHideFailed={autoHideFailed} - onAutoHideFailedChange={setAutoHideFailed} - /> -
- ); - } - - 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 ( @@ -2832,7 +2025,64 @@ export default function ProviderDetailPageClient() { {!isSearchProvider && !isUpstreamProxyProvider && (

{t("availableModels")}

- {renderModelsSection()} + {/* Phase 1m: extracted to components/ProviderModelsSection.tsx */} + {/* Custom Models — available for all providers */} )} - {/* 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" && ( -
- -
- )} -
- + t={t} + /> {/* Adapta Web — Tutorial Modal */} {providerId === "adapta-web" && ( diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ImportProgressModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ImportProgressModal.tsx new file mode 100644 index 0000000000..905749a8bf --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ImportProgressModal.tsx @@ -0,0 +1,142 @@ +"use client"; + +/** + * ImportProgressModal — Issue #3501 Phase 1k + * + * Extracted from the inline Import Progress Modal JSX in ProviderDetailPageClient. + * Pure presentational component driven entirely by props. + * + * Cycle-safe: no import from ProviderDetailPageClient. + */ + +import { Modal } from "@/shared/components"; +import type { ImportProgress } from "../hooks/useModelImportHandlers"; +import type { ProviderMessageTranslator } from "../providerPageHelpers"; + +interface ImportProgressModalProps { + importProgress: ImportProgress; + isOpen: boolean; + onClose: () => void; + t: ProviderMessageTranslator; +} + +export default function ImportProgressModal({ + importProgress, + isOpen, + onClose, + t, +}: ImportProgressModalProps) { + return ( + +
+ {/* 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" && ( +
+ +
+ )} +
+ + ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModelsSection.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModelsSection.tsx new file mode 100644 index 0000000000..c4e68811db --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModelsSection.tsx @@ -0,0 +1,466 @@ +"use client"; + +/** + * ProviderModelsSection — Issue #3501 Phase 1m + * + * Extracted from the renderModelsSection() inline function in + * ProviderDetailPageClient. Receives all model/compat state + handlers + * as props (from useModelImportHandlers, useModelVisibilityHandlers, + * useModelCompatState, useProviderModels). + * + * Cycle-safe: no import from ProviderDetailPageClient. + */ + +import { Button } from "@/shared/components"; +import { matchesModelCatalogQuery } from "@/shared/utils/modelCatalogSearch"; +import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers"; +import ModelRow, { ModelVisibilityToolbar } from "./ModelRow"; +import PassthroughModelsSection from "./PassthroughModelsSection"; +import CompatibleModelsSection from "./CompatibleModelsSection"; +import type { ModelCompatSavePatch } from "../hooks/useModelVisibilityHandlers"; + +export interface ProviderModelsSectionProps { + // Provider identity + providerId: string; + providerAlias: string; + providerStorageAlias: string; + providerDisplayAlias: string; + providerInfo: { + name?: string; + passthroughModels?: boolean; + } | null; + + // Provider-type flags + isCcCompatible: boolean; + isAnthropicCompatible: boolean; + isAnthropicProtocolCompatible: boolean; + isManagedAvailableModelsProvider: boolean; + compatibleSupportsModelImport: boolean; + + // Models data + models: Array<{ id: string; name?: string; source?: string }>; + modelMeta: { customModels: any[]; modelCompatOverrides?: any[] }; + modelAliases: Record; + syncedAvailableModels: any[]; + compatibleFallbackModels: any[]; + + // Clipboard + copied: string | null; + onCopy: (text: string) => void; + + // Model alias handlers + onSetAlias: (modelId: string, alias: string, providerAlias: string) => Promise; + onDeleteAlias: (alias: string) => Promise; + fetchProviderModelMeta: () => Promise; + + // Connections + connections: any[]; + selectedConnection: any; + + // Phase 1k: import handlers + canImportModels: boolean; + importingModels: boolean; + handleImportModels: () => Promise; + isAutoSyncEnabled: boolean; + togglingAutoSync: boolean; + handleToggleAutoSync: () => Promise; + handleCompatibleImportWithProgress: (connectionId: string) => Promise; + + // Phase 1l: visibility handlers + compatSavingModelId: string | null; + togglingModelId: string | null; + bulkVisibilityAction: "select" | "deselect" | null; + clearingModels: boolean; + modelFilter: string; + testingModelId: string | null; + modelTestStatus: Record; + testingAll: boolean; + testProgress: { done: number; total: number } | null; + autoHideFailed: boolean; + visibilityFilter: "all" | "visible" | "hidden"; + providerAliasEntries: [string, string][]; + setModelFilter: (v: string) => void; + setAutoHideFailed: (v: boolean) => void; + setVisibilityFilter: (v: "all" | "visible" | "hidden") => void; + saveModelCompatFlags: (modelId: string, patch: ModelCompatSavePatch) => Promise; + handleToggleModelHidden: ( + providerKey: string, + modelId: string, + hidden: boolean + ) => Promise; + handleBulkToggleModelHidden: ( + providerKey: string, + modelIds: string[], + hidden: boolean + ) => Promise; + handleClearAllModels: () => Promise; + onTestModel: (modelId: string, fullModel: string) => Promise; + handleTestAll: (targets: Array<{ modelId: string; fullModel: string }>) => Promise; + + // Compat state (from useModelCompatState) + effectiveModelNormalize: (modelId: string, protocol?: string) => boolean; + effectiveModelPreserveDeveloper: (modelId: string, protocol?: string) => boolean; + effectiveModelHidden: (modelId: string) => boolean; + getUpstreamHeadersRecordForModel: (modelId: string, protocol: string) => Record; + + // Translation + t: ProviderMessageTranslator; +} + +export default function ProviderModelsSection({ + providerId, + providerAlias, + providerStorageAlias, + providerDisplayAlias, + providerInfo, + isCcCompatible, + isAnthropicCompatible, + isAnthropicProtocolCompatible, + isManagedAvailableModelsProvider, + compatibleSupportsModelImport, + models, + modelMeta, + modelAliases, + syncedAvailableModels, + compatibleFallbackModels, + copied, + onCopy, + onSetAlias, + onDeleteAlias, + fetchProviderModelMeta, + connections, + selectedConnection, + canImportModels, + importingModels, + handleImportModels, + isAutoSyncEnabled, + togglingAutoSync, + handleToggleAutoSync, + handleCompatibleImportWithProgress, + compatSavingModelId, + togglingModelId, + bulkVisibilityAction, + clearingModels, + modelFilter, + testingModelId, + modelTestStatus, + testingAll, + testProgress, + autoHideFailed, + visibilityFilter, + providerAliasEntries, + setModelFilter, + setAutoHideFailed, + setVisibilityFilter, + saveModelCompatFlags, + handleToggleModelHidden, + handleBulkToggleModelHidden, + handleClearAllModels, + onTestModel, + handleTestAll, + effectiveModelNormalize, + effectiveModelPreserveDeveloper, + effectiveModelHidden, + getUpstreamHeadersRecordForModel, + t, +}: ProviderModelsSectionProps) { + 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 ?? ""} + autoHideFailed={autoHideFailed} + onAutoHideFailedChange={setAutoHideFailed} + /> +
+ ); + } + + 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, + })} +

+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelImportHandlers.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelImportHandlers.ts new file mode 100644 index 0000000000..9357405ff3 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelImportHandlers.ts @@ -0,0 +1,382 @@ +"use client"; + +/** + * useModelImportHandlers — Issue #3501 Phase 1k + * + * Owns import-progress state and handlers that were previously inline in + * ProviderDetailPageClient: + * - importingModels, showImportModal, importProgress, togglingAutoSync + * - handleImportModels, handleCompatibleImportWithProgress, handleToggleAutoSync + * - canImportModels (derived), isAutoSyncEnabled (derived), autoSyncConnection (derived) + * + * Cycle-safe: imports only from leaf modules and React. + * No import from ProviderDetailPageClient. + */ + +import React, { useState } from "react"; +import type { ProviderMessageTranslator } from "../providerPageHelpers"; +import { useNotificationStore } from "@/store/notificationStore"; + +type NotifyStore = ReturnType; + +// ──── types ────────────────────────────────────────────────────────────────── + +export interface ImportProgress { + current: number; + total: number; + phase: "idle" | "fetching" | "importing" | "done" | "error"; + status: string; + logs: string[]; + error: string; + importedCount: number; +} + +export interface UseModelImportHandlersParams { + providerId: string; + models: Array<{ id: string; name?: string }>; + modelMeta: { customModels: Array<{ id: string }>; modelCompatOverrides?: unknown[] }; + modelAliases: Record; + connections: Array<{ id?: string; isActive?: boolean; providerSpecificData?: Record }>; + isFreeNoAuth: boolean; + handleSetAlias: (modelId: string, alias: string, providerAlias: string) => Promise; + fetchAliases: () => Promise; + fetchProviderModelMeta: () => Promise; + fetchConnections: () => Promise; + notify: NotifyStore; + t: ProviderMessageTranslator; + providerStorageAlias: string; +} + +export interface UseModelImportHandlersReturn { + importingModels: boolean; + showImportModal: boolean; + importProgress: ImportProgress; + togglingAutoSync: boolean; + canImportModels: boolean; + isAutoSyncEnabled: boolean; + autoSyncConnection: UseModelImportHandlersParams["connections"][number] | undefined; + setShowImportModal: (v: boolean) => void; + setImportProgress: React.Dispatch>; + handleImportModels: () => Promise; + handleCompatibleImportWithProgress: (connectionId: string) => Promise; + handleToggleAutoSync: () => Promise; +} + +// ──── hook ─────────────────────────────────────────────────────────────────── + +export function useModelImportHandlers({ + providerId, + models, + modelMeta, + modelAliases, + connections, + isFreeNoAuth, + handleSetAlias, + fetchAliases, + fetchProviderModelMeta, + fetchConnections, + notify, + t, + providerStorageAlias, +}: UseModelImportHandlersParams): UseModelImportHandlersReturn { + const [importingModels, setImportingModels] = useState(false); + const [showImportModal, setShowImportModal] = useState(false); + const [importProgress, setImportProgress] = useState({ + current: 0, + total: 0, + phase: "idle", + status: "", + logs: [], + error: "", + importedCount: 0, + }); + const [togglingAutoSync, setTogglingAutoSync] = useState(false); + + // Derived + const canImportModels = isFreeNoAuth || connections.some((conn) => conn.isActive !== false); + const autoSyncConnection = connections.find((conn) => conn.isActive !== false); + const isAutoSyncEnabled = !!(autoSyncConnection as any)?.providerSpecificData?.autoSync; + + const handleImportModels = async () => { + if (importingModels) return; + const activeConnection = connections.find((conn) => conn.isActive !== false); + 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 })], + })); + + 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 } + : {}), + }), + }); + 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, + })); + + 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); + } + }; + + 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 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); + } + }; + + return { + importingModels, + showImportModal, + importProgress, + togglingAutoSync, + canImportModels, + isAutoSyncEnabled, + autoSyncConnection, + setShowImportModal, + setImportProgress, + handleImportModels, + handleCompatibleImportWithProgress, + handleToggleAutoSync, + }; +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelVisibilityHandlers.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelVisibilityHandlers.ts new file mode 100644 index 0000000000..4061c81613 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelVisibilityHandlers.ts @@ -0,0 +1,411 @@ +"use client"; + +/** + * useModelVisibilityHandlers — Issue #3501 Phase 1l + * + * Owns model-visibility/compat state and handlers previously inline in + * ProviderDetailPageClient: + * - State: compatSavingModelId, togglingModelId, bulkVisibilityAction, + * clearingModels, modelFilter, testingModelId, modelTestStatus, + * testingAll, testProgress, autoHideFailed, visibilityFilter + * - Derived: providerAliasEntries + * - Handlers: saveModelCompatFlags, handleToggleModelHidden, + * handleBulkToggleModelHidden, handleClearAllModels, + * onTestModel, handleTestAll + * + * onTestModel and handleTestAll share handleToggleModelHidden — kept in the + * same hook to avoid cross-hook cycles. + * + * Cycle-safe: imports only from leaf modules. No import from + * ProviderDetailPageClient. + */ + +import { useState, useMemo } from "react"; +import { + formatProviderModelsErrorResponse, + providerText, + type ProviderMessageTranslator, + type CompatByProtocolMap, +} from "../providerPageHelpers"; +import { useNotificationStore } from "@/store/notificationStore"; + +type NotifyStore = ReturnType; + +// ──── types ────────────────────────────────────────────────────────────────── + +/** Subset of ModelCompatSavePatch fields needed by this hook. */ +export interface ModelCompatSavePatch { + normalizeToolCallId?: boolean; + preserveOpenAIDeveloperRole?: boolean; + upstreamHeaders?: Record; + compatByProtocol?: CompatByProtocolMap; + isHidden?: boolean; +} + +export interface UseModelVisibilityHandlersParams { + providerId: string; + modelAliases: Record; + /** The computed custom-model map from useModelCompatState. */ + customMap: Map; + providerStorageAlias: string; + fetchProviderModelMeta: () => Promise; + fetchAliases: () => Promise; + notify: NotifyStore; + t: ProviderMessageTranslator; + formatProviderModelsErrorResponse?: typeof formatProviderModelsErrorResponse; + /** The current selected connection (may be null). */ + selectedConnection: any; + /** The provider node (may be null). */ + providerNode: any; +} + +export interface UseModelVisibilityHandlersReturn { + compatSavingModelId: string | null; + togglingModelId: string | null; + bulkVisibilityAction: "select" | "deselect" | null; + clearingModels: boolean; + modelFilter: string; + testingModelId: string | null; + modelTestStatus: Record; + testingAll: boolean; + testProgress: { done: number; total: number } | null; + autoHideFailed: boolean; + visibilityFilter: "all" | "visible" | "hidden"; + providerAliasEntries: [string, string][]; + setModelFilter: (v: string) => void; + setAutoHideFailed: (v: boolean) => void; + setVisibilityFilter: (v: "all" | "visible" | "hidden") => void; + saveModelCompatFlags: (modelId: string, patch: ModelCompatSavePatch) => Promise; + handleToggleModelHidden: ( + providerKey: string, + modelId: string, + hidden: boolean + ) => Promise; + handleBulkToggleModelHidden: ( + providerKey: string, + modelIds: string[], + hidden: boolean + ) => Promise; + handleClearAllModels: () => Promise; + onTestModel: (modelId: string, fullModel: string) => Promise; + handleTestAll: (targets: Array<{ modelId: string; fullModel: string }>) => Promise; +} + +// ──── hook ─────────────────────────────────────────────────────────────────── + +export function useModelVisibilityHandlers({ + providerId, + modelAliases, + customMap, + providerStorageAlias, + fetchProviderModelMeta, + fetchAliases, + notify, + t, + selectedConnection, + providerNode, +}: UseModelVisibilityHandlersParams): UseModelVisibilityHandlersReturn { + const [compatSavingModelId, setCompatSavingModelId] = useState(null); + const [togglingModelId, setTogglingModelId] = useState(null); + const [bulkVisibilityAction, setBulkVisibilityAction] = useState< + "select" | "deselect" | null + >(null); + const [clearingModels, setClearingModels] = useState(false); + const [modelFilter, setModelFilter] = useState(""); + 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 providerAliasEntries = useMemo( + () => + Object.entries(modelAliases).filter( + ([, model]) => typeof model === "string" && model.startsWith(`${providerStorageAlias}/`) + ) as [string, string][], + [modelAliases, providerStorageAlias] + ); + + 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 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 onTestModel = async (modelId: string, fullModel: string) => { + setTestingModelId(modelId); + setModelTestStatus((prev) => ({ ...prev, [modelId]: undefined as any })); + 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" })); + await handleToggleModelHidden(providerStorageAlias, modelId, true); + } + } catch (err) { + notify.error("Network error testing model"); + setModelTestStatus((prev) => ({ ...prev, [modelId]: "error" })); + 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); + }; + + return { + compatSavingModelId, + togglingModelId, + bulkVisibilityAction, + clearingModels, + modelFilter, + testingModelId, + modelTestStatus, + testingAll, + testProgress, + autoHideFailed, + visibilityFilter, + providerAliasEntries, + setModelFilter, + setAutoHideFailed, + setVisibilityFilter, + saveModelCompatFlags, + handleToggleModelHidden, + handleBulkToggleModelHidden, + handleClearAllModels, + onTestModel, + handleTestAll, + }; +}