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

This commit is contained in:
nguyenha935
2026-06-20 02:44:17 +07:00
committed by decolua
parent 8efacc1147
commit 707a91555d
6 changed files with 282 additions and 118 deletions

View File

@@ -3,6 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { Button } from "@/shared/components"; import { Button } from "@/shared/components";
import { getProviderCustomModelRows } from "@/shared/utils/providerCustomModels";
function CompatibleModelRow({ modelId, fullModel, copied, onCopy, onDeleteAlias, onTest, testStatus, isTesting }) { function CompatibleModelRow({ modelId, fullModel, copied, onCopy, onDeleteAlias, onTest, testStatus, isTesting }) {
const borderColor = testStatus === "ok" const borderColor = testStatus === "ok"
? "border-green-500/40" ? "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 [newModel, setNewModel] = useState("");
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
@@ -95,44 +96,24 @@ export default function CompatibleModelsSection({ providerStorageAlias, provider
} }
}; };
const providerAliases = Object.entries(modelAliases).filter( const allModels = getProviderCustomModelRows({
([, model]) => model.startsWith(`${providerStorageAlias}/`) customModels,
); modelAliases,
providerAlias: providerStorageAlias,
const allModels = providerAliases.map(([alias, fullModel]) => ({ type: "llm",
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 handleAdd = async () => { const handleAdd = async () => {
if (!newModel.trim() || adding) return; if (!newModel.trim() || adding) return;
const modelId = newModel.trim(); const modelId = newModel.trim();
const resolvedAlias = resolveAlias(modelId); if (allModels.some((model) => model.id === modelId)) {
if (!resolvedAlias) { alert("Model already exists for this provider.");
alert("All suggested aliases already exist. Please choose a different model or remove conflicting aliases.");
return; return;
} }
setAdding(true); setAdding(true);
try { try {
await onSetAlias(modelId, resolvedAlias, providerStorageAlias); await onAddCustomModel(modelId);
setNewModel(""); setNewModel("");
} catch (error) { } catch (error) {
console.log("Error adding model:", error); console.log("Error adding model:", error);
@@ -163,9 +144,8 @@ export default function CompatibleModelsSection({ providerStorageAlias, provider
for (const model of models) { for (const model of models) {
const modelId = model.id || model.name || model.model; const modelId = model.id || model.name || model.model;
if (!modelId) continue; if (!modelId) continue;
const resolvedAlias = resolveAlias(modelId); if (allModels.some((entry) => entry.id === modelId)) continue;
if (!resolvedAlias) continue; await onAddCustomModel(modelId);
await onSetAlias(modelId, resolvedAlias, providerStorageAlias);
importedCount += 1; importedCount += 1;
} }
if (importedCount === 0) { if (importedCount === 0) {
@@ -215,17 +195,17 @@ export default function CompatibleModelsSection({ providerStorageAlias, provider
{allModels.length > 0 && ( {allModels.length > 0 && (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{allModels.map(({ modelId, fullModel, alias }) => ( {allModels.map(({ id, alias, source }) => (
<CompatibleModelRow <CompatibleModelRow
key={fullModel} key={`${source}-${providerStorageAlias}/${id}`}
modelId={modelId} modelId={id}
fullModel={`${providerDisplayAlias}/${modelId}`} fullModel={`${providerDisplayAlias}/${id}`}
copied={copied} copied={copied}
onCopy={onCopy} onCopy={onCopy}
onDeleteAlias={() => onDeleteAlias(alias)} onDeleteAlias={() => source === "custom" ? onDeleteCustomModel(id) : onDeleteAlias(alias)}
onTest={connections.length > 0 ? () => handleTestModel(modelId) : undefined} onTest={connections.length > 0 ? () => handleTestModel(id) : undefined}
testStatus={modelTestResults[modelId]} testStatus={modelTestResults[id]}
isTesting={testingModelId === modelId} isTesting={testingModelId === id}
/> />
))} ))}
</div> </div>
@@ -238,10 +218,12 @@ CompatibleModelsSection.propTypes = {
providerStorageAlias: PropTypes.string.isRequired, providerStorageAlias: PropTypes.string.isRequired,
providerDisplayAlias: PropTypes.string.isRequired, providerDisplayAlias: PropTypes.string.isRequired,
modelAliases: PropTypes.object.isRequired, modelAliases: PropTypes.object.isRequired,
customModels: PropTypes.arrayOf(PropTypes.object),
copied: PropTypes.string, copied: PropTypes.string,
onCopy: PropTypes.func.isRequired, onCopy: PropTypes.func.isRequired,
onSetAlias: PropTypes.func.isRequired,
onDeleteAlias: PropTypes.func.isRequired, onDeleteAlias: PropTypes.func.isRequired,
onAddCustomModel: PropTypes.func.isRequired,
onDeleteCustomModel: PropTypes.func.isRequired,
connections: PropTypes.arrayOf(PropTypes.shape({ connections: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string, id: PropTypes.string,
isActive: PropTypes.bool, isActive: PropTypes.bool,

View File

@@ -3,6 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { Button } from "@/shared/components"; import { Button } from "@/shared/components";
import { getProviderCustomModelRows } from "@/shared/utils/providerCustomModels";
function PassthroughModelRow({ modelId, fullModel, copied, onCopy, onDeleteAlias, onTest, testStatus, isTesting }) { function PassthroughModelRow({ modelId, fullModel, copied, onCopy, onDeleteAlias, onTest, testStatus, isTesting }) {
const borderColor = testStatus === "ok" const borderColor = testStatus === "ok"
@@ -86,41 +87,29 @@ PassthroughModelRow.propTypes = {
isTesting: PropTypes.bool, 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 [newModel, setNewModel] = useState("");
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
// Filter aliases for this provider - models are persisted via alias const allModels = getProviderCustomModelRows({
const providerAliases = Object.entries(modelAliases).filter( customModels,
([, model]) => model.startsWith(`${providerAlias}/`) modelAliases,
); providerAlias,
type: "llm",
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 handleAdd = async () => { const handleAdd = async () => {
if (!newModel.trim() || adding) return; if (!newModel.trim() || adding) return;
const modelId = newModel.trim(); const modelId = newModel.trim();
const defaultAlias = generateDefaultAlias(modelId);
if (allModels.some((model) => model.id === modelId)) {
// Check if alias already exists alert("Model already exists for this provider.");
if (modelAliases[defaultAlias]) {
alert(`Alias "${defaultAlias}" already exists. Please use a different model or edit existing alias.`);
return; return;
} }
setAdding(true); setAdding(true);
try { try {
await onSetAlias(modelId, defaultAlias); await onAddCustomModel(modelId);
setNewModel(""); setNewModel("");
} catch (error) { } catch (error) {
console.log("Error adding model:", error); console.log("Error adding model:", error);
@@ -157,14 +146,14 @@ export default function PassthroughModelsSection({ providerAlias, modelAliases,
{/* Models list */} {/* Models list */}
{allModels.length > 0 && ( {allModels.length > 0 && (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{allModels.map(({ modelId, fullModel, alias }) => ( {allModels.map(({ id, fullModel, alias, source }) => (
<PassthroughModelRow <PassthroughModelRow
key={fullModel} key={`${source}-${fullModel}`}
modelId={modelId} modelId={id}
fullModel={fullModel} fullModel={fullModel}
copied={copied} copied={copied}
onCopy={onCopy} onCopy={onCopy}
onDeleteAlias={() => onDeleteAlias(alias)} onDeleteAlias={() => source === "custom" ? onDeleteCustomModel(id) : onDeleteAlias(alias)}
/> />
))} ))}
</div> </div>
@@ -176,8 +165,10 @@ export default function PassthroughModelsSection({ providerAlias, modelAliases,
PassthroughModelsSection.propTypes = { PassthroughModelsSection.propTypes = {
providerAlias: PropTypes.string.isRequired, providerAlias: PropTypes.string.isRequired,
modelAliases: PropTypes.object.isRequired, modelAliases: PropTypes.object.isRequired,
customModels: PropTypes.arrayOf(PropTypes.object),
copied: PropTypes.string, copied: PropTypes.string,
onCopy: PropTypes.func.isRequired, onCopy: PropTypes.func.isRequired,
onSetAlias: PropTypes.func.isRequired,
onDeleteAlias: PropTypes.func.isRequired, onDeleteAlias: PropTypes.func.isRequired,
onAddCustomModel: PropTypes.func.isRequired,
onDeleteCustomModel: PropTypes.func.isRequired,
}; };

View File

@@ -11,6 +11,7 @@ import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { useModelCaps } from "@/shared/hooks/useModelCaps"; import { useModelCaps } from "@/shared/hooks/useModelCaps";
import { translate } from "@/i18n/runtime"; import { translate } from "@/i18n/runtime";
import { fetchSuggestedModels } from "@/shared/utils/providerModelsFetcher"; import { fetchSuggestedModels } from "@/shared/utils/providerModelsFetcher";
import { getProviderCustomModelRows } from "@/shared/utils/providerCustomModels";
import ModelRow from "./ModelRow"; import ModelRow from "./ModelRow";
import PassthroughModelsSection from "./PassthroughModelsSection"; import PassthroughModelsSection from "./PassthroughModelsSection";
import CompatibleModelsSection from "./CompatibleModelsSection"; import CompatibleModelsSection from "./CompatibleModelsSection";
@@ -45,6 +46,7 @@ export default function ProviderDetailPage() {
const [showBulkProxyModal, setShowBulkProxyModal] = useState(false); const [showBulkProxyModal, setShowBulkProxyModal] = useState(false);
const [selectedConnection, setSelectedConnection] = useState(null); const [selectedConnection, setSelectedConnection] = useState(null);
const [modelAliases, setModelAliases] = useState({}); const [modelAliases, setModelAliases] = useState({});
const [customModels, setCustomModels] = useState([]);
const [headerImgError, setHeaderImgError] = useState(false); const [headerImgError, setHeaderImgError] = useState(false);
const [modelTestResults, setModelTestResults] = useState({}); const [modelTestResults, setModelTestResults] = useState({});
const [modelsTestError, setModelsTestError] = 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 // Fetch free models from Kilo API for kilocode provider
useEffect(() => { useEffect(() => {
if (providerId !== "kilocode") return; if (providerId !== "kilocode") return;
@@ -393,8 +407,9 @@ export default function ProviderDetailPage() {
useEffect(() => { useEffect(() => {
fetchConnections(); fetchConnections();
fetchAliases(); fetchAliases();
fetchCustomModels();
fetchDisabledModels(); fetchDisabledModels();
}, [fetchConnections, fetchAliases, fetchDisabledModels]); }, [fetchConnections, fetchAliases, fetchCustomModels, fetchDisabledModels]);
// Fetch suggested models from provider's public API (if configured) // Fetch suggested models from provider's public API (if configured)
useEffect(() => { 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 // Fetch Qoder model list and automatically add to available models
const handleImportQoderModels = async () => { const handleImportQoderModels = async () => {
if (importingQoderModels) return; 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 // Qoder model ID format may be "qoder/auto" or "auto", need to remove prefix
const cleanModelId = modelId.replace(/^qoder\//, ""); const cleanModelId = modelId.replace(/^qoder\//, "");
const fullModel = `${providerStorageAlias}/${cleanModelId}`; const alreadyExists = customModels.some(
(entry) => entry.providerAlias === providerStorageAlias && entry.id === cleanModelId && (entry.kind || entry.type || "llm") === "llm"
// Check if already exists ) || Object.values(modelAliases).includes(`${providerStorageAlias}/${cleanModelId}`);
if (Object.values(modelAliases).includes(fullModel)) { if (alreadyExists) {
continue; continue;
} }
// Use model ID as alias await handleAddCustomModel(cleanModelId, "llm", providerStorageAlias);
const alias = cleanModelId;
if (modelAliases[alias]) {
continue;
}
await handleSetAlias(cleanModelId, alias, providerStorageAlias);
importedCount += 1; importedCount += 1;
} }
@@ -926,10 +967,13 @@ export default function ProviderDetailPage() {
providerStorageAlias={providerStorageAlias} providerStorageAlias={providerStorageAlias}
providerDisplayAlias={providerDisplayAlias} providerDisplayAlias={providerDisplayAlias}
modelAliases={modelAliases} modelAliases={modelAliases}
customModels={customModels}
copied={copied} copied={copied}
onCopy={copy} onCopy={copy}
onSetAlias={handleSetAlias} onSetAlias={handleSetAlias}
onDeleteAlias={handleDeleteAlias} onDeleteAlias={handleDeleteAlias}
onAddCustomModel={(modelId) => handleAddCustomModel(modelId, "llm", providerStorageAlias)}
onDeleteCustomModel={(modelId) => handleDeleteCustomModel(modelId, "llm", providerStorageAlias)}
connections={connections} connections={connections}
isAnthropic={isAnthropicCompatible} isAnthropic={isAnthropicCompatible}
/> />
@@ -944,36 +988,33 @@ export default function ProviderDetailPage() {
const disabledSet = new Set(disabledModelIds); const disabledSet = new Set(disabledModelIds);
const displayModels = allModels.filter((m) => !disabledSet.has(m.id)); const displayModels = allModels.filter((m) => !disabledSet.has(m.id));
const disabledDisplayModels = 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 customModelRows = getProviderCustomModelRows({
const customModels = Object.entries(modelAliases) customModels,
.filter(([alias, fullModel]) => { modelAliases,
const prefix = `${providerStorageAlias}/`; providerAlias: providerStorageAlias,
if (!fullModel.startsWith(prefix)) return false; builtInModels: models,
const modelId = fullModel.slice(prefix.length); type: "llm",
// 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,
}));
return ( return (
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{/* Custom models first */} {/* Custom models first */}
{customModels.map((model) => ( {customModelRows.map((model) => (
<ModelRow <ModelRow
key={model.id} key={`${model.source}-${model.fullModel}`}
model={{ id: model.id }} model={{ id: model.id, name: model.name }}
fullModel={`${providerDisplayAlias}/${model.id}`} fullModel={`${providerDisplayAlias}/${model.id}`}
alias={model.alias} alias={model.alias}
copied={copied} copied={copied}
onCopy={copy} onCopy={copy}
onSetAlias={() => {}} onSetAlias={() => {}}
onDeleteAlias={() => handleDeleteAlias(model.alias)} onDeleteAlias={() => {
if (model.source === "custom") {
handleDeleteCustomModel(model.id, "llm", providerStorageAlias);
} else {
handleDeleteAlias(model.alias);
}
}}
testStatus={modelTestResults[model.id]} testStatus={modelTestResults[model.id]}
onTest={connections.length > 0 || isFreeNoAuth ? () => handleTestModel(model.id) : undefined} onTest={connections.length > 0 || isFreeNoAuth ? () => handleTestModel(model.id) : undefined}
isTesting={testingModelIds.has(model.id)} isTesting={testingModelIds.has(model.id)}
@@ -1034,7 +1075,10 @@ export default function ProviderDetailPage() {
{/* Suggested models from provider API — show only models not yet added */} {/* Suggested models from provider API — show only models not yet added */}
{suggestedModels.length > 0 && (() => { {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 hardcodedIds = new Set(models.map((m) => m.id));
const notAdded = suggestedModels.filter( const notAdded = suggestedModels.filter(
(m) => !addedFullModels.has(`${providerStorageAlias}/${m.id}`) && !hardcodedIds.has(m.id) (m) => !addedFullModels.has(`${providerStorageAlias}/${m.id}`) && !hardcodedIds.has(m.id)
@@ -1048,8 +1092,7 @@ export default function ProviderDetailPage() {
<button <button
key={m.id} key={m.id}
onClick={async () => { onClick={async () => {
const alias = m.id.split("/").pop(); await handleAddCustomModel(m.id, "llm", providerStorageAlias);
await handleSetAlias(m.id, alias, 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" 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`} title={`${m.name} · ${(m.contextLength / 1000).toFixed(0)}k ctx`}
@@ -1580,11 +1623,7 @@ export default function ProviderDetailPage() {
providerAlias={providerStorageAlias} providerAlias={providerStorageAlias}
providerDisplayAlias={providerDisplayAlias} providerDisplayAlias={providerDisplayAlias}
onSave={async (modelId) => { onSave={async (modelId) => {
// For passthrough providers (OpenRouter), use last segment as alias to avoid slash conflicts await handleAddCustomModel(modelId, "llm", providerStorageAlias);
const alias = providerInfo?.passthroughModels
? modelId.split("/").pop()
: modelId;
await handleSetAlias(modelId, alias, providerStorageAlias);
setShowAddCustomModel(false); setShowAddCustomModel(false);
}} }}
onClose={() => setShowAddCustomModel(false)} onClose={() => setShowAddCustomModel(false)}

View File

@@ -181,26 +181,41 @@ export default function ModelSelectModal({
name: aliasName, name: aliasName,
value: fullModel, 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) // For typed kinds, only include hardcoded typed models (aliases are typically LLM-only and lack type info)
let combined = aliasModels; let combined = aliasModels;
if (kindFilter && TYPED_KINDS.has(kindFilter)) { 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) .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) // Fallback: provider-as-model when no hardcoded models match (tts/image/webFetch only)
if (combined.length === 0 && ALLOW_PROVIDER_FALLBACK_KINDS.has(kindFilter)) { if (combined.length === 0 && ALLOW_PROVIDER_FALLBACK_KINDS.has(kindFilter)) {
const supports = (providerInfo.serviceKinds || ["llm"]).includes(kindFilter); const supports = (providerInfo.serviceKinds || ["llm"]).includes(kindFilter);
if (supports) combined = [{ id: providerId, name: providerInfo.name, value: alias }]; if (supports) combined = [{ id: providerId, name: providerInfo.name, value: alias }];
} }
} else { } else {
// LLM/null kind: merge hardcoded models (e.g. mimo-free → mimo-auto) with aliases // LLM/null kind: merge hardcoded models (e.g. mimo-free → mimo-auto) with user-added models
const seen = new Set(aliasModels.map((m) => m.value)); const registeredLlms = customRegisteredModels.filter((m) => !getModelKind(m) || getModelKind(m) === "llm");
const seen = new Set([...aliasModels, ...registeredLlms].map((m) => m.value));
const hardcoded = getModelsByProviderId(providerId) const hardcoded = getModelsByProviderId(providerId)
.filter((m) => !getModelKind(m) || getModelKind(m) === "llm") .filter((m) => !getModelKind(m) || getModelKind(m) === "llm")
.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) => !seen.has(m.value)); .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) { if (combined.length > 0) {
@@ -551,4 +566,3 @@ ModelSelectModal.propTypes = {
addedModelValues: PropTypes.arrayOf(PropTypes.string), addedModelValues: PropTypes.arrayOf(PropTypes.string),
closeOnSelect: PropTypes.bool, closeOnSelect: PropTypes.bool,
}; };

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;
}

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",
},
]);
});
});