feat(combos): Cursor/Claude Default presets + bulk select/delete/strategy

- Add Cursor Default / Claude Default on Dashboard -> Combos to generate
  unprefixed combo names that match Cursor/Claude client model IDs,
  seeded with cu/... or cc/... so those clients can route through 9Router.
- Add multi-select bulk Delete and bulk Set strategy (Fallback / Round Robin / Fusion).
- Docs and unit tests for preset builder.
This commit is contained in:
Amir Seify
2026-09-21 19:49:13 +07:00
parent c933eefc27
commit 253199f16f
7 changed files with 666 additions and 44 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -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)

View File

@@ -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 (
<div className="flex flex-col gap-6">
@@ -196,7 +361,7 @@ export default function CombosPage() {
return (
<div className="flex min-w-0 flex-col gap-6 px-1 sm:px-0">
{/* Header */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<p className="text-sm text-text-muted mt-1">
Group models under one name, then pick a strategy per combo:
@@ -206,10 +371,40 @@ export default function CombosPage() {
<li><span className="font-medium text-text-main">Round Robin</span> — rotates models across requests to spread load</li>
<li><span className="font-medium text-text-main">Fusion</span> — 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)</li>
</ul>
<p className="text-xs text-text-muted mt-3 max-w-2xl">
<span className="font-medium text-text-main">Cursor / Claude Default</span> create combos named exactly like those clients&apos; model IDs (e.g. <code className="font-mono">composer-2.5</code>, <code className="font-mono">opus</code>), seeded with the matching <code className="font-mono">cu/…</code> or <code className="font-mono">cc/…</code> route so traffic can hit 9router without the prefix.
{" "}Note: Cursor IDE itself often blocks built-in Composer / Grok from Override OpenAI Base URL (&quot;model does not support custom API&quot;); add them via Cursor&apos;s <span className="font-medium text-text-main">Add Custom Model</span> using the combo name, or pick a model Cursor allows through the custom endpoint.
</p>
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:items-stretch">
<Button icon="add" onClick={() => setShowCreateModal(true)} className="w-full sm:w-auto whitespace-nowrap">
Create Combo
</Button>
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-col">
<Button
variant="secondary"
size="sm"
icon="edit_note"
loading={presetLoading === "cursor"}
disabled={!!presetLoading}
onClick={() => handleGeneratePresets("cursor")}
className="w-full whitespace-nowrap"
>
Cursor Default
</Button>
<Button
variant="secondary"
size="sm"
icon="smart_toy"
loading={presetLoading === "claude"}
disabled={!!presetLoading}
onClick={() => handleGeneratePresets("claude")}
className="w-full whitespace-nowrap"
>
Claude Default
</Button>
</div>
</div>
<Button icon="add" onClick={() => setShowCreateModal(true)} className="w-full sm:w-auto whitespace-nowrap">
Create Combo
</Button>
</div>
{/* Combos List */}
@@ -227,25 +422,88 @@ export default function CombosPage() {
</div>
</Card>
) : (
<div className="flex flex-col gap-4">
{(() => {
const comboByName = Object.fromEntries(combos.map((c) => [c.name, c.models]));
return combos.map((combo) => (
<ComboCard
key={combo.id}
combo={combo}
getCaps={getCaps}
comboByName={comboByName}
activeProviders={activeProviders}
copied={copied}
onCopy={copy}
onEdit={() => setEditingCombo(combo)}
onDelete={() => handleDelete(combo.id)}
strategy={comboStrategies[combo.name] || {}}
onSetStrategy={(patch) => handleSetComboStrategy(combo.name, patch)}
<div className="flex flex-col gap-3">
{/* Selection toolbar */}
<div className="flex min-w-0 flex-col gap-2 rounded-lg border border-black/5 bg-black/[0.015] px-3 py-2 dark:border-white/5 dark:bg-white/[0.02] sm:flex-row sm:items-center sm:justify-between">
<label className="flex cursor-pointer items-center gap-2 text-xs text-text-muted hover:text-primary select-none">
<input
type="checkbox"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = someSelected && !allSelected;
}}
onChange={toggleSelectAll}
className="h-3.5 w-3.5 rounded border-gray-300 text-primary focus:ring-primary"
/>
));
})()}
<span>
{someSelected
? `${selectedIds.length} selected`
: `Select all (${combos.length})`}
</span>
</label>
<div className="flex min-w-0 flex-wrap items-center gap-2">
{someSelected && (
<>
<div className="w-full min-w-[160px] sm:w-[200px]">
<Select
options={STRATEGY_OPTIONS}
value=""
placeholder="Set strategy…"
disabled={bulkBusy}
onChange={(e) => {
const v = e.target.value;
if (v) handleBulkSetStrategy(v);
}}
selectClassName="py-1.5 text-xs"
/>
</div>
<Button
size="sm"
variant="danger"
icon="delete"
disabled={bulkBusy}
loading={bulkBusy}
onClick={handleBulkDelete}
className="whitespace-nowrap"
>
Delete ({selectedIds.length})
</Button>
<Button
size="sm"
variant="ghost"
onClick={clearSelection}
disabled={bulkBusy}
>
Clear
</Button>
</>
)}
</div>
</div>
<div className="flex flex-col gap-3">
{(() => {
const comboByName = Object.fromEntries(combos.map((c) => [c.name, c.models]));
return combos.map((combo) => (
<ComboCard
key={combo.id}
combo={combo}
getCaps={getCaps}
comboByName={comboByName}
activeProviders={activeProviders}
copied={copied}
onCopy={copy}
onEdit={() => 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)}
/>
));
})()}
</div>
</div>
)}
@@ -279,14 +537,16 @@ export default function CombosPage() {
/>
)}
{/* Confirm Delete Modal */}
{/* Confirm (delete / generate presets) */}
<ConfirmModal
isOpen={!!confirmState}
onClose={() => 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}
/>
</div>
);
@@ -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 (
<Card padding="sm" className="group">
<Card padding="sm" className={`group ${selected ? "ring-1 ring-primary/40 bg-primary/[0.03]" : ""}`}>
<div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 flex-1 items-start gap-3 sm:items-center">
<label className="flex shrink-0 items-center pt-1 sm:pt-0 cursor-pointer" title="Select combo">
<input
type="checkbox"
checked={selected}
onChange={onToggleSelect}
onClick={(e) => e.stopPropagation()}
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
aria-label={`Select ${combo.name}`}
/>
</label>
<div className="size-8 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
<span className="material-symbols-outlined text-primary text-[18px]">layers</span>
</div>

View File

@@ -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<Array<{id: string, name?: string}>|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 });
}
}

121
src/lib/comboPresets.js Normal file
View File

@@ -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<string>} [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<string>} 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<string> }} [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),
}));
}

View File

@@ -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"]);
});
});