fix: add multi-model support for Factory Droid CLI tool (closes #521) (#618)

This commit is contained in:
Anurag Saxena
2026-04-17 01:04:46 -04:00
committed by GitHub
parent 63dbf89207
commit 1d872ce254
2 changed files with 154 additions and 72 deletions

View File

@@ -23,7 +23,8 @@ export default function DroidToolCard({
const [restoring, setRestoring] = useState(false); const [restoring, setRestoring] = useState(false);
const [message, setMessage] = useState(null); const [message, setMessage] = useState(null);
const [selectedApiKey, setSelectedApiKey] = useState(""); const [selectedApiKey, setSelectedApiKey] = useState("");
const [selectedModel, setSelectedModel] = useState(""); const [modelList, setModelList] = useState([]);
const [modelInput, setModelInput] = useState("");
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [modelAliases, setModelAliases] = useState({}); const [modelAliases, setModelAliases] = useState({});
const [showManualConfigModal, setShowManualConfigModal] = useState(false); const [showManualConfigModal, setShowManualConfigModal] = useState(false);
@@ -32,7 +33,8 @@ export default function DroidToolCard({
const getConfigStatus = () => { const getConfigStatus = () => {
if (!droidStatus?.installed) return null; if (!droidStatus?.installed) return null;
const currentConfig = droidStatus.settings?.customModels?.find(m => m.id === "custom:9Router-0"); // Check for any 9Router model entry (support multi-model: custom:9Router-0, custom:9Router-1, ...)
const currentConfig = droidStatus.settings?.customModels?.find(m => m.id?.startsWith("custom:9Router"));
if (!currentConfig) return "not_configured"; if (!currentConfig) return "not_configured";
const localMatch = currentConfig.baseUrl?.includes("localhost") || currentConfig.baseUrl?.includes("127.0.0.1"); const localMatch = currentConfig.baseUrl?.includes("localhost") || currentConfig.baseUrl?.includes("127.0.0.1");
const cloudMatch = cloudEnabled && CLOUD_URL && currentConfig.baseUrl?.startsWith(CLOUD_URL); const cloudMatch = cloudEnabled && CLOUD_URL && currentConfig.baseUrl?.startsWith(CLOUD_URL);
@@ -71,18 +73,25 @@ export default function DroidToolCard({
} }
}; };
// Pre-fill model list from existing config (supports multi-model)
useEffect(() => { useEffect(() => {
if (droidStatus?.installed && !hasInitializedModel.current) { if (droidStatus?.installed && !hasInitializedModel.current) {
hasInitializedModel.current = true; hasInitializedModel.current = true;
const customModel = droidStatus.settings?.customModels?.find(m => m.id === "custom:9Router-0"); const existingModels = (droidStatus.settings?.customModels || [])
if (customModel) { .filter(m => m.id?.startsWith("custom:9Router"))
if (customModel.model) setSelectedModel(customModel.model); .sort((a, b) => (a.index || 0) - (b.index || 0))
if (customModel.apiKey && apiKeys?.some(k => k.key === customModel.apiKey)) { .map(m => m.model);
setSelectedApiKey(customModel.apiKey); if (existingModels.length > 0) {
setModelList(existingModels);
} else {
// Legacy: single model stored as custom:9Router-0
const legacy = droidStatus.settings?.customModels?.find(m => m.id === "custom:9Router-0");
if (legacy?.model) {
setModelList([legacy.model]);
} }
} }
} }
}, [droidStatus, apiKeys]); }, [droidStatus]);
const checkDroidStatus = async () => { const checkDroidStatus = async () => {
setCheckingDroid(true); setCheckingDroid(true);
@@ -107,21 +116,37 @@ export default function DroidToolCard({
return url.endsWith("/v1") ? url : `${url}/v1`; return url.endsWith("/v1") ? url : `${url}/v1`;
}; };
const addModel = () => {
const val = modelInput.trim();
if (!val || modelList.includes(val)) return;
setModelList((prev) => [...prev, val]);
setModelInput("");
};
const removeModel = (id) => setModelList((prev) => prev.filter((m) => m !== id));
const handleModelSelect = (model) => {
if (!model.value || modelList.includes(model.value)) return;
setModelList((prev) => [...prev, model.value]);
setModalOpen(false);
};
const handleApplySettings = async () => { const handleApplySettings = async () => {
setApplying(true); setApplying(true);
setMessage(null); setMessage(null);
try { try {
const keyToUse = selectedApiKey?.trim() const keyToUse = selectedApiKey?.trim()
|| (apiKeys?.length > 0 ? apiKeys[0].key : null) || (apiKeys?.length > 0 ? apiKeys[0].key : null)
|| (!cloudEnabled ? "sk_9router" : null); || (!cloudEnabled ? "sk_9router" : null);
const res = await fetch("/api/cli-tools/droid-settings", { const res = await fetch("/api/cli-tools/droid-settings", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
baseUrl: getEffectiveBaseUrl(), baseUrl: getEffectiveBaseUrl(),
apiKey: keyToUse, apiKey: keyToUse,
model: selectedModel models: modelList,
activeModel: modelList[0] || "",
}), }),
}); });
const data = await res.json(); const data = await res.json();
@@ -146,8 +171,7 @@ export default function DroidToolCard({
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
setMessage({ type: "success", text: "Settings reset successfully!" }); setMessage({ type: "success", text: "Settings reset successfully!" });
setSelectedModel(""); setModelList([]);
setSelectedApiKey("");
checkDroidStatus(); checkDroidStatus();
} else { } else {
setMessage({ type: "error", text: data.error || "Failed to reset settings" }); setMessage({ type: "error", text: data.error || "Failed to reset settings" });
@@ -159,35 +183,28 @@ export default function DroidToolCard({
} }
}; };
const handleModelSelect = (model) => {
setSelectedModel(model.value);
setModalOpen(false);
};
const getManualConfigs = () => { const getManualConfigs = () => {
const keyToUse = (selectedApiKey && selectedApiKey.trim()) const keyToUse = (selectedApiKey && selectedApiKey.trim())
? selectedApiKey ? selectedApiKey
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>"); : (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
const settingsContent = { const settingsContent = {
customModels: [ customModels: modelList.map((m, i) => ({
{ model: m,
model: selectedModel || "provider/model-id", id: `custom:9Router-${i}`,
id: "custom:9Router-0", index: i,
index: 0, baseUrl: getEffectiveBaseUrl(),
baseUrl: getEffectiveBaseUrl(), apiKey: keyToUse,
apiKey: keyToUse, displayName: m,
displayName: selectedModel || "provider/model-id", maxOutputTokens: 131072,
maxOutputTokens: 131072, noImageSupport: false,
noImageSupport: false, provider: "openai",
provider: "openai", })),
},
],
}; };
const platform = typeof navigator !== "undefined" && navigator.platform; const platform = typeof navigator !== "undefined" && navigator.platform;
const isWindows = platform?.toLowerCase().includes("win"); const isWindows = platform?.toLowerCase().includes("win");
const settingsPath = isWindows const settingsPath = isWindows
? "%USERPROFILE%\\.factory\\settings.json" ? "%USERPROFILE%\\.factory\\settings.json"
: "~/.factory/settings.json"; : "~/.factory/settings.json";
@@ -242,12 +259,12 @@ export default function DroidToolCard({
<> <>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{/* Current Base URL */} {/* Current Base URL */}
{droidStatus?.settings?.customModels?.find(m => m.id === "custom:9Router-0")?.baseUrl && ( {droidStatus?.settings?.customModels?.find(m => m.id?.startsWith("custom:9Router"))?.baseUrl && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">Current</span> <span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">Current</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span> <span className="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span>
<span className="flex-1 px-2 py-1.5 text-xs text-text-muted truncate"> <span className="flex-1 px-2 py-1.5 text-xs text-text-muted truncate">
{droidStatus.settings.customModels.find(m => m.id === "custom:9Router-0").baseUrl} {droidStatus.settings.customModels.find(m => m.id?.startsWith("custom:9Router")).baseUrl}
</span> </span>
</div> </div>
)} )}
@@ -256,12 +273,12 @@ export default function DroidToolCard({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">Base URL</span> <span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">Base URL</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span> <span className="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span>
<input <input
type="text" type="text"
value={getDisplayUrl()} value={getDisplayUrl()}
onChange={(e) => setCustomBaseUrl(e.target.value)} onChange={(e) => setCustomBaseUrl(e.target.value)}
placeholder="https://.../v1" placeholder="https://.../v1"
className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
/> />
{customBaseUrl && customBaseUrl !== baseUrl && ( {customBaseUrl && customBaseUrl !== baseUrl && (
<button onClick={() => setCustomBaseUrl("")} className="p-1 text-text-muted hover:text-primary rounded transition-colors" title="Reset to default"> <button onClick={() => setCustomBaseUrl("")} className="p-1 text-text-muted hover:text-primary rounded transition-colors" title="Reset to default">
@@ -285,13 +302,48 @@ export default function DroidToolCard({
)} )}
</div> </div>
{/* Model */} {/* Models */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">Model</span> <span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">
Models {modelList.length > 0 && <span className="text-primary">({modelList.length})</span>}
</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span> <span className="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span>
<input type="text" value={selectedModel} onChange={(e) => setSelectedModel(e.target.value)} placeholder="provider/model-id" className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" /> <div className="flex-1 flex flex-col gap-1">
<button onClick={() => setModalOpen(true)} disabled={!hasActiveProviders} className={`px-2 py-1.5 rounded border text-xs transition-colors shrink-0 whitespace-nowrap ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select Model</button> {/* Model list */}
{selectedModel && <button onClick={() => setSelectedModel("")} className="p-1 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>} {modelList.length > 0 && (
<div className="flex flex-col gap-0.5 mb-1">
{modelList.map((id) => (
<div key={id} className="flex items-center gap-1.5 px-2 py-1 bg-bg-secondary rounded border border-border">
<span className="flex-1 text-xs font-mono truncate">{id}</span>
<button onClick={() => removeModel(id)} className="text-text-muted hover:text-red-500 transition-colors shrink-0" title="Remove">
<span className="material-symbols-outlined text-[12px]">close</span>
</button>
</div>
))}
</div>
)}
{/* Model input row */}
<div className="flex items-center gap-1.5">
<input
type="text"
value={modelInput}
onChange={(e) => setModelInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addModel(); } }}
placeholder="provider/model-id"
className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
<button
onClick={() => setModalOpen(true)}
disabled={!hasActiveProviders}
className={`px-2 py-1.5 rounded border text-xs shrink-0 ${hasActiveProviders ? "bg-surface border-border hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}
>
Select
</button>
<button onClick={addModel} disabled={!modelInput.trim()} className="px-2 py-1.5 rounded border bg-surface border-border hover:border-primary text-xs shrink-0 disabled:opacity-50" title="Add model">
<span className="material-symbols-outlined text-[14px]">add</span>
</button>
</div>
</div>
</div> </div>
</div> </div>
@@ -303,7 +355,7 @@ export default function DroidToolCard({
)} )}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="primary" size="sm" onClick={handleApplySettings} disabled={!selectedModel} loading={applying}> <Button variant="primary" size="sm" onClick={handleApplySettings} disabled={modelList.length === 0} loading={applying}>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply <span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
</Button> </Button>
<Button variant="outline" size="sm" onClick={handleResetSettings} disabled={!droidStatus?.has9Router} loading={restoring}> <Button variant="outline" size="sm" onClick={handleResetSettings} disabled={!droidStatus?.has9Router} loading={restoring}>
@@ -322,7 +374,7 @@ export default function DroidToolCard({
isOpen={modalOpen} isOpen={modalOpen}
onClose={() => setModalOpen(false)} onClose={() => setModalOpen(false)}
onSelect={handleModelSelect} onSelect={handleModelSelect}
selectedModel={selectedModel} selectedModel={null}
activeProviders={activeProviders} activeProviders={activeProviders}
modelAliases={modelAliases} modelAliases={modelAliases}
title="Select Model for Factory Droid" title="Select Model for Factory Droid"
@@ -336,4 +388,4 @@ export default function DroidToolCard({
/> />
</Card> </Card>
); );
} }

View File

@@ -47,7 +47,7 @@ const readSettings = async () => {
// Check if settings has 9Router customModels // Check if settings has 9Router customModels
const has9RouterConfig = (settings) => { const has9RouterConfig = (settings) => {
if (!settings || !settings.customModels) return false; if (!settings || !settings.customModels) return false;
return settings.customModels.some(m => m.id === "custom:9Router-0"); return settings.customModels.some(m => m.id?.startsWith("custom:9Router"));
}; };
// GET - Check droid CLI and read current settings // GET - Check droid CLI and read current settings
@@ -78,12 +78,17 @@ export async function GET() {
} }
// POST - Update 9Router customModels (merge with existing settings) // POST - Update 9Router customModels (merge with existing settings)
// Accepts either `model` (string, legacy single-model) or `models` (array of strings, multi-model)
// Also accepts `activeModel` to set which model is active/primary
export async function POST(request) { export async function POST(request) {
try { try {
const { baseUrl, apiKey, model } = await request.json(); const { baseUrl, apiKey, model, models, activeModel } = await request.json();
if (!baseUrl || !model) { // Accept either `models` (array) or `model` (string, legacy)
return NextResponse.json({ error: "baseUrl and model are required" }, { status: 400 }); const modelsArray = Array.isArray(models) ? models.slice() : (typeof model === "string" ? [model] : []);
if (!baseUrl || modelsArray.length === 0) {
return NextResponse.json({ error: "baseUrl and at least one model are required" }, { status: 400 });
} }
const droidDir = getDroidDir(); const droidDir = getDroidDir();
@@ -104,26 +109,51 @@ export async function POST(request) {
settings.customModels = []; settings.customModels = [];
} }
// Remove existing 9Router config if any // Remove all existing 9Router configs
settings.customModels = settings.customModels.filter(m => m.id !== "custom:9Router-0"); settings.customModels = settings.customModels.filter(m => !m.id?.startsWith("custom:9Router"));
// Normalize baseUrl to ensure /v1 suffix // Normalize baseUrl to ensure /v1 suffix
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
const keyToUse = apiKey || "your_api_key";
// Add new 9Router config // Determine active model: prefer explicit activeModel, else first of modelsArray
const customModel = { // If activeModel is explicitly empty string, no model will be set as default
model: model, let defaultIndex = 0;
id: "custom:9Router-0", if (typeof activeModel === "string") {
index: 0, if (activeModel === "") {
baseUrl: normalizedBaseUrl, defaultIndex = -1; // signal: don't set a default
apiKey: apiKey || "your_api_key", } else {
displayName: model, const idx = modelsArray.indexOf(activeModel);
maxOutputTokens: 131072, defaultIndex = idx >= 0 ? idx : 0;
noImageSupport: false, }
provider: "openai", }
};
settings.customModels.unshift(customModel); // Add entries for all requested models
// The first one (index 0) will be the default if defaultIndex >= 0
for (let i = 0; i < modelsArray.length; i++) {
const m = modelsArray[i];
if (!m || typeof m !== "string") continue;
settings.customModels.push({
model: m,
id: `custom:9Router-${i}`,
index: i,
baseUrl: normalizedBaseUrl,
apiKey: keyToUse,
displayName: m,
maxOutputTokens: 131072,
noImageSupport: false,
provider: "openai",
});
}
// Set default model if applicable
if (defaultIndex >= 0 && settings.customModels[defaultIndex]) {
// Reorder so the default comes first
const [defaultEntry] = settings.customModels.splice(defaultIndex, 1);
settings.customModels.unshift({ ...defaultEntry, index: 0 });
// Re-index the rest
settings.customModels.forEach((m, i) => { m.index = i; });
}
// Write settings // Write settings
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2)); await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
@@ -161,7 +191,7 @@ export async function DELETE() {
// Remove 9Router customModels // Remove 9Router customModels
if (settings.customModels) { if (settings.customModels) {
settings.customModels = settings.customModels.filter(m => m.id !== "custom:9Router-0"); settings.customModels = settings.customModels.filter(m => !m.id?.startsWith("custom:9Router"));
// Remove customModels array if empty // Remove customModels array if empty
if (settings.customModels.length === 0) { if (settings.customModels.length === 0) {
@@ -180,4 +210,4 @@ export async function DELETE() {
console.log("Error resetting droid settings:", error); console.log("Error resetting droid settings:", error);
return NextResponse.json({ error: "Failed to reset droid settings" }, { status: 500 }); return NextResponse.json({ error: "Failed to reset droid settings" }, { status: 500 });
} }
} }