diff --git a/src/app/(dashboard)/dashboard/cli-code/components/GrokBuildToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/GrokBuildToolCard.tsx new file mode 100644 index 0000000000..ff849b22ed --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-code/components/GrokBuildToolCard.tsx @@ -0,0 +1,667 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { useTranslations } from "next-intl"; + +import CliStatusBadge from "./CliStatusBadge"; + +import { Button, Card, ManualConfigModal, ModelSelectModal } from "@/shared/components"; +import ProviderIcon from "@/shared/components/ProviderIcon"; +import type { ToolBatchStatus } from "@/shared/types/cliBatchStatus"; + +const SETTINGS_ENDPOINT = "/api/cli-tools/grok-build-settings"; +const PRESETS_KEY = "omniroute.grokBuildEndpointPresets"; +const CUSTOM_ENDPOINT = "__custom__"; +const SUBAGENTS = ["general-purpose", "explore", "plan"] as const; + +type SubagentType = (typeof SUBAGENTS)[number]; +type Message = { type: "success" | "error"; text: string } | null; +type ModelOption = { value: string; label?: string }; +type ApiKeyOption = { id: string; name?: string; key?: string }; +type EndpointOption = { id: string; label: string; url: string }; +type SavedEndpoint = { name: string; baseUrl: string }; +type Backup = { id: string; createdAt: string }; +type GrokModelStatus = { + model: string | null; + base_url: string | null; + context_window: number | null; +}; +type GrokStatus = { + installed?: boolean; + runnable?: boolean; + hasOmniRoute?: boolean; + apiKeyConfigured?: boolean; + configPath?: string; + config?: { + model?: GrokModelStatus | null; + subagentModels?: Partial>; + }; + error?: { message?: string } | string; +}; + +interface GrokBuildToolCardProps { + tool: { name: string; description?: string }; + isExpanded?: boolean; + onToggle?: () => void; + apiKeys?: ApiKeyOption[]; + activeProviders?: Array<{ + provider: string; + id?: string | number; + providerSpecificData?: unknown; + }>; + hasActiveProviders?: boolean; + availableModels?: ModelOption[]; + batchStatus?: ToolBatchStatus | null; + lastConfiguredAt?: string | null; +} + +const errorText = (body: unknown, fallback: string): string => { + if (!body || typeof body !== "object") return fallback; + const error = (body as { error?: unknown }).error; + if (typeof error === "string") return error; + if ( + error && + typeof error === "object" && + typeof (error as { message?: unknown }).message === "string" + ) { + return (error as { message: string }).message; + } + return fallback; +}; + +const ensureV1 = (value: string): string => { + const trimmed = value.trim().replace(/\/+$/, ""); + if (!trimmed) return ""; + return `${trimmed.replace(/(?:\/v1)+$/, "")}/v1`; +}; + +const readPresets = (): SavedEndpoint[] => { + try { + const value: unknown = JSON.parse(localStorage.getItem(PRESETS_KEY) ?? "[]"); + if (!Array.isArray(value)) return []; + return value.filter((item): item is SavedEndpoint => + Boolean( + item && + typeof item === "object" && + typeof item.name === "string" && + typeof item.baseUrl === "string" + ) + ); + } catch { + return []; + } +}; + +const getTunnelUrl = (body: unknown): string => { + if (!body || typeof body !== "object") return ""; + const record = body as Record; + for (const key of ["apiUrl", "publicUrl", "tunnelUrl"]) { + if (typeof record[key] === "string" && record[key]) return ensureV1(record[key]); + } + return ""; +}; + +const modelLabel = (type: SubagentType): string => + type === "general-purpose" ? "General purpose" : `${type[0].toUpperCase()}${type.slice(1)}`; + +/** Configure Grok Build model slots and endpoint access. */ +export default function GrokBuildToolCard({ + tool, + isExpanded = true, + onToggle = () => undefined, + apiKeys = [], + activeProviders = [], + hasActiveProviders = false, + availableModels = [], + batchStatus = null, + lastConfiguredAt = null, +}: GrokBuildToolCardProps) { + const t = useTranslations("cliTools"); + const [status, setStatus] = useState(null); + const [checking, setChecking] = useState(true); + const [applying, setApplying] = useState(false); + const [resetting, setResetting] = useState(false); + const [message, setMessage] = useState(null); + const [model, setModel] = useState(""); + const [subagentModels, setSubagentModels] = useState>>({}); + const [selectedKeyId, setSelectedKeyId] = useState(""); + const [endpoints, setEndpoints] = useState([]); + const [selectedEndpoint, setSelectedEndpoint] = useState(""); + const [customEndpoint, setCustomEndpoint] = useState(""); + const [modelTarget, setModelTarget] = useState<"main" | SubagentType | null>(null); + const [showManual, setShowManual] = useState(false); + const [backups, setBackups] = useState([]); + const [showBackups, setShowBackups] = useState(false); + const [restoringBackup, setRestoringBackup] = useState(null); + + useEffect(() => { + if (!selectedKeyId && apiKeys[0]?.id) setSelectedKeyId(apiKeys[0].id); + }, [apiKeys, selectedKeyId]); + + const hydrateStatus = useCallback((next: GrokStatus) => { + setStatus(next); + setModel(next.config?.model?.model ?? ""); + setSubagentModels( + Object.fromEntries( + SUBAGENTS.flatMap((type) => { + const value = next.config?.subagentModels?.[type]?.model; + return value ? [[type, value]] : []; + }) + ) + ); + }, []); + + const refreshStatus = useCallback(async () => { + setChecking(true); + try { + const response = await fetch(SETTINGS_ENDPOINT); + const body = (await response.json()) as GrokStatus; + if (!response.ok) throw new Error(errorText(body, "Failed to read Grok Build settings")); + hydrateStatus(body); + } catch (error) { + setMessage({ type: "error", text: error instanceof Error ? error.message : String(error) }); + } finally { + setChecking(false); + } + }, [hydrateStatus]); + + const refreshEndpoints = useCallback(async () => { + const requests = [ + fetch("/api/settings"), + fetch("/api/tunnels/cloudflared"), + fetch("/api/tunnels/tailscale"), + fetch("/api/tunnels/ngrok"), + ]; + const results = await Promise.allSettled(requests); + const bodies = await Promise.all( + results.map(async (result) => + result.status === "fulfilled" && result.value.ok ? result.value.json() : null + ) + ); + const settings = (bodies[0] ?? {}) as Record; + const next: EndpointOption[] = []; + if (typeof settings.apiPort === "number") { + next.push({ + id: "local", + label: "Local", + url: `http://127.0.0.1:${settings.apiPort}/v1`, + }); + } + if (typeof settings.cloudUrl === "string" && typeof settings.machineId === "string") { + next.push({ + id: "cloud", + label: "Cloud", + url: ensureV1(`${settings.cloudUrl.replace(/\/+$/, "")}/${settings.machineId}`), + }); + } + ["cloudflared", "tailscale", "ngrok"].forEach((name, index) => { + const url = getTunnelUrl(bodies[index + 1]); + if (url) next.push({ id: name, label: name, url }); + }); + readPresets().forEach((preset, index) => { + next.push({ id: `saved-${index}`, label: preset.name, url: ensureV1(preset.baseUrl) }); + }); + next.push({ id: CUSTOM_ENDPOINT, label: "Custom", url: "" }); + setEndpoints(next); + setSelectedEndpoint((current) => current || next[0]?.id || CUSTOM_ENDPOINT); + }, []); + + const refreshBackups = useCallback(async () => { + try { + const response = await fetch("/api/cli-tools/backups?tool=grok-build"); + const body = (await response.json()) as { backups?: Backup[] }; + if (response.ok) setBackups(body.backups ?? []); + } catch { + setBackups([]); + } + }, []); + + useEffect(() => { + if (!isExpanded) return; + void Promise.all([refreshStatus(), refreshEndpoints(), refreshBackups()]); + }, [isExpanded, refreshBackups, refreshEndpoints, refreshStatus]); + + const baseUrl = useMemo(() => { + if (selectedEndpoint === CUSTOM_ENDPOINT) return ensureV1(customEndpoint); + return endpoints.find((endpoint) => endpoint.id === selectedEndpoint)?.url ?? ""; + }, [customEndpoint, endpoints, selectedEndpoint]); + + const manualToml = useMemo(() => { + const contextWindowFor = (selected: string): number => + ( + availableModels.find((candidate) => candidate.value === selected) as + (ModelOption & { contextWindow?: number; contextLength?: number }) | undefined + )?.contextWindow ?? + ( + availableModels.find((candidate) => candidate.value === selected) as + (ModelOption & { contextWindow?: number; contextLength?: number }) | undefined + )?.contextLength ?? + 200000; + const mainModel = model || "provider/model-id"; + const blocks = [ + `[models]\ndefault = "omniroute"`, + `[model.omniroute]\nmodel = "${mainModel}"\nbase_url = "${baseUrl || "http://127.0.0.1:/v1"}"\nname = "OmniRoute"\ndescription = "Routed via OmniRoute gateway"\napi_backend = "chat_completions"\napi_key = ""\ncontext_window = ${contextWindowFor(mainModel)}`, + ]; + const mappings: string[] = []; + for (const type of SUBAGENTS) { + const selected = subagentModels[type]?.trim(); + if (!selected) continue; + const slot = `omniroute-${type}`; + mappings.push(`${type} = "${slot}"`); + blocks.push( + `[model.${slot}]\nmodel = "${selected}"\nbase_url = "${baseUrl || "http://127.0.0.1:/v1"}"\nname = "OmniRoute ${type}"\ndescription = "Routed via OmniRoute gateway"\napi_backend = "chat_completions"\napi_key = ""\ncontext_window = ${contextWindowFor(selected)}` + ); + } + if (mappings.length) blocks.splice(1, 0, `[subagents.models]\n${mappings.join("\n")}`); + return `${blocks.join("\n\n")}\n`; + }, [availableModels, baseUrl, model, subagentModels]); + + const apply = async () => { + setApplying(true); + setMessage(null); + try { + const desiredSubagents = Object.fromEntries( + SUBAGENTS.flatMap((type) => { + const selected = subagentModels[type]?.trim(); + return selected ? [[type, { model: selected }]] : []; + }) + ); + const selectedContext = (selected: string): number | undefined => { + const option = availableModels.find((candidate) => candidate.value === selected) as + (ModelOption & { contextWindow?: number; contextLength?: number }) | undefined; + return option?.contextWindow ?? option?.contextLength; + }; + const response = await fetch(SETTINGS_ENDPOINT, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + baseUrl, + keyId: selectedKeyId || null, + model, + contextWindow: selectedContext(model), + subagentModels: Object.fromEntries( + Object.entries(desiredSubagents).map(([type, entry]) => [ + type, + { ...entry, contextWindow: selectedContext(entry.model) }, + ]) + ), + }), + }); + const body: unknown = await response.json(); + if (!response.ok) throw new Error(errorText(body, "Failed to apply settings")); + setMessage({ type: "success", text: "Grok Build settings applied." }); + await Promise.all([refreshStatus(), refreshBackups()]); + } catch (error) { + setMessage({ type: "error", text: error instanceof Error ? error.message : String(error) }); + } finally { + setApplying(false); + } + }; + + const reset = async () => { + setResetting(true); + setMessage(null); + try { + const response = await fetch(SETTINGS_ENDPOINT, { method: "DELETE" }); + const body: unknown = await response.json(); + if (!response.ok) throw new Error(errorText(body, "Failed to reset settings")); + setMessage({ type: "success", text: "Grok Build settings reset." }); + await Promise.all([refreshStatus(), refreshBackups()]); + } catch (error) { + setMessage({ type: "error", text: error instanceof Error ? error.message : String(error) }); + } finally { + setResetting(false); + } + }; + + const selectModel = (selection: unknown) => { + const value = (selection as { value?: unknown })?.value; + if (typeof value !== "string") return; + if (modelTarget === "main") setModel(value); + else if (modelTarget) setSubagentModels((current) => ({ ...current, [modelTarget]: value })); + setModelTarget(null); + }; + + const restoreBackup = async (backupId: string) => { + setRestoringBackup(backupId); + setMessage(null); + try { + const response = await fetch("/api/cli-tools/backups", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ tool: "grok-build", backupId }), + }); + const body: unknown = await response.json(); + if (!response.ok) throw new Error(errorText(body, "Failed to restore backup")); + setMessage({ type: "success", text: "Grok Build backup restored." }); + await Promise.all([refreshStatus(), refreshBackups()]); + } catch (error) { + setMessage({ type: "error", text: error instanceof Error ? error.message : String(error) }); + } finally { + setRestoringBackup(null); + } + }; + + const configured = Boolean(status?.hasOmniRoute); + const cliReady = Boolean(status?.installed && status?.runnable); + const effectiveConfigStatus = status + ? cliReady + ? configured + ? "configured" + : "not_configured" + : "not_installed" + : (batchStatus?.config.status ?? null); + const rowClass = "flex items-center gap-2"; + const labelClass = "w-32 shrink-0 text-right text-sm font-semibold text-text-main"; + const inputClass = + "min-w-0 flex-1 rounded border border-border bg-surface px-2 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"; + + return ( + +
+
+
+ +
+
+
+

{tool.name}

+ +
+

{tool.description}

+
+
+ + expand_more + +
+ + {isExpanded && ( +
+ {checking && ( +
+ progress_activity + {t("checkingCli", { tool: "Grok Build" })} +
+ )} + + {!checking && status && !cliReady && ( +
+ warning +
+

+ {status.installed + ? t("cliNotRunnable", { tool: "Grok Build" }) + : t("cliNotInstalled", { tool: "Grok Build" })} +

+

+ Direct Apply needs the Grok Build CLI on this computer. Manual Config stays + available. +

+
+
+ )} + + {status?.config?.model?.base_url && ( +
+ {t("current")} + + arrow_forward + + + {status.config.model.base_url} + +
+ )} + +
+ + + arrow_forward + + +
+ {selectedEndpoint === CUSTOM_ENDPOINT && ( +
+ Custom URL + + arrow_forward + + setCustomEndpoint(event.target.value)} + placeholder="https://gateway.example/v1" + /> +
+ )} + +
+ + + arrow_forward + + +
+ +
+ {t("model")} + + arrow_forward + + + setModel(event.target.value)} + placeholder={availableModels[0]?.value || "provider/model-id"} + /> + {model && ( + + )} +
+ +
+
+ Subagent model overrides +
+ {SUBAGENTS.map((type) => ( +
+ + {modelLabel(type)} + + + arrow_forward + + + + setSubagentModels((current) => ({ ...current, [type]: event.target.value })) + } + placeholder={`Use ${model || "the main model"}`} + /> + {subagentModels[type] && ( + + )} +
+ ))} + + {message && ( +

+ {message.text} +

+ )} + +
+ + + +
+ +
+ + {showBackups && ( +
+

+ history + {t("configBackups")} +

+ {backups.length === 0 ? ( +

{t("noBackupsYet")}

+ ) : ( +
+ {backups.map((backup) => ( +
+ + description + + + {backup.id} + + + {new Date(backup.createdAt).toLocaleString()} + + +
+ ))} +
+ )} +
+ )} +
+ )} + + {modelTarget && ( + setModelTarget(null)} + onSelect={selectModel} + selectedModel={modelTarget === "main" ? model : (subagentModels[modelTarget] ?? "")} + activeProviders={activeProviders} + title={`Select ${modelTarget === "main" ? "main" : modelLabel(modelTarget)} model`} + /> + )} + setShowManual(false)} + title="Grok Build manual config" + configs={[{ filename: "~/.grok/config.toml", content: manualToml }]} + /> + + ); +} diff --git a/src/app/(dashboard)/dashboard/cli-code/components/ToolDetailClient.tsx b/src/app/(dashboard)/dashboard/cli-code/components/ToolDetailClient.tsx index 96771fd0c6..fdad0931f8 100644 --- a/src/app/(dashboard)/dashboard/cli-code/components/ToolDetailClient.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/ToolDetailClient.tsx @@ -15,6 +15,7 @@ import { DefaultToolCard, DroidToolCard, HermesAgentToolCard, + GrokBuildToolCard, KiloToolCard, OpenClawToolCard, } from "./index"; @@ -268,6 +269,8 @@ export default function ToolDetailClient({ toolId, category }: ToolDetailClientP return ; case "hermes-agent": return ; + case "grok-build": + return ; case "antigravity": return ; case "custom": diff --git a/src/app/(dashboard)/dashboard/cli-code/components/index.tsx b/src/app/(dashboard)/dashboard/cli-code/components/index.tsx index bae6f2571b..a4dd222847 100644 --- a/src/app/(dashboard)/dashboard/cli-code/components/index.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/index.tsx @@ -9,3 +9,4 @@ export { default as AntigravityToolCard } from "./AntigravityToolCard"; export { default as CopilotToolCard } from "./CopilotToolCard"; export { default as CustomCliCard } from "./CustomCliCard"; export { default as HermesAgentToolCard } from "./HermesAgentToolCard"; +export { default as GrokBuildToolCard } from "./GrokBuildToolCard"; diff --git a/src/app/api/cli-tools/all-statuses/route.ts b/src/app/api/cli-tools/all-statuses/route.ts index 3b78a3f00d..1092b1ef51 100644 --- a/src/app/api/cli-tools/all-statuses/route.ts +++ b/src/app/api/cli-tools/all-statuses/route.ts @@ -8,10 +8,18 @@ import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts"; import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth"; import { CLI_TOOLS } from "@/shared/constants/cliTools"; -import { getCliRuntimeStatus, getCliPrimaryConfigPath } from "@/shared/services/cliRuntime"; +import { + getCliConfigHome, + getCliRuntimeStatus, + getCliPrimaryConfigPath, +} from "@/shared/services/cliRuntime"; import { getAllCliToolLastConfigured } from "@/lib/db/cliToolState"; import { checkToolConfigStatus } from "@/lib/cliTools/checkToolConfigStatus"; import { findOmniRouteQwenCodeModel } from "@/shared/services/qwenCodeConfig"; +import { + parseGrokBuildConfig, + resolveGrokBuildConfigPath, +} from "@/shared/services/grokBuildConfig"; import { getCached, setCached } from "@/lib/cliTools/batchStatusCache"; import type { ToolBatchStatus, ToolBatchStatusMap } from "@/shared/types/cliBatchStatus"; @@ -19,6 +27,11 @@ const logger = pino({ name: "cli-tools-all-statuses-api" }); const TOOL_CHECK_TIMEOUT_MS = 5000; // 5s per tool max +const getConfigPath = (toolId: string): string | null => + toolId === "grok-build" + ? resolveGrokBuildConfigPath(process.env, getCliConfigHome()) + : getCliPrimaryConfigPath(toolId); + /** * Attempt to extract the endpoint from a config file for a given toolId. * Returns null if extraction is not possible or the file is not parseable. @@ -30,6 +43,10 @@ async function extractEndpointFromConfig( try { const content = await fs.readFile(configPath, "utf-8"); + if (toolId === "grok-build") { + return parseGrokBuildConfig(content).model?.base_url ?? null; + } + // TOML-based tools (codex) — do a best-effort text search if (toolId === "codex") { const match = content.match(/base_url\s*=\s*["']([^"'\n]+)["']/i); @@ -96,7 +113,7 @@ export async function GET(request: Request): Promise { const mtimesMap: Record = {}; await Promise.allSettled( toolIds.map(async (toolId) => { - const configPath = getCliPrimaryConfigPath(toolId); + const configPath = getConfigPath(toolId); if (!configPath) { mtimesMap[toolId] = 0; return; @@ -149,7 +166,7 @@ export async function GET(request: Request): Promise { !runtime.installed || !runtime.runnable ? "not_installed" : configStatus; // Try to extract endpoint from config file - const configPath = getCliPrimaryConfigPath(toolId); + const configPath = getConfigPath(toolId); const endpoint = configPath ? await extractEndpointFromConfig(toolId, configPath) : null; const result: ToolBatchStatus = { diff --git a/src/app/api/cli-tools/backups/route.ts b/src/app/api/cli-tools/backups/route.ts index a183a261ba..c32d5d3a56 100644 --- a/src/app/api/cli-tools/backups/route.ts +++ b/src/app/api/cli-tools/backups/route.ts @@ -8,7 +8,7 @@ import { cliBackupMutationSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; -const VALID_TOOLS = ["claude", "codex", "droid", "openclaw", "cline", "kilo", "qwen"]; +const VALID_TOOLS = ["claude", "codex", "droid", "grok-build", "openclaw", "cline", "kilo", "qwen"]; // GET /api/cli-tools/backups?tool=claude — list backups export async function GET(request) { diff --git a/src/app/api/cli-tools/grok-build-settings/route.ts b/src/app/api/cli-tools/grok-build-settings/route.ts index 23fe3db042..6b0be06d4f 100644 --- a/src/app/api/cli-tools/grok-build-settings/route.ts +++ b/src/app/api/cli-tools/grok-build-settings/route.ts @@ -1,300 +1,268 @@ "use server"; -import { NextResponse } from "next/server"; -import fs from "fs/promises"; -import path from "path"; -import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth"; -import { - ensureCliConfigWriteAllowed, - getCliPrimaryConfigPath, - getCliRuntimeStatus, -} from "@/shared/services/cliRuntime"; -import { createBackup } from "@/shared/services/backupService"; -import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState"; -import { cliModelConfigSchema } from "@/shared/validation/schemas"; -import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; -import { resolveApiKey } from "@/shared/services/apiKeyResolver"; +import fs from "node:fs/promises"; +import path from "node:path"; + import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { NextResponse } from "next/server"; +import pino from "pino"; +import { z } from "zod"; +import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth"; +import { guardCliConfigWrite } from "@/lib/api/cliConfigWriteGuard"; +import { deleteCliToolLastConfigured, saveCliToolLastConfigured } from "@/lib/db/cliToolState"; +import { getResolvedModelCapabilities } from "@/lib/modelCapabilities"; +import { resolveApiKey } from "@/shared/services/apiKeyResolver"; +import { createBackup } from "@/shared/services/backupService"; +import { getCliConfigHome, getCliRuntimeStatus } from "@/shared/services/cliRuntime"; +import { + applyGrokBuildConfig, + GrokBuildConfigConflictError, + GROK_SUBAGENT_TYPES, + parseGrokBuildConfig, + resetGrokBuildConfig, + resolveGrokBuildConfigPath, + type GrokBuildApplyOptions, + type GrokSubagentType, +} from "@/shared/services/grokBuildConfig"; + +const logger = pino({ name: "grok-build-settings-api" }); const TOOL_ID = "grok-build"; -const MODEL_SLOT = "omniroute"; -// Grok Build ships with a built-in default model id; restored on Reset when no -// prior custom default was recorded. -const BUILTIN_DEFAULT_MODEL = "grok-build"; +const DEFAULT_CONTEXT_WINDOW = 200000; -const getGrokBuildConfigPath = (): string => - getCliPrimaryConfigPath(TOOL_ID) ?? path.join(process.env.HOME ?? "~", ".grok", "config.toml"); +const modelSelectionSchema = z.object({ + model: z.string().trim().min(1), + contextWindow: z.number().int().positive().optional(), +}); -const getGrokBuildDir = () => path.dirname(getGrokBuildConfigPath()); +const grokBuildConfigSchema = z.object({ + baseUrl: z + .string() + .trim() + .url() + .refine((value) => ["http:", "https:"].includes(new URL(value).protocol), { + message: "baseUrl must use HTTP or HTTPS", + }), + apiKey: z.string().nullable().optional(), + keyId: z.string().trim().min(1).nullable().optional(), + model: z.string().trim().min(1), + contextWindow: z.number().int().positive().optional(), + subagentModels: z + .object({ + "general-purpose": modelSelectionSchema.optional(), + explore: modelSelectionSchema.optional(), + plan: modelSelectionSchema.optional(), + }) + .optional(), +}); -// [model.omniroute] ... until the next [section] header or EOF -const MODEL_SECTION_RE = new RegExp( - `^\\[model\\.${MODEL_SLOT}\\][ \\t]*\\r?\\n(?:(?!\\[)[^\\r\\n]*\\r?\\n?)*`, - "m" -); -const MODELS_SECTION_RE = /^\[models\][ \t]*\r?\n((?:(?!\[)[^\r\n]*\r?\n?)*)/m; -// Marker written on Apply so Reset can restore the previously configured default. -const PREV_DEFAULT_RE = /^# omniroute-prev-default = "([^"]*)"[ \t]*\r?\n?/m; +/** Resolve Grok Build config.toml from GROK_HOME or the CLI config home. */ +function getGrokBuildConfigPath( + env: NodeJS.ProcessEnv = process.env, + configHome = getCliConfigHome() +): string { + return resolveGrokBuildConfigPath(env, configHome); +} -const getTomlField = (body: string, key: string): string | null => { - const m = body.match(new RegExp(`^[ \\t]*${key}[ \\t]*=[ \\t]*"([^"]*)"`, "m")); - return m ? m[1] : null; -}; - -type GrokModelSection = { - model: string | null; - base_url: string | null; - name: string | null; - api_key: string | null; - api_backend: string | null; -}; - -/** - * Parse the `~/.grok/config.toml` produced by the Grok Build CLI (a subset of - * TOML — flat `key = "value"` pairs inside `[section]` headers). Grok Build's - * config format is not guaranteed to be quote-escaped or nested, so this reads - * only the flat string fields OmniRoute itself writes. - */ -const parseModelSection = (toml: string): GrokModelSection | null => { - const match = toml.match(MODEL_SECTION_RE); - if (!match) return null; - const body = match[0].replace(/^\[model\.[^\]]+\][ \t]*\r?\n/, ""); - return { - model: getTomlField(body, "model"), - base_url: getTomlField(body, "base_url"), - name: getTomlField(body, "name"), - api_key: getTomlField(body, "api_key"), - api_backend: getTomlField(body, "api_backend"), - }; -}; - -const parseModelsDefault = (toml: string): string | null => { - const match = toml.match(MODELS_SECTION_RE); - if (!match) return null; - return getTomlField(match[1] || "", "default"); -}; - -const escapeTomlString = (value: string): string => value.replace(/["\\]/g, "\\$&"); - -const buildModelSection = (model: string, baseUrl: string, apiKey: string): string => { - const lines = [ - `[model.${MODEL_SLOT}]`, - `model = "${escapeTomlString(model)}"`, - `base_url = "${escapeTomlString(baseUrl)}"`, - `name = "OmniRoute"`, - `description = "Routed via OmniRoute gateway"`, - `api_backend = "chat_completions"`, - ]; - if (apiKey) lines.push(`api_key = "${escapeTomlString(apiKey)}"`); - return `${lines.join("\n")}\n`; -}; - -/** Insert/replace the `[model.omniroute]` section, preserving the rest of the file. */ -const upsertModelSection = (toml: string, section: string): string => { - if (MODEL_SECTION_RE.test(toml)) return toml.replace(MODEL_SECTION_RE, section); - const needsNl = toml.length > 0 && !toml.endsWith("\n"); - return `${toml}${needsNl ? "\n" : ""}\n${section}`; -}; - -const removeModelSection = (toml: string): string => - toml.replace(MODEL_SECTION_RE, "").replace(/\n{3,}/g, "\n\n"); - -/** Set or insert `default = "..."` inside an existing `[models]`, or create the section. */ -const setModelsDefault = (toml: string, value: string): string => { - const match = toml.match(MODELS_SECTION_RE); - if (match) { - const body = match[1] || ""; - const newBody = /^[ \t]*default[ \t]*=/m.test(body) - ? body.replace(/^[ \t]*default[ \t]*=[ \t]*"[^"]*"/m, `default = "${value}"`) - : `default = "${value}"\n${body}`; - return toml.replace(match[0], `[models]\n${newBody}`); - } - const block = `[models]\ndefault = "${value}"\n\n`; - return toml.length > 0 ? block + toml : block; -}; - -/** Remember the previous default once so re-Apply never clobbers it with our own slot. */ -const rememberPrevDefault = (toml: string): string => { - if (PREV_DEFAULT_RE.test(toml)) return toml; - const current = parseModelsDefault(toml); - if (!current || current === MODEL_SLOT) return toml; - const marker = `# omniroute-prev-default = "${current}"\n`; - if (MODEL_SECTION_RE.test(toml)) { - return toml.replace(MODEL_SECTION_RE, (section) => marker + section); - } - const needsNl = toml.length > 0 && !toml.endsWith("\n"); - return `${toml}${needsNl ? "\n" : ""}${marker}`; -}; - -/** If `[models].default` still points at our slot, restore the remembered default. */ -const clearModelsDefaultIfOurs = (toml: string): string => { - const prevMatch = toml.match(PREV_DEFAULT_RE); - const restoreTo = prevMatch?.[1] || BUILTIN_DEFAULT_MODEL; - let next = toml.replace(PREV_DEFAULT_RE, ""); - const current = parseModelsDefault(next); - if (current === MODEL_SLOT) { - next = setModelsDefault(next, restoreTo); - } - return next; -}; - -const hasOmniRouteConfig = (modelCfg: GrokModelSection | null): boolean => - Boolean(modelCfg?.base_url); - -// Read current config.toml -const readConfigToml = async (): Promise => { +const readConfigToml = async (configPath: string): Promise => { try { - return await fs.readFile(getGrokBuildConfigPath(), "utf-8"); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") return ""; - throw err; + return await fs.readFile(configPath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return ""; + throw error; } }; -// GET — check Grok Build CLI and return current [model.omniroute] config -export async function GET(request: Request) { +const writeAtomic = async (filePath: string, content: string): Promise => { + const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + const mode = process.platform === "win32" ? undefined : 0o600; + try { + await fs.writeFile(tempPath, content, { encoding: "utf8", mode }); + if (mode !== undefined) await fs.chmod(tempPath, mode); + await fs.rename(tempPath, filePath); + } catch (error) { + await fs.unlink(tempPath).catch(() => undefined); + throw error; + } +}; + +const normalizeBaseUrl = (baseUrl: string): string => { + const url = new URL(baseUrl); + url.pathname = `${url.pathname.replace(/(?:\/v1)*\/?$/, "")}/v1`.replace(/\/+/g, "/"); + url.search = ""; + url.hash = ""; + return url.toString().replace(/\/$/, ""); +}; + +const resolveContextWindow = (value: number | undefined, model: string): number => { + if (value !== undefined) return value; + return getResolvedModelCapabilities(model).contextWindow ?? DEFAULT_CONTEXT_WINDOW; +}; + +const normalizeApplyOptions = ( + data: z.infer, + apiKey: string +): GrokBuildApplyOptions => { + const options: GrokBuildApplyOptions = { + baseUrl: normalizeBaseUrl(data.baseUrl), + apiKey, + model: data.model, + contextWindow: resolveContextWindow(data.contextWindow, data.model), + }; + if (data.subagentModels !== undefined) { + options.subagentModels = {}; + for (const type of GROK_SUBAGENT_TYPES) { + const selected = data.subagentModels[type]; + if (!selected) continue; + options.subagentModels[type] = { + model: selected.model, + contextWindow: resolveContextWindow(selected.contextWindow, selected.model), + }; + } + } + return options; +}; + +const omitApiKey = (model: T): Omit => { + const { api_key: _apiKey, ...publicModel } = model; + return publicModel; +}; + +const omitApiKeys = (settings: ReturnType) => ({ + ...settings, + model: settings.model ? omitApiKey(settings.model) : null, + subagentModels: Object.fromEntries( + GROK_SUBAGENT_TYPES.map((type) => [ + type, + settings.subagentModels[type] ? omitApiKey(settings.subagentModels[type]) : null, + ]) + ) as Record< + GrokSubagentType, + Omit, "api_key"> | null + >, +}); + +const hasOmniRouteConfig = (settings: GrokBuildSettings): boolean => + settings.default === "omniroute" && + settings.model?.base_url !== null && + settings.model?.api_backend === "chat_completions"; + +/** Return Grok Build runtime and OmniRoute config status. */ +export async function GET(request: Request): Promise { const authError = await requireCliToolsAuth(request); if (authError) return authError; try { - const runtime = await getCliRuntimeStatus(TOOL_ID); - - if (!runtime.installed || !runtime.runnable) { - return NextResponse.json({ - installed: runtime.installed, - runnable: runtime.runnable, - command: runtime.command, - commandPath: runtime.commandPath, - runtimeMode: runtime.runtimeMode, - reason: runtime.reason, - config: null, - message: - runtime.installed && !runtime.runnable - ? "Grok Build is installed but not runnable" - : "Grok Build is not installed", - }); - } - - const toml = await readConfigToml(); - const model = parseModelSection(toml); - const defaultModel = parseModelsDefault(toml); + const configPath = getGrokBuildConfigPath(); + const [runtime, toml] = await Promise.all([ + getCliRuntimeStatus(TOOL_ID), + readConfigToml(configPath), + ]); + const settings = parseGrokBuildConfig(toml); + const publicSettings = omitApiKeys(settings); + const apiKeyConfigured = Boolean( + settings.model?.api_key || + GROK_SUBAGENT_TYPES.some((type) => settings.subagentModels[type]?.api_key) + ); return NextResponse.json({ - installed: runtime.installed, - runnable: runtime.runnable, - command: runtime.command, - commandPath: runtime.commandPath, - runtimeMode: runtime.runtimeMode, - reason: runtime.reason, - config: { model, default: defaultModel }, - hasOmniRoute: hasOmniRouteConfig(model), - configPath: getGrokBuildConfigPath(), + ...runtime, + config: publicSettings, + settings: publicSettings, + hasOmniRoute: hasOmniRouteConfig(settings), + apiKeyConfigured, + configPath, }); - } catch (err) { - return NextResponse.json({ error: { message: sanitizeErrorMessage(err) } }, { status: 500 }); + } catch (error) { + logger.error({ err: error }, "Failed to read Grok Build settings"); + return NextResponse.json({ error: { message: sanitizeErrorMessage(error) } }, { status: 500 }); } } -// POST — write the [model.omniroute] section into ~/.grok/config.toml and set it default -export async function POST(request: Request) { +/** Apply OmniRoute model slots to Grok Build. */ +export async function POST(request: Request): Promise { const authError = await requireCliToolsAuth(request); if (authError) return authError; - let rawBody; + let rawBody: unknown; try { rawBody = await request.json(); } catch { return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 }); } + const validation = grokBuildConfigSchema.safeParse(rawBody); + if (!validation.success) { + return NextResponse.json( + { error: { message: "Invalid request", details: validation.error.issues } }, + { status: 400 } + ); + } + try { - const writeGuard = ensureCliConfigWriteAllowed(); - if (writeGuard) { - return NextResponse.json({ error: writeGuard }, { status: 403 }); - } - - // Extract keyId BEFORE Zod validation — Zod strips unknown fields - const keyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null; - - const validation = validateBody(cliModelConfigSchema, rawBody); - if (isValidationFailure(validation)) { - return NextResponse.json({ error: validation.error }, { status: 400 }); - } - const { baseUrl, model } = validation.data; - const apiKey = await resolveApiKey(keyId, validation.data.apiKey); - const configPath = getGrokBuildConfigPath(); - const grokDir = getGrokBuildDir(); + const writeError = guardCliConfigWrite(configPath, { toolLabel: "Grok Build" }); + if (writeError) return writeError; - await fs.mkdir(grokDir, { recursive: true }); + const apiKey = await resolveApiKey(validation.data.keyId, validation.data.apiKey); + const toml = applyGrokBuildConfig( + await readConfigToml(configPath), + normalizeApplyOptions(validation.data, apiKey) + ); + + await fs.mkdir(path.dirname(configPath), { recursive: true }); await createBackup(TOOL_ID, configPath); - - const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; - - let toml = await readConfigToml(); - toml = rememberPrevDefault(toml); - toml = upsertModelSection(toml, buildModelSection(model, normalizedBaseUrl, apiKey || "")); - toml = setModelsDefault(toml, MODEL_SLOT); - - await fs.writeFile(configPath, toml, "utf-8"); - + await writeAtomic(configPath, toml); try { saveCliToolLastConfigured(TOOL_ID); } catch { - /* non-critical */ + logger.warn("Failed to record Grok Build config time"); } return NextResponse.json({ success: true, message: "Grok Build settings applied successfully!", configPath, - modelSlot: MODEL_SLOT, + modelSlot: "omniroute", }); - } catch (err) { - return NextResponse.json({ error: { message: sanitizeErrorMessage(err) } }, { status: 500 }); + } catch (error) { + if (error instanceof GrokBuildConfigConflictError) { + return NextResponse.json({ error: { message: error.message } }, { status: 409 }); + } + logger.error({ err: error }, "Failed to apply Grok Build settings"); + return NextResponse.json({ error: { message: sanitizeErrorMessage(error) } }, { status: 500 }); } } -// DELETE — remove the [model.omniroute] section and restore the previous default -export async function DELETE(request: Request) { +/** Remove OmniRoute model slots from Grok Build. */ +export async function DELETE(request: Request): Promise { const authError = await requireCliToolsAuth(request); if (authError) return authError; try { - const writeGuard = ensureCliConfigWriteAllowed(); - if (writeGuard) { - return NextResponse.json({ error: writeGuard }, { status: 403 }); - } - const configPath = getGrokBuildConfigPath(); + const writeError = guardCliConfigWrite(configPath, { toolLabel: "Grok Build" }); + if (writeError) return writeError; - let toml: string; - try { - toml = await fs.readFile(configPath, "utf-8"); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - return NextResponse.json({ success: true, message: "No config file to reset" }); - } - throw err; + const current = await readConfigToml(configPath); + if (!current) { + return NextResponse.json({ success: true, message: "No config file to reset" }); } await createBackup(TOOL_ID, configPath); - - toml = removeModelSection(toml); - toml = clearModelsDefaultIfOurs(toml); - await fs.writeFile(configPath, toml, "utf-8"); - + await writeAtomic(configPath, resetGrokBuildConfig(current)); try { deleteCliToolLastConfigured(TOOL_ID); } catch { - /* non-critical */ + logger.warn("Failed to clear Grok Build config time"); } return NextResponse.json({ success: true, - message: "OmniRoute model slot removed from Grok Build", + message: "OmniRoute model slots removed from Grok Build", }); - } catch (err) { - return NextResponse.json({ error: { message: sanitizeErrorMessage(err) } }, { status: 500 }); + } catch (error) { + logger.error({ err: error }, "Failed to reset Grok Build settings"); + return NextResponse.json({ error: { message: sanitizeErrorMessage(error) } }, { status: 500 }); } } diff --git a/src/lib/cliTools/checkToolConfigStatus.ts b/src/lib/cliTools/checkToolConfigStatus.ts index 75fcc5237a..76d421c95a 100644 --- a/src/lib/cliTools/checkToolConfigStatus.ts +++ b/src/lib/cliTools/checkToolConfigStatus.ts @@ -1,8 +1,12 @@ // DRY: shared between /api/cli-tools/status and /api/cli-tools/all-statuses (plan 14 F2) import fs from "fs/promises"; -import { getCliPrimaryConfigPath } from "@/shared/services/cliRuntime"; +import { getCliConfigHome, getCliPrimaryConfigPath } from "@/shared/services/cliRuntime"; import { hasOmniRouteQwenCodeConfig } from "@/shared/services/qwenCodeConfig"; +import { + parseGrokBuildConfig, + resolveGrokBuildConfigPath, +} from "@/shared/services/grokBuildConfig"; import { getRuntimePorts } from "@/lib/runtime/ports"; const { apiPort } = getRuntimePorts(); @@ -21,11 +25,24 @@ export async function checkToolConfigStatus( _configPathOverride?: string ): Promise<"configured" | "not_configured" | "not_installed" | "unknown" | "other"> { try { - const configPath = _configPathOverride ?? getCliPrimaryConfigPath(toolId); + const configPath = + _configPathOverride ?? + (toolId === "grok-build" + ? resolveGrokBuildConfigPath(process.env, getCliConfigHome()) + : getCliPrimaryConfigPath(toolId)); if (!configPath) return "unknown"; const content = await fs.readFile(configPath, "utf-8"); + if (toolId === "grok-build") { + const settings = parseGrokBuildConfig(content); + return settings.default === "omniroute" && + settings.model?.base_url && + settings.model.api_backend === "chat_completions" + ? "configured" + : "not_configured"; + } + // Codex uses TOML config — parse as raw text, not JSON if (toolId === "codex") { const lower = content.toLowerCase(); @@ -88,8 +105,8 @@ export async function checkToolConfigStatus( // (user may configure an external domain instead of localhost) if ( toolId === "cline" && - ((config.actModeApiProvider === "openai" || config.planModeApiProvider === "openai") && - ((config.openAiBaseUrl as string) || "").trim().length > 0) + (config.actModeApiProvider === "openai" || config.planModeApiProvider === "openai") && + ((config.openAiBaseUrl as string) || "").trim().length > 0 ) { return "configured"; } diff --git a/src/shared/services/grokBuildConfig.ts b/src/shared/services/grokBuildConfig.ts new file mode 100644 index 0000000000..fde856c7fc --- /dev/null +++ b/src/shared/services/grokBuildConfig.ts @@ -0,0 +1,314 @@ +import path from "node:path"; + +import { parse as parseToml } from "smol-toml"; + +export const GROK_MAIN_MODEL_SLOT = "omniroute"; +export const GROK_SUBAGENT_TYPES = ["general-purpose", "explore", "plan"] as const; + +export type GrokSubagentType = (typeof GROK_SUBAGENT_TYPES)[number]; + +export interface GrokModelConfig { + model: string | null; + base_url: string | null; + name: string | null; + api_key: string | null; + api_backend: string | null; + context_window: number | null; +} + +export interface GrokBuildSettings { + model: GrokModelConfig | null; + default: string | null; + subagentModels: Record; + subagentMappings: Record; +} + +export interface GrokBuildApplyOptions { + baseUrl: string; + apiKey?: string | null; + model: string; + contextWindow?: number; + subagentModels?: Partial>; +} + +/** Resolve config.toml from GROK_HOME or the CLI config home. */ +export function resolveGrokBuildConfigPath(env: NodeJS.ProcessEnv, configHome: string): string { + const grokHome = env.GROK_HOME?.trim(); + if (!grokHome) return path.join(configHome, ".grok", "config.toml"); + const hasTraversal = grokHome.split(/[\\/]+/).includes(".."); + if (!path.isAbsolute(grokHome) || hasTraversal) { + throw new Error("GROK_HOME must be an absolute path without traversal"); + } + if (/[$`;&|<>\r\n\0]/.test(grokHome)) { + throw new Error("GROK_HOME contains invalid characters"); + } + return path.join(path.normalize(grokHome), "config.toml"); +} + +const UNSET_SENTINEL = "__omniroute_unset__"; +const MANAGED_MARKER = '# omniroute-managed = "true"'; +const LEGACY_DESCRIPTION = "Routed via OmniRoute gateway"; +const MODELS_SECTION = "models"; +const SUBAGENT_MODELS_SECTION = "subagents.models"; + +const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +const tomlString = (value: string): string => JSON.stringify(String(value)); +const modelSlot = (type: GrokSubagentType): string => `${GROK_MAIN_MODEL_SLOT}-${type}`; + +const sectionRegExp = (section: string): RegExp => + new RegExp(`^\\[${escapeRegExp(section)}\\][ \\t]*\\r?\\n((?:(?!\\[)[^\\r\\n]*\\r?\\n?)*)`, "m"); + +const previousDefaultRegExp = /^# omniroute-prev-default = "([^"]*)"[ \t]*\r?\n?/m; +const previousSubagentRegExp = (type: GrokSubagentType): RegExp => + new RegExp(`^# omniroute-prev-subagent-${escapeRegExp(type)} = "([^"]*)"[ \\t]*\\r?\\n?`, "m"); + +const getSectionBody = (toml: string, section: string): string | null => + toml.match(sectionRegExp(section))?.[1] ?? null; + +const getSectionString = (toml: string, section: string, key: string): string | null => { + const body = getSectionBody(toml, section); + if (body === null) return null; + const field = body.match(new RegExp(`^[ \\t]*${escapeRegExp(key)}[ \\t]*=[ \\t]*"([^"]*)"`, "m")); + return field?.[1] ?? null; +}; + +const getSectionNumber = (toml: string, section: string, key: string): number | null => { + const body = getSectionBody(toml, section); + if (body === null) return null; + const field = body.match(new RegExp(`^[ \\t]*${escapeRegExp(key)}[ \\t]*=[ \\t]*([0-9]+)`, "m")); + if (!field) return null; + const value = Number(field[1]); + return Number.isSafeInteger(value) && value > 0 ? value : null; +}; + +const setSectionString = (toml: string, section: string, key: string, value: string): string => { + const match = toml.match(sectionRegExp(section)); + const line = `${key} = ${tomlString(value)}`; + if (!match) { + const prefix = toml.length > 0 && !toml.endsWith("\n") ? `${toml}\n` : toml; + return `${prefix}${prefix ? "\n" : ""}[${section}]\n${line}\n`; + } + + const body = match[1] ?? ""; + const fieldRegExp = new RegExp( + `^[ \\t]*${escapeRegExp(key)}[ \\t]*=[^\\r\\n]*(?:\\r?\\n|$)`, + "m" + ); + const nextBody = fieldRegExp.test(body) + ? body.replace(fieldRegExp, `${line}\n`) + : `${line}\n${body}`; + return toml.replace(match[0], `[${section}]\n${nextBody}`); +}; + +const deleteSectionField = (toml: string, section: string, key: string): string => { + const match = toml.match(sectionRegExp(section)); + if (!match) return toml; + const fieldRegExp = new RegExp( + `^[ \\t]*${escapeRegExp(key)}[ \\t]*=[^\\r\\n]*(?:\\r?\\n|$)`, + "m" + ); + const nextBody = (match[1] ?? "").replace(fieldRegExp, ""); + if (!nextBody.trim()) return toml.replace(match[0], "").replace(/\n{3,}/g, "\n\n"); + return toml.replace(match[0], `[${section}]\n${nextBody}`); +}; + +const parseModelSection = (toml: string, slot: string): GrokModelConfig | null => { + if (getSectionBody(toml, `model.${slot}`) === null) return null; + return { + model: getSectionString(toml, `model.${slot}`, "model"), + base_url: getSectionString(toml, `model.${slot}`, "base_url"), + name: getSectionString(toml, `model.${slot}`, "name"), + api_key: getSectionString(toml, `model.${slot}`, "api_key"), + api_backend: getSectionString(toml, `model.${slot}`, "api_backend"), + context_window: getSectionNumber(toml, `model.${slot}`, "context_window"), + }; +}; + +const buildModelSection = (options: { + slot: string; + model: string; + baseUrl: string; + apiKey?: string | null; + contextWindow?: number; + name: string; +}): string => { + const lines = [ + `[model.${options.slot}]`, + MANAGED_MARKER, + `model = ${tomlString(options.model)}`, + `base_url = ${tomlString(options.baseUrl)}`, + `name = ${tomlString(options.name)}`, + `description = ${tomlString(LEGACY_DESCRIPTION)}`, + 'api_backend = "chat_completions"', + ]; + if (options.apiKey) lines.push(`api_key = ${tomlString(options.apiKey)}`); + if (Number.isSafeInteger(options.contextWindow) && Number(options.contextWindow) > 0) { + lines.push(`context_window = ${Math.floor(Number(options.contextWindow))}`); + } + return `${lines.join("\n")}\n`; +}; + +const upsertModelSection = ( + toml: string, + options: Parameters[0] +): string => { + const regexp = sectionRegExp(`model.${options.slot}`); + const section = buildModelSection(options); + if (regexp.test(toml)) return toml.replace(regexp, section); + const prefix = toml.length > 0 && !toml.endsWith("\n") ? `${toml}\n` : toml; + return `${prefix}${prefix ? "\n" : ""}${section}`; +}; + +const removeModelSection = (toml: string, slot: string): string => + toml.replace(sectionRegExp(`model.${slot}`), "").replace(/\n{3,}/g, "\n\n"); + +const insertMarker = (toml: string, marker: string): string => { + const mainSection = sectionRegExp(`model.${GROK_MAIN_MODEL_SLOT}`); + if (mainSection.test(toml)) { + return toml.replace(mainSection, (section) => `${marker}${section}`); + } + const prefix = toml.length > 0 && !toml.endsWith("\n") ? `${toml}\n` : toml; + return `${prefix}${marker}`; +}; + +const rememberPreviousDefault = (toml: string): string => { + if (previousDefaultRegExp.test(toml)) return toml; + const current = getSectionString(toml, MODELS_SECTION, "default"); + const previous = !current || current === "grok-build" ? UNSET_SENTINEL : current; + if (current === GROK_MAIN_MODEL_SLOT) return toml; + return insertMarker(toml, `# omniroute-prev-default = ${tomlString(previous)}\n`); +}; + +const restorePreviousDefault = (toml: string): string => { + const previous = toml.match(previousDefaultRegExp)?.[1] ?? UNSET_SENTINEL; + let next = toml.replace(previousDefaultRegExp, ""); + if (getSectionString(next, MODELS_SECTION, "default") !== GROK_MAIN_MODEL_SLOT) return next; + if (previous === UNSET_SENTINEL || previous === "grok-build") { + return deleteSectionField(next, MODELS_SECTION, "default"); + } + return setSectionString(next, MODELS_SECTION, "default", previous); +}; + +const rememberPreviousSubagent = (toml: string, type: GrokSubagentType): string => { + if (previousSubagentRegExp(type).test(toml)) return toml; + const current = getSectionString(toml, SUBAGENT_MODELS_SECTION, type); + const previous = current ?? UNSET_SENTINEL; + return insertMarker(toml, `# omniroute-prev-subagent-${type} = ${tomlString(previous)}\n`); +}; + +const restorePreviousSubagent = (toml: string, type: GrokSubagentType): string => { + const regexp = previousSubagentRegExp(type); + const previous = toml.match(regexp)?.[1] ?? UNSET_SENTINEL; + let next = toml.replace(regexp, ""); + if (getSectionString(next, SUBAGENT_MODELS_SECTION, type) !== modelSlot(type)) return next; + if (previous === UNSET_SENTINEL) { + return deleteSectionField(next, SUBAGENT_MODELS_SECTION, type); + } + return setSectionString(next, SUBAGENT_MODELS_SECTION, type, previous); +}; + +const isLegacyOwnedMainSection = (toml: string): boolean => { + const section = parseModelSection(toml, GROK_MAIN_MODEL_SLOT); + if (!section) return false; + const body = getSectionBody(toml, `model.${GROK_MAIN_MODEL_SLOT}`) ?? ""; + const keys = [...body.matchAll(/^\s*([A-Za-z0-9_-]+)\s*=/gm)].map((match) => match[1]); + const allowed = new Set(["model", "base_url", "name", "description", "api_backend", "api_key"]); + return ( + keys.every((key) => allowed.has(key)) && + section.model !== null && + section.base_url !== null && + section.name === "OmniRoute" && + section.api_backend === "chat_completions" && + getSectionString(toml, `model.${GROK_MAIN_MODEL_SLOT}`, "description") === LEGACY_DESCRIPTION + ); +}; + +const assertMainSlotOwnership = (toml: string): void => { + const body = getSectionBody(toml, `model.${GROK_MAIN_MODEL_SLOT}`); + if (body === null || body.includes(MANAGED_MARKER) || isLegacyOwnedMainSection(toml)) return; + throw new GrokBuildConfigConflictError(); +}; + +export class GrokBuildConfigConflictError extends Error { + constructor() { + super("The [model.omniroute] table exists and OmniRoute does not own it"); + this.name = "GrokBuildConfigConflictError"; + } +} + +/** Parse the Grok Build fields that OmniRoute manages. */ +export function parseGrokBuildConfig(toml: string): GrokBuildSettings { + if (toml.trim()) parseToml(toml); + const subagentModels = {} as Record; + const subagentMappings = {} as Record; + for (const type of GROK_SUBAGENT_TYPES) { + const mapping = getSectionString(toml, SUBAGENT_MODELS_SECTION, type); + subagentMappings[type] = mapping; + subagentModels[type] = mapping === modelSlot(type) ? parseModelSection(toml, mapping) : null; + } + return { + model: parseModelSection(toml, GROK_MAIN_MODEL_SLOT), + default: getSectionString(toml, MODELS_SECTION, "default"), + subagentModels, + subagentMappings, + }; +} + +/** Apply the OmniRoute model slots and preserve unrelated TOML text. */ +export function applyGrokBuildConfig(toml: string, options: GrokBuildApplyOptions): string { + if (toml.trim()) parseToml(toml); + assertMainSlotOwnership(toml); + let next = rememberPreviousDefault(toml); + next = upsertModelSection(next, { + slot: GROK_MAIN_MODEL_SLOT, + model: options.model, + baseUrl: options.baseUrl, + apiKey: options.apiKey, + contextWindow: options.contextWindow, + name: "OmniRoute", + }); + next = setSectionString(next, MODELS_SECTION, "default", GROK_MAIN_MODEL_SLOT); + + if (options.subagentModels !== undefined) { + for (const type of GROK_SUBAGENT_TYPES) { + const selected = options.subagentModels[type]; + const slot = modelSlot(type); + if (selected?.model) { + next = rememberPreviousSubagent(next, type); + next = upsertModelSection(next, { + slot, + model: selected.model, + baseUrl: options.baseUrl, + apiKey: options.apiKey, + contextWindow: selected.contextWindow, + name: `OmniRoute ${type}`, + }); + next = setSectionString(next, SUBAGENT_MODELS_SECTION, type, slot); + } else { + next = restorePreviousSubagent(next, type); + next = removeModelSection(next, slot); + } + } + } + return next; +} + +/** Remove the OmniRoute model slots and restore values that users did not change. */ +export function resetGrokBuildConfig(toml: string): string { + if (toml.trim()) parseToml(toml); + let next = toml; + for (const type of GROK_SUBAGENT_TYPES) { + next = restorePreviousSubagent(next, type); + next = removeModelSection(next, modelSlot(type)); + } + next = removeModelSection(next, GROK_MAIN_MODEL_SLOT); + next = restorePreviousDefault(next); + return next.replace(/\n{3,}/g, "\n\n"); +} + +/** Return the managed slot for a Grok Build subagent type. */ +export function getGrokSubagentSlot(type: string): string | null { + return GROK_SUBAGENT_TYPES.includes(type as GrokSubagentType) + ? modelSlot(type as GrokSubagentType) + : null; +} diff --git a/tests/integration/all-statuses-route.test.ts b/tests/integration/all-statuses-route.test.ts index b3179862f1..858b09e993 100644 --- a/tests/integration/all-statuses-route.test.ts +++ b/tests/integration/all-statuses-route.test.ts @@ -254,3 +254,40 @@ test("refresh=true bypasses a matching cached CLI result", async () => { const body = (await response.json()) as Record; assert.notEqual(body[toolId]?.detection?.version, cachedVersion); }); + +test("grok-build status uses GROK_HOME and returns its managed endpoint", async () => { + const grokHome = fs.mkdtempSync(path.join(os.tmpdir(), "all-statuses-grok-home-")); + const original = process.env.GROK_HOME; + process.env.GROK_HOME = grokHome; + try { + fs.writeFileSync( + path.join(grokHome, "config.toml"), + [ + "[models]", + 'default = "omniroute"', + "", + "[model.omniroute]", + 'model = "openai/gpt-5.5"', + 'base_url = "https://gateway.example/v1"', + 'api_backend = "chat_completions"', + "", + ].join("\n") + ); + const response = await allStatusesRoute.GET( + new Request("http://localhost/api/cli-tools/all-statuses?refresh=true") + ); + assert.equal(response.status, 200); + const body = (await response.json()) as Record< + string, + { config?: { status?: string; endpoint?: string | null } } + >; + if (body["grok-build"]?.config?.status !== "not_installed") { + assert.equal(body["grok-build"]?.config?.status, "configured"); + } + assert.equal(body["grok-build"]?.config?.endpoint, "https://gateway.example/v1"); + } finally { + if (original === undefined) delete process.env.GROK_HOME; + else process.env.GROK_HOME = original; + fs.rmSync(grokHome, { recursive: true, force: true }); + } +}); diff --git a/tests/integration/cli-settings-grok-build.test.ts b/tests/integration/cli-settings-grok-build.test.ts index 6d63965279..3353d3602a 100644 --- a/tests/integration/cli-settings-grok-build.test.ts +++ b/tests/integration/cli-settings-grok-build.test.ts @@ -20,15 +20,21 @@ const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-grok-buil process.env.DATA_DIR = TEST_DATA_DIR; process.env.API_KEY_SECRET = "test-api-key-secret-grok-build"; process.env.JWT_SECRET = "test-jwt-secret-grok-build"; +// guardCliConfigWrite refuses a write when the target isn't bind-mounted from +// the host inside a real container (see cliConfigWriteGuard.ts). This test +// suite runs inside CI/devbox containers with no such mount for its tmpdir +// fixtures, so allow the write here — the refusal path itself is covered by +// tests/unit/cli-tools-apply-container-422.test.ts. +process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE = "1"; // Import DB reset helpers (must be before route import) const core = await import("../../src/lib/db/core.ts"); const localDb = await import("../../src/lib/localDb.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); // Import route handlers -const { GET, POST, DELETE } = await import( - "../../src/app/api/cli-tools/grok-build-settings/route.ts" -); +const { GET, POST, DELETE } = + await import("../../src/app/api/cli-tools/grok-build-settings/route.ts"); async function resetStorage() { delete process.env.INITIAL_PASSWORD; @@ -149,10 +155,10 @@ test("grok-build-settings POST: writes [model.omniroute] section and preserves e preservedLines.includes('base_url = "https://example.test/v1"'), "Pre-existing unrelated [model.*] section must be preserved" ); - // The previous default must be remembered for Reset to restore. + // The obsolete built-in default must become an absent-value sentinel. assert.ok( - content.includes('omniroute-prev-default = "grok-build"'), - "Previous default should be remembered as a marker comment" + content.includes('omniroute-prev-default = "__omniroute_unset__"'), + "An obsolete default must use the absent-value sentinel" ); } } finally { @@ -175,7 +181,7 @@ test("grok-build-settings DELETE: removes our section, preserves the rest, resto "[models]", 'default = "omniroute"', "", - "# omniroute-prev-default = \"grok-build\"", + '# omniroute-prev-default = "grok-build"', "[model.omniroute]", 'model = "grok-4.5"', 'base_url = "http://localhost:20128/v1"', @@ -209,7 +215,7 @@ test("grok-build-settings DELETE: removes our section, preserves the rest, resto survivingLines.includes('base_url = "https://example.test/v1"'), "Unrelated section must survive" ); - assert.ok(content.includes('default = "grok-build"'), "Previous default should be restored"); + assert.ok(!/^default\s*=/m.test(content), "The obsolete default must not be restored"); } } finally { process.env.HOME = origHome; @@ -235,6 +241,111 @@ test("grok-build-settings DELETE: no-op success when no config file exists", asy } }); +test("grok-build-settings: honors GROK_HOME and rejects a relative value", async () => { + const grokHome = fs.mkdtempSync(path.join(os.tmpdir(), "grok-build-env-home-")); + const original = process.env.GROK_HOME; + try { + process.env.GROK_HOME = grokHome; + const res = await POST( + new Request("http://localhost/api/cli-tools/grok-build-settings", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + baseUrl: "http://localhost:20128/v1/v1/", + apiKey: "sk-private", + model: "openai/gpt-5.5", + contextWindow: 321000, + subagentModels: { + explore: { model: "xai/grok-4", contextWindow: 256000 }, + }, + }), + }) + ); + assert.equal(res.status, 200); + const configPath = path.join(grokHome, "config.toml"); + const content = fs.readFileSync(configPath, "utf8"); + assert.match(content, /base_url = "http:\/\/localhost:20128\/v1"/); + assert.match(content, /context_window = 321000/); + assert.match(content, /\[model\.omniroute-explore\]/); + assert.match(content, /explore = "omniroute-explore"/); + + const getRes = await GET(new Request("http://localhost/api/cli-tools/grok-build-settings")); + assert.equal(getRes.status, 200); + const body = await getRes.json(); + assert.equal(body.apiKeyConfigured, true); + assert.equal("api_key" in body.config.model, false); + assert.equal("api_key" in body.config.subagentModels.explore, false); + assert.doesNotMatch(JSON.stringify(body), /sk-private/); + + process.env.GROK_HOME = "relative/path"; + const invalid = await POST( + new Request("http://localhost/api/cli-tools/grok-build-settings", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ baseUrl: "http://localhost:20128", model: "xai/grok-4" }), + }) + ); + assert.equal(invalid.status, 500); + } finally { + if (original === undefined) delete process.env.GROK_HOME; + else process.env.GROK_HOME = original; + fs.rmSync(grokHome, { recursive: true, force: true }); + } +}); + +test("grok-build-settings POST: returns 409 for an unowned omniroute slot", async () => { + const grokHome = fs.mkdtempSync(path.join(os.tmpdir(), "grok-build-conflict-")); + const original = process.env.GROK_HOME; + process.env.GROK_HOME = grokHome; + try { + fs.writeFileSync( + path.join(grokHome, "config.toml"), + '[model.omniroute]\nmodel = "private"\nbase_url = "https://example.test/v1"\n' + ); + const res = await POST( + new Request("http://localhost/api/cli-tools/grok-build-settings", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ baseUrl: "http://localhost:20128", model: "xai/grok-4" }), + }) + ); + assert.equal(res.status, 409); + } finally { + if (original === undefined) delete process.env.GROK_HOME; + else process.env.GROK_HOME = original; + fs.rmSync(grokHome, { recursive: true, force: true }); + } +}); + +test("grok-build-settings POST: resolves keyId to an unmasked key", async () => { + const grokHome = fs.mkdtempSync(path.join(os.tmpdir(), "grok-build-key-id-")); + const original = process.env.GROK_HOME; + process.env.GROK_HOME = grokHome; + try { + const key = await apiKeysDb.createApiKey("Grok Build key", "grok-build-test-machine"); + const res = await POST( + new Request("http://localhost/api/cli-tools/grok-build-settings", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + baseUrl: "http://localhost:20128", + keyId: key.id, + apiKey: "sk_****", + model: "openai/gpt-5.5", + }), + }) + ); + assert.equal(res.status, 200); + const content = fs.readFileSync(path.join(grokHome, "config.toml"), "utf8"); + assert.match(content, new RegExp(`api_key = ${JSON.stringify(key.key)}`)); + assert.doesNotMatch(content, /sk_\*\*\*\*/); + } finally { + if (original === undefined) delete process.env.GROK_HOME; + else process.env.GROK_HOME = original; + fs.rmSync(grokHome, { recursive: true, force: true }); + } +}); + // ── Test 6: Error sanitization (Hard Rule #12) ─────────────────────────────── test("grok-build-settings: error responses do not leak stack traces", async () => { diff --git a/tests/unit/check-tool-config-status.test.ts b/tests/unit/check-tool-config-status.test.ts index 598bdd5354..fad55e935b 100644 --- a/tests/unit/check-tool-config-status.test.ts +++ b/tests/unit/check-tool-config-status.test.ts @@ -40,11 +40,7 @@ async function writeCodexConfig(opts: { if (opts.authApiKey !== undefined) { const authPath = path.join(tmpDir, "auth.json"); - await fs.writeFile( - authPath, - JSON.stringify({ OPENAI_API_KEY: opts.authApiKey }), - "utf-8" - ); + await fs.writeFile(authPath, JSON.stringify({ OPENAI_API_KEY: opts.authApiKey }), "utf-8"); } return configPath; @@ -62,10 +58,7 @@ test("claude: returns 'configured' when ANTHROPIC_BASE_URL is set", async () => }); test("claude: returns 'not_configured' when ANTHROPIC_BASE_URL is absent", async () => { - const configPath = await writeTempFile( - "settings.json", - JSON.stringify({ env: {} }) - ); + const configPath = await writeTempFile("settings.json", JSON.stringify({ env: {} })); const result = await checkToolConfigStatus("claude", configPath); assert.equal(result, "not_configured"); }); @@ -116,6 +109,29 @@ test("hermes: returns 'not_configured' when config points elsewhere", async () = assert.equal(result, "not_configured"); }); +test("grok-build: requires the managed default and chat completions backend", async () => { + const configured = await writeTempFile( + "config.toml", + [ + "[models]", + 'default = "omniroute"', + "", + "[model.omniroute]", + 'model = "openai/gpt-5.5"', + 'base_url = "https://gateway.example/v1"', + 'api_backend = "chat_completions"', + "", + ].join("\n") + ); + assert.equal(await checkToolConfigStatus("grok-build", configured), "configured"); + + const inactive = await writeTempFile( + "config.toml", + '[models]\ndefault = "custom"\n\n[model.omniroute]\nbase_url = "https://gateway.example/v1"\n' + ); + assert.equal(await checkToolConfigStatus("grok-build", inactive), "not_configured"); +}); + // ── Droid / Openclaw / Kilo ─────────────────────────────────────────────────── test("droid: returns 'configured' when JSON config contains sk_omniroute marker", async () => { @@ -205,10 +221,7 @@ test("error path: non-existent file returns 'not_configured' (no throw)", async test("unknown toolId: returns 'unknown' (no configPath for unknown tool)", async () => { // unknown tool has no config path via getCliPrimaryConfigPath — configPathOverride not needed // but we can also test via override with a valid JSON file to hit the default branch - const configPath = await writeTempFile( - "unknown.json", - JSON.stringify({ foo: "bar" }) - ); + const configPath = await writeTempFile("unknown.json", JSON.stringify({ foo: "bar" })); const result = await checkToolConfigStatus("totally-unknown-tool-id", configPath); assert.equal(result, "unknown"); }); diff --git a/tests/unit/grok-build-config.test.ts b/tests/unit/grok-build-config.test.ts new file mode 100644 index 0000000000..189035c429 --- /dev/null +++ b/tests/unit/grok-build-config.test.ts @@ -0,0 +1,165 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + applyGrokBuildConfig, + GrokBuildConfigConflictError, + parseGrokBuildConfig, + resetGrokBuildConfig, +} from "../../src/shared/services/grokBuildConfig.ts"; + +const main = { + baseUrl: "http://localhost:20128/v1", + apiKey: "sk-test", + model: "openai/gpt-5.5", + contextWindow: 400000, +}; + +test("apply adds the main slot and preserves unrelated TOML", () => { + const source = [ + "# user comment", + "[models]", + 'default = "custom"', + 'theme = "dark"', + "", + "[model.custom]", + 'model = "custom-model"', + 'base_url = "https://example.test/v1"', + "", + ].join("\n"); + + const result = applyGrokBuildConfig(source, main); + const parsed = parseGrokBuildConfig(result); + + assert.equal(parsed.default, "omniroute"); + assert.equal(parsed.model?.model, main.model); + assert.equal(parsed.model?.context_window, main.contextWindow); + assert.match(result, /# omniroute-prev-default = "custom"/); + assert.match(result, /# user comment/); + assert.match(result, /theme = "dark"/); + assert.match(result, /\[model\.custom\]/); +}); + +test("apply records an absent default once and reset removes it", () => { + const appliedTwice = applyGrokBuildConfig(applyGrokBuildConfig("[ui]\ncompact = true\n", main), { + ...main, + model: "anthropic/claude-opus-4-1", + }); + + assert.equal(appliedTwice.match(/omniroute-prev-default/g)?.length, 1); + const reset = resetGrokBuildConfig(appliedTwice); + assert.doesNotMatch(reset, /^default\s*=/m); + assert.doesNotMatch(reset, /\[model\.omniroute\]/); + assert.match(reset, /\[ui\]\ncompact = true/); +}); + +test("reset never restores the obsolete grok-build default", () => { + const source = [ + "[models]", + 'default = "omniroute"', + "", + '# omniroute-prev-default = "grok-build"', + "[model.omniroute]", + '# omniroute-managed = "true"', + 'model = "openai/gpt-5.5"', + 'base_url = "http://localhost:20128/v1"', + 'name = "OmniRoute"', + 'description = "Routed via OmniRoute gateway"', + 'api_backend = "chat_completions"', + "", + ].join("\n"); + + const result = resetGrokBuildConfig(source); + assert.doesNotMatch(result, /^default\s*=/m); + assert.doesNotMatch(result, /grok-build/); +}); + +test("apply manages all subagent slots and keeps context windows", () => { + const result = applyGrokBuildConfig( + ["[subagents.models]", 'general-purpose = "old-general"', 'explore = "old-explore"', ""].join( + "\n" + ), + { + ...main, + subagentModels: { + "general-purpose": { model: "google/gemini-2.5-pro", contextWindow: 1048576 }, + plan: { model: "anthropic/claude-sonnet-4", contextWindow: 200000 }, + }, + } + ); + const parsed = parseGrokBuildConfig(result); + + assert.equal(parsed.subagentMappings["general-purpose"], "omniroute-general-purpose"); + assert.equal(parsed.subagentMappings.explore, "old-explore"); + assert.equal(parsed.subagentMappings.plan, "omniroute-plan"); + assert.equal(parsed.subagentModels["general-purpose"]?.context_window, 1048576); + assert.equal(parsed.subagentModels.plan?.context_window, 200000); + assert.match(result, /omniroute-prev-subagent-general-purpose = "old-general"/); + assert.match(result, /omniroute-prev-subagent-plan = "__omniroute_unset__"/); +}); + +test("an absent subagentModels property preserves current subagent values", () => { + const source = applyGrokBuildConfig("", { + ...main, + subagentModels: { explore: { model: "xai/grok-4", contextWindow: 256000 } }, + }); + const result = applyGrokBuildConfig(source, { ...main, model: "openai/gpt-5.5-codex" }); + + assert.equal(parseGrokBuildConfig(result).subagentModels.explore?.model, "xai/grok-4"); +}); + +test("an empty subagentModels object removes all managed overrides", () => { + const source = applyGrokBuildConfig('[subagents.models]\nexplore = "user-explore"\n', { + ...main, + subagentModels: { + explore: { model: "xai/grok-4", contextWindow: 256000 }, + plan: { model: "openai/gpt-5.5", contextWindow: 400000 }, + }, + }); + const result = applyGrokBuildConfig(source, { ...main, subagentModels: {} }); + const parsed = parseGrokBuildConfig(result); + + assert.equal(parsed.subagentMappings.explore, "user-explore"); + assert.equal(parsed.subagentMappings.plan, null); + assert.doesNotMatch(result, /\[model\.omniroute-(?:explore|plan)\]/); +}); + +test("reset restores only mappings that still reference managed slots", () => { + let source = applyGrokBuildConfig('[subagents.models]\nexplore = "old-explore"\n', { + ...main, + subagentModels: { explore: { model: "xai/grok-4", contextWindow: 256000 } }, + }); + source = source.replace('explore = "omniroute-explore"', 'explore = "user-changed-explore"'); + + const result = resetGrokBuildConfig(source); + assert.match(result, /explore = "user-changed-explore"/); + assert.doesNotMatch(result, /omniroute-prev-subagent/); +}); + +test("apply accepts the exact legacy OmniRoute table", () => { + const legacy = [ + "[model.omniroute]", + 'model = "grok-4.5"', + 'base_url = "http://localhost:20128/v1"', + 'name = "OmniRoute"', + 'description = "Routed via OmniRoute gateway"', + 'api_backend = "chat_completions"', + 'api_key = "sk-old"', + "", + ].join("\n"); + + assert.doesNotThrow(() => applyGrokBuildConfig(legacy, main)); +}); + +test("apply rejects an unowned model.omniroute table", () => { + const source = [ + "[model.omniroute]", + 'model = "private-model"', + 'base_url = "https://example.test/v1"', + 'name = "User model"', + 'api_backend = "chat_completions"', + "", + ].join("\n"); + + assert.throws(() => applyGrokBuildConfig(source, main), GrokBuildConfigConflictError); +}); diff --git a/tests/unit/ui/GrokBuildToolCard.test.tsx b/tests/unit/ui/GrokBuildToolCard.test.tsx new file mode 100644 index 0000000000..8b693c2780 --- /dev/null +++ b/tests/unit/ui/GrokBuildToolCard.test.tsx @@ -0,0 +1,264 @@ +// @vitest-environment jsdom +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +let manualConfig = ""; + +vi.mock("@/shared/components", () => ({ + Card: ({ children }: { children: React.ReactNode }) =>
{children}
, + Button: ({ children, onClick, disabled }: React.ButtonHTMLAttributes) => ( + + ), + ModelSelectModal: () => null, + ManualConfigModal: ({ + isOpen, + configs, + }: { + isOpen: boolean; + configs: Array<{ content: string }>; + }) => { + manualConfig = configs[0]?.content ?? ""; + return isOpen ?
{manualConfig}
: null; + }, +})); + +const fetchMock = vi.fn(); +vi.stubGlobal("fetch", fetchMock); + +const { default: GrokBuildToolCard } = + await import("@/app/(dashboard)/dashboard/cli-code/components/GrokBuildToolCard"); + +let container: HTMLElement; +let root: Root; + +const response = (body: unknown, ok = true) => ({ ok, json: async () => body }); + +const renderCard = async () => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root.render( + + ); + await Promise.resolve(); + await Promise.resolve(); + }); +}; + +beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + manualConfig = ""; + localStorage.clear(); + localStorage.setItem( + "omniroute.grokBuildEndpointPresets", + JSON.stringify([{ name: "Saved office", baseUrl: "https://office.example" }]) + ); + fetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url === "/api/cli-tools/grok-build-settings" && init?.method === "POST") { + return Promise.resolve(response({ success: true })); + } + if (url === "/api/cli-tools/grok-build-settings" && init?.method === "DELETE") { + return Promise.resolve(response({ success: true })); + } + if (url === "/api/cli-tools/grok-build-settings") { + return Promise.resolve( + response({ + installed: true, + runnable: true, + hasOmniRoute: true, + config: { + model: { + model: "openai/gpt-5.5", + base_url: "http://127.0.0.1:30200/v1", + context_window: 400000, + }, + subagentModels: {}, + }, + }) + ); + } + if (url === "/api/settings") { + return Promise.resolve( + response({ + apiPort: 30200, + cloudUrl: "https://cloud.example", + machineId: "machine-1", + }) + ); + } + if (url === "/api/tunnels/cloudflared") { + return Promise.resolve(response({ apiUrl: "https://cf.example/v1" })); + } + if (url === "/api/tunnels/tailscale") { + return Promise.resolve(response({ tunnelUrl: "https://tail.example" })); + } + if (url === "/api/tunnels/ngrok") { + return Promise.resolve(response({ publicUrl: "https://ngrok.example" })); + } + if (url === "/api/cli-tools/backups?tool=grok-build") { + return Promise.resolve(response({ backups: [] })); + } + return Promise.resolve(response({})); + }); +}); + +afterEach(() => { + act(() => root?.unmount()); + container?.remove(); + vi.clearAllMocks(); +}); + +describe("GrokBuildToolCard", () => { + it("loads local, cloud, tunnel, saved, and custom endpoint choices", async () => { + await renderCard(); + const text = container.textContent ?? ""; + expect(text).toContain("http://127.0.0.1:30200/v1"); + expect(text).toContain("https://cloud.example/machine-1/v1"); + expect(text).toContain("https://cf.example/v1"); + expect(text).toContain("https://tail.example/v1"); + expect(text).toContain("https://ngrok.example/v1"); + expect(text).toContain("Saved office"); + expect(text).toContain("Custom"); + }); + + it("sends keyId and the complete subagent set on Apply", async () => { + await renderCard(); + const inputs = Array.from(container.querySelectorAll("input")); + const explore = inputs.find((input) => input.getAttribute("aria-label") === "Explore model"); + await act(async () => { + if (!explore) throw new Error("Explore model input is missing"); + const trackedValue = ( + explore as HTMLInputElement & { _valueTracker?: { setValue(value: string): void } } + )._valueTracker; + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + setter?.call(explore, "xai/grok-4"); + trackedValue?.setValue(""); + explore.dispatchEvent(new Event("input", { bubbles: true })); + await Promise.resolve(); + }); + const apply = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Apply") + ); + await act(async () => apply?.click()); + + const call = fetchMock.mock.calls.find( + ([url, init]) => url === "/api/cli-tools/grok-build-settings" && init?.method === "POST" + ); + const body = JSON.parse(String(call?.[1]?.body)); + expect(body.keyId).toBe("key-1"); + expect(body.apiKey).toBeUndefined(); + expect(body.model).toBe("openai/gpt-5.5"); + expect(body.subagentModels).toEqual({ explore: { model: "xai/grok-4" } }); + }); + + it("resets settings and shows manual TOML without a real key", async () => { + await renderCard(); + const buttons = Array.from(container.querySelectorAll("button")); + await act(async () => buttons.find((button) => button.textContent?.includes("Reset"))?.click()); + expect(fetchMock).toHaveBeenCalledWith( + "/api/cli-tools/grok-build-settings", + expect.objectContaining({ method: "DELETE" }) + ); + + await act(async () => + buttons.find((button) => button.textContent?.includes("Manual Config"))?.click() + ); + expect(container.querySelector("[data-testid='manual-toml']")).not.toBeNull(); + expect(manualConfig).toContain(""); + expect(manualConfig).toContain('api_backend = "chat_completions"'); + expect(manualConfig).not.toContain("sk_****"); + }); + + it("shows API errors", async () => { + fetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => { + if (String(input) === "/api/cli-tools/grok-build-settings" && init?.method === "POST") { + return Promise.resolve(response({ error: { message: "Conflict" } }, false)); + } + if (String(input) === "/api/settings") { + return Promise.resolve(response({ apiPort: 30200 })); + } + if (String(input) === "/api/cli-tools/grok-build-settings") { + return Promise.resolve( + response({ + installed: true, + runnable: true, + hasOmniRoute: false, + config: { model: { model: "openai/gpt-5.5" } }, + }) + ); + } + return Promise.resolve(response({})); + }); + await renderCard(); + const apply = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Apply") + ); + await act(async () => apply?.click()); + expect(container.querySelector("[role='status']")?.textContent).toContain("Conflict"); + }); + + it("uses the standard CLI card rows and restores backups", async () => { + fetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url === "/api/cli-tools/backups" && init?.method === "POST") { + return Promise.resolve(response({ success: true })); + } + if (url === "/api/cli-tools/backups?tool=grok-build") { + return Promise.resolve( + response({ backups: [{ id: "config_2026.toml", createdAt: "2026-08-20T00:00:00Z" }] }) + ); + } + if (url === "/api/settings") return Promise.resolve(response({ apiPort: 30200 })); + if (url === "/api/cli-tools/grok-build-settings") { + return Promise.resolve( + response({ + installed: true, + runnable: true, + hasOmniRoute: true, + config: { + model: { + model: "openai/gpt-5.5", + base_url: "http://127.0.0.1:30200/v1", + }, + subagentModels: {}, + }, + }) + ); + } + return Promise.resolve(response({})); + }); + await renderCard(); + expect(container.textContent).toContain("Base URL"); + expect(container.textContent).toContain("Subagent model overrides"); + const backups = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Backups") + ); + await act(async () => backups?.click()); + const restore = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Restore" + ); + await act(async () => restore?.click()); + const call = fetchMock.mock.calls.find( + ([url, init]) => url === "/api/cli-tools/backups" && init?.method === "POST" + ); + expect(JSON.parse(String(call?.[1]?.body))).toEqual({ + tool: "grok-build", + backupId: "config_2026.toml", + }); + }); +}); diff --git a/tests/unit/ui/ToolDetailClient.test.tsx b/tests/unit/ui/ToolDetailClient.test.tsx index e65be3684e..40f8d56682 100644 --- a/tests/unit/ui/ToolDetailClient.test.tsx +++ b/tests/unit/ui/ToolDetailClient.test.tsx @@ -73,6 +73,17 @@ vi.mock("@/shared/constants/cliTools", () => ({ baseUrlSupport: "full", defaultModels: [], }, + "grok-build": { + id: "grok-build", + name: "Grok Build", + icon: "terminal", + color: "#1DA1F2", + category: "code", + configType: "custom", + vendor: "xAI", + baseUrlSupport: "full", + defaultModels: [], + }, "hermes-agent": { id: "hermes-agent", name: "Hermes Agent", @@ -125,6 +136,7 @@ vi.mock("../../../src/app/(dashboard)/dashboard/cli-code/components/index", () = CopilotToolCard: () =>
, CustomCliCard: () =>
, HermesAgentToolCard: () =>
, + GrokBuildToolCard: () =>
, })); vi.mock("../../../src/app/(dashboard)/dashboard/cli-code/components/CliproxyapiToolCard", () => ({ @@ -288,6 +300,13 @@ describe("ToolDetailClient", () => { expect(card!.getAttribute("data-toolid")).toBe("forge"); }); + it("renders GrokBuildToolCard for grok-build", async () => { + const container = renderDetail("grok-build", "code"); + await act(async () => {}); + expect(container.querySelector("[data-testid='GrokBuildToolCard']")).not.toBeNull(); + expect(container.querySelector("[data-testid='DefaultToolCard']")).toBeNull(); + }); + it("renders nothing (null) for completely unknown toolId", async () => { const container = renderDetail("totally-unknown-xyz", "code"); await act(async () => {});