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:
decolua
2026-09-01 10:45:51 +07:00
parent 90b52e06ff
commit 38f031f4c9
6 changed files with 97 additions and 23 deletions

View File

@@ -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
</p>
</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 */}
{testStatus === "ok" && (
<div className="flex items-center gap-2 text-sm text-green-600">

View File

@@ -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)}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;
});

View File

@@ -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(