diff --git a/src/app/(dashboard)/dashboard/providers/[id]/AddCustomModelModal.js b/src/app/(dashboard)/dashboard/providers/[id]/AddCustomModelModal.js index d0bf1659..3c9b21d0 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/AddCustomModelModal.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/AddCustomModelModal.js @@ -2,17 +2,21 @@ import { useState, useEffect } from "react"; import PropTypes from "prop-types"; -import { Button, Modal } from "@/shared/components"; +import { Button, Modal, Toggle } from "@/shared/components"; +import { CAPACITY_META } from "@/shared/constants/models"; + +const defaultCaps = () => Object.fromEntries(Object.keys(CAPACITY_META).map((key) => [key, false])); export default function AddCustomModelModal({ isOpen, providerAlias, providerDisplayAlias, onSave, onClose }) { const [modelId, setModelId] = useState(""); + const [caps, setCaps] = useState(defaultCaps); const [testStatus, setTestStatus] = useState(null); // null | "testing" | "ok" | "error" const [testError, setTestError] = useState(""); const [saving, setSaving] = useState(false); // Reset state when modal opens useEffect(() => { - if (isOpen) { setModelId(""); setTestStatus(null); setTestError(""); } + if (isOpen) { setModelId(""); setCaps(defaultCaps()); setTestStatus(null); setTestError(""); } }, [isOpen]); // Strip provider's own alias prefix (e.g. "cc/model" -> "model" for cc provider) @@ -46,7 +50,7 @@ export default function AddCustomModelModal({ isOpen, providerAlias, providerDis if (!cleanId || saving) return; setSaving(true); try { - await onSave(cleanId); + await onSave(cleanId, caps); } finally { setSaving(false); } @@ -86,6 +90,22 @@ export default function AddCustomModelModal({ isOpen, providerAlias, providerDis

+
+ +
+ {Object.entries(CAPACITY_META).map(([key, meta]) => ( + setCaps((prev) => ({ ...prev, [key]: v }))} + label={meta.label} + description={meta.desc} + size="sm" + /> + ))} +
+
+ {/* Test result */} {testStatus === "ok" && (
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index 4b4ad014..b7c802c0 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -527,12 +527,12 @@ export default function ProviderDetailPage() { } }; - const handleAddCustomModel = async (modelId, type = "llm", providerAliasOverride = providerStorageAlias) => { + const handleAddCustomModel = async (modelId, type = "llm", providerAliasOverride = providerStorageAlias, caps) => { try { const res = await fetch("/api/models/custom", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ providerAlias: providerAliasOverride, id: modelId, type }), + body: JSON.stringify({ providerAlias: providerAliasOverride, id: modelId, type, ...(caps ? { caps } : {}) }), }); if (res.ok) { await fetchCustomModels(); @@ -1781,8 +1781,8 @@ export default function ProviderDetailPage() { isOpen={showAddCustomModel} providerAlias={providerStorageAlias} providerDisplayAlias={providerDisplayAlias} - onSave={async (modelId) => { - await handleAddCustomModel(modelId, "llm", providerStorageAlias); + onSave={async (modelId, caps) => { + await handleAddCustomModel(modelId, "llm", providerStorageAlias, caps); setShowAddCustomModel(false); }} onClose={() => setShowAddCustomModel(false)} diff --git a/src/app/api/models/custom/route.js b/src/app/api/models/custom/route.js index 76e14c96..ce917d76 100644 --- a/src/app/api/models/custom/route.js +++ b/src/app/api/models/custom/route.js @@ -1,8 +1,19 @@ import { NextResponse } from "next/server"; import { getCustomModels, addCustomModel, deleteCustomModel } from "@/models"; +import { CAPACITY_META } from "@/shared/constants/models"; export const dynamic = "force-dynamic"; +// Whitelist capability keys to boolean values — ignore anything else +function sanitizeCaps(caps) { + if (!caps || typeof caps !== "object") return null; + const clean = {}; + for (const key of Object.keys(CAPACITY_META)) { + if (typeof caps[key] === "boolean") clean[key] = caps[key]; + } + return Object.keys(clean).length ? clean : null; +} + // GET /api/models/custom - List all custom models export async function GET() { try { @@ -17,11 +28,12 @@ export async function GET() { // POST /api/models/custom - Add custom model export async function POST(request) { try { - const { providerAlias, id, type, name } = await request.json(); + const { providerAlias, id, type, name, caps } = await request.json(); if (!providerAlias || !id) { return NextResponse.json({ error: "providerAlias and id required" }, { status: 400 }); } - const added = await addCustomModel({ providerAlias, id, type: type || "llm", name }); + const cleanCaps = sanitizeCaps(caps); + const added = await addCustomModel({ providerAlias, id, type: type || "llm", name, ...(cleanCaps ? { caps: cleanCaps } : {}) }); return NextResponse.json({ success: true, added }); } catch (error) { console.log("Error adding custom model:", error); diff --git a/src/app/api/models/route.js b/src/app/api/models/route.js index b2e4fe0a..ce5619f9 100644 --- a/src/app/api/models/route.js +++ b/src/app/api/models/route.js @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { getModelAliases, setModelAlias } from "@/models"; +import { getModelAliases, setModelAlias, getCustomModels } from "@/models"; import { getDisabledModels } from "@/lib/disabledModelsDb"; import { AI_MODELS } from "@/shared/constants/config"; import { getProviderAlias } from "@/shared/constants/providers"; @@ -37,6 +37,33 @@ export async function GET() { }; }); + // Custom models ride along; their stored caps override the name heuristic + const seenFull = new Set(models.map((m) => m.fullModel)); + const customModels = (await getCustomModels()).filter((m) => { + if (!m?.id || (m.kind || m.type || "llm") !== "llm") return false; + return !seenFull.has(`${m.providerAlias}/${m.id}`); + }); + for (const m of customModels) { + const fullModel = `${m.providerAlias}/${m.id}`; + const c = getCapabilitiesForModel(m.providerAlias, m.id); + models.push({ + provider: m.providerAlias, + model: m.id, + name: m.name || m.id, + fullModel, + routedModel: fullModel, + alias: modelAliases[fullModel] || m.id, + caps: { + vision: c.vision, + search: c.search, + reasoning: c.reasoning, + contextWindow: c.contextWindow, + maxOutput: c.maxOutput, + ...(m.caps || {}), + }, + }); + } + return NextResponse.json({ models }); } catch (error) { console.log("Error fetching models:", error); diff --git a/src/lib/db/repos/aliasRepo.js b/src/lib/db/repos/aliasRepo.js index fe4ef22f..3cfafa83 100644 --- a/src/lib/db/repos/aliasRepo.js +++ b/src/lib/db/repos/aliasRepo.js @@ -29,15 +29,21 @@ export async function getCustomModels() { return Object.values(all); } -// Atomic check-then-insert inside transaction to prevent duplicate races -export async function addCustomModel({ providerAlias, id, type = "llm", name }) { +// Atomic upsert inside transaction to prevent duplicate races. +// Re-adding an existing model updates caps/name without resetting omitted fields. +export async function addCustomModel({ providerAlias, id, type = "llm", name, caps }) { const k = customKey(providerAlias, id, type); const db = await getAdapter(); let added = false; db.transaction(() => { - const row = db.get(`SELECT 1 FROM kv WHERE scope = 'customModels' AND key = ?`, [k]); - if (row) return; - const value = stringifyJson({ providerAlias, id, type, name: name || id }); + const row = db.get(`SELECT value FROM kv WHERE scope = 'customModels' AND key = ?`, [k]); + if (row) { + const prev = parseJson(row.value) || {}; + const next = { ...prev, ...(name ? { name } : {}), ...(caps ? { caps } : {}) }; + db.run(`UPDATE kv SET value = ? WHERE scope = 'customModels' AND key = ?`, [stringifyJson(next), k]); + return; + } + const value = stringifyJson({ providerAlias, id, type, name: name || id, ...(caps ? { caps } : {}) }); db.run(`INSERT INTO kv(scope, key, value) VALUES('customModels', ?, ?)`, [k, value]); added = true; }); diff --git a/src/shared/hooks/useModelCaps.js b/src/shared/hooks/useModelCaps.js index 92bcec21..88c6410f 100644 --- a/src/shared/hooks/useModelCaps.js +++ b/src/shared/hooks/useModelCaps.js @@ -59,16 +59,25 @@ export function useModelCaps() { const [byId, setById] = useState(() => cache?.byId || {}); useEffect(() => { - if (cache) { - setByFull(cache.byFull); - setById(cache.byId); - return; - } let alive = true; - loadModelCaps().then((maps) => { + const sync = (maps) => { if (alive) { setByFull(maps.byFull); setById(maps.byId); } - }); - return () => { alive = false; }; + }; + if (cache) { + sync(cache); + } else { + loadModelCaps().then(sync); + } + // Custom models change at runtime — drop the shared cache and refetch + const invalidate = () => { + cache = null; + loadModelCaps().then(sync); + }; + window.addEventListener("customModelChanged", invalidate); + return () => { + alive = false; + window.removeEventListener("customModelChanged", invalidate); + }; }, []); const getCaps = useCallback(