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 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">
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user