feat(models): capability toggles for custom models with upsert and live caps refresh
- AddCustomModelModal lets users pick vision/reasoning caps when adding a model - POST /api/models/custom whitelists caps to booleans - aliasRepo.addCustomModel upserts — re-adding updates caps/name in place - /api/models includes custom llm models with stored caps overriding the heuristic - useModelCaps refetches on customModelChanged instead of trusting a stale cache Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -2,17 +2,21 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import PropTypes from "prop-types";
|
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 }) {
|
export default function AddCustomModelModal({ isOpen, providerAlias, providerDisplayAlias, onSave, onClose }) {
|
||||||
const [modelId, setModelId] = useState("");
|
const [modelId, setModelId] = useState("");
|
||||||
|
const [caps, setCaps] = useState(defaultCaps);
|
||||||
const [testStatus, setTestStatus] = useState(null); // null | "testing" | "ok" | "error"
|
const [testStatus, setTestStatus] = useState(null); // null | "testing" | "ok" | "error"
|
||||||
const [testError, setTestError] = useState("");
|
const [testError, setTestError] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
// Reset state when modal opens
|
// Reset state when modal opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) { setModelId(""); setTestStatus(null); setTestError(""); }
|
if (isOpen) { setModelId(""); setCaps(defaultCaps()); setTestStatus(null); setTestError(""); }
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
// Strip provider's own alias prefix (e.g. "cc/model" -> "model" for cc provider)
|
// 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;
|
if (!cleanId || saving) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await onSave(cleanId);
|
await onSave(cleanId, caps);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -86,6 +90,22 @@ export default function AddCustomModelModal({ isOpen, providerAlias, providerDis
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium mb-1.5 block">Capabilities</label>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
{Object.entries(CAPACITY_META).map(([key, meta]) => (
|
||||||
|
<Toggle
|
||||||
|
key={key}
|
||||||
|
checked={!!caps[key]}
|
||||||
|
onChange={(v) => setCaps((prev) => ({ ...prev, [key]: v }))}
|
||||||
|
label={meta.label}
|
||||||
|
description={meta.desc}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Test result */}
|
{/* Test result */}
|
||||||
{testStatus === "ok" && (
|
{testStatus === "ok" && (
|
||||||
<div className="flex items-center gap-2 text-sm text-green-600">
|
<div className="flex items-center gap-2 text-sm text-green-600">
|
||||||
|
|||||||
@@ -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 {
|
try {
|
||||||
const res = await fetch("/api/models/custom", {
|
const res = await fetch("/api/models/custom", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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) {
|
if (res.ok) {
|
||||||
await fetchCustomModels();
|
await fetchCustomModels();
|
||||||
@@ -1781,8 +1781,8 @@ export default function ProviderDetailPage() {
|
|||||||
isOpen={showAddCustomModel}
|
isOpen={showAddCustomModel}
|
||||||
providerAlias={providerStorageAlias}
|
providerAlias={providerStorageAlias}
|
||||||
providerDisplayAlias={providerDisplayAlias}
|
providerDisplayAlias={providerDisplayAlias}
|
||||||
onSave={async (modelId) => {
|
onSave={async (modelId, caps) => {
|
||||||
await handleAddCustomModel(modelId, "llm", providerStorageAlias);
|
await handleAddCustomModel(modelId, "llm", providerStorageAlias, caps);
|
||||||
setShowAddCustomModel(false);
|
setShowAddCustomModel(false);
|
||||||
}}
|
}}
|
||||||
onClose={() => setShowAddCustomModel(false)}
|
onClose={() => setShowAddCustomModel(false)}
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getCustomModels, addCustomModel, deleteCustomModel } from "@/models";
|
import { getCustomModels, addCustomModel, deleteCustomModel } from "@/models";
|
||||||
|
import { CAPACITY_META } from "@/shared/constants/models";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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
|
// GET /api/models/custom - List all custom models
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@@ -17,11 +28,12 @@ export async function GET() {
|
|||||||
// POST /api/models/custom - Add custom model
|
// POST /api/models/custom - Add custom model
|
||||||
export async function POST(request) {
|
export async function POST(request) {
|
||||||
try {
|
try {
|
||||||
const { providerAlias, id, type, name } = await request.json();
|
const { providerAlias, id, type, name, caps } = await request.json();
|
||||||
if (!providerAlias || !id) {
|
if (!providerAlias || !id) {
|
||||||
return NextResponse.json({ error: "providerAlias and id required" }, { status: 400 });
|
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 });
|
return NextResponse.json({ success: true, added });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Error adding custom model:", error);
|
console.log("Error adding custom model:", error);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getModelAliases, setModelAlias } from "@/models";
|
import { getModelAliases, setModelAlias, getCustomModels } from "@/models";
|
||||||
import { getDisabledModels } from "@/lib/disabledModelsDb";
|
import { getDisabledModels } from "@/lib/disabledModelsDb";
|
||||||
import { AI_MODELS } from "@/shared/constants/config";
|
import { AI_MODELS } from "@/shared/constants/config";
|
||||||
import { getProviderAlias } from "@/shared/constants/providers";
|
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 });
|
return NextResponse.json({ models });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Error fetching models:", error);
|
console.log("Error fetching models:", error);
|
||||||
|
|||||||
@@ -29,15 +29,21 @@ export async function getCustomModels() {
|
|||||||
return Object.values(all);
|
return Object.values(all);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atomic check-then-insert inside transaction to prevent duplicate races
|
// Atomic upsert inside transaction to prevent duplicate races.
|
||||||
export async function addCustomModel({ providerAlias, id, type = "llm", name }) {
|
// 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 k = customKey(providerAlias, id, type);
|
||||||
const db = await getAdapter();
|
const db = await getAdapter();
|
||||||
let added = false;
|
let added = false;
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
const row = db.get(`SELECT 1 FROM kv WHERE scope = 'customModels' AND key = ?`, [k]);
|
const row = db.get(`SELECT value FROM kv WHERE scope = 'customModels' AND key = ?`, [k]);
|
||||||
if (row) return;
|
if (row) {
|
||||||
const value = stringifyJson({ providerAlias, id, type, name: name || id });
|
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]);
|
db.run(`INSERT INTO kv(scope, key, value) VALUES('customModels', ?, ?)`, [k, value]);
|
||||||
added = true;
|
added = true;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -59,16 +59,25 @@ export function useModelCaps() {
|
|||||||
const [byId, setById] = useState(() => cache?.byId || {});
|
const [byId, setById] = useState(() => cache?.byId || {});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (cache) {
|
|
||||||
setByFull(cache.byFull);
|
|
||||||
setById(cache.byId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let alive = true;
|
let alive = true;
|
||||||
loadModelCaps().then((maps) => {
|
const sync = (maps) => {
|
||||||
if (alive) { setByFull(maps.byFull); setById(maps.byId); }
|
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(
|
const getCaps = useCallback(
|
||||||
|
|||||||
Reference in New Issue
Block a user