From 707a91555db8131bc916700025ee243d8fdf7783 Mon Sep 17 00:00:00 2001 From: nguyenha935 <208228297+nguyenha935@users.noreply.github.com> Date: Sat, 20 Jun 2026 02:44:17 +0700 Subject: [PATCH] fix(models): store provider custom models by provider scope --- .../providers/[id]/CompatibleModelsSection.js | 66 ++++------ .../[id]/PassthroughModelsSection.js | 49 +++---- .../dashboard/providers/[id]/page.js | 121 ++++++++++++------ src/shared/components/ModelSelectModal.js | 26 +++- src/shared/utils/providerCustomModels.js | 54 ++++++++ tests/unit/provider-custom-models.test.js | 84 ++++++++++++ 6 files changed, 282 insertions(+), 118 deletions(-) create mode 100644 src/shared/utils/providerCustomModels.js create mode 100644 tests/unit/provider-custom-models.test.js diff --git a/src/app/(dashboard)/dashboard/providers/[id]/CompatibleModelsSection.js b/src/app/(dashboard)/dashboard/providers/[id]/CompatibleModelsSection.js index 33f1f05a..bfc12a13 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/CompatibleModelsSection.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/CompatibleModelsSection.js @@ -3,6 +3,7 @@ import { useState } from "react"; import PropTypes from "prop-types"; import { Button } from "@/shared/components"; +import { getProviderCustomModelRows } from "@/shared/utils/providerCustomModels"; function CompatibleModelRow({ modelId, fullModel, copied, onCopy, onDeleteAlias, onTest, testStatus, isTesting }) { const borderColor = testStatus === "ok" ? "border-green-500/40" @@ -70,7 +71,7 @@ function CompatibleModelRow({ modelId, fullModel, copied, onCopy, onDeleteAlias, ); } -export default function CompatibleModelsSection({ providerStorageAlias, providerDisplayAlias, modelAliases, copied, onCopy, onSetAlias, onDeleteAlias, connections, isAnthropic }) { +export default function CompatibleModelsSection({ providerStorageAlias, providerDisplayAlias, modelAliases, customModels, copied, onCopy, onDeleteAlias, onAddCustomModel, onDeleteCustomModel, connections, isAnthropic }) { const [newModel, setNewModel] = useState(""); const [adding, setAdding] = useState(false); const [importing, setImporting] = useState(false); @@ -95,44 +96,24 @@ export default function CompatibleModelsSection({ providerStorageAlias, provider } }; - const providerAliases = Object.entries(modelAliases).filter( - ([, model]) => model.startsWith(`${providerStorageAlias}/`) - ); - - const allModels = providerAliases.map(([alias, fullModel]) => ({ - modelId: fullModel.replace(`${providerStorageAlias}/`, ""), - fullModel, - alias, - })); - - const generateDefaultAlias = (modelId) => { - const parts = modelId.split("/"); - return parts[parts.length - 1]; - }; - - const resolveAlias = (modelId) => { - const fullModel = `${providerStorageAlias}/${modelId}`; - // Skip if this exact model already has an alias - if (Object.values(modelAliases).includes(fullModel)) return null; - const baseAlias = generateDefaultAlias(modelId); - if (!modelAliases[baseAlias]) return baseAlias; - const prefixedAlias = `${providerDisplayAlias}-${baseAlias}`; - if (!modelAliases[prefixedAlias]) return prefixedAlias; - return null; - }; + const allModels = getProviderCustomModelRows({ + customModels, + modelAliases, + providerAlias: providerStorageAlias, + type: "llm", + }); const handleAdd = async () => { if (!newModel.trim() || adding) return; const modelId = newModel.trim(); - const resolvedAlias = resolveAlias(modelId); - if (!resolvedAlias) { - alert("All suggested aliases already exist. Please choose a different model or remove conflicting aliases."); + if (allModels.some((model) => model.id === modelId)) { + alert("Model already exists for this provider."); return; } setAdding(true); try { - await onSetAlias(modelId, resolvedAlias, providerStorageAlias); + await onAddCustomModel(modelId); setNewModel(""); } catch (error) { console.log("Error adding model:", error); @@ -163,9 +144,8 @@ export default function CompatibleModelsSection({ providerStorageAlias, provider for (const model of models) { const modelId = model.id || model.name || model.model; if (!modelId) continue; - const resolvedAlias = resolveAlias(modelId); - if (!resolvedAlias) continue; - await onSetAlias(modelId, resolvedAlias, providerStorageAlias); + if (allModels.some((entry) => entry.id === modelId)) continue; + await onAddCustomModel(modelId); importedCount += 1; } if (importedCount === 0) { @@ -215,17 +195,17 @@ export default function CompatibleModelsSection({ providerStorageAlias, provider {allModels.length > 0 && (
- {allModels.map(({ modelId, fullModel, alias }) => ( + {allModels.map(({ id, alias, source }) => ( onDeleteAlias(alias)} - onTest={connections.length > 0 ? () => handleTestModel(modelId) : undefined} - testStatus={modelTestResults[modelId]} - isTesting={testingModelId === modelId} + onDeleteAlias={() => source === "custom" ? onDeleteCustomModel(id) : onDeleteAlias(alias)} + onTest={connections.length > 0 ? () => handleTestModel(id) : undefined} + testStatus={modelTestResults[id]} + isTesting={testingModelId === id} /> ))}
@@ -238,10 +218,12 @@ CompatibleModelsSection.propTypes = { providerStorageAlias: PropTypes.string.isRequired, providerDisplayAlias: PropTypes.string.isRequired, modelAliases: PropTypes.object.isRequired, + customModels: PropTypes.arrayOf(PropTypes.object), copied: PropTypes.string, onCopy: PropTypes.func.isRequired, - onSetAlias: PropTypes.func.isRequired, onDeleteAlias: PropTypes.func.isRequired, + onAddCustomModel: PropTypes.func.isRequired, + onDeleteCustomModel: PropTypes.func.isRequired, connections: PropTypes.arrayOf(PropTypes.shape({ id: PropTypes.string, isActive: PropTypes.bool, diff --git a/src/app/(dashboard)/dashboard/providers/[id]/PassthroughModelsSection.js b/src/app/(dashboard)/dashboard/providers/[id]/PassthroughModelsSection.js index a58d34f8..606db9e7 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/PassthroughModelsSection.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/PassthroughModelsSection.js @@ -3,6 +3,7 @@ import { useState } from "react"; import PropTypes from "prop-types"; import { Button } from "@/shared/components"; +import { getProviderCustomModelRows } from "@/shared/utils/providerCustomModels"; function PassthroughModelRow({ modelId, fullModel, copied, onCopy, onDeleteAlias, onTest, testStatus, isTesting }) { const borderColor = testStatus === "ok" @@ -86,41 +87,29 @@ PassthroughModelRow.propTypes = { isTesting: PropTypes.bool, }; -export default function PassthroughModelsSection({ providerAlias, modelAliases, copied, onCopy, onSetAlias, onDeleteAlias }) { +export default function PassthroughModelsSection({ providerAlias, modelAliases, customModels, copied, onCopy, onDeleteAlias, onAddCustomModel, onDeleteCustomModel }) { const [newModel, setNewModel] = useState(""); const [adding, setAdding] = useState(false); - // Filter aliases for this provider - models are persisted via alias - const providerAliases = Object.entries(modelAliases).filter( - ([, model]) => model.startsWith(`${providerAlias}/`) - ); - - const allModels = providerAliases.map(([alias, fullModel]) => ({ - modelId: fullModel.replace(`${providerAlias}/`, ""), - fullModel, - alias, - })); - - // Generate default alias from modelId (last part after /) - const generateDefaultAlias = (modelId) => { - const parts = modelId.split("/"); - return parts[parts.length - 1]; - }; + const allModels = getProviderCustomModelRows({ + customModels, + modelAliases, + providerAlias, + type: "llm", + }); 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(`Alias "${defaultAlias}" already exists. Please use a different model or edit existing alias.`); + + if (allModels.some((model) => model.id === modelId)) { + alert("Model already exists for this provider."); return; } - + setAdding(true); try { - await onSetAlias(modelId, defaultAlias); + await onAddCustomModel(modelId); setNewModel(""); } catch (error) { console.log("Error adding model:", error); @@ -157,14 +146,14 @@ export default function PassthroughModelsSection({ providerAlias, modelAliases, {/* Models list */} {allModels.length > 0 && (
- {allModels.map(({ modelId, fullModel, alias }) => ( + {allModels.map(({ id, fullModel, alias, source }) => ( onDeleteAlias(alias)} + onDeleteAlias={() => source === "custom" ? onDeleteCustomModel(id) : onDeleteAlias(alias)} /> ))}
@@ -176,8 +165,10 @@ export default function PassthroughModelsSection({ providerAlias, modelAliases, PassthroughModelsSection.propTypes = { providerAlias: PropTypes.string.isRequired, modelAliases: PropTypes.object.isRequired, + customModels: PropTypes.arrayOf(PropTypes.object), copied: PropTypes.string, onCopy: PropTypes.func.isRequired, - onSetAlias: PropTypes.func.isRequired, onDeleteAlias: PropTypes.func.isRequired, + onAddCustomModel: PropTypes.func.isRequired, + onDeleteCustomModel: PropTypes.func.isRequired, }; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index f9883b02..5cb3154c 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -11,6 +11,7 @@ import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { useModelCaps } from "@/shared/hooks/useModelCaps"; import { translate } from "@/i18n/runtime"; import { fetchSuggestedModels } from "@/shared/utils/providerModelsFetcher"; +import { getProviderCustomModelRows } from "@/shared/utils/providerCustomModels"; import ModelRow from "./ModelRow"; import PassthroughModelsSection from "./PassthroughModelsSection"; import CompatibleModelsSection from "./CompatibleModelsSection"; @@ -45,6 +46,7 @@ export default function ProviderDetailPage() { const [showBulkProxyModal, setShowBulkProxyModal] = useState(false); const [selectedConnection, setSelectedConnection] = useState(null); const [modelAliases, setModelAliases] = useState({}); + const [customModels, setCustomModels] = useState([]); const [headerImgError, setHeaderImgError] = useState(false); const [modelTestResults, setModelTestResults] = useState({}); const [modelsTestError, setModelsTestError] = useState(""); @@ -224,6 +226,18 @@ export default function ProviderDetailPage() { } }, []); + const fetchCustomModels = useCallback(async () => { + try { + const res = await fetch("/api/models/custom", { cache: "no-store" }); + const data = await res.json(); + if (res.ok) { + setCustomModels(data.models || []); + } + } catch (error) { + console.log("Error fetching custom models:", error); + } + }, []); + // Fetch free models from Kilo API for kilocode provider useEffect(() => { if (providerId !== "kilocode") return; @@ -393,8 +407,9 @@ export default function ProviderDetailPage() { useEffect(() => { fetchConnections(); fetchAliases(); + fetchCustomModels(); fetchDisabledModels(); - }, [fetchConnections, fetchAliases, fetchDisabledModels]); + }, [fetchConnections, fetchAliases, fetchCustomModels, fetchDisabledModels]); // Fetch suggested models from provider's public API (if configured) useEffect(() => { @@ -435,6 +450,38 @@ export default function ProviderDetailPage() { } }; + const handleAddCustomModel = async (modelId, type = "llm", providerAliasOverride = providerStorageAlias) => { + try { + const res = await fetch("/api/models/custom", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ providerAlias: providerAliasOverride, id: modelId, type }), + }); + if (res.ok) { + await fetchCustomModels(); + if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent("customModelChanged")); + } else { + const data = await res.json(); + alert(data.error || "Failed to add custom model"); + } + } catch (error) { + console.log("Error adding custom model:", error); + } + }; + + const handleDeleteCustomModel = async (modelId, type = "llm", providerAliasOverride = providerStorageAlias) => { + try { + const params = new URLSearchParams({ providerAlias: providerAliasOverride, id: modelId, type }); + const res = await fetch(`/api/models/custom?${params}`, { method: "DELETE" }); + if (res.ok) { + await fetchCustomModels(); + if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent("customModelChanged")); + } + } catch (error) { + console.log("Error deleting custom model:", error); + } + }; + // Fetch Qoder model list and automatically add to available models const handleImportQoderModels = async () => { if (importingQoderModels) return; @@ -465,20 +512,14 @@ export default function ProviderDetailPage() { // Qoder model ID format may be "qoder/auto" or "auto", need to remove prefix const cleanModelId = modelId.replace(/^qoder\//, ""); - const fullModel = `${providerStorageAlias}/${cleanModelId}`; - - // Check if already exists - if (Object.values(modelAliases).includes(fullModel)) { + const alreadyExists = customModels.some( + (entry) => entry.providerAlias === providerStorageAlias && entry.id === cleanModelId && (entry.kind || entry.type || "llm") === "llm" + ) || Object.values(modelAliases).includes(`${providerStorageAlias}/${cleanModelId}`); + if (alreadyExists) { continue; } - - // Use model ID as alias - const alias = cleanModelId; - if (modelAliases[alias]) { - continue; - } - - await handleSetAlias(cleanModelId, alias, providerStorageAlias); + + await handleAddCustomModel(cleanModelId, "llm", providerStorageAlias); importedCount += 1; } @@ -926,10 +967,13 @@ export default function ProviderDetailPage() { providerStorageAlias={providerStorageAlias} providerDisplayAlias={providerDisplayAlias} modelAliases={modelAliases} + customModels={customModels} copied={copied} onCopy={copy} onSetAlias={handleSetAlias} onDeleteAlias={handleDeleteAlias} + onAddCustomModel={(modelId) => handleAddCustomModel(modelId, "llm", providerStorageAlias)} + onDeleteCustomModel={(modelId) => handleDeleteCustomModel(modelId, "llm", providerStorageAlias)} connections={connections} isAnthropic={isAnthropicCompatible} /> @@ -944,36 +988,33 @@ export default function ProviderDetailPage() { const disabledSet = new Set(disabledModelIds); const displayModels = allModels.filter((m) => !disabledSet.has(m.id)); const disabledDisplayModels = allModels.filter((m) => disabledSet.has(m.id)); - // Custom models added by user (stored as aliases: modelId → providerAlias/modelId) - const customModels = Object.entries(modelAliases) - .filter(([alias, fullModel]) => { - const prefix = `${providerStorageAlias}/`; - if (!fullModel.startsWith(prefix)) return false; - const modelId = fullModel.slice(prefix.length); - // Only show if not already in hardcoded list - // For passthroughModels, include all aliases (model IDs may contain slashes like "anthropic/claude-3") - if (providerInfo.passthroughModels) return !models.some((m) => m.id === modelId); - return !models.some((m) => m.id === modelId) && alias === modelId; - }) - .map(([alias, fullModel]) => ({ - id: fullModel.slice(`${providerStorageAlias}/`.length), - alias, - fullModel, - })); + const customModelRows = getProviderCustomModelRows({ + customModels, + modelAliases, + providerAlias: providerStorageAlias, + builtInModels: models, + type: "llm", + }); return (
{/* Custom models first */} - {customModels.map((model) => ( + {customModelRows.map((model) => ( {}} - onDeleteAlias={() => handleDeleteAlias(model.alias)} + onDeleteAlias={() => { + if (model.source === "custom") { + handleDeleteCustomModel(model.id, "llm", providerStorageAlias); + } else { + handleDeleteAlias(model.alias); + } + }} testStatus={modelTestResults[model.id]} onTest={connections.length > 0 || isFreeNoAuth ? () => handleTestModel(model.id) : undefined} isTesting={testingModelIds.has(model.id)} @@ -1034,7 +1075,10 @@ export default function ProviderDetailPage() { {/* Suggested models from provider API — show only models not yet added */} {suggestedModels.length > 0 && (() => { - const addedFullModels = new Set(Object.values(modelAliases)); + const addedFullModels = new Set([ + ...Object.values(modelAliases), + ...customModelRows.map((model) => model.fullModel), + ]); const hardcodedIds = new Set(models.map((m) => m.id)); const notAdded = suggestedModels.filter( (m) => !addedFullModels.has(`${providerStorageAlias}/${m.id}`) && !hardcodedIds.has(m.id) @@ -1048,8 +1092,7 @@ export default function ProviderDetailPage() {