mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
fix(models): store provider custom models by provider scope
This commit is contained in:
@@ -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)}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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