diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ConfigGeneratorCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ConfigGeneratorCard.js index 0f7323d2..1c90488c 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ConfigGeneratorCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ConfigGeneratorCard.js @@ -11,6 +11,13 @@ import ApiKeySelect from "./ApiKeySelect"; const DEFAULT_MODEL = "provider/model-id"; const COPILOT_API_KEY_INPUT = "${input:chat.lm.secret.9router}"; +const DUPLICATE_MODEL_COLORS = [ + "border-blue-400 bg-blue-50 dark:bg-blue-950", + "border-emerald-400 bg-emerald-50 dark:bg-emerald-950", + "border-amber-400 bg-amber-50 dark:bg-amber-950", + "border-purple-400 bg-purple-50 dark:bg-purple-950", + "border-rose-400 bg-rose-50 dark:bg-rose-950", +]; const normalizeV1 = (url) => { const trimmed = (url || "").replace(/\/+$/, ""); @@ -50,6 +57,16 @@ const formatModelName = (modelId) => { return aliasSuffix ? `${modelName} (${formatTerms(aliasSuffix)})` : modelName; }; +const getModelOccurrence = (models, model, index) => { + const total = models.filter((item) => item === model).length; + const occurrence = models.slice(0, index + 1).filter((item) => item === model).length; + return { total, occurrence }; +}; + +const getDuplicateModelClass = ({ total, occurrence }) => ( + total > 1 ? DUPLICATE_MODEL_COLORS[(occurrence - 1) % DUPLICATE_MODEL_COLORS.length] : "border-border bg-bg-secondary" +); + function buildConfigs(toolId, { baseUrl, apiKey, models, claudeModels = {}, claudeThinking = {}, codexModel = "", codexThinking = "", opencodeModels = [], opencodeDefaultModel = "", coworkThinking = {}, copilotTokens = {}, copilotThinking = {}, connectedModels = [] }) { const endpoint = normalizeV1(baseUrl); const selectedModels = models.length ? models : [DEFAULT_MODEL]; @@ -83,6 +100,7 @@ function buildConfigs(toolId, { baseUrl, apiKey, models, claudeModels = {}, clau // custom provider is "9router", while the 9Router model ID itself keeps // its upstream provider prefix (for example, "cc/claude-sonnet-5"). const configuredModels = opencodeModels.length ? opencodeModels : [DEFAULT_MODEL]; + const modelsForConfig = [...new Set(configuredModels)]; const modelId = configuredModels.includes(opencodeDefaultModel) ? opencodeDefaultModel : configuredModels[0]; @@ -95,7 +113,7 @@ function buildConfigs(toolId, { baseUrl, apiKey, models, claudeModels = {}, clau npm: "@ai-sdk/openai-compatible", name: "9Router", options: { baseURL: endpoint, apiKey }, - models: Object.fromEntries(configuredModels.map((id) => [id, { name: id }])), + models: Object.fromEntries(modelsForConfig.map((id) => [id, { name: id }])), }, }, model: `9router/${modelId}`, @@ -280,7 +298,7 @@ export default function ConfigGeneratorCard({ }; const addModel = (selected) => { - if (!selected?.value || selectedModels.includes(selected.value)) return; + if (!selected?.value) return; setSelectedModels((current) => [...current, selected.value]); if (toolId === "cowork") setCoworkThinking((current) => ({ ...current, [selected.value]: "" })); if (toolId === "copilot") { @@ -290,27 +308,33 @@ export default function ConfigGeneratorCard({ } }; - const removeCoworkModel = (model) => { - setSelectedModels((current) => current.filter((item) => item !== model)); - setCoworkThinking((current) => { - const remainingThinking = { ...current }; - delete remainingThinking[model]; - return remainingThinking; - }); + const removeCoworkModel = (model, index) => { + const remainingModels = selectedModels.filter((_, currentIndex) => currentIndex !== index); + setSelectedModels(remainingModels); + if (!remainingModels.includes(model)) { + setCoworkThinking((current) => { + const remainingThinking = { ...current }; + delete remainingThinking[model]; + return remainingThinking; + }); + } }; - const removeCopilotModel = (model) => { - setSelectedModels((current) => current.filter((item) => item !== model)); - setCopilotThinking((current) => { - const remaining = { ...current }; - delete remaining[model]; - return remaining; - }); - setCopilotTokens((current) => { - const remaining = { ...current }; - delete remaining[model]; - return remaining; - }); + const removeCopilotModel = (model, index) => { + const remainingModels = selectedModels.filter((_, currentIndex) => currentIndex !== index); + setSelectedModels(remainingModels); + if (!remainingModels.includes(model)) { + setCopilotThinking((current) => { + const remaining = { ...current }; + delete remaining[model]; + return remaining; + }); + setCopilotTokens((current) => { + const remaining = { ...current }; + delete remaining[model]; + return remaining; + }); + } }; const selectClaudeModel = (selected) => { @@ -332,15 +356,21 @@ export default function ConfigGeneratorCard({ }; const selectOpenCodeModel = (selected) => { - if (!selected?.value || opencodeModels.includes(selected.value)) return; + if (!selected?.value) return; setOpencodeModels((current) => [...current, selected.value]); if (!opencodeDefaultModel) setOpencodeDefaultModel(selected.value); }; - const removeOpenCodeModel = (model) => { - const remainingModels = opencodeModels.filter((item) => item !== model); + const removeOpenCodeModel = (model, index) => { + const remainingModels = opencodeModels.filter((_, currentIndex) => currentIndex !== index); setOpencodeModels(remainingModels); - if (opencodeDefaultModel === model) setOpencodeDefaultModel(remainingModels[0] || ""); + if (opencodeDefaultModel === model && !remainingModels.includes(model)) { + setOpencodeDefaultModel(remainingModels[0] || ""); + } + }; + + const removeSelectedModel = (index) => { + setSelectedModels((current) => current.filter((_, currentIndex) => currentIndex !== index)); }; return ( @@ -461,14 +491,15 @@ export default function ConfigGeneratorCard({ {opencodeModels.length ? (
- {opencodeModels.map((model) => { + {opencodeModels.map((model, index) => { const isDefault = model === opencodeDefaultModel; + const occurrence = getModelOccurrence(opencodeModels, model, index); return ( -
+
-
@@ -489,11 +520,12 @@ export default function ConfigGeneratorCard({
{selectedModels.length ? (
- {selectedModels.map((model) => { + {selectedModels.map((model, index) => { const thinkingLevels = getThinkingLevelsForModel(model); + const occurrence = getModelOccurrence(selectedModels, model, index); return ( -
- {model} +
+ {model}{occurrence.total > 1 && #{occurrence.occurrence}} {thinkingLevels && ( )} - +
); })} @@ -526,16 +558,17 @@ export default function ConfigGeneratorCard({
{selectedModels.length ? (
- {selectedModels.map((model) => { + {selectedModels.map((model, index) => { const thinkingLevels = getThinkingLevelsForModel(model); const tokens = copilotTokens[model] || DEFAULT_MODEL_TOKEN_LIMITS; const inputOptions = getInputTokenOptions(tokens); const outputOptions = getOutputTokenOptions(tokens); + const occurrence = getModelOccurrence(selectedModels, model, index); return ( -
+
- {model} - + {model}{occurrence.total > 1 && #{occurrence.occurrence}} +
{thinkingLevels && (
{selectedModels.length ? (
- {selectedModels.map((model) => ( - - ))} + {selectedModels.map((model, index) => { + const occurrence = getModelOccurrence(selectedModels, model, index); + return ( + + ); + })}
) :

No model selected. The generated file uses {DEFAULT_MODEL} as a placeholder.

}
@@ -633,6 +669,7 @@ export default function ConfigGeneratorCard({ title={toolId === "claude" && claudeModelSlot ? `Select ${claudeModelSlot} model` : toolId === "codex" ? "Select Codex model" : toolId === "opencode" ? "Add OpenCode model" : `Add model for ${tool.name}`} closeOnSelect={toolId === "claude" || toolId === "codex"} addedModelValues={toolId === "claude" ? Object.values(claudeModels).filter(Boolean) : toolId === "codex" ? [codexModel].filter(Boolean) : toolId === "opencode" ? opencodeModels : selectedModels} + allowDuplicates={!['claude', 'codex'].includes(toolId)} availableModels={availableModels} /> setConfigModalOpen(false)} title={`${tool.name} configuration`} configs={configs} /> diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js index 79170f09..a5c323dd 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js @@ -6,6 +6,20 @@ import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import Image from "next/image"; import ApiKeySelect from "./ApiKeySelect"; +const DUPLICATE_MODEL_COLORS = [ + "border-blue-400 bg-blue-50 dark:bg-blue-950", + "border-emerald-400 bg-emerald-50 dark:bg-emerald-950", + "border-amber-400 bg-amber-50 dark:bg-amber-950", + "border-purple-400 bg-purple-50 dark:bg-purple-950", + "border-rose-400 bg-rose-50 dark:bg-rose-950", +]; + +const getModelOccurrence = (models, model, index) => { + const total = models.filter((item) => item === model).length; + const occurrence = models.slice(0, index + 1).filter((item) => item === model).length; + return { total, occurrence }; +}; + export default function DefaultToolCard({ toolId, tool, baseUrl, apiKeys, activeProviders = [], availableModels = [], cloudEnabled = false, initialConfig, onSaveConfig }) { const [copiedField, setCopiedField] = useState(null); const [showModelModal, setShowModelModal] = useState(false); @@ -65,12 +79,12 @@ export default function DefaultToolCard({ toolId, tool, baseUrl, apiKeys, active }; const addSelectedModel = (model) => { - if (!model?.value || selectedModels.includes(model.value)) return; + if (!model?.value) return; setSelectedModels((current) => [...current, model.value]); }; - const removeSelectedModel = (model) => { - setSelectedModels((current) => current.filter((value) => value !== model.value)); + const removeSelectedModel = (index) => { + setSelectedModels((current) => current.filter((_, currentIndex) => currentIndex !== index)); }; const handleSave = async () => { @@ -119,16 +133,22 @@ export default function DefaultToolCard({ toolId, tool, baseUrl, apiKeys, active
{selectedModels.length ? (
- {selectedModels.map((model) => ( -
- - -
- ))} + {selectedModels.map((model, index) => { + const occurrence = getModelOccurrence(selectedModels, model, index); + const colorClass = occurrence.total > 1 + ? DUPLICATE_MODEL_COLORS[(occurrence.occurrence - 1) % DUPLICATE_MODEL_COLORS.length] + : "border-border bg-bg-secondary"; + return ( +
+ + +
+ ); + })}
) :

Select every 9Router model you plan to add as a Cursor custom model.

}
@@ -361,12 +381,12 @@ export default function DefaultToolCard({ toolId, tool, baseUrl, apiKeys, active isOpen={showModelModal} onClose={() => setShowModelModal(false)} onSelect={tool.modelSelection === "multiple" ? addSelectedModel : handleSelectModel} - onDeselect={tool.modelSelection === "multiple" ? removeSelectedModel : undefined} selectedModel={modelValue} activeProviders={activeProviders} title={tool.modelSelection === "multiple" ? "Add Cursor custom model" : "Select Model"} closeOnSelect={tool.modelSelection !== "multiple"} addedModelValues={tool.modelSelection === "multiple" ? selectedModels : []} + allowDuplicates={tool.modelSelection === "multiple"} availableModels={availableModels} /> diff --git a/src/shared/components/ModelSelectModal.js b/src/shared/components/ModelSelectModal.js index d076ced1..f92f280a 100644 --- a/src/shared/components/ModelSelectModal.js +++ b/src/shared/components/ModelSelectModal.js @@ -31,6 +31,7 @@ export default function ModelSelectModal({ modelAliases = {}, kindFilter = null, addedModelValues = [], + allowDuplicates = false, closeOnSelect = true, availableModels = null, }) { @@ -403,6 +404,8 @@ export default function ModelSelectModal({ return [...added, ...rest]; }; + const getAddedModelCount = (value) => addedModelValues.filter((addedValue) => addedValue === value).length; + // Filter models by search query const filteredGroups = useMemo(() => { const query = searchQuery.trim().toLowerCase(); @@ -432,7 +435,9 @@ export default function ModelSelectModal({ const value = model?.value || model?.name || model; const isAdded = addedModelValues.includes(value); - if (isAdded && onDeselect) { + if (allowDuplicates) { + onSelect(model); + } else if (isAdded && onDeselect) { onDeselect(model); } else { onSelect(model); @@ -459,7 +464,7 @@ export default function ModelSelectModal({ {/* Info bar */}
info - Click to add, click again to remove. Changes are saved automatically. + {allowDuplicates ? "Click to add. Use the X button on tags to remove. Changes are saved automatically." : "Click to add, click again to remove. Changes are saved automatically."}
{/* Search - compact */} @@ -491,6 +496,7 @@ export default function ModelSelectModal({
{filteredCombos.map((combo) => { const isSelected = selectedModel === combo.name; + const addedCount = getAddedModelCount(combo.name); return ( ); })} @@ -540,6 +547,7 @@ export default function ModelSelectModal({ {group.models.map((model) => { const isSelected = selectedModel === model.value; const isPlaceholder = model.isPlaceholder; + const addedCount = getAddedModelCount(model.value); return (