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 [message, setMessage] = useState(null);
const [selectedApiKey, setSelectedApiKey] = useState("");
const [selectedModel, setSelectedModel] = useState("");
const [modelList, setModelList] = useState([]);
const [modelInput, setModelInput] = useState("");
const [modalOpen, setModalOpen] = useState(false);
const [modelAliases, setModelAliases] = useState({});
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
@@ -32,7 +33,8 @@ export default function DroidToolCard({
const getConfigStatus = () => {
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";
const localMatch = currentConfig.baseUrl?.includes("localhost") || currentConfig.baseUrl?.includes("127.0.0.1");
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(() => {
if (droidStatus?.installed && !hasInitializedModel.current) {
hasInitializedModel.current = true;
const customModel = droidStatus.settings?.customModels?.find(m => m.id === "custom:9Router-0");
if (customModel) {
if (customModel.model) setSelectedModel(customModel.model);
if (customModel.apiKey && apiKeys?.some(k => k.key === customModel.apiKey)) {
setSelectedApiKey(customModel.apiKey);
const existingModels = (droidStatus.settings?.customModels || [])
.filter(m => m.id?.startsWith("custom:9Router"))
.sort((a, b) => (a.index || 0) - (b.index || 0))
.map(m => m.model);
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 () => {
setCheckingDroid(true);
@@ -107,21 +116,37 @@ export default function DroidToolCard({
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 () => {
setApplying(true);
setMessage(null);
try {
const keyToUse = selectedApiKey?.trim()
const keyToUse = selectedApiKey?.trim()
|| (apiKeys?.length > 0 ? apiKeys[0].key : null)
|| (!cloudEnabled ? "sk_9router" : null);
const res = await fetch("/api/cli-tools/droid-settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
baseUrl: getEffectiveBaseUrl(),
body: JSON.stringify({
baseUrl: getEffectiveBaseUrl(),
apiKey: keyToUse,
model: selectedModel
models: modelList,
activeModel: modelList[0] || "",
}),
});
const data = await res.json();
@@ -146,8 +171,7 @@ export default function DroidToolCard({
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings reset successfully!" });
setSelectedModel("");
setSelectedApiKey("");
setModelList([]);
checkDroidStatus();
} else {
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 keyToUse = (selectedApiKey && selectedApiKey.trim())
? selectedApiKey
const keyToUse = (selectedApiKey && selectedApiKey.trim())
? selectedApiKey
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
const settingsContent = {
customModels: [
{
model: selectedModel || "provider/model-id",
id: "custom:9Router-0",
index: 0,
baseUrl: getEffectiveBaseUrl(),
apiKey: keyToUse,
displayName: selectedModel || "provider/model-id",
maxOutputTokens: 131072,
noImageSupport: false,
provider: "openai",
},
],
customModels: modelList.map((m, i) => ({
model: m,
id: `custom:9Router-${i}`,
index: i,
baseUrl: getEffectiveBaseUrl(),
apiKey: keyToUse,
displayName: m,
maxOutputTokens: 131072,
noImageSupport: false,
provider: "openai",
})),
};
const platform = typeof navigator !== "undefined" && navigator.platform;
const isWindows = platform?.toLowerCase().includes("win");
const settingsPath = isWindows
const settingsPath = isWindows
? "%USERPROFILE%\\.factory\\settings.json"
: "~/.factory/settings.json";
@@ -242,12 +259,12 @@ export default function DroidToolCard({
<>
<div className="flex flex-col gap-2">
{/* 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">
<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="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>
</div>
)}
@@ -256,12 +273,12 @@ export default function DroidToolCard({
<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="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span>
<input
type="text"
value={getDisplayUrl()}
onChange={(e) => setCustomBaseUrl(e.target.value)}
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"
<input
type="text"
value={getDisplayUrl()}
onChange={(e) => setCustomBaseUrl(e.target.value)}
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"
/>
{customBaseUrl && customBaseUrl !== baseUrl && (
<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>
{/* Model */}
{/* Models */}
<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>
<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" />
<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>
{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>}
<div className="flex-1 flex flex-col gap-1">
{/* Model list */}
{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>
@@ -303,7 +355,7 @@ export default function DroidToolCard({
)}
<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
</Button>
<Button variant="outline" size="sm" onClick={handleResetSettings} disabled={!droidStatus?.has9Router} loading={restoring}>
@@ -322,7 +374,7 @@ export default function DroidToolCard({
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleModelSelect}
selectedModel={selectedModel}
selectedModel={null}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Factory Droid"
@@ -336,4 +388,4 @@ export default function DroidToolCard({
/>
</Card>
);
}
}

View File

@@ -47,7 +47,7 @@ const readSettings = async () => {
// Check if settings has 9Router customModels
const has9RouterConfig = (settings) => {
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
@@ -78,12 +78,17 @@ export async function GET() {
}
// 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) {
try {
const { baseUrl, apiKey, model } = await request.json();
const { baseUrl, apiKey, model, models, activeModel } = await request.json();
if (!baseUrl || !model) {
return NextResponse.json({ error: "baseUrl and model are required" }, { status: 400 });
// Accept either `models` (array) or `model` (string, legacy)
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();
@@ -104,26 +109,51 @@ export async function POST(request) {
settings.customModels = [];
}
// Remove existing 9Router config if any
settings.customModels = settings.customModels.filter(m => m.id !== "custom:9Router-0");
// Remove all existing 9Router configs
settings.customModels = settings.customModels.filter(m => !m.id?.startsWith("custom:9Router"));
// Normalize baseUrl to ensure /v1 suffix
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
const keyToUse = apiKey || "your_api_key";
// Add new 9Router config
const customModel = {
model: model,
id: "custom:9Router-0",
index: 0,
baseUrl: normalizedBaseUrl,
apiKey: apiKey || "your_api_key",
displayName: model,
maxOutputTokens: 131072,
noImageSupport: false,
provider: "openai",
};
// Determine active model: prefer explicit activeModel, else first of modelsArray
// If activeModel is explicitly empty string, no model will be set as default
let defaultIndex = 0;
if (typeof activeModel === "string") {
if (activeModel === "") {
defaultIndex = -1; // signal: don't set a default
} else {
const idx = modelsArray.indexOf(activeModel);
defaultIndex = idx >= 0 ? idx : 0;
}
}
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
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
@@ -161,7 +191,7 @@ export async function DELETE() {
// Remove 9Router 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
if (settings.customModels.length === 0) {
@@ -180,4 +210,4 @@ export async function DELETE() {
console.log("Error resetting droid settings:", error);
return NextResponse.json({ error: "Failed to reset droid settings" }, { status: 500 });
}
}
}