mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-21 22:52:19 +03:00
feat(cli): support Grok Build custom host configuration (#10830)
Adds a Grok Build card to the CLI Code dashboard: configures the main model and per-subagent model overrides, surgically upserts only the [model.omniroute] section of config.toml (preserving unrelated user sections), resolves config.toml via GROK_HOME or the CLI config home, and omits API keys from settings responses. Closes #10829. Validated in an isolated worktree boarded onto origin/release/v3.8.50 (0 conflicts, 14 files): - 6/48 focused node:test cases in tests/integration/cli-settings-grok-build.test.ts initially failed with 422 instead of 200/409 — traced to this route being the first CLI-tool settings route to actually pass a targetPath into the shared guardCliConfigWrite/ensureCliConfigWriteAllowed container-ephemeral-write guard (every other cli-tools/*-settings route calls it with no path, which always no-ops). This devbox genuinely runs inside Docker with no bind mount for the test's tmpdir fixtures, so the guard correctly refused — a real environment-dependent gap in the PR's own test setup (missing OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE=1, the pattern already used by tests/unit/cli-tools-apply-container-422.test.ts for this exact guard). Fixed by setting that flag in the test file's setup, pushed fix-in-place. - 61/61 tests pass after the fix (grok-build-config, check-tool-config-status, all-statuses-route, cli-settings-grok-build, plus 13/13 UI tests via vitest for GrokBuildToolCard/ToolDetailClient). - Verified the "omit API keys from settings responses" claim: GET applies omitApiKeys() to both config/settings before responding. - check-file-size, check-changelog-integrity: OK. - typecheck:core: clean. - check-complexity / check-cognitive-complexity: OK, both under baseline. Co-authored-by: tuandinh0801 <tuandinh0801@users.noreply.github.com>
This commit is contained in:
@@ -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<Record<SubagentType, GrokModelStatus | null>>;
|
||||
};
|
||||
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<string, unknown>;
|
||||
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<GrokStatus | null>(null);
|
||||
const [checking, setChecking] = useState(true);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [message, setMessage] = useState<Message>(null);
|
||||
const [model, setModel] = useState("");
|
||||
const [subagentModels, setSubagentModels] = useState<Partial<Record<SubagentType, string>>>({});
|
||||
const [selectedKeyId, setSelectedKeyId] = useState("");
|
||||
const [endpoints, setEndpoints] = useState<EndpointOption[]>([]);
|
||||
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<Backup[]>([]);
|
||||
const [showBackups, setShowBackups] = useState(false);
|
||||
const [restoringBackup, setRestoringBackup] = useState<string | null>(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<string, unknown>;
|
||||
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:<API_PORT>/v1"}"\nname = "OmniRoute"\ndescription = "Routed via OmniRoute gateway"\napi_backend = "chat_completions"\napi_key = "<API_KEY_FROM_DASHBOARD>"\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:<API_PORT>/v1"}"\nname = "OmniRoute ${type}"\ndescription = "Routed via OmniRoute gateway"\napi_backend = "chat_completions"\napi_key = "<API_KEY_FROM_DASHBOARD>"\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 (
|
||||
<Card padding="sm" className="overflow-hidden">
|
||||
<div className="flex cursor-pointer items-center justify-between" onClick={onToggle}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center">
|
||||
<ProviderIcon providerId="grok" size={32} type="color" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium">{tool.name}</h3>
|
||||
<CliStatusBadge
|
||||
effectiveConfigStatus={effectiveConfigStatus}
|
||||
batchStatus={null}
|
||||
lastConfiguredAt={lastConfiguredAt ?? batchStatus?.config.lastConfiguredAt ?? null}
|
||||
/>
|
||||
</div>
|
||||
<p className="truncate text-xs text-text-muted">{tool.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[20px] text-text-muted transition-transform ${isExpanded ? "rotate-180" : ""}`}
|
||||
>
|
||||
expand_more
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-4 flex flex-col gap-4 border-t border-border pt-4">
|
||||
{checking && (
|
||||
<div className="flex items-center gap-2 text-text-muted">
|
||||
<span className="material-symbols-outlined animate-spin">progress_activity</span>
|
||||
<span>{t("checkingCli", { tool: "Grok Build" })}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checking && status && !cliReady && (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-4">
|
||||
<span className="material-symbols-outlined text-yellow-500">warning</span>
|
||||
<div>
|
||||
<p className="font-medium text-yellow-600 dark:text-yellow-400">
|
||||
{status.installed
|
||||
? t("cliNotRunnable", { tool: "Grok Build" })
|
||||
: t("cliNotInstalled", { tool: "Grok Build" })}
|
||||
</p>
|
||||
<p className="text-sm text-text-muted">
|
||||
Direct Apply needs the Grok Build CLI on this computer. Manual Config stays
|
||||
available.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status?.config?.model?.base_url && (
|
||||
<div className={rowClass}>
|
||||
<span className={labelClass}>{t("current")}</span>
|
||||
<span className="material-symbols-outlined text-[14px] text-text-muted">
|
||||
arrow_forward
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate px-2 py-1.5 text-xs text-text-muted">
|
||||
{status.config.model.base_url}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={rowClass}>
|
||||
<label className={labelClass} htmlFor="grok-build-endpoint">
|
||||
{t("baseUrl")}
|
||||
</label>
|
||||
<span className="material-symbols-outlined text-[14px] text-text-muted">
|
||||
arrow_forward
|
||||
</span>
|
||||
<select
|
||||
id="grok-build-endpoint"
|
||||
className={inputClass}
|
||||
value={selectedEndpoint}
|
||||
onChange={(event) => setSelectedEndpoint(event.target.value)}
|
||||
>
|
||||
{endpoints.map((endpoint) => (
|
||||
<option key={endpoint.id} value={endpoint.id}>
|
||||
{endpoint.label}: {endpoint.url || "Enter a URL"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{selectedEndpoint === CUSTOM_ENDPOINT && (
|
||||
<div className={rowClass}>
|
||||
<span className={labelClass}>Custom URL</span>
|
||||
<span className="material-symbols-outlined text-[14px] text-text-muted">
|
||||
arrow_forward
|
||||
</span>
|
||||
<input
|
||||
aria-label="Custom endpoint"
|
||||
className={inputClass}
|
||||
type="url"
|
||||
value={customEndpoint}
|
||||
onChange={(event) => setCustomEndpoint(event.target.value)}
|
||||
placeholder="https://gateway.example/v1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={rowClass}>
|
||||
<label className={labelClass} htmlFor="grok-build-api-key">
|
||||
{t("apiKey")}
|
||||
</label>
|
||||
<span className="material-symbols-outlined text-[14px] text-text-muted">
|
||||
arrow_forward
|
||||
</span>
|
||||
<select
|
||||
id="grok-build-api-key"
|
||||
className={inputClass}
|
||||
value={selectedKeyId}
|
||||
onChange={(event) => setSelectedKeyId(event.target.value)}
|
||||
>
|
||||
<option value="">Use the OmniRoute default key</option>
|
||||
{apiKeys.map((key) => (
|
||||
<option key={key.id} value={key.id}>
|
||||
{key.key || key.name || key.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={rowClass}>
|
||||
<span className={labelClass}>{t("model")}</span>
|
||||
<span className="material-symbols-outlined text-[14px] text-text-muted">
|
||||
arrow_forward
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasActiveProviders}
|
||||
onClick={() => setModelTarget("main")}
|
||||
>
|
||||
{t("selectModel")}
|
||||
</Button>
|
||||
<input
|
||||
aria-label="Main model"
|
||||
className={inputClass}
|
||||
value={model}
|
||||
onChange={(event) => setModel(event.target.value)}
|
||||
placeholder={availableModels[0]?.value || "provider/model-id"}
|
||||
/>
|
||||
{model && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 text-text-muted transition-colors hover:text-red-500"
|
||||
title={t("clear")}
|
||||
onClick={() => setModel("")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">close</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="my-2 h-px bg-border/50" />
|
||||
<div className="mb-2 text-right text-[11px] font-medium uppercase tracking-wider text-text-muted">
|
||||
Subagent model overrides
|
||||
</div>
|
||||
{SUBAGENTS.map((type) => (
|
||||
<div className={`${rowClass} group`} key={type}>
|
||||
<span className="w-32 shrink-0 truncate text-right text-[11px] font-mono text-text-main opacity-70 transition-opacity group-hover:opacity-100">
|
||||
{modelLabel(type)}
|
||||
</span>
|
||||
<span className="material-symbols-outlined text-[14px] text-border transition-colors group-hover:text-primary">
|
||||
arrow_forward
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasActiveProviders}
|
||||
onClick={() => setModelTarget(type)}
|
||||
>
|
||||
{t("selectModel")}
|
||||
</Button>
|
||||
<input
|
||||
aria-label={`${modelLabel(type)} model`}
|
||||
className={inputClass}
|
||||
value={subagentModels[type] ?? ""}
|
||||
onChange={(event) =>
|
||||
setSubagentModels((current) => ({ ...current, [type]: event.target.value }))
|
||||
}
|
||||
placeholder={`Use ${model || "the main model"}`}
|
||||
/>
|
||||
{subagentModels[type] && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 text-text-muted transition-colors hover:text-red-500"
|
||||
title={t("clear")}
|
||||
onClick={() =>
|
||||
setSubagentModels((current) => ({ ...current, [type]: undefined }))
|
||||
}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">close</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{message && (
|
||||
<p
|
||||
role="status"
|
||||
className={`rounded px-2 py-1.5 text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}
|
||||
>
|
||||
{message.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
loading={applying}
|
||||
disabled={!cliReady || !model || !baseUrl}
|
||||
onClick={apply}
|
||||
>
|
||||
<span className="material-symbols-outlined mr-1 text-[14px]">save</span>
|
||||
{t("apply")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
loading={resetting}
|
||||
disabled={!configured}
|
||||
onClick={reset}
|
||||
>
|
||||
<span className="material-symbols-outlined mr-1 text-[14px]">restore</span>
|
||||
{t("reset")}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowManual(true)}>
|
||||
<span className="material-symbols-outlined mr-1 text-[14px]">content_copy</span>
|
||||
{t("manualConfig")}
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowBackups((current) => !current);
|
||||
if (!showBackups) void refreshBackups();
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-outlined mr-1 text-[14px]">history</span>
|
||||
{t("backups")}
|
||||
{backups.length > 0 && ` (${backups.length})`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showBackups && (
|
||||
<div className="mt-2 rounded-lg border border-border bg-surface p-3">
|
||||
<h4 className="mb-2 flex items-center gap-1 text-xs font-semibold text-text-main">
|
||||
<span className="material-symbols-outlined text-[14px]">history</span>
|
||||
{t("configBackups")}
|
||||
</h4>
|
||||
{backups.length === 0 ? (
|
||||
<p className="text-xs text-text-muted">{t("noBackupsYet")}</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{backups.map((backup) => (
|
||||
<div
|
||||
key={backup.id}
|
||||
className="flex items-center gap-2 rounded bg-black/5 px-2 py-1.5 text-xs dark:bg-white/5"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] text-text-muted">
|
||||
description
|
||||
</span>
|
||||
<span className="flex-1 truncate font-mono" title={backup.id}>
|
||||
{backup.id}
|
||||
</span>
|
||||
<span className="whitespace-nowrap text-text-muted">
|
||||
{new Date(backup.createdAt).toLocaleString()}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary transition-colors hover:bg-primary/20 disabled:opacity-50"
|
||||
disabled={restoringBackup === backup.id}
|
||||
onClick={() => void restoreBackup(backup.id)}
|
||||
>
|
||||
{restoringBackup === backup.id ? "..." : t("restore")}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modelTarget && (
|
||||
<ModelSelectModal
|
||||
isOpen
|
||||
onClose={() => setModelTarget(null)}
|
||||
onSelect={selectModel}
|
||||
selectedModel={modelTarget === "main" ? model : (subagentModels[modelTarget] ?? "")}
|
||||
activeProviders={activeProviders}
|
||||
title={`Select ${modelTarget === "main" ? "main" : modelLabel(modelTarget)} model`}
|
||||
/>
|
||||
)}
|
||||
<ManualConfigModal
|
||||
isOpen={showManual}
|
||||
onClose={() => setShowManual(false)}
|
||||
title="Grok Build manual config"
|
||||
configs={[{ filename: "~/.grok/config.toml", content: manualToml }]}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 <CopilotToolCard {...cardProps} />;
|
||||
case "hermes-agent":
|
||||
return <HermesAgentToolCard {...cardProps} />;
|
||||
case "grok-build":
|
||||
return <GrokBuildToolCard {...cardProps} />;
|
||||
case "antigravity":
|
||||
return <AntigravityToolCard {...cardProps} />;
|
||||
case "custom":
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<Response> {
|
||||
const mtimesMap: Record<string, number> = {};
|
||||
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<Response> {
|
||||
!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 = {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string> => {
|
||||
const readConfigToml = async (configPath: string): Promise<string> => {
|
||||
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<void> => {
|
||||
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<typeof grokBuildConfigSchema>,
|
||||
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 = <T extends { api_key: string | null }>(model: T): Omit<T, "api_key"> => {
|
||||
const { api_key: _apiKey, ...publicModel } = model;
|
||||
return publicModel;
|
||||
};
|
||||
|
||||
const omitApiKeys = (settings: ReturnType<typeof parseGrokBuildConfig>) => ({
|
||||
...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<NonNullable<(typeof settings.subagentModels)[GrokSubagentType]>, "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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
314
src/shared/services/grokBuildConfig.ts
Normal file
314
src/shared/services/grokBuildConfig.ts
Normal file
@@ -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<GrokSubagentType, GrokModelConfig | null>;
|
||||
subagentMappings: Record<GrokSubagentType, string | null>;
|
||||
}
|
||||
|
||||
export interface GrokBuildApplyOptions {
|
||||
baseUrl: string;
|
||||
apiKey?: string | null;
|
||||
model: string;
|
||||
contextWindow?: number;
|
||||
subagentModels?: Partial<Record<GrokSubagentType, { model: string; contextWindow?: number }>>;
|
||||
}
|
||||
|
||||
/** 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<typeof buildModelSection>[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<GrokSubagentType, GrokModelConfig | null>;
|
||||
const subagentMappings = {} as Record<GrokSubagentType, string | null>;
|
||||
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;
|
||||
}
|
||||
@@ -254,3 +254,40 @@ test("refresh=true bypasses a matching cached CLI result", async () => {
|
||||
const body = (await response.json()) as Record<string, { detection?: { version?: string } }>;
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
165
tests/unit/grok-build-config.test.ts
Normal file
165
tests/unit/grok-build-config.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
264
tests/unit/ui/GrokBuildToolCard.test.tsx
Normal file
264
tests/unit/ui/GrokBuildToolCard.test.tsx
Normal file
@@ -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 }) => <div>{children}</div>,
|
||||
Button: ({ children, onClick, disabled }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
|
||||
<button type="button" onClick={onClick} disabled={disabled}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
ModelSelectModal: () => null,
|
||||
ManualConfigModal: ({
|
||||
isOpen,
|
||||
configs,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
configs: Array<{ content: string }>;
|
||||
}) => {
|
||||
manualConfig = configs[0]?.content ?? "";
|
||||
return isOpen ? <div data-testid="manual-toml">{manualConfig}</div> : 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(
|
||||
<GrokBuildToolCard
|
||||
tool={{ name: "Grok Build", description: "Custom model host" }}
|
||||
isExpanded
|
||||
apiKeys={[{ id: "key-1", name: "Dashboard key", key: "sk_****" }]}
|
||||
activeProviders={[{ provider: "openai" }]}
|
||||
hasActiveProviders
|
||||
availableModels={[{ value: "openai/gpt-5.5" }]}
|
||||
/>
|
||||
);
|
||||
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("<API_KEY_FROM_DASHBOARD>");
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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: () => <div data-testid="CopilotToolCard" />,
|
||||
CustomCliCard: () => <div data-testid="CustomCliCard" />,
|
||||
HermesAgentToolCard: () => <div data-testid="HermesAgentToolCard" />,
|
||||
GrokBuildToolCard: () => <div data-testid="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 () => {});
|
||||
|
||||
Reference in New Issue
Block a user