fix(models): store provider custom models by provider scope
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
|
||||||
|
|
||||||
// Check if alias already exists
|
if (allModels.some((model) => model.id === modelId)) {
|
||||||
if (modelAliases[defaultAlias]) {
|
alert("Model already exists for this provider.");
|
||||||
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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
54
src/shared/utils/providerCustomModels.js
Normal file
54
src/shared/utils/providerCustomModels.js
Normal 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
tests/unit/provider-custom-models.test.js
Normal file
84
tests/unit/provider-custom-models.test.js
Normal 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",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user