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:
BIN
.github/issue-assets/combo-defaults/cursor-model-list.png
vendored
Normal file
BIN
.github/issue-assets/combo-defaults/cursor-model-list.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
BIN
.github/issue-assets/combo-defaults/override-openai-base-url.png
vendored
Normal file
BIN
.github/issue-assets/combo-defaults/override-openai-base-url.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
@@ -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)
|
||||
|
||||
@@ -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' 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 ("model does not support custom API"); add them via Cursor'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>
|
||||
|
||||
110
src/app/api/combos/presets/route.js
Normal file
110
src/app/api/combos/presets/route.js
Normal 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
121
src/lib/comboPresets.js
Normal 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),
|
||||
}));
|
||||
}
|
||||
102
tests/unit/combo-presets.test.js
Normal file
102
tests/unit/combo-presets.test.js
Normal 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"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user