+ {/* Current Base URL */}
+ {codexStatus?.config && (() => {
+ const parsed = codexStatus.config.match(/base_url\s*=\s*"([^"]+)"/);
+ const currentBaseUrl = parsed ? parsed[1] : null;
+ return currentBaseUrl ? (
+
+ ) : null;
+ })()}
+
{/* Base URL */}
Base URL
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js
new file mode 100644
index 00000000..e2efbc78
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js
@@ -0,0 +1,332 @@
+"use client";
+
+import { useState, useEffect, useRef } from "react";
+import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
+import Image from "next/image";
+
+const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
+
+export default function DroidToolCard({
+ tool,
+ isExpanded,
+ onToggle,
+ baseUrl,
+ hasActiveProviders,
+ apiKeys,
+ activeProviders,
+ cloudEnabled,
+}) {
+ const [droidStatus, setDroidStatus] = useState(null);
+ const [checkingDroid, setCheckingDroid] = useState(false);
+ const [applying, setApplying] = useState(false);
+ const [restoring, setRestoring] = useState(false);
+ const [message, setMessage] = useState(null);
+ const [selectedApiKey, setSelectedApiKey] = useState("");
+ const [selectedModel, setSelectedModel] = useState("");
+ const [modalOpen, setModalOpen] = useState(false);
+ const [modelAliases, setModelAliases] = useState({});
+ const [showManualConfigModal, setShowManualConfigModal] = useState(false);
+ const [customBaseUrl, setCustomBaseUrl] = useState("");
+ const hasInitializedModel = useRef(false);
+
+ const getConfigStatus = () => {
+ if (!droidStatus?.installed) return null;
+ const currentConfig = droidStatus.settings?.customModels?.find(m => m.id === "custom:9Router-0");
+ 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);
+ if (localMatch || cloudMatch) return "configured";
+ return "other";
+ };
+
+ const configStatus = getConfigStatus();
+
+ useEffect(() => {
+ if (apiKeys?.length > 0 && !selectedApiKey) {
+ setSelectedApiKey(apiKeys[0].key);
+ }
+ }, [apiKeys, selectedApiKey]);
+
+ useEffect(() => {
+ if (isExpanded && !droidStatus) {
+ checkDroidStatus();
+ fetchModelAliases();
+ }
+ }, [isExpanded, droidStatus]);
+
+ const fetchModelAliases = async () => {
+ try {
+ const res = await fetch("/api/models/alias");
+ const data = await res.json();
+ if (res.ok) setModelAliases(data.aliases || {});
+ } catch (error) {
+ console.log("Error fetching model aliases:", error);
+ }
+ };
+
+ 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);
+ }
+ }
+ }
+ }, [droidStatus, apiKeys]);
+
+ const checkDroidStatus = async () => {
+ setCheckingDroid(true);
+ try {
+ const res = await fetch("/api/cli-tools/droid-settings");
+ const data = await res.json();
+ setDroidStatus(data);
+ } catch (error) {
+ setDroidStatus({ installed: false, error: error.message });
+ } finally {
+ setCheckingDroid(false);
+ }
+ };
+
+ const getEffectiveBaseUrl = () => {
+ const url = customBaseUrl || baseUrl;
+ return url.endsWith("/v1") ? url : `${url}/v1`;
+ };
+
+ const getDisplayUrl = () => {
+ const url = customBaseUrl || baseUrl;
+ return url.endsWith("/v1") ? url : `${url}/v1`;
+ };
+
+ const handleApplySettings = async () => {
+ setApplying(true);
+ setMessage(null);
+ try {
+ 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(),
+ apiKey: keyToUse,
+ model: selectedModel
+ }),
+ });
+ const data = await res.json();
+ if (res.ok) {
+ setMessage({ type: "success", text: "Settings applied successfully!" });
+ checkDroidStatus();
+ } else {
+ setMessage({ type: "error", text: data.error || "Failed to apply settings" });
+ }
+ } catch (error) {
+ setMessage({ type: "error", text: error.message });
+ } finally {
+ setApplying(false);
+ }
+ };
+
+ const handleResetSettings = async () => {
+ setRestoring(true);
+ setMessage(null);
+ try {
+ const res = await fetch("/api/cli-tools/droid-settings", { method: "DELETE" });
+ const data = await res.json();
+ if (res.ok) {
+ setMessage({ type: "success", text: "Settings reset successfully!" });
+ setSelectedModel("");
+ setSelectedApiKey("");
+ checkDroidStatus();
+ } else {
+ setMessage({ type: "error", text: data.error || "Failed to reset settings" });
+ }
+ } catch (error) {
+ setMessage({ type: "error", text: error.message });
+ } finally {
+ setRestoring(false);
+ }
+ };
+
+ const handleModelSelect = (model) => {
+ setSelectedModel(model.value);
+ setModalOpen(false);
+ };
+
+ const getManualConfigs = () => {
+ const keyToUse = (selectedApiKey && selectedApiKey.trim())
+ ? selectedApiKey
+ : (!cloudEnabled ? "sk_9router" : "
");
+
+ 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",
+ },
+ ],
+ };
+
+ const platform = typeof navigator !== "undefined" && navigator.platform;
+ const isWindows = platform?.toLowerCase().includes("win");
+ const settingsPath = isWindows
+ ? "%USERPROFILE%\\.factory\\settings.json"
+ : "~/.factory/settings.json";
+
+ return [
+ {
+ filename: settingsPath,
+ content: JSON.stringify(settingsContent, null, 2),
+ },
+ ];
+ };
+
+ return (
+
+
+
+
+ { e.target.style.display = "none"; }} />
+
+
+
+
{tool.name}
+ {configStatus === "configured" && Connected}
+ {configStatus === "not_configured" && Not configured}
+ {configStatus === "other" && Other}
+
+
{tool.description}
+
+
+
expand_more
+
+
+ {isExpanded && (
+
+ {checkingDroid && (
+
+ progress_activity
+ Checking Factory Droid CLI...
+
+ )}
+
+ {!checkingDroid && droidStatus && !droidStatus.installed && (
+
+
warning
+
+
Factory Droid CLI not installed
+
Please install Factory Droid CLI to use this feature.
+
+
+ )}
+
+ {!checkingDroid && droidStatus?.installed && (
+ <>
+
+ {/* Current Base URL */}
+ {droidStatus?.settings?.customModels?.find(m => m.id === "custom:9Router-0")?.baseUrl && (
+
+ Current
+ arrow_forward
+
+ {droidStatus.settings.customModels.find(m => m.id === "custom:9Router-0").baseUrl}
+
+
+ )}
+
+ {/* Base URL */}
+
+ Base URL
+ arrow_forward
+ 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 && (
+
+ )}
+
+
+ {/* API Key */}
+
+ API Key
+ arrow_forward
+ {apiKeys.length > 0 ? (
+
+ ) : (
+
+ {cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"}
+
+ )}
+
+
+ {/* Model */}
+
+ Model
+ arrow_forward
+ 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" />
+
+ {selectedModel && }
+
+
+
+ {message && (
+
+ {message.type === "success" ? "check_circle" : "error"}
+ {message.text}
+
+ )}
+
+
+
+
+
+
+ >
+ )}
+
+ )}
+
+ setModalOpen(false)}
+ onSelect={handleModelSelect}
+ selectedModel={selectedModel}
+ activeProviders={activeProviders}
+ modelAliases={modelAliases}
+ title="Select Model for Factory Droid"
+ />
+
+ setShowManualConfigModal(false)}
+ title="Factory Droid - Manual Configuration"
+ configs={getManualConfigs()}
+ />
+
+ );
+}
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js
new file mode 100644
index 00000000..2bec5e8b
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js
@@ -0,0 +1,339 @@
+"use client";
+
+import { useState, useEffect, useRef } from "react";
+import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
+import Image from "next/image";
+
+const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
+
+export default function OpenClawToolCard({
+ tool,
+ isExpanded,
+ onToggle,
+ baseUrl,
+ hasActiveProviders,
+ apiKeys,
+ activeProviders,
+ cloudEnabled,
+}) {
+ const [openclawStatus, setOpenclawStatus] = useState(null);
+ const [checkingOpenclaw, setCheckingOpenclaw] = useState(false);
+ const [applying, setApplying] = useState(false);
+ const [restoring, setRestoring] = useState(false);
+ const [message, setMessage] = useState(null);
+ const [selectedApiKey, setSelectedApiKey] = useState("");
+ const [selectedModel, setSelectedModel] = useState("");
+ const [modalOpen, setModalOpen] = useState(false);
+ const [modelAliases, setModelAliases] = useState({});
+ const [showManualConfigModal, setShowManualConfigModal] = useState(false);
+ const [customBaseUrl, setCustomBaseUrl] = useState("");
+ const hasInitializedModel = useRef(false);
+
+ const getConfigStatus = () => {
+ if (!openclawStatus?.installed) return null;
+ const currentProvider = openclawStatus.settings?.models?.providers?.["9router"];
+ if (!currentProvider) return "not_configured";
+ const localMatch = currentProvider.baseUrl?.includes("localhost") || currentProvider.baseUrl?.includes("127.0.0.1");
+ const cloudMatch = cloudEnabled && CLOUD_URL && currentProvider.baseUrl?.startsWith(CLOUD_URL);
+ if (localMatch || cloudMatch) return "configured";
+ return "other";
+ };
+
+ const configStatus = getConfigStatus();
+
+ useEffect(() => {
+ if (apiKeys?.length > 0 && !selectedApiKey) {
+ setSelectedApiKey(apiKeys[0].key);
+ }
+ }, [apiKeys, selectedApiKey]);
+
+ useEffect(() => {
+ if (isExpanded && !openclawStatus) {
+ checkOpenclawStatus();
+ fetchModelAliases();
+ }
+ }, [isExpanded, openclawStatus]);
+
+ const fetchModelAliases = async () => {
+ try {
+ const res = await fetch("/api/models/alias");
+ const data = await res.json();
+ if (res.ok) setModelAliases(data.aliases || {});
+ } catch (error) {
+ console.log("Error fetching model aliases:", error);
+ }
+ };
+
+ useEffect(() => {
+ if (openclawStatus?.installed && !hasInitializedModel.current) {
+ hasInitializedModel.current = true;
+ const provider = openclawStatus.settings?.models?.providers?.["9router"];
+ if (provider) {
+ const primaryModel = openclawStatus.settings?.agents?.defaults?.model?.primary;
+ if (primaryModel) {
+ const modelId = primaryModel.replace("9router/", "");
+ setSelectedModel(modelId);
+ }
+ if (provider.apiKey && apiKeys?.some(k => k.key === provider.apiKey)) {
+ setSelectedApiKey(provider.apiKey);
+ }
+ }
+ }
+ }, [openclawStatus, apiKeys]);
+
+ const checkOpenclawStatus = async () => {
+ setCheckingOpenclaw(true);
+ try {
+ const res = await fetch("/api/cli-tools/openclaw-settings");
+ const data = await res.json();
+ setOpenclawStatus(data);
+ } catch (error) {
+ setOpenclawStatus({ installed: false, error: error.message });
+ } finally {
+ setCheckingOpenclaw(false);
+ }
+ };
+
+ const getEffectiveBaseUrl = () => {
+ const url = customBaseUrl || baseUrl;
+ return url.endsWith("/v1") ? url : `${url}/v1`;
+ };
+
+ const getDisplayUrl = () => {
+ const url = customBaseUrl || baseUrl;
+ return url.endsWith("/v1") ? url : `${url}/v1`;
+ };
+
+ const handleApplySettings = async () => {
+ setApplying(true);
+ setMessage(null);
+ try {
+ const keyToUse = selectedApiKey?.trim()
+ || (apiKeys?.length > 0 ? apiKeys[0].key : null)
+ || (!cloudEnabled ? "sk_9router" : null);
+
+ const res = await fetch("/api/cli-tools/openclaw-settings", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ baseUrl: getEffectiveBaseUrl(),
+ apiKey: keyToUse,
+ model: selectedModel
+ }),
+ });
+ const data = await res.json();
+ if (res.ok) {
+ setMessage({ type: "success", text: "Settings applied successfully!" });
+ checkOpenclawStatus();
+ } else {
+ setMessage({ type: "error", text: data.error || "Failed to apply settings" });
+ }
+ } catch (error) {
+ setMessage({ type: "error", text: error.message });
+ } finally {
+ setApplying(false);
+ }
+ };
+
+ const handleResetSettings = async () => {
+ setRestoring(true);
+ setMessage(null);
+ try {
+ const res = await fetch("/api/cli-tools/openclaw-settings", { method: "DELETE" });
+ const data = await res.json();
+ if (res.ok) {
+ setMessage({ type: "success", text: "Settings reset successfully!" });
+ setSelectedModel("");
+ setSelectedApiKey("");
+ checkOpenclawStatus();
+ } else {
+ setMessage({ type: "error", text: data.error || "Failed to reset settings" });
+ }
+ } catch (error) {
+ setMessage({ type: "error", text: error.message });
+ } finally {
+ setRestoring(false);
+ }
+ };
+
+ const handleModelSelect = (model) => {
+ setSelectedModel(model.value);
+ setModalOpen(false);
+ };
+
+ const getManualConfigs = () => {
+ const keyToUse = (selectedApiKey && selectedApiKey.trim())
+ ? selectedApiKey
+ : (!cloudEnabled ? "sk_9router" : "");
+
+ const settingsContent = {
+ agents: {
+ defaults: {
+ model: {
+ primary: `9router/${selectedModel || "provider/model-id"}`,
+ },
+ },
+ },
+ models: {
+ providers: {
+ "9router": {
+ baseUrl: getEffectiveBaseUrl(),
+ apiKey: keyToUse,
+ api: "openai-completions",
+ models: [
+ {
+ id: selectedModel || "provider/model-id",
+ name: (selectedModel || "provider/model-id").split("/").pop(),
+ },
+ ],
+ },
+ },
+ },
+ };
+
+ return [
+ {
+ filename: "~/.openclaw/openclaw.json",
+ content: JSON.stringify(settingsContent, null, 2),
+ },
+ ];
+ };
+
+ return (
+
+
+
+
+ { e.target.style.display = "none"; }} />
+
+
+
+
{tool.name}
+ {configStatus === "configured" && Connected}
+ {configStatus === "not_configured" && Not configured}
+ {configStatus === "other" && Other}
+
+
{tool.description}
+
+
+
expand_more
+
+
+ {isExpanded && (
+
+ {checkingOpenclaw && (
+
+ progress_activity
+ Checking Open Claw CLI...
+
+ )}
+
+ {!checkingOpenclaw && openclawStatus && !openclawStatus.installed && (
+
+
warning
+
+
Open Claw CLI not installed
+
Please install Open Claw CLI to use this feature.
+
+
+ )}
+
+ {!checkingOpenclaw && openclawStatus?.installed && (
+ <>
+
+ {/* Current Base URL */}
+ {openclawStatus?.settings?.models?.providers?.["9router"]?.baseUrl && (
+
+ Current
+ arrow_forward
+
+ {openclawStatus.settings.models.providers["9router"].baseUrl}
+
+
+ )}
+
+ {/* Base URL */}
+
+ Base URL
+ arrow_forward
+ 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 && (
+
+ )}
+
+
+ {/* API Key */}
+
+ API Key
+ arrow_forward
+ {apiKeys.length > 0 ? (
+
+ ) : (
+
+ {cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"}
+
+ )}
+
+
+ {/* Model */}
+
+ Model
+ arrow_forward
+ 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" />
+
+ {selectedModel && }
+
+
+
+ {message && (
+
+ {message.type === "success" ? "check_circle" : "error"}
+ {message.text}
+
+ )}
+
+
+
+
+
+
+ >
+ )}
+
+ )}
+
+ setModalOpen(false)}
+ onSelect={handleModelSelect}
+ selectedModel={selectedModel}
+ activeProviders={activeProviders}
+ modelAliases={modelAliases}
+ title="Select Model for Open Claw"
+ />
+
+ setShowManualConfigModal(false)}
+ title="Open Claw - Manual Configuration"
+ configs={getManualConfigs()}
+ />
+
+ );
+}
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/index.js b/src/app/(dashboard)/dashboard/cli-tools/components/index.js
index dd07abcb..63d3492a 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/index.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/index.js
@@ -1,4 +1,6 @@
export { default as ClaudeToolCard } from "./ClaudeToolCard";
export { default as CodexToolCard } from "./CodexToolCard";
+export { default as DroidToolCard } from "./DroidToolCard";
+export { default as OpenClawToolCard } from "./OpenClawToolCard";
export { default as DefaultToolCard } from "./DefaultToolCard";
diff --git a/src/app/api/cli-tools/claude-settings/route.js b/src/app/api/cli-tools/claude-settings/route.js
index 3c2a84a6..e64d0944 100644
--- a/src/app/api/cli-tools/claude-settings/route.js
+++ b/src/app/api/cli-tools/claude-settings/route.js
@@ -102,6 +102,13 @@ export async function POST(request) {
}
}
+ // Normalize ANTHROPIC_BASE_URL to ensure /v1 suffix
+ if (env.ANTHROPIC_BASE_URL) {
+ env.ANTHROPIC_BASE_URL = env.ANTHROPIC_BASE_URL.endsWith("/v1")
+ ? env.ANTHROPIC_BASE_URL
+ : `${env.ANTHROPIC_BASE_URL}/v1`;
+ }
+
// Merge new env with existing settings
const newSettings = {
...currentSettings,
diff --git a/src/app/api/cli-tools/codex-settings/route.js b/src/app/api/cli-tools/codex-settings/route.js
index 387f9a5b..18f17eb9 100644
--- a/src/app/api/cli-tools/codex-settings/route.js
+++ b/src/app/api/cli-tools/codex-settings/route.js
@@ -155,9 +155,11 @@ export async function POST(request) {
parsed._root.model_provider = "9router";
// Update or create 9router provider section (no api_key - Codex reads from auth.json)
+ // Ensure /v1 suffix is added only once
+ const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
parsed._sections["model_providers.9router"] = {
name: "9Router",
- base_url: `${baseUrl}/v1`,
+ base_url: normalizedBaseUrl,
wire_api: "responses",
};
diff --git a/src/app/api/cli-tools/droid-settings/route.js b/src/app/api/cli-tools/droid-settings/route.js
new file mode 100644
index 00000000..12f65545
--- /dev/null
+++ b/src/app/api/cli-tools/droid-settings/route.js
@@ -0,0 +1,175 @@
+"use server";
+
+import { NextResponse } from "next/server";
+import { exec } from "child_process";
+import { promisify } from "util";
+import fs from "fs/promises";
+import path from "path";
+import os from "os";
+
+const execAsync = promisify(exec);
+
+const getDroidDir = () => path.join(os.homedir(), ".factory");
+const getDroidSettingsPath = () => path.join(getDroidDir(), "settings.json");
+
+// Check if droid CLI is installed
+const checkDroidInstalled = async () => {
+ try {
+ const isWindows = os.platform() === "win32";
+ const command = isWindows ? "where droid" : "which droid";
+ await execAsync(command);
+ return true;
+ } catch {
+ return false;
+ }
+};
+
+// Read current settings.json
+const readSettings = async () => {
+ try {
+ const settingsPath = getDroidSettingsPath();
+ const content = await fs.readFile(settingsPath, "utf-8");
+ return JSON.parse(content);
+ } catch (error) {
+ if (error.code === "ENOENT") return null;
+ throw error;
+ }
+};
+
+// 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");
+};
+
+// GET - Check droid CLI and read current settings
+export async function GET() {
+ try {
+ const isInstalled = await checkDroidInstalled();
+
+ if (!isInstalled) {
+ return NextResponse.json({
+ installed: false,
+ settings: null,
+ message: "Factory Droid CLI is not installed",
+ });
+ }
+
+ const settings = await readSettings();
+
+ return NextResponse.json({
+ installed: true,
+ settings,
+ has9Router: has9RouterConfig(settings),
+ settingsPath: getDroidSettingsPath(),
+ });
+ } catch (error) {
+ console.log("Error checking droid settings:", error);
+ return NextResponse.json({ error: "Failed to check droid settings" }, { status: 500 });
+ }
+}
+
+// POST - Update 9Router customModels (merge with existing settings)
+export async function POST(request) {
+ try {
+ const { baseUrl, apiKey, model } = await request.json();
+
+ if (!baseUrl || !model) {
+ return NextResponse.json({ error: "baseUrl and model are required" }, { status: 400 });
+ }
+
+ const droidDir = getDroidDir();
+ const settingsPath = getDroidSettingsPath();
+
+ // Ensure directory exists
+ await fs.mkdir(droidDir, { recursive: true });
+
+ // Read existing settings or create new
+ let settings = {};
+ try {
+ const existingSettings = await fs.readFile(settingsPath, "utf-8");
+ settings = JSON.parse(existingSettings);
+ } catch { /* No existing settings */ }
+
+ // Ensure customModels array exists
+ if (!settings.customModels) {
+ settings.customModels = [];
+ }
+
+ // Remove existing 9Router config if any
+ settings.customModels = settings.customModels.filter(m => m.id !== "custom:9Router-0");
+
+ // Normalize baseUrl to ensure /v1 suffix
+ const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
+
+ // 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",
+ };
+
+ settings.customModels.unshift(customModel);
+
+ // Write settings
+ await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
+
+ return NextResponse.json({
+ success: true,
+ message: "Factory Droid settings applied successfully!",
+ settingsPath,
+ });
+ } catch (error) {
+ console.log("Error updating droid settings:", error);
+ return NextResponse.json({ error: "Failed to update droid settings" }, { status: 500 });
+ }
+}
+
+// DELETE - Remove 9Router customModels only (keep other settings)
+export async function DELETE() {
+ try {
+ const settingsPath = getDroidSettingsPath();
+
+ // Read existing settings
+ let settings = {};
+ try {
+ const existingSettings = await fs.readFile(settingsPath, "utf-8");
+ settings = JSON.parse(existingSettings);
+ } catch (error) {
+ if (error.code === "ENOENT") {
+ return NextResponse.json({
+ success: true,
+ message: "No settings file to reset",
+ });
+ }
+ throw error;
+ }
+
+ // Remove 9Router customModels
+ if (settings.customModels) {
+ settings.customModels = settings.customModels.filter(m => m.id !== "custom:9Router-0");
+
+ // Remove customModels array if empty
+ if (settings.customModels.length === 0) {
+ delete settings.customModels;
+ }
+ }
+
+ // Write updated settings
+ await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
+
+ return NextResponse.json({
+ success: true,
+ message: "9Router settings removed successfully",
+ });
+ } catch (error) {
+ console.log("Error resetting droid settings:", error);
+ return NextResponse.json({ error: "Failed to reset droid settings" }, { status: 500 });
+ }
+}
diff --git a/src/app/api/cli-tools/openclaw-settings/route.js b/src/app/api/cli-tools/openclaw-settings/route.js
new file mode 100644
index 00000000..cd46bcdc
--- /dev/null
+++ b/src/app/api/cli-tools/openclaw-settings/route.js
@@ -0,0 +1,180 @@
+"use server";
+
+import { NextResponse } from "next/server";
+import { exec } from "child_process";
+import { promisify } from "util";
+import fs from "fs/promises";
+import path from "path";
+import os from "os";
+
+const execAsync = promisify(exec);
+
+const getOpenClawDir = () => path.join(os.homedir(), ".openclaw");
+const getOpenClawSettingsPath = () => path.join(getOpenClawDir(), "openclaw.json");
+
+// Check if openclaw CLI is installed
+const checkOpenClawInstalled = async () => {
+ try {
+ const isWindows = os.platform() === "win32";
+ const command = isWindows ? "where openclaw" : "which openclaw";
+ await execAsync(command);
+ return true;
+ } catch {
+ return false;
+ }
+};
+
+// Read current settings.json
+const readSettings = async () => {
+ try {
+ const settingsPath = getOpenClawSettingsPath();
+ const content = await fs.readFile(settingsPath, "utf-8");
+ return JSON.parse(content);
+ } catch (error) {
+ if (error.code === "ENOENT") return null;
+ throw error;
+ }
+};
+
+// Check if settings has 9Router config
+const has9RouterConfig = (settings) => {
+ if (!settings || !settings.models || !settings.models.providers) return false;
+ return !!settings.models.providers["9router"];
+};
+
+// GET - Check openclaw CLI and read current settings
+export async function GET() {
+ try {
+ const isInstalled = await checkOpenClawInstalled();
+
+ if (!isInstalled) {
+ return NextResponse.json({
+ installed: false,
+ settings: null,
+ message: "Open Claw CLI is not installed",
+ });
+ }
+
+ const settings = await readSettings();
+
+ return NextResponse.json({
+ installed: true,
+ settings,
+ has9Router: has9RouterConfig(settings),
+ settingsPath: getOpenClawSettingsPath(),
+ });
+ } catch (error) {
+ console.log("Error checking openclaw settings:", error);
+ return NextResponse.json({ error: "Failed to check openclaw settings" }, { status: 500 });
+ }
+}
+
+// POST - Update 9Router settings (merge with existing settings)
+export async function POST(request) {
+ try {
+ const { baseUrl, apiKey, model } = await request.json();
+
+ if (!baseUrl || !model) {
+ return NextResponse.json({ error: "baseUrl and model are required" }, { status: 400 });
+ }
+
+ const openclawDir = getOpenClawDir();
+ const settingsPath = getOpenClawSettingsPath();
+
+ // Ensure directory exists
+ await fs.mkdir(openclawDir, { recursive: true });
+
+ // Read existing settings or create new
+ let settings = {};
+ try {
+ const existingSettings = await fs.readFile(settingsPath, "utf-8");
+ settings = JSON.parse(existingSettings);
+ } catch { /* No existing settings */ }
+
+ // Ensure structure exists
+ if (!settings.agents) settings.agents = {};
+ if (!settings.agents.defaults) settings.agents.defaults = {};
+ if (!settings.agents.defaults.model) settings.agents.defaults.model = {};
+ if (!settings.models) settings.models = {};
+ if (!settings.models.providers) settings.models.providers = {};
+
+ // Normalize baseUrl to ensure /v1 suffix
+ const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
+
+ // Update agents.defaults.model.primary
+ settings.agents.defaults.model.primary = `9router/${model}`;
+
+ // Update models.providers.9router
+ settings.models.providers["9router"] = {
+ baseUrl: normalizedBaseUrl,
+ apiKey: apiKey || "your_api_key",
+ api: "openai-completions",
+ models: [
+ {
+ id: model,
+ name: model.split("/").pop() || model,
+ },
+ ],
+ };
+
+ // Write settings
+ await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
+
+ return NextResponse.json({
+ success: true,
+ message: "Open Claw settings applied successfully!",
+ settingsPath,
+ });
+ } catch (error) {
+ console.log("Error updating openclaw settings:", error);
+ return NextResponse.json({ error: "Failed to update openclaw settings" }, { status: 500 });
+ }
+}
+
+// DELETE - Remove 9Router settings only (keep other settings)
+export async function DELETE() {
+ try {
+ const settingsPath = getOpenClawSettingsPath();
+
+ // Read existing settings
+ let settings = {};
+ try {
+ const existingSettings = await fs.readFile(settingsPath, "utf-8");
+ settings = JSON.parse(existingSettings);
+ } catch (error) {
+ if (error.code === "ENOENT") {
+ return NextResponse.json({
+ success: true,
+ message: "No settings file to reset",
+ });
+ }
+ throw error;
+ }
+
+ // Remove 9Router from models.providers
+ if (settings.models && settings.models.providers) {
+ delete settings.models.providers["9router"];
+
+ // Remove providers object if empty
+ if (Object.keys(settings.models.providers).length === 0) {
+ delete settings.models.providers;
+ }
+ }
+
+ // Reset agents.defaults.model.primary if it uses 9router
+ if (settings.agents?.defaults?.model?.primary?.startsWith("9router/")) {
+ delete settings.agents.defaults.model.primary;
+ }
+
+ // Write updated settings
+ await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
+
+ return NextResponse.json({
+ success: true,
+ message: "9Router settings removed successfully",
+ });
+ } catch (error) {
+ console.log("Error resetting openclaw settings:", error);
+ return NextResponse.json({ error: "Failed to reset openclaw settings" }, { status: 500 });
+ }
+}
diff --git a/src/app/api/provider-nodes/route.js b/src/app/api/provider-nodes/route.js
index cba15167..61df88e4 100644
--- a/src/app/api/provider-nodes/route.js
+++ b/src/app/api/provider-nodes/route.js
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { createProviderNode, getProviderNodes } from "@/models";
import { OPENAI_COMPATIBLE_PREFIX, ANTHROPIC_COMPATIBLE_PREFIX } from "@/shared/constants/providers";
+import { generateId } from "@/shared/utils";
const OPENAI_COMPATIBLE_DEFAULTS = {
baseUrl: "https://api.openai.com/v1",
@@ -44,7 +45,7 @@ export async function POST(request) {
}
const node = await createProviderNode({
- id: `${OPENAI_COMPATIBLE_PREFIX}${apiType}-${crypto.randomUUID()}`,
+ id: `${OPENAI_COMPATIBLE_PREFIX}${apiType}-${generateId()}`,
type: "openai-compatible",
prefix: prefix.trim(),
apiType,
@@ -63,7 +64,7 @@ export async function POST(request) {
}
const node = await createProviderNode({
- id: `${ANTHROPIC_COMPATIBLE_PREFIX}${crypto.randomUUID()}`,
+ id: `${ANTHROPIC_COMPATIBLE_PREFIX}${generateId()}`,
type: "anthropic-compatible",
prefix: prefix.trim(),
baseUrl: sanitizedBaseUrl,
diff --git a/src/lib/localDb.js b/src/lib/localDb.js
index 6c39b0f6..690b2c10 100644
--- a/src/lib/localDb.js
+++ b/src/lib/localDb.js
@@ -147,6 +147,12 @@ export async function getProviderNodeById(id) {
*/
export async function createProviderNode(data) {
const db = await getDb();
+
+ // Initialize providerNodes if undefined (backward compatibility)
+ if (!db.data.providerNodes) {
+ db.data.providerNodes = [];
+ }
+
const now = new Date().toISOString();
const node = {
@@ -171,6 +177,10 @@ export async function createProviderNode(data) {
*/
export async function updateProviderNode(id, data) {
const db = await getDb();
+ if (!db.data.providerNodes) {
+ db.data.providerNodes = [];
+ }
+
const index = db.data.providerNodes.findIndex((node) => node.id === id);
if (index === -1) return null;
@@ -191,6 +201,10 @@ export async function updateProviderNode(id, data) {
*/
export async function deleteProviderNode(id) {
const db = await getDb();
+ if (!db.data.providerNodes) {
+ db.data.providerNodes = [];
+ }
+
const index = db.data.providerNodes.findIndex((node) => node.id === id);
if (index === -1) return null;
diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js
index 18e5755e..631aed34 100644
--- a/src/shared/constants/cliTools.js
+++ b/src/shared/constants/cliTools.js
@@ -30,6 +30,22 @@ export const CLI_TOOLS = {
description: "OpenAI Codex CLI",
configType: "custom",
},
+ droid: {
+ id: "droid",
+ name: "Factory Droid",
+ image: "/providers/droid.png",
+ color: "#00D4FF",
+ description: "Factory Droid AI Assistant",
+ configType: "custom",
+ },
+ openclaw: {
+ id: "openclaw",
+ name: "Open Claw",
+ image: "/providers/openclaw.png",
+ color: "#FF6B35",
+ description: "Open Claw AI Assistant",
+ configType: "custom",
+ },
cursor: {
id: "cursor",
name: "Cursor",
diff --git a/src/shared/utils/index.js b/src/shared/utils/index.js
index d7db9d95..f64461ab 100644
--- a/src/shared/utils/index.js
+++ b/src/shared/utils/index.js
@@ -2,6 +2,14 @@
export { cn } from "./cn";
export * as api from "./api";
+import { v4 as uuidv4 } from "uuid";
+
+/**
+ * Generate unique ID (UUID v4)
+ * @returns {string} UUID v4 string
+ */
+export const generateId = uuidv4;
+
/**
* Extract error code from error message (401, 429, 503...)
* @param {string} lastError - Error message