fix(models): store provider custom models by provider scope

This commit is contained in:
nguyenha935
2026-06-20 15:19:22 +07:00
committed by decolua
parent 8efacc1147
commit 707a91555d
6 changed files with 282 additions and 118 deletions
@@ -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 && (
<div className="flex flex-col gap-3">
{allModels.map(({ modelId, fullModel, alias }) => (
{allModels.map(({ id, alias, source }) => (
<CompatibleModelRow
key={fullModel}
modelId={modelId}
fullModel={`${providerDisplayAlias}/${modelId}`}
key={`${source}-${providerStorageAlias}/${id}`}
modelId={id}
fullModel={`${providerDisplayAlias}/${id}`}
copied={copied}
onCopy={onCopy}
onDeleteAlias={() => 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}
/>
))}
</div>
@@ -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,
@@ -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 && (
<div className="flex flex-col gap-3">
{allModels.map(({ modelId, fullModel, alias }) => (
{allModels.map(({ id, fullModel, alias, source }) => (
<PassthroughModelRow
key={fullModel}
modelId={modelId}
key={`${source}-${fullModel}`}
modelId={id}
fullModel={fullModel}
copied={copied}
onCopy={onCopy}
onDeleteAlias={() => onDeleteAlias(alias)}
onDeleteAlias={() => source === "custom" ? onDeleteCustomModel(id) : onDeleteAlias(alias)}
/>
))}
</div>
@@ -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,
};
@@ -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 (
<div className="flex flex-wrap gap-3">
{/* Custom models first */}
{customModels.map((model) => (
{customModelRows.map((model) => (
<ModelRow
key={model.id}
model={{ id: model.id }}
key={`${model.source}-${model.fullModel}`}
model={{ id: model.id, name: model.name }}
fullModel={`${providerDisplayAlias}/${model.id}`}
alias={model.alias}
copied={copied}
onCopy={copy}
onSetAlias={() => {}}
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() {
<button
key={m.id}
onClick={async () => {
const alias = m.id.split("/").pop();
await handleSetAlias(m.id, alias, providerStorageAlias);
await handleAddCustomModel(m.id, "llm", providerStorageAlias);
}}
className="flex items-center gap-1 px-2.5 py-1.5 rounded-lg border border-black/10 dark:border-white/10 text-xs text-text-muted hover:text-primary hover:border-primary/40 hover:bg-primary/5 transition-colors"
title={`${m.name} · ${(m.contextLength / 1000).toFixed(0)}k ctx`}
@@ -1580,11 +1623,7 @@ export default function ProviderDetailPage() {
providerAlias={providerStorageAlias}
providerDisplayAlias={providerDisplayAlias}
onSave={async (modelId) => {
// For passthrough providers (OpenRouter), use last segment as alias to avoid slash conflicts
const alias = providerInfo?.passthroughModels
? modelId.split("/").pop()
: modelId;
await handleSetAlias(modelId, alias, providerStorageAlias);
await handleAddCustomModel(modelId, "llm", providerStorageAlias);
setShowAddCustomModel(false);
}}
onClose={() => setShowAddCustomModel(false)}
+20 -6
View File
@@ -181,26 +181,41 @@ export default function ModelSelectModal({
name: aliasName,
value: fullModel,
}));
const customRegisteredModels = customModels
.filter((m) => m.providerAlias === alias)
.map((m) => ({
id: m.id,
name: m.name || m.id,
value: `${alias}/${m.id}`,
kind: getModelKind(m),
isCustom: true,
}));
// For typed kinds, only include hardcoded typed models (aliases are typically LLM-only and lack type info)
let combined = aliasModels;
if (kindFilter && TYPED_KINDS.has(kindFilter)) {
combined = getModelsByProviderId(providerId)
const registeredTyped = customRegisteredModels.filter((m) => getModelKind(m) === kindFilter);
combined = [
...registeredTyped,
...getModelsByProviderId(providerId)
.filter((m) => getModelKind(m) === kindFilter)
.map((m) => ({ id: m.id, name: m.name, value: `${alias}/${m.id}`, kind: getModelKind(m) }));
.map((m) => ({ id: m.id, name: m.name, value: `${alias}/${m.id}`, kind: getModelKind(m) }))
.filter((m) => !registeredTyped.some((registered) => registered.value === m.value)),
];
// Fallback: provider-as-model when no hardcoded models match (tts/image/webFetch only)
if (combined.length === 0 && ALLOW_PROVIDER_FALLBACK_KINDS.has(kindFilter)) {
const supports = (providerInfo.serviceKinds || ["llm"]).includes(kindFilter);
if (supports) combined = [{ id: providerId, name: providerInfo.name, value: alias }];
}
} else {
// LLM/null kind: merge hardcoded models (e.g. mimo-free → mimo-auto) with aliases
const seen = new Set(aliasModels.map((m) => m.value));
// LLM/null kind: merge hardcoded models (e.g. mimo-free → mimo-auto) with user-added models
const registeredLlms = customRegisteredModels.filter((m) => !getModelKind(m) || getModelKind(m) === "llm");
const seen = new Set([...aliasModels, ...registeredLlms].map((m) => m.value));
const hardcoded = getModelsByProviderId(providerId)
.filter((m) => !getModelKind(m) || getModelKind(m) === "llm")
.map((m) => ({ id: m.id, name: m.name, value: `${alias}/${m.id}`, kind: getModelKind(m) }))
.filter((m) => !seen.has(m.value));
combined = [...aliasModels, ...hardcoded];
combined = [...registeredLlms, ...aliasModels.filter((m) => !registeredLlms.some((registered) => registered.value === m.value)), ...hardcoded];
}
if (combined.length > 0) {
@@ -551,4 +566,3 @@ ModelSelectModal.propTypes = {
addedModelValues: PropTypes.arrayOf(PropTypes.string),
closeOnSelect: PropTypes.bool,
};
+54
View File
@@ -0,0 +1,54 @@
function modelType(model) {
return model?.kind || model?.type || "llm";
}
export function getProviderCustomModelRows({
customModels = [],
modelAliases = {},
providerAlias,
builtInModels = [],
type = "llm",
includeLegacyAliases = true,
}) {
const builtInIds = new Set(builtInModels.map((model) => model.id));
const seenFullModels = new Set();
const rows = [];
for (const model of customModels) {
if (!model?.id || model.providerAlias !== providerAlias) continue;
const rowType = modelType(model);
if (type && rowType !== type) continue;
if (builtInIds.has(model.id)) continue;
const fullModel = `${providerAlias}/${model.id}`;
if (seenFullModels.has(fullModel)) continue;
seenFullModels.add(fullModel);
rows.push({
id: model.id,
name: model.name || model.id,
fullModel,
source: "custom",
type: rowType,
});
}
if (!includeLegacyAliases) return rows;
const prefix = `${providerAlias}/`;
for (const [alias, fullModel] of Object.entries(modelAliases || {})) {
if (typeof fullModel !== "string" || !fullModel.startsWith(prefix)) continue;
const id = fullModel.slice(prefix.length);
if (!id || builtInIds.has(id) || seenFullModels.has(fullModel)) continue;
seenFullModels.add(fullModel);
rows.push({
id,
alias,
fullModel,
source: "legacyAlias",
type: type || "llm",
});
}
return rows;
}
+84
View File
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import { getProviderCustomModelRows } from "@/shared/utils/providerCustomModels.js";
describe("provider custom model rows", () => {
it("keeps identical model IDs separate per provider", () => {
const customModels = [
{ providerAlias: "ollama", id: "minimax-m2.5", type: "llm", name: "MiniMax M2.5" },
{ providerAlias: "opencode-go", id: "minimax-m2.5", type: "llm", name: "MiniMax M2.5" },
];
expect(getProviderCustomModelRows({ customModels, providerAlias: "ollama" })).toEqual([
{
id: "minimax-m2.5",
name: "MiniMax M2.5",
fullModel: "ollama/minimax-m2.5",
source: "custom",
type: "llm",
},
]);
expect(getProviderCustomModelRows({ customModels, providerAlias: "opencode-go" })).toEqual([
{
id: "minimax-m2.5",
name: "MiniMax M2.5",
fullModel: "opencode-go/minimax-m2.5",
source: "custom",
type: "llm",
},
]);
});
it("keeps legacy alias-backed models visible without duplicating custom models", () => {
const rows = getProviderCustomModelRows({
customModels: [
{ providerAlias: "ollama", id: "custom-a", type: "llm", name: "Custom A" },
],
modelAliases: {
"custom-a": "ollama/custom-a",
"legacy-b": "ollama/legacy-b",
"other-provider": "opencode-go/legacy-b",
},
providerAlias: "ollama",
});
expect(rows).toEqual([
{
id: "custom-a",
name: "Custom A",
fullModel: "ollama/custom-a",
source: "custom",
type: "llm",
},
{
id: "legacy-b",
alias: "legacy-b",
fullModel: "ollama/legacy-b",
source: "legacyAlias",
type: "llm",
},
]);
});
it("filters built-in models and typed custom models", () => {
const rows = getProviderCustomModelRows({
customModels: [
{ providerAlias: "ollama", id: "llama3", type: "llm", name: "Llama 3" },
{ providerAlias: "ollama", id: "custom-image", type: "image", name: "Custom Image" },
{ providerAlias: "ollama", id: "custom-llm", type: "llm", name: "Custom LLM" },
],
providerAlias: "ollama",
builtInModels: [{ id: "llama3" }],
type: "llm",
});
expect(rows).toEqual([
{
id: "custom-llm",
name: "Custom LLM",
fullModel: "ollama/custom-llm",
source: "custom",
type: "llm",
},
]);
});
});