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() {