diff --git a/.github/issue-assets/combo-defaults/cursor-model-list.png b/.github/issue-assets/combo-defaults/cursor-model-list.png
new file mode 100644
index 00000000..93abfbb1
Binary files /dev/null and b/.github/issue-assets/combo-defaults/cursor-model-list.png differ
diff --git a/.github/issue-assets/combo-defaults/override-openai-base-url.png b/.github/issue-assets/combo-defaults/override-openai-base-url.png
new file mode 100644
index 00000000..e7923899
Binary files /dev/null and b/.github/issue-assets/combo-defaults/override-openai-base-url.png differ
diff --git a/gitbook/content/en/features/combos.md b/gitbook/content/en/features/combos.md
index 2b94eff5..f91c1f00 100644
--- a/gitbook/content/en/features/combos.md
+++ b/gitbook/content/en/features/combos.md
@@ -118,6 +118,31 @@ Cursor/Cline/Any tool:
---
+## Cursor / Claude Default Combos
+
+Cursor and Claude Code send **unprefixed** model IDs (`composer-2.5`, `claude-opus-5`, `opus`), while 9Router routes with provider prefixes (`cu/composer-2.5`, `cc/claude-opus-5`). Default combo generators bridge that gap.
+
+On **Dashboard → Combos**:
+
+1. Click **Cursor Default** or **Claude Default**
+2. Confirm the preview (new vs already-existing names)
+3. 9Router creates one combo per client model ID, seeded with the matching prefixed route
+
+**Examples:**
+
+| Combo name (what the client sends) | Seeded model (what 9Router routes) |
+|------------------------------------|------------------------------------|
+| `composer-2.5` | `cu/composer-2.5` |
+| `cursor-grok-4.6-high-fast` | `cu/cursor-grok-4.6-high-fast` |
+| `claude-opus-5` | `cc/claude-opus-5` |
+| `opus` | `cc/claude-opus-5` |
+
+Existing combo names are **skipped** (not overwritten). Edit any generated combo afterward to add fallbacks. Click the button again later to pick up new catalog IDs.
+
+> These combos help when Cursor/Claude already talk to 9Router (`/v1` or `ANTHROPIC_BASE_URL`) and send their native model IDs. They do not change Cursor’s built-in Models tab by themselves.
+
+---
+
## Example Combos
### Example 1: Premium Coding (Subscription → Cheap → Free)
diff --git a/src/app/(dashboard)/dashboard/combos/page.js b/src/app/(dashboard)/dashboard/combos/page.js
index 52ed7459..c0e20f3e 100644
--- a/src/app/(dashboard)/dashboard/combos/page.js
+++ b/src/app/(dashboard)/dashboard/combos/page.js
@@ -44,6 +44,12 @@ function normalizeCapEntry(entry) {
return { ...EMPTY_CAP_ENTRY };
}
+const STRATEGY_OPTIONS = [
+ { value: "fallback", label: "Fallback — try in order" },
+ { value: "round-robin", label: "Round Robin — rotate" },
+ { value: "fusion", label: "Fusion — panel + judge" },
+];
+
export default function CombosPage() {
const [combos, setCombos] = useState([]);
const [loading, setLoading] = useState(true);
@@ -52,14 +58,99 @@ export default function CombosPage() {
const [activeProviders, setActiveProviders] = useState([]);
const [comboStrategies, setComboStrategies] = useState({});
const [capacityAdapter, setCapacityAdapter] = useState(EMPTY_CAPACITY_ADAPTER);
- const [confirmState, setConfirmState] = useState(null);
const { getCaps } = useModelCaps();
+ const [confirmState, setConfirmState] = useState(null);
+ const [presetLoading, setPresetLoading] = useState(null); // "cursor" | "claude" | null
+ const [selectedIds, setSelectedIds] = useState([]);
+ const [bulkBusy, setBulkBusy] = useState(false);
const { copied, copy } = useCopyToClipboard();
useEffect(() => {
fetchData();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
+ // Drop stale selection when the combo list changes (delete / refresh).
+ useEffect(() => {
+ const alive = new Set(combos.map((c) => c.id));
+ setSelectedIds((prev) => prev.filter((id) => alive.has(id)));
+ }, [combos]);
+
+ const selectedCombos = combos.filter((c) => selectedIds.includes(c.id));
+ const allSelected = combos.length > 0 && selectedIds.length === combos.length;
+ const someSelected = selectedIds.length > 0;
+
+ const toggleSelect = (id) => {
+ setSelectedIds((prev) => (
+ prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
+ ));
+ };
+
+ const toggleSelectAll = () => {
+ setSelectedIds(allSelected ? [] : combos.map((c) => c.id));
+ };
+
+ const clearSelection = () => setSelectedIds([]);
+
+ const handleGeneratePresets = async (source) => {
+ const label = source === "cursor" ? "Cursor Default" : "Claude Default";
+ setPresetLoading(source);
+ try {
+ const previewRes = await fetch(`/api/combos/presets?source=${source}`);
+ const preview = await previewRes.json();
+ if (!previewRes.ok) {
+ alert(preview.error || `Failed to preview ${label}`);
+ return;
+ }
+
+ const toCreate = preview.toCreate ?? (preview.items || []).filter((i) => !i.exists).length;
+ const toSkip = preview.toSkip ?? (preview.items || []).filter((i) => i.exists).length;
+ const total = (preview.items || []).length;
+
+ if (total === 0) {
+ alert(`No ${label} models available to generate.`);
+ return;
+ }
+
+ if (toCreate === 0) {
+ alert(`All ${total} ${label} combos already exist. Nothing to create.`);
+ return;
+ }
+
+ setConfirmState({
+ title: `Generate ${label}`,
+ message: `Create ${toCreate} combo${toCreate === 1 ? "" : "s"} named like ${source === "cursor" ? "Cursor" : "Claude"} model IDs (seeded with cu/… or cc/…). ${toSkip} already exist and will be skipped. You can edit any combo afterward to add fallbacks.`,
+ confirmText: "Generate",
+ variant: "primary",
+ onConfirm: async () => {
+ setConfirmState((prev) => prev ? { ...prev, loading: true } : null);
+ try {
+ const res = await fetch("/api/combos/presets", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ source }),
+ });
+ const data = await res.json();
+ if (!res.ok) {
+ alert(data.error || `Failed to generate ${label}`);
+ return;
+ }
+ await fetchData();
+ setConfirmState(null);
+ } catch (error) {
+ console.log(`Error generating ${label}:`, error);
+ alert(`Failed to generate ${label}`);
+ setConfirmState((prev) => prev ? { ...prev, loading: false } : null);
+ }
+ },
+ });
+ } catch (error) {
+ console.log(`Error previewing ${label}:`, error);
+ alert(`Failed to preview ${label}`);
+ } finally {
+ setPresetLoading(null);
+ }
+ };
+
const fetchData = async () => {
try {
const [combosRes, providersRes, settingsRes] = await Promise.all([
@@ -141,24 +232,80 @@ export default function CombosPage() {
}
};
+ const pruneStrategiesForNames = (names, base = comboStrategies) => {
+ const updated = { ...base };
+ for (const name of names) delete updated[name];
+ return updated;
+ };
+
+ const persistComboStrategies = async (updated) => {
+ await fetch("/api/settings", {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ comboStrategies: updated }),
+ });
+ setComboStrategies(updated);
+ };
+
const handleDelete = async (id) => {
+ const combo = combos.find((c) => c.id === id);
setConfirmState({
title: "Delete Combo",
- message: "Delete this combo?",
+ message: combo ? `Delete combo "${combo.name}"?` : "Delete this combo?",
onConfirm: async () => {
- setConfirmState(null);
+ setConfirmState((prev) => prev ? { ...prev, loading: true } : null);
try {
const res = await fetch(`/api/combos/${id}`, { method: "DELETE" });
if (res.ok) {
- setCombos(combos.filter(c => c.id !== id));
+ if (combo?.name) {
+ await persistComboStrategies(pruneStrategiesForNames([combo.name]));
+ }
+ setCombos((prev) => prev.filter((c) => c.id !== id));
+ setSelectedIds((prev) => prev.filter((x) => x !== id));
}
+ setConfirmState(null);
} catch (error) {
console.log("Error deleting combo:", error);
+ setConfirmState((prev) => prev ? { ...prev, loading: false } : null);
}
}
});
};
+ const handleBulkDelete = () => {
+ if (selectedCombos.length === 0) return;
+ const count = selectedCombos.length;
+ setConfirmState({
+ title: "Delete Selected Combos",
+ message: `Delete ${count} selected combo${count === 1 ? "" : "s"}? This cannot be undone.`,
+ confirmText: "Delete",
+ variant: "danger",
+ onConfirm: async () => {
+ setConfirmState((prev) => prev ? { ...prev, loading: true } : null);
+ setBulkBusy(true);
+ try {
+ const ids = selectedCombos.map((c) => c.id);
+ const names = selectedCombos.map((c) => c.name);
+ const results = await Promise.all(
+ ids.map((id) => fetch(`/api/combos/${id}`, { method: "DELETE" }))
+ );
+ const failed = results.filter((r) => !r.ok).length;
+ await persistComboStrategies(pruneStrategiesForNames(names));
+ setCombos((prev) => prev.filter((c) => !ids.includes(c.id)));
+ clearSelection();
+ setConfirmState(null);
+ if (failed > 0) alert(`Deleted with ${failed} failure${failed === 1 ? "" : "s"}.`);
+ } catch (error) {
+ console.log("Error bulk deleting combos:", error);
+ alert("Failed to delete selected combos");
+ setConfirmState((prev) => prev ? { ...prev, loading: false } : null);
+ } finally {
+ setBulkBusy(false);
+ }
+ },
+ });
+ };
+
// Merge a per-combo strategy patch into settings.comboStrategies. Passing an empty
// patch (strategy back to default "fallback") drops the entry entirely.
const handleSetComboStrategy = async (comboName, patch) => {
@@ -172,18 +319,36 @@ export default function CombosPage() {
updated[comboName] = next;
}
- await fetch("/api/settings", {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ comboStrategies: updated }),
- });
-
- setComboStrategies(updated);
+ await persistComboStrategies(updated);
} catch (error) {
console.log("Error updating combo strategy:", error);
}
};
+ const handleBulkSetStrategy = async (strategy) => {
+ if (selectedCombos.length === 0 || !strategy) return;
+ setBulkBusy(true);
+ try {
+ const updated = { ...comboStrategies };
+ for (const combo of selectedCombos) {
+ if (!strategy || strategy === "fallback") {
+ delete updated[combo.name];
+ } else {
+ updated[combo.name] = {
+ ...(updated[combo.name] || {}),
+ fallbackStrategy: strategy,
+ };
+ }
+ }
+ await persistComboStrategies(updated);
+ } catch (error) {
+ console.log("Error bulk updating combo strategy:", error);
+ alert("Failed to update strategy for selected combos");
+ } finally {
+ setBulkBusy(false);
+ }
+ };
+
if (loading) {
return (
@@ -196,7 +361,7 @@ export default function CombosPage() {
return (
{/* Header */}
-
+
Group models under one name, then pick a strategy per combo:
@@ -206,10 +371,40 @@ export default function CombosPage() {
Round Robin — rotates models across requests to spread load
Fusion — queries all models in parallel, then a judge synthesizes one answer. Best quality, but costs the most: every request bills all panel models + the judge (N+1 calls)
+
+ Cursor / Claude Default create combos named exactly like those clients' model IDs (e.g. composer-2.5, opus), seeded with the matching cu/… or cc/… route so traffic can hit 9router without the prefix.
+ {" "}Note: Cursor IDE itself often blocks built-in Composer / Grok from Override OpenAI Base URL ("model does not support custom API"); add them via Cursor's Add Custom Model using the combo name, or pick a model Cursor allows through the custom endpoint.
+
+
+
+
setShowCreateModal(true)} className="w-full sm:w-auto whitespace-nowrap">
+ Create Combo
+
+
+ handleGeneratePresets("cursor")}
+ className="w-full whitespace-nowrap"
+ >
+ Cursor Default
+
+ handleGeneratePresets("claude")}
+ className="w-full whitespace-nowrap"
+ >
+ Claude Default
+
+
-
setShowCreateModal(true)} className="w-full sm:w-auto whitespace-nowrap">
- Create Combo
-
{/* Combos List */}
@@ -227,25 +422,88 @@ export default function CombosPage() {
) : (
-
- {(() => {
- const comboByName = Object.fromEntries(combos.map((c) => [c.name, c.models]));
- return combos.map((combo) => (
-
setEditingCombo(combo)}
- onDelete={() => handleDelete(combo.id)}
- strategy={comboStrategies[combo.name] || {}}
- onSetStrategy={(patch) => handleSetComboStrategy(combo.name, patch)}
+
+ {/* Selection toolbar */}
+
+
+ {
+ if (el) el.indeterminate = someSelected && !allSelected;
+ }}
+ onChange={toggleSelectAll}
+ className="h-3.5 w-3.5 rounded border-gray-300 text-primary focus:ring-primary"
/>
- ));
- })()}
+
+ {someSelected
+ ? `${selectedIds.length} selected`
+ : `Select all (${combos.length})`}
+
+
+
+
+ {someSelected && (
+ <>
+
+ {
+ const v = e.target.value;
+ if (v) handleBulkSetStrategy(v);
+ }}
+ selectClassName="py-1.5 text-xs"
+ />
+
+
+ Delete ({selectedIds.length})
+
+
+ Clear
+
+ >
+ )}
+
+
+
+
+ {(() => {
+ const comboByName = Object.fromEntries(combos.map((c) => [c.name, c.models]));
+ return combos.map((combo) => (
+ setEditingCombo(combo)}
+ onDelete={() => handleDelete(combo.id)}
+ strategy={comboStrategies[combo.name] || {}}
+ onSetStrategy={(patch) => handleSetComboStrategy(combo.name, patch)}
+ selected={selectedIds.includes(combo.id)}
+ onToggleSelect={() => toggleSelect(combo.id)}
+ />
+ ));
+ })()}
+
)}
@@ -279,14 +537,16 @@ export default function CombosPage() {
/>
)}
- {/* Confirm Delete Modal */}
+ {/* Confirm (delete / generate presets) */}
setConfirmState(null)}
+ onClose={() => !confirmState?.loading && setConfirmState(null)}
onConfirm={confirmState?.onConfirm}
title={confirmState?.title || "Confirm"}
message={confirmState?.message}
- variant="danger"
+ confirmText={confirmState?.confirmText || "Confirm"}
+ variant={confirmState?.variant || "danger"}
+ loading={!!confirmState?.loading}
/>
);
@@ -301,13 +561,7 @@ const fmtK = (n) => {
return `${Math.round(n / 1000)}k`;
};
-const STRATEGY_OPTIONS = [
- { value: "fallback", label: "Fallback — try in order" },
- { value: "round-robin", label: "Round Robin — rotate" },
- { value: "fusion", label: "Fusion — panel + judge" },
-];
-
-function ComboCard({ combo, getCaps, comboByName = {}, activeProviders = [], copied, onCopy, onEdit, onDelete, strategy = {}, onSetStrategy }) {
+function ComboCard({ combo, getCaps, comboByName = {}, activeProviders = [], copied, onCopy, onEdit, onDelete, strategy = {}, onSetStrategy, selected = false, onToggleSelect }) {
const [showJudgeSelect, setShowJudgeSelect] = useState(false);
const current = strategy.fallbackStrategy || "fallback";
const judge = strategy.judgeModel || "";
@@ -315,9 +569,19 @@ function ComboCard({ combo, getCaps, comboByName = {}, activeProviders = [], cop
const comboCaps = aggregateComboCapabilities(combo.models, comboByName);
return (
-
+
+
+ e.stopPropagation()}
+ className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
+ aria-label={`Select ${combo.name}`}
+ />
+
layers
diff --git a/src/app/api/combos/presets/route.js b/src/app/api/combos/presets/route.js
new file mode 100644
index 00000000..58d0541b
--- /dev/null
+++ b/src/app/api/combos/presets/route.js
@@ -0,0 +1,110 @@
+import { NextResponse } from "next/server";
+import { getCombos, createCombo, getProviderConnections } from "@/lib/localDb";
+import { buildPresetItems, PRESET_SOURCES } from "@/lib/comboPresets";
+import { resolveCursorModels } from "open-sse/services/cursorModels.js";
+
+export const dynamic = "force-dynamic";
+
+/**
+ * Resolve live Cursor catalog from the first active cursor connection, if any.
+ * @returns {Promise
|null>}
+ */
+async function fetchCursorLiveModels() {
+ try {
+ const connections = await getProviderConnections();
+ const conn = (connections || []).find(
+ (c) => c.provider === "cursor" && c.isActive !== false
+ );
+ if (!conn) return null;
+ const result = await resolveCursorModels({
+ accessToken: conn.accessToken,
+ providerSpecificData: conn.providerSpecificData || {},
+ }, { log: console });
+ return result?.models?.length ? result.models : null;
+ } catch (error) {
+ console.log("combo presets: cursor live catalog failed", error?.message || error);
+ return null;
+ }
+}
+
+/**
+ * @param {string} source
+ * @returns {Promise<{ name: string, models: string[], exists: boolean }[]>}
+ */
+async function resolvePresetItems(source) {
+ const combos = await getCombos();
+ const existingNames = (combos || []).map((c) => c.name);
+ const liveModels = source === "cursor" ? await fetchCursorLiveModels() : null;
+ return buildPresetItems(source, {
+ liveModels: liveModels || undefined,
+ existingNames,
+ });
+}
+
+function parseSource(value) {
+ if (!value || !PRESET_SOURCES.has(value)) return null;
+ return value;
+}
+
+// GET /api/combos/presets?source=cursor|claude — preview items
+export async function GET(request) {
+ try {
+ const { searchParams } = new URL(request.url);
+ const source = parseSource(searchParams.get("source"));
+ if (!source) {
+ return NextResponse.json(
+ { error: "source must be 'cursor' or 'claude'" },
+ { status: 400 }
+ );
+ }
+
+ const items = await resolvePresetItems(source);
+ return NextResponse.json({
+ source,
+ items,
+ toCreate: items.filter((i) => !i.exists).length,
+ toSkip: items.filter((i) => i.exists).length,
+ });
+ } catch (error) {
+ console.log("Error previewing combo presets:", error);
+ return NextResponse.json({ error: "Failed to preview combo presets" }, { status: 500 });
+ }
+}
+
+// POST /api/combos/presets — create missing combos for a source
+export async function POST(request) {
+ try {
+ const body = await request.json().catch(() => ({}));
+ const source = parseSource(body?.source);
+ if (!source) {
+ return NextResponse.json(
+ { error: "source must be 'cursor' or 'claude'" },
+ { status: 400 }
+ );
+ }
+
+ const items = await resolvePresetItems(source);
+ const created = [];
+ const skipped = [];
+
+ for (const item of items) {
+ if (item.exists) {
+ skipped.push(item.name);
+ continue;
+ }
+ const combo = await createCombo({ name: item.name, models: item.models });
+ created.push(combo);
+ }
+
+ return NextResponse.json({
+ source,
+ created,
+ skipped,
+ createdCount: created.length,
+ skippedCount: skipped.length,
+ });
+ } catch (error) {
+ console.log("Error creating combo presets:", error);
+ return NextResponse.json({ error: "Failed to create combo presets" }, { status: 500 });
+ }
+}
diff --git a/src/lib/comboPresets.js b/src/lib/comboPresets.js
new file mode 100644
index 00000000..f45ae058
--- /dev/null
+++ b/src/lib/comboPresets.js
@@ -0,0 +1,121 @@
+/**
+ * Build Cursor / Claude default combo presets.
+ * Combo names match client-native model IDs (no provider prefix);
+ * each is seeded with the matching prefixed 9router model so routing works.
+ */
+
+import { getProviderModels } from "open-sse/config/providerModels.js";
+import { CLI_TOOLS } from "@/shared/constants/cliTools";
+
+export const VALID_COMBO_NAME_REGEX = /^[a-zA-Z0-9_.\-]+$/;
+export const PRESET_SOURCES = new Set(["cursor", "claude"]);
+
+const CURSOR_ALIAS = "cu";
+const CLAUDE_ALIAS = "cc";
+
+/** Extra Claude Code aliases not listed in defaultModels. */
+const CLAUDE_EXTRA_ALIAS_TARGETS = {
+ default: "cc/claude-sonnet-5",
+ opusplan: "cc/claude-opus-5",
+};
+
+/**
+ * @param {string} name
+ * @returns {boolean}
+ */
+export function isValidComboPresetName(name) {
+ return typeof name === "string" && name.length > 0 && VALID_COMBO_NAME_REGEX.test(name);
+}
+
+/**
+ * @param {string} name
+ * @param {string[]} models
+ * @param {Set} [seen]
+ * @returns {{ name: string, models: string[] }|null}
+ */
+function pushItem(name, models, seen) {
+ if (!isValidComboPresetName(name)) return null;
+ if (seen?.has(name)) return null;
+ if (!Array.isArray(models) || models.length === 0) return null;
+ seen?.add(name);
+ return { name, models };
+}
+
+/**
+ * @param {Array<{id?: string}|string>} modelList
+ * @param {string} providerAlias
+ * @param {Set} seen
+ * @returns {{ name: string, models: string[] }[]}
+ */
+function itemsFromProviderModels(modelList, providerAlias, seen) {
+ const out = [];
+ for (const entry of modelList || []) {
+ const id = typeof entry === "string" ? entry : entry?.id;
+ if (!id) continue;
+ const item = pushItem(id, [`${providerAlias}/${id}`], seen);
+ if (item) out.push(item);
+ }
+ return out;
+}
+
+/**
+ * Build Cursor default presets.
+ * Prefer live catalog when provided; otherwise static cu registry.
+ * @param {{ liveModels?: Array<{id: string}|string> }} [opts]
+ * @returns {{ name: string, models: string[] }[]}
+ */
+export function buildCursorPresetItems(opts = {}) {
+ const seen = new Set();
+ const live = Array.isArray(opts.liveModels) ? opts.liveModels : null;
+ if (live?.length) {
+ return itemsFromProviderModels(live, CURSOR_ALIAS, seen);
+ }
+ return itemsFromProviderModels(getProviderModels(CURSOR_ALIAS), CURSOR_ALIAS, seen);
+}
+
+/**
+ * Build Claude default presets from cc registry + Claude Code aliases.
+ * @returns {{ name: string, models: string[] }[]}
+ */
+export function buildClaudePresetItems() {
+ const seen = new Set();
+ const out = itemsFromProviderModels(getProviderModels(CLAUDE_ALIAS), CLAUDE_ALIAS, seen);
+
+ const claudeTool = CLI_TOOLS.claude || {};
+ for (const entry of claudeTool.defaultModels || []) {
+ const name = entry.alias || entry.id;
+ const target = entry.defaultValue;
+ if (!name || !target) continue;
+ const item = pushItem(name, [target], seen);
+ if (item) out.push(item);
+ }
+
+ for (const alias of claudeTool.modelAliases || []) {
+ if (seen.has(alias)) continue;
+ const target = CLAUDE_EXTRA_ALIAS_TARGETS[alias];
+ if (!target) continue;
+ const item = pushItem(alias, [target], seen);
+ if (item) out.push(item);
+ }
+
+ return out;
+}
+
+/**
+ * @param {"cursor"|"claude"} source
+ * @param {{ liveModels?: Array<{id: string}|string>, existingNames?: Iterable }} [opts]
+ * @returns {{ name: string, models: string[], exists: boolean }[]}
+ */
+export function buildPresetItems(source, opts = {}) {
+ if (!PRESET_SOURCES.has(source)) return [];
+
+ const items = source === "cursor"
+ ? buildCursorPresetItems({ liveModels: opts.liveModels })
+ : buildClaudePresetItems();
+
+ const existing = new Set(opts.existingNames || []);
+ return items.map((item) => ({
+ ...item,
+ exists: existing.has(item.name),
+ }));
+}
diff --git a/tests/unit/combo-presets.test.js b/tests/unit/combo-presets.test.js
new file mode 100644
index 00000000..e5a06554
--- /dev/null
+++ b/tests/unit/combo-presets.test.js
@@ -0,0 +1,102 @@
+import { describe, it, expect } from "vitest";
+import {
+ buildPresetItems,
+ buildCursorPresetItems,
+ buildClaudePresetItems,
+ isValidComboPresetName,
+} from "../../src/lib/comboPresets.js";
+
+describe("combo presets", () => {
+ it("rejects combo names with slashes or invalid chars", () => {
+ expect(isValidComboPresetName("composer-2.5")).toBe(true);
+ expect(isValidComboPresetName("claude-opus-5")).toBe(true);
+ expect(isValidComboPresetName("cu/composer-2.5")).toBe(false);
+ expect(isValidComboPresetName("bad name")).toBe(false);
+ expect(isValidComboPresetName("")).toBe(false);
+ });
+
+ it("Cursor live ids become unprefixed names seeded with cu/…", () => {
+ const items = buildCursorPresetItems({
+ liveModels: [
+ { id: "composer-2.5", name: "Composer 2.5" },
+ { id: "cursor-grok-4.6-high-fast", name: "Grok" },
+ { id: "bad/with-slash", name: "Invalid" },
+ ],
+ });
+
+ expect(items).toEqual([
+ { name: "composer-2.5", models: ["cu/composer-2.5"] },
+ { name: "cursor-grok-4.6-high-fast", models: ["cu/cursor-grok-4.6-high-fast"] },
+ ]);
+ });
+
+ it("Cursor falls back to static cu registry when live catalog is empty", () => {
+ const items = buildCursorPresetItems({ liveModels: [] });
+ expect(items.length).toBeGreaterThan(0);
+ expect(items.every((i) => i.models[0].startsWith("cu/"))).toBe(true);
+ expect(items.some((i) => i.name === "default")).toBe(true);
+ // No slash in combo name
+ expect(items.every((i) => !i.name.includes("/"))).toBe(true);
+ });
+
+ it("Claude aliases map opus → cc/claude-opus-5 and registry models seed cc/…", () => {
+ const items = buildClaudePresetItems();
+ const byName = Object.fromEntries(items.map((i) => [i.name, i]));
+
+ expect(byName["claude-opus-5"]).toEqual({
+ name: "claude-opus-5",
+ models: ["cc/claude-opus-5"],
+ });
+ expect(byName.opus).toEqual({
+ name: "opus",
+ models: ["cc/claude-opus-5"],
+ });
+ expect(byName.sonnet).toEqual({
+ name: "sonnet",
+ models: ["cc/claude-sonnet-5"],
+ });
+ expect(byName.haiku).toEqual({
+ name: "haiku",
+ models: ["cc/claude-haiku-4-5-20251001"],
+ });
+ expect(byName.fable).toEqual({
+ name: "fable",
+ models: ["cc/claude-fable-5"],
+ });
+ expect(byName.default).toEqual({
+ name: "default",
+ models: ["cc/claude-sonnet-5"],
+ });
+ expect(byName.opusplan).toEqual({
+ name: "opusplan",
+ models: ["cc/claude-opus-5"],
+ });
+ });
+
+ it("marks existing names with exists: true", () => {
+ const items = buildPresetItems("cursor", {
+ liveModels: [
+ { id: "composer-2.5" },
+ { id: "gpt-5.3-codex" },
+ ],
+ existingNames: ["composer-2.5"],
+ });
+
+ expect(items).toEqual([
+ { name: "composer-2.5", models: ["cu/composer-2.5"], exists: true },
+ { name: "gpt-5.3-codex", models: ["cu/gpt-5.3-codex"], exists: false },
+ ]);
+ });
+
+ it("returns empty for unknown source", () => {
+ expect(buildPresetItems("unknown")).toEqual([]);
+ });
+
+ it("drops invalid names from Claude/Cursor catalogs", () => {
+ const cursor = buildPresetItems("cursor", {
+ liveModels: [{ id: "ok-model" }, { id: "no/slash" }, { id: "has space" }],
+ existingNames: [],
+ });
+ expect(cursor.map((i) => i.name)).toEqual(["ok-model"]);
+ });
+});