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. +

    +
    +
    + +
    + + +
    -
    {/* 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 */} +
    + + +
    + {someSelected && ( + <> +
    + 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"]); + }); +});