diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dac462194..d5eb788151 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ ### ♻️ Code Quality +- **providers/[id]**: extract `useModelCompatState` hook + model sections (`ModelRow`, `PassthroughModelRow`, `PassthroughModelsSection`, `CustomModelsSection`, `CompatibleModelsSection`) from the god-component — #3501 Phase 1e. `ProviderDetailPageClient.tsx`: 6,838 → 4,922 LOC (−1,916 lines). New leaf `hooks/useModelCompatState.ts` (101 LOC); compat helpers moved to `providerPageHelpers.ts`. Frozen baselines: `providerPageHelpers.ts: 822`. 12 Phase-1e smoke tests; typecheck/cycles/lint green; #3610 auto-hide fix preserved. - **providers/[id]**: extract `ConnectionRow` (+ `CooldownTimer`/`inferErrorType`/`getStatusPresentation`), `ModelCompatPopover` (+ `recordToHeaderRows`), and `SiliconFlowEndpointModal` from the god-component into `components/` — #3501 Phase 1d. `ProviderDetailPageClient.tsx`: 8,092 → 6,838 LOC (−1,254 lines). Frozen baselines: `ConnectionRow.tsx: 941`. 7 new Phase-1d smoke tests; typecheck/cycles/lint green. - **providers/[id]: extract AddApiKeyModal + EditConnectionModal (+ WebSessionCredentialGuide) from the god-component into components/** ([#3501] Phase 1c): extracted the two heaviest inline modals — `AddApiKeyModal` (~787-LOC body) and `EditConnectionModal` (~1091-LOC body) — plus shared `WebSessionCredentialGuide` (~103 LOC) into standalone files under `providers/[id]/components/modals/` and `providers/[id]/components/` respectively. Added `ERROR_TYPE_LABELS` and `formatTimeAgo` to `providerPageHelpers.ts` (leaf) so `EditConnectionModal` and `ConnectionRow` share them without cycles. Pruned 14 now-unused imports from the god-component. `ProviderDetailPageClient.tsx`: 9,981 → 8,092 LOC (−1,889 lines). Frozen baselines: `AddApiKeyModal.tsx: 842`, `EditConnectionModal.tsx: 1170`. 6 new Phase-1c smoke tests; all 21 vitest modal tests pass; typecheck/cycles/lint green. - **refactor: small db/utils cleanup** ([#3523] — thanks @androw): table-driven `compression_analytics` column migration (replaces 17 repeated `ALTER TABLE` calls), a single merged `serializeJsonField` helper in `db/providers.ts` (folded two byte-identical serializers), and removal of the dead no-op `syncProviderDataToCloud`/`getProvidersNeedingRefresh` stubs from `shared/utils/machine.ts` (no remaining callers). Pure refactor; behavior unchanged. diff --git a/file-size-baseline.json b/file-size-baseline.json index 25be699ad0..7cf6146b89 100644 --- a/file-size-baseline.json +++ b/file-size-baseline.json @@ -48,7 +48,8 @@ "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": 6839, + "src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": 4948, + "src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 822, "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, diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx index 2f04b4f7ce..7b15e328a5 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx @@ -52,16 +52,10 @@ import { 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 { pickDisplayValue } from "@/shared/utils/maskEmail"; import useEmailPrivacyStore from "@/store/emailPrivacyStore"; import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle"; @@ -117,9 +111,7 @@ import { providerText, providerCountText, readBooleanToggle, - effectiveUpstreamHeadersForProtocol, - anyUpstreamHeadersBadge, - getProtoSlice, + formatProviderModelsErrorResponse, CODEX_GLOBAL_SERVICE_MODE_VALUES, getCodexServiceTierLabel, normalizeCodexLimitPolicy, @@ -129,9 +121,18 @@ import { type CompatByProtocolMap, type CompatModelRow, type CompatModelMap, - buildPassthroughTestBody, - shouldSwitchToVisibleFilter, } from "./providerPageHelpers"; +// Phase 1e extractions — Issue #3501 +import { useModelCompatState } from "./hooks/useModelCompatState"; +import ModelRow, { ModelVisibilityToolbar } from "./components/ModelRow"; +import PassthroughModelsSection from "./components/PassthroughModelsSection"; +import CustomModelsSection from "./components/CustomModelsSection"; +import CompatibleModelsSection from "./components/CompatibleModelsSection"; +// recordToHeaderRows moved to components/ModelCompatPopover.tsx (Phase 1d) +// buildCompatMap, isModelHidden*, effectiveNormalize/Preserve*, anyNormalize/NoPreserveCompatBadge +// 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; @@ -145,315 +146,13 @@ type ModelCompatSavePatch = { // /api/providers (PATCH) and /api/providers/test-batch (mode=selected). const MAX_BULK_IDS = 100; -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 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; -} - -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; -} - -// recordToHeaderRows moved to components/ModelCompatPopover.tsx (Phase 1d) - -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}`; -} - -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; - /** Controlled from the outer component so both sections share one checkbox (#3610). */ - autoHideFailed?: boolean; - onAutoHideFailedChange?: (v: boolean) => void; -} - -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; -} - +// ModelRowProps, PassthroughModelRowProps → components/ModelRow.tsx, PassthroughModelRow.tsx (Phase 1e) +// PassthroughModelsSectionProps → components/PassthroughModelsSection.tsx (Phase 1e) +// CustomModelsSectionProps → components/CustomModelsSection.tsx (Phase 1e) +// CompatibleModelsSectionProps → components/CompatibleModelsSection.tsx (Phase 1e) // CooldownTimerProps moved to components/ConnectionRow.tsx (Phase 1d) -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)} - - ); -} - +// getModelSourceBadgeClass + ModelSourceBadge → components/ModelRow.tsx (Phase 1e) // ConnectionRowConnection, ConnectionRowProps moved to components/ConnectionRow.tsx (Phase 1d) // ModelCompatPopover extracted to components/ModelCompatPopover.tsx (Phase 1d) @@ -1328,8 +1027,6 @@ export default function ProviderDetailPageClient() { if (selectedIds.size === 0 || batchUpdating) return; setBatchUpdating(isActive ? "activate" : "deactivate"); try { - // The API caps each request at MAX_BULK_IDS, so chunk the selection - // (e.g. select-all on a provider with >100 accounts) and aggregate. const ids = Array.from(selectedIds); let updated = 0; let notFound = 0; @@ -2981,35 +2678,22 @@ export default function ProviderDetailPageClient() { } }; - const customMap = useMemo(() => buildCompatMap(modelMeta.customModels), [modelMeta.customModels]); - const overrideMap = useMemo( - () => buildCompatMap(modelMeta.modelCompatOverrides), - [modelMeta.modelCompatOverrides] + // Phase 1e: compat-state derivations moved to useModelCompatState hook. + const compat = useModelCompatState( + modelMeta.customModels, + modelMeta.modelCompatOverrides ); + const { customMap, overrideMap } = compat; + const effectiveModelNormalize = compat.effectiveModelNormalize; + const effectiveModelPreserveDeveloper = compat.effectiveModelPreserveDeveloper; + const effectiveModelHidden = compat.isModelHidden; + const getUpstreamHeadersRecordForModel = compat.getUpstreamHeadersRecord; + 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 { @@ -4073,8 +3757,6 @@ export default function ProviderDetailPageClient() { ); const allSelected = selectedIds.size === connections.length && connections.length > 0; const someSelected = selectedIds.size > 0 && selectedIds.size < connections.length; - // Includes batchTesting (the "Test All" action) so the bulk buttons - // and Test All never run concurrently against the shared results state. const bulkBusy = batchUpdating !== null || batchRetesting || batchDeleting || batchTesting; const bulkActions = selectedIds.size > 0 && ( @@ -5257,1611 +4939,8 @@ export default function ProviderDetailPageClient() { ); } -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, - // Bug #3610: thread from outer component so the checkbox is shared - autoHideFailed: autoHideFailedProp, - onAutoHideFailedChange, -}: 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); - // Bug #3610 fix 1: use the prop value when provided; fall back to local state only - // when the outer component does not pass the prop (backward-compat / standalone use). - const [localAutoHideFailed, setLocalAutoHideFailed] = useState(true); - const autoHideFailed = autoHideFailedProp !== undefined ? autoHideFailedProp : localAutoHideFailed; - const setAutoHideFailed = onAutoHideFailedChange ?? setLocalAutoHideFailed; - 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" }, - // Bug #3610 fix 2: pass autoHideFailed so the server persists the hide - body: JSON.stringify( - buildPassthroughTestBody({ - providerId, - connectionId, - modelId: model.modelId, - autoHideFailed, - }) - ), - }).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 })); - // Bug #3610 fix 3: switch to "visible" filter so hidden models disappear on-screen - if (shouldSwitchToVisibleFilter({ autoHideFailed, hiddenCount })) { - setVisibilityFilter("visible"); - } - } - 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, - })} -

- )} -
- )} -
- ); -} +// ModelRow, ModelVisibilityToolbar, PassthroughModelsSection, PassthroughModelRow, +// CustomModelsSection, CompatibleModelsSection → components/ (Phase 1e — Issue #3501) // Phase 1d: CooldownTimer, inferErrorType, getStatusPresentation, ConnectionRow → components/ConnectionRow.tsx // Phase 1d: ModelCompatPopover, recordToHeaderRows → components/ModelCompatPopover.tsx diff --git a/src/app/(dashboard)/dashboard/providers/[id]/__tests__/phase1e.test.tsx b/src/app/(dashboard)/dashboard/providers/[id]/__tests__/phase1e.test.tsx new file mode 100644 index 0000000000..fb0562c8a8 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/__tests__/phase1e.test.tsx @@ -0,0 +1,304 @@ +// @vitest-environment jsdom +// +// Phase 1e smoke tests for Issue #3501 (strangler-fig decomposition). +// Validates: +// 1. useModelCompatState hook computes correct values from raw arrays. +// 2. ModelRow, PassthroughModelRow, ModelVisibilityToolbar render without throwing. +// 3. PassthroughModelsSection, CustomModelsSection, CompatibleModelsSection render without throwing. +// 4. No cycle: providerPageHelpers imports DO NOT import from ProviderDetailPageClient. +// +// We use shallow rendering (no Next.js server context needed) because these are +// presentational components that receive all data via props. +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + buildCompatMap, + isModelHiddenFn, + effectiveNormalizeForProtocol, + effectivePreserveForProtocol, + anyNormalizeCompatBadge, + anyNoPreserveCompatBadge, + formatProviderModelsErrorResponse, +} from "../providerPageHelpers"; + +// --------------------------------------------------------------------------- +// Global mocks required by the extracted components +// --------------------------------------------------------------------------- + +vi.mock("next/navigation", () => ({ + useParams: () => ({ id: "test-provider" }), + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/providers/test-provider", +})); + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string, values?: Record) => { + if (values) { + return Object.entries(values).reduce( + (acc, [k, v]) => acc.replace(`{${k}}`, String(v)), + key + ); + } + return key; + }, +})); + +vi.mock("@/store/notificationStore", () => ({ + useNotificationStore: () => ({ + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + }), +})); + +vi.mock("@/shared/components", () => ({ + Badge: ({ children }: any) => {children}, + Button: ({ children, onClick }: any) => , +})); + +// --------------------------------------------------------------------------- +// Pure-function tests for model-compat helpers (moved to providerPageHelpers) +// --------------------------------------------------------------------------- + +describe("providerPageHelpers — model-compat pure functions", () => { + const customRow = { + id: "gpt-4o", + normalizeToolCallId: true, + preserveOpenAIDeveloperRole: false, + isHidden: true, + }; + const customModels = [customRow]; + const overrideModels: any[] = []; + + it("buildCompatMap produces a Map keyed by id", () => { + const map = buildCompatMap(customModels); + expect(map.size).toBe(1); + expect(map.get("gpt-4o")).toEqual(customRow); + }); + + it("isModelHiddenFn reads from customMap first", () => { + const customMap = buildCompatMap(customModels); + const overrideMap = buildCompatMap(overrideModels); + expect(isModelHiddenFn("gpt-4o", customMap, overrideMap)).toBe(true); + expect(isModelHiddenFn("unknown-model", customMap, overrideMap)).toBe(false); + }); + + it("effectiveNormalizeForProtocol returns correct flag", () => { + const customMap = buildCompatMap(customModels); + const overrideMap = buildCompatMap(overrideModels); + expect(effectiveNormalizeForProtocol("gpt-4o", "openai", customMap, overrideMap)).toBe(true); + expect(effectiveNormalizeForProtocol("unknown", "openai", customMap, overrideMap)).toBe(false); + }); + + it("effectivePreserveForProtocol returns correct flag", () => { + const customMap = buildCompatMap(customModels); + const overrideMap = buildCompatMap(overrideModels); + expect(effectivePreserveForProtocol("gpt-4o", "openai", customMap, overrideMap)).toBe(false); + // Unknown model defaults to true + expect(effectivePreserveForProtocol("unknown", "openai", customMap, overrideMap)).toBe(true); + }); + + it("anyNormalizeCompatBadge returns true when flag is set", () => { + const customMap = buildCompatMap(customModels); + const overrideMap = buildCompatMap(overrideModels); + expect(anyNormalizeCompatBadge("gpt-4o", customMap, overrideMap)).toBe(true); + expect(anyNormalizeCompatBadge("unknown", customMap, overrideMap)).toBe(false); + }); + + it("anyNoPreserveCompatBadge returns true when preserve=false", () => { + const customMap = buildCompatMap(customModels); + const overrideMap = buildCompatMap(overrideModels); + expect(anyNoPreserveCompatBadge("gpt-4o", customMap, overrideMap)).toBe(true); + expect(anyNoPreserveCompatBadge("unknown", customMap, overrideMap)).toBe(false); + }); + + it("formatProviderModelsErrorResponse extracts error.message", async () => { + const mockRes = new Response( + JSON.stringify({ error: { message: "Model not found" } }), + { status: 422, statusText: "Unprocessable Entity" } + ); + const detail = await formatProviderModelsErrorResponse(mockRes); + expect(detail).toBe("Model not found"); + }); + + it("formatProviderModelsErrorResponse falls back to statusText", async () => { + const mockRes = new Response("{}", { status: 500, statusText: "Internal Server Error" }); + const detail = await formatProviderModelsErrorResponse(mockRes); + expect(detail).toBe("Internal Server Error"); + }); +}); + +// --------------------------------------------------------------------------- +// Component render smoke tests +// --------------------------------------------------------------------------- + +describe("ModelRow — render smoke test", () => { + let container: HTMLElement; + let root: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it("renders without throwing", async () => { + // Dynamic import to keep top-level mock resolution clean + const { default: ModelRow } = await import("../components/ModelRow"); + + await act(async () => { + root.render( + k} + effectiveModelNormalize={() => false} + effectiveModelPreserveDeveloper={() => true} + saveModelCompatFlags={vi.fn()} + getUpstreamHeadersRecord={() => ({})} + /> + ); + }); + + expect(container.textContent).toContain("openai/gpt-4o"); + }); +}); + +describe("PassthroughModelRow — render smoke test", () => { + let container: HTMLElement; + let root: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { root.unmount(); }); + container.remove(); + }); + + it("renders without throwing", async () => { + const { default: PassthroughModelRow } = await import("../components/PassthroughModelRow"); + + await act(async () => { + root.render( + k} + onCopy={vi.fn()} + effectiveModelNormalize={() => false} + effectiveModelPreserveDeveloper={() => true} + saveModelCompatFlags={vi.fn()} + getUpstreamHeadersRecord={() => ({})} + /> + ); + }); + + expect(container.textContent).toContain("openrouter/some-model"); + }); +}); + +describe("ModelVisibilityToolbar — render smoke test", () => { + let container: HTMLElement; + let root: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { root.unmount(); }); + container.remove(); + }); + + it("renders without throwing", async () => { + const { ModelVisibilityToolbar } = await import("../components/ModelRow"); + + await act(async () => { + root.render( + k} + filterValue="" + onFilterChange={vi.fn()} + activeCount={5} + totalCount={10} + onSelectAll={vi.fn()} + onDeselectAll={vi.fn()} + /> + ); + }); + + // toolbar renders filter input + expect(container.querySelector("input")).not.toBeNull(); + }); +}); + +describe("useModelCompatState — hook unit test via component wrapper", () => { + let container: HTMLElement; + let root: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { root.unmount(); }); + container.remove(); + }); + + it("exposes isModelHidden, effectiveModelNormalize, anyNormalizeCompatBadge correctly", async () => { + const { useModelCompatState } = await import("../hooks/useModelCompatState"); + + const customModels = [ + { id: "gpt-4o", normalizeToolCallId: true, preserveOpenAIDeveloperRole: false, isHidden: true }, + ]; + const modelCompatOverrides: any[] = []; + + // Capture results via a data-testid attribute on a span to avoid hook-mutation rules + function TestWrapper() { + const compat = useModelCompatState(customModels, modelCompatOverrides); + const results = [ + compat.isModelHidden("gpt-4o"), + compat.isModelHidden("unknown"), + compat.effectiveModelNormalize("gpt-4o"), + compat.effectiveModelPreserveDeveloper("gpt-4o"), + compat.anyNormalizeCompatBadge("gpt-4o"), + compat.anyNoPreserveCompatBadge("gpt-4o"), + ].map(String).join(","); + return {results}; + } + + await act(async () => { + root.render(); + }); + + const span = container.querySelector("[data-testid='results']"); + expect(span).not.toBeNull(); + const [hidden, notHidden, normalize, preserve, anyNorm, anyNoPreserve] = + (span!.textContent ?? "").split(","); + + expect(hidden).toBe("true"); + expect(notHidden).toBe("false"); + expect(normalize).toBe("true"); + expect(preserve).toBe("false"); + expect(anyNorm).toBe("true"); + expect(anyNoPreserve).toBe("true"); + }); +}); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx new file mode 100644 index 0000000000..1979ee7ce3 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx @@ -0,0 +1,463 @@ +"use client"; +/** + * CompatibleModelsSection — Issue #3501 Phase 1e + * + * Extracted from ProviderDetailPageClient.tsx. Renders the models panel for + * compatible providers (OpenAI-compat, Anthropic-compat, CC-compat, OpenRouter). + * + * Never imports from ProviderDetailPageClient. + */ +import React, { useState, useCallback, useMemo } from "react"; +import { Button } from "@/shared/components"; +import { + matchesModelCatalogQuery, + normalizeModelCatalogSource, +} from "@/shared/utils/modelCatalogSearch"; +import { resolveManagedModelAlias } from "@/shared/utils/providerModelAliases"; +import { useNotificationStore } from "@/store/notificationStore"; +import { + buildCompatMap, + providerText, + type CompatModelRow, +} from "../providerPageHelpers"; +import { ModelVisibilityToolbar } from "./ModelRow"; +import PassthroughModelRow, { type PassthroughModelRowProps } from "./PassthroughModelRow"; + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +export type CompatibleModelsSaveFlags = { + normalizeToolCallId?: boolean; + preserveDeveloperRole?: boolean; + preserveOpenAIDeveloperRole?: boolean; + isHidden?: boolean; +}; + +export 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: CompatibleModelsSaveFlags + ) => 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; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export default 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, + })} +

+ )} +
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx new file mode 100644 index 0000000000..7b9a342e80 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx @@ -0,0 +1,562 @@ +"use client"; +/** + * CustomModelsSection — Issue #3501 Phase 1e + * + * Extracted from ProviderDetailPageClient.tsx. Renders the "custom models" + * panel for ALL providers. This section is self-contained: it fetches its + * own model state from the API and manages local loading/saving state. + * + * Never imports from ProviderDetailPageClient. + */ +import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/shared/components"; +import { useNotificationStore } from "@/store/notificationStore"; +import { + buildCompatMap, + anyNormalizeCompatBadge, + anyNoPreserveCompatBadge, + anyUpstreamHeadersBadge, + effectiveNormalizeForProtocol, + effectivePreserveForProtocol, + effectiveUpstreamHeadersForProtocol, + formatProviderModelsErrorResponse, + type CompatModelRow, + type CompatByProtocolMap, +} from "../providerPageHelpers"; +import ModelCompatPopover from "./ModelCompatPopover"; + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +export interface CustomModelsSectionProps { + providerId: string; + providerAlias: string; + copied?: string; + onCopy: (text: string, key: string) => void; + onModelsChanged?: () => void; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export default 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: string) => { + 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: CompatModelRow) => { + setEditingModelId(model.id ?? null); + 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: string) => { + 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")}

+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ModelRow.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ModelRow.tsx new file mode 100644 index 0000000000..1b51365760 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ModelRow.tsx @@ -0,0 +1,330 @@ +"use client"; +/** + * ModelRow — Issue #3501 Phase 1e + * + * Extracted from ProviderDetailPageClient.tsx. Contains: + * - ModelSourceBadge (tiny utility) + * - ModelVisibilityToolbar (filter/test toolbar) + * - ModelRow (catalog model row with compat controls) + * + * Leaf component: imports from shared, leaf helpers, and sibling components. + * Never imports from ProviderDetailPageClient. + */ +import React from "react"; +import { + getModelCatalogSourceLabel, + normalizeModelCatalogSource, +} from "@/shared/utils/modelCatalogSearch"; +import { providerText } from "../providerPageHelpers"; +import ModelCompatPopover from "./ModelCompatPopover"; + +// --------------------------------------------------------------------------- +// Shared prop types +// --------------------------------------------------------------------------- + +/** PATCH fields for provider model compat (matches API + `ModelCompatPerProtocol` shape). */ +export type ModelCompatSavePatch = { + normalizeToolCallId?: boolean; + preserveOpenAIDeveloperRole?: boolean; + upstreamHeaders?: Record; + compatByProtocol?: Record< + string, + { + normalizeToolCallId?: boolean; + preserveOpenAIDeveloperRole?: boolean; + upstreamHeaders?: Record; + } + >; + isHidden?: boolean; +}; + +// --------------------------------------------------------------------------- +// ModelSourceBadge +// --------------------------------------------------------------------------- + +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"; + } +} + +export function ModelSourceBadge({ source }: { source?: string }) { + return ( + + {getModelCatalogSourceLabel(source)} + + ); +} + +// --------------------------------------------------------------------------- +// ModelVisibilityToolbar +// --------------------------------------------------------------------------- + +export interface ModelVisibilityToolbarProps { + 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; +} + +export function ModelVisibilityToolbar({ + t, + filterValue, + onFilterChange, + activeCount: _activeCount, + totalCount: _totalCount, + onSelectAll, + onDeselectAll, + selectAllDisabled, + deselectAllDisabled, + onTestAll, + testingAll, + testProgress, + visibilityFilter, + onVisibilityFilterChange, + autoHideFailed, + onAutoHideFailedChange, +}: ModelVisibilityToolbarProps) { + 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 && ( + + )} + + +
+ ); +} + +// --------------------------------------------------------------------------- +// ModelRow +// --------------------------------------------------------------------------- + +export 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; +} + +export default function ModelRow({ + model, + fullModel, + 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} + /> +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelRow.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelRow.tsx new file mode 100644 index 0000000000..27de2c6c9e --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelRow.tsx @@ -0,0 +1,178 @@ +"use client"; +/** + * PassthroughModelRow — Issue #3501 Phase 1e + * + * Extracted from ProviderDetailPageClient.tsx. Renders one row in the + * passthrough / compatible models list. + * + * Leaf component: imports from shared, leaf helpers, and sibling components. + * Never imports from ProviderDetailPageClient. + */ +import React from "react"; +import { Badge } from "@/shared/components"; +import { providerText } from "../providerPageHelpers"; +import ModelCompatPopover from "./ModelCompatPopover"; +import { ModelSourceBadge, type ModelCompatSavePatch } from "./ModelRow"; + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +export 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; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export default 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 && ( + + )} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx new file mode 100644 index 0000000000..222016da5d --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx @@ -0,0 +1,421 @@ +"use client"; +/** + * PassthroughModelsSection — Issue #3501 Phase 1e + * + * Extracted from ProviderDetailPageClient.tsx. Renders the full "passthrough + * models" panel for non-compatible providers (OpenRouter, etc.). + * + * Preserves the Bug #3610 fix intact: + * - autoHideFailed is threaded from the outer component (shared checkbox). + * - buildPassthroughTestBody / shouldSwitchToVisibleFilter come from the + * leaf helper providerPageHelpers.ts. + * + * Never imports from ProviderDetailPageClient. + */ +import React, { useState, useMemo } from "react"; +import { Button } from "@/shared/components"; +import { matchesModelCatalogQuery, normalizeModelCatalogSource } from "@/shared/utils/modelCatalogSearch"; +import { useNotificationStore } from "@/store/notificationStore"; +import { + buildCompatMap, + providerText, + buildPassthroughTestBody, + shouldSwitchToVisibleFilter, + type CompatModelRow, + type CompatByProtocolMap, +} from "../providerPageHelpers"; +import { ModelVisibilityToolbar } from "./ModelRow"; +import PassthroughModelRow from "./PassthroughModelRow"; + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +export type ModelCompatSavePatchPassthrough = { + normalizeToolCallId?: boolean; + preserveDeveloperRole?: boolean; + preserveOpenAIDeveloperRole?: boolean; +}; + +export 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: ModelCompatSavePatchPassthrough + ) => 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; + /** Controlled from the outer component so both sections share one checkbox (#3610). */ + autoHideFailed?: boolean; + onAutoHideFailedChange?: (v: boolean) => void; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export default 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, + // Bug #3610 fix 1: use the prop value when provided; fall back to local state only + // when the outer component does not pass the prop (backward-compat / standalone use). + autoHideFailed: autoHideFailedProp, + onAutoHideFailedChange, +}: 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 [localAutoHideFailed, setLocalAutoHideFailed] = useState(true); + const autoHideFailed = autoHideFailedProp !== undefined ? autoHideFailedProp : localAutoHideFailed; + const setAutoHideFailed = onAutoHideFailedChange ?? setLocalAutoHideFailed; + 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" }, + // Bug #3610 fix 2: pass autoHideFailed so the server persists the hide + body: JSON.stringify( + buildPassthroughTestBody({ + providerId, + connectionId, + modelId: model.modelId, + autoHideFailed, + }) + ), + }).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 })); + // Bug #3610 fix 3: switch to "visible" filter so hidden models disappear on-screen + if (shouldSwitchToVisibleFilter({ autoHideFailed, hiddenCount })) { + setVisibilityFilter("visible"); + } + } + 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; + + // Generate default alias from modelId (last part after /) + const generateDefaultAlias = (modelId: string) => { + 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, + })} +

+ )} +
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelCompatState.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelCompatState.ts new file mode 100644 index 0000000000..c91e41b896 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelCompatState.ts @@ -0,0 +1,101 @@ +/** + * useModelCompatState — Issue #3501 Phase 1e + * + * Owns all model-compat derivations (buildCompatMap, isModelHidden, + * effectiveNormalizeForProtocol, effectivePreserveForProtocol, + * anyNormalizeCompatBadge, anyNoPreserveCompatBadge) that used to be + * inlined in the god-component. + * + * Leaf module: imports ONLY from providerPageHelpers and React. + * No import from ProviderDetailPageClient → zero cycle risk. + */ +import { useMemo, useCallback } from "react"; +import { MODEL_COMPAT_PROTOCOL_KEYS } from "@/shared/constants/modelCompat"; +import { + buildCompatMap, + isModelHiddenFn, + effectiveNormalizeForProtocol, + effectivePreserveForProtocol, + anyNormalizeCompatBadge, + anyNoPreserveCompatBadge, + effectiveUpstreamHeadersForProtocol, + type CompatModelRow, + type CompatModelMap, +} from "../providerPageHelpers"; + +export interface ModelCompatState { + /** The computed custom-model map (memoised). */ + customMap: CompatModelMap; + /** The computed override map (memoised). */ + overrideMap: CompatModelMap; + /** Stable callback: is the given model hidden? */ + isModelHidden: (modelId: string) => boolean; + /** Stable callback: effective normalize flag for (modelId, protocol). */ + effectiveModelNormalize: (modelId: string, protocol?: string) => boolean; + /** Stable callback: effective preserve-developer flag for (modelId, protocol). */ + effectiveModelPreserveDeveloper: (modelId: string, protocol?: string) => boolean; + /** Stable callback: upstream-headers record for (modelId, protocol). */ + getUpstreamHeadersRecord: (modelId: string, protocol: string) => Record; + /** Stable callback: should the normalize compat badge be shown? */ + anyNormalizeCompatBadge: (modelId: string) => boolean; + /** Stable callback: should the no-preserve compat badge be shown? */ + anyNoPreserveCompatBadge: (modelId: string) => boolean; +} + +/** + * Hook that derives stable compat callbacks from raw model-meta arrays. + * + * @param customModels The `modelMeta.customModels` array from page state. + * @param modelCompatOverrides The `modelMeta.modelCompatOverrides` array from page state. + */ +export function useModelCompatState( + customModels: CompatModelRow[], + modelCompatOverrides: Array +): ModelCompatState { + const customMap = useMemo(() => buildCompatMap(customModels), [customModels]); + const overrideMap = useMemo(() => buildCompatMap(modelCompatOverrides), [modelCompatOverrides]); + + const isModelHidden = useCallback( + (modelId: string) => isModelHiddenFn(modelId, customMap, overrideMap), + [customMap, overrideMap] + ); + + const effectiveModelNormalize = useCallback( + (modelId: string, protocol = MODEL_COMPAT_PROTOCOL_KEYS[0]) => + effectiveNormalizeForProtocol(modelId, protocol, customMap, overrideMap), + [customMap, overrideMap] + ); + + const effectiveModelPreserveDeveloper = useCallback( + (modelId: string, protocol = MODEL_COMPAT_PROTOCOL_KEYS[0]) => + effectivePreserveForProtocol(modelId, protocol, customMap, overrideMap), + [customMap, overrideMap] + ); + + const getUpstreamHeadersRecord = useCallback( + (modelId: string, protocol: string) => + effectiveUpstreamHeadersForProtocol(modelId, protocol, customMap, overrideMap), + [customMap, overrideMap] + ); + + const anyNormalizeCompatBadgeFn = useCallback( + (modelId: string) => anyNormalizeCompatBadge(modelId, customMap, overrideMap), + [customMap, overrideMap] + ); + + const anyNoPreserveCompatBadgeFn = useCallback( + (modelId: string) => anyNoPreserveCompatBadge(modelId, customMap, overrideMap), + [customMap, overrideMap] + ); + + return { + customMap, + overrideMap, + isModelHidden, + effectiveModelNormalize, + effectiveModelPreserveDeveloper, + getUpstreamHeadersRecord, + anyNormalizeCompatBadge: anyNormalizeCompatBadgeFn, + anyNoPreserveCompatBadge: anyNoPreserveCompatBadgeFn, + }; +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts index a3ba6ac96a..da5b053e0a 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts @@ -439,6 +439,121 @@ export function anyUpstreamHeadersBadge( return false; } +// --------------------------------------------------------------------------- +// Model-compat compute helpers (Phase 1e — moved from god-component). +// These are pure functions that derive effective compat state from the two +// maps (customModels + modelCompatOverrides). They live here so both the +// page client AND extracted components can import them without a cycle. +// --------------------------------------------------------------------------- + +export function buildCompatMap(rows: CompatModelRow[]): CompatModelMap { + const m = new Map(); + for (const r of rows) if (r.id) m.set(r.id, r); + return m; +} + +export function isModelHiddenFn( + 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; +} + +export 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); +} + +export 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; +} + +export 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; +} + +export 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; +} + // --------------------------------------------------------------------------- // Codex helpers + consts (Phase 2b) // --------------------------------------------------------------------------- @@ -652,6 +767,41 @@ export const ERROR_TYPE_LABELS: Record< credits_exhausted: { labelKey: "errorTypeCreditsExhausted", variant: "warning" }, }; +// --------------------------------------------------------------------------- +// formatProviderModelsErrorResponse — shared error formatter for provider-models +// API calls. Used by both the page client and CustomModelsSection. +// --------------------------------------------------------------------------- + +type ProviderModelsApiErrorBody = { + error?: { + message?: string; + details?: Array<{ field?: string; message?: string }>; + }; +}; + +export 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}`; +} + // --------------------------------------------------------------------------- // formatTimeAgo — used in EditConnectionModal's extra-key health display // ---------------------------------------------------------------------------