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(