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 (
-
+
setOpencodeDefaultModel(model)} className="rounded-full px-2 py-1 hover:text-primary" title="Set as default model">
- {model}{isDefault && default }
+ {model}{occurrence.total > 1 && #{occurrence.occurrence} }{isDefault && default }
- removeOpenCodeModel(model)} className="rounded-full p-1 hover:text-red-500" title="Remove model" aria-label={`Remove ${model}`}>
+ removeOpenCodeModel(model, index)} className="rounded-full p-1 hover:text-red-500" title="Remove model" aria-label={`Remove ${model} instance ${index + 1}`}>
close
@@ -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 && (
Reasoning / thinking
@@ -507,7 +539,7 @@ export default function ConfigGeneratorCard({
)}
- removeCoworkModel(model)} aria-label={`Remove ${model}`}>Remove
+ removeCoworkModel(model, index)} aria-label={`Remove ${model} instance ${index + 1}`}>Remove
);
})}
@@ -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}
- removeCopilotModel(model)} aria-label={`Remove ${model}`}>Remove
+ {model}{occurrence.total > 1 && #{occurrence.occurrence} }
+ removeCopilotModel(model, index)} aria-label={`Remove ${model} instance ${index + 1}`}>Remove
{thinkingLevels && (
@@ -598,11 +631,14 @@ export default function ConfigGeneratorCard({
{selectedModels.length ? (
- {selectedModels.map((model) => (
- setSelectedModels((current) => current.filter((item) => item !== model))} className="inline-flex items-center gap-1 rounded-full border border-border bg-bg-secondary px-2 py-1 text-xs text-text-main hover:border-red-500/50" title="Remove model">
- {model}close
-
- ))}
+ {selectedModels.map((model, index) => {
+ const occurrence = getModelOccurrence(selectedModels, model, index);
+ return (
+ removeSelectedModel(index)} className={`inline-flex items-center gap-1 rounded-full border px-2 py-1 text-xs text-text-main hover:border-red-500/50 ${getDuplicateModelClass(occurrence)}`} title="Remove model">
+ {model}{occurrence.total > 1 && #{occurrence.occurrence} }close
+
+ );
+ })}
) :
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) => (
-
- handleCopy(model, `model-${model}`)} className="min-w-0 truncate hover:text-primary" title="Copy model ID">
- {model}
-
- setSelectedModels((current) => current.filter((value) => value !== model))} className="rounded-full p-1 hover:text-red-500" title="Remove model" aria-label={`Remove ${model}`}>
- close
-
-
- ))}
+ {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 (
+
+ handleCopy(model, `model-${model}-${index}`)} className="min-w-0 truncate hover:text-primary" title="Copy model ID">
+ {model}{occurrence.total > 1 && #{occurrence.occurrence} }
+
+ removeSelectedModel(index)} className="rounded-full p-1 hover:text-red-500" title="Remove model" aria-label={`Remove ${model} instance ${index + 1}`}>
+ close
+
+
+ );
+ })}
) :
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 (
check
)}
{combo.name}
+ {allowDuplicates && addedCount > 0 && ×{addedCount} }
);
})}
@@ -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 (
check
)}
+ {allowDuplicates && addedCount > 0 && !isPlaceholder && ×{addedCount} }
{isPlaceholder ? (
<>
edit
@@ -614,6 +623,7 @@ ModelSelectModal.propTypes = {
modelAliases: PropTypes.object,
kindFilter: PropTypes.string,
addedModelValues: PropTypes.arrayOf(PropTypes.string),
+ allowDuplicates: PropTypes.bool,
closeOnSelect: PropTypes.bool,
availableModels: PropTypes.arrayOf(PropTypes.shape({
fullModel: PropTypes.string.isRequired,
diff --git a/src/shared/constants/cliToolConfig.js b/src/shared/constants/cliToolConfig.js
index 6b90076c..01562582 100644
--- a/src/shared/constants/cliToolConfig.js
+++ b/src/shared/constants/cliToolConfig.js
@@ -68,7 +68,7 @@ function normalizeModels(value, field = "selectedModels") {
if (!normalized) throw new CliToolConfigValidationError(`${field} cannot contain empty model IDs`);
return normalized;
});
- return [...new Set(models)];
+ return models;
}
function normalizeThinking(value, field) {
diff --git a/tests/unit/cli-tool-config-contract.test.js b/tests/unit/cli-tool-config-contract.test.js
index 4c57b175..6296aad9 100644
--- a/tests/unit/cli-tool-config-contract.test.js
+++ b/tests/unit/cli-tool-config-contract.test.js
@@ -47,24 +47,24 @@ describe("CLI tool configuration contract", () => {
apiKeyId: null,
opencodeModels: ["cc/a", "cc/a", "cx/b"],
opencodeDefaultModel: "missing/model",
- })).toMatchObject({ opencodeModels: ["cc/a", "cx/b"], opencodeDefaultModel: "cc/a" });
+ })).toMatchObject({ opencodeModels: ["cc/a", "cc/a", "cx/b"], opencodeDefaultModel: "cc/a" });
expect(normalizeCliToolConfig("cowork", {
baseUrl: "https://router.example",
apiKeyMode: "managed",
- selectedModels: ["cc/a"],
+ selectedModels: ["cc/a", "cc/a"],
coworkThinking: { "cc/a": "high", "stale/model": "low" },
- })).toMatchObject({ selectedModels: ["cc/a"], coworkThinking: { "cc/a": "high" } });
+ })).toMatchObject({ selectedModels: ["cc/a", "cc/a"], coworkThinking: { "cc/a": "high" } });
expect(normalizeCliToolConfig("cursor", {
apiKeyMode: "managed",
apiKeyId: "key-1",
- selectedModels: ["cc/a", "cx/b"],
- })).toEqual({ apiKeyMode: "managed", apiKeyId: "key-1", selectedModels: ["cc/a", "cx/b"] });
+ selectedModels: ["cc/a", "cc/a", "cx/b"],
+ })).toEqual({ apiKeyMode: "managed", apiKeyId: "key-1", selectedModels: ["cc/a", "cc/a", "cx/b"] });
expect(normalizeCliToolConfig("copilot", {
baseUrl: "https://router.example/v1",
- selectedModels: ["cc/a"],
+ selectedModels: ["cc/a", "cc/a"],
copilotThinking: { "cc/a": "high", "stale/model": "low" },
copilotTokens: {
"cc/a": { maxInputTokens: 100000, maxOutputTokens: 32000 },
@@ -72,7 +72,7 @@ describe("CLI tool configuration contract", () => {
},
})).toEqual({
baseUrl: "https://router.example/v1",
- selectedModels: ["cc/a"],
+ selectedModels: ["cc/a", "cc/a"],
copilotThinking: { "cc/a": "high" },
copilotTokens: { "cc/a": { maxInputTokens: 100000, maxOutputTokens: 32000 } },
});
diff --git a/tests/unit/permanent-model-delete.test.js b/tests/unit/permanent-model-delete.test.js
index 7be327db..3ddc2e99 100644
--- a/tests/unit/permanent-model-delete.test.js
+++ b/tests/unit/permanent-model-delete.test.js
@@ -74,7 +74,7 @@ describe("permanent model deletion", () => {
});
await db.upsertCliToolConfig(cliUser.id, "cowork", {
baseUrl: "http://127.0.0.1:20127",
- selectedModels: ["deleted-alias", `${providerPrefix}/gpt-keep`],
+ selectedModels: ["deleted-alias", "deleted-alias", `${providerPrefix}/gpt-keep`],
coworkThinking: { "deleted-alias": "high", [`${providerPrefix}/gpt-keep`]: "low" },
});