feat(cli-tools): configure Grok Build subagent models

Add separate model selectors for Grok Build main, general-purpose,
explore, and plan agents. Each override gets an independent 9Router
custom-model slot and context_window derived from 9Router model
capabilities. Preserve and restore pre-existing config on reset.
This commit is contained in:
rixzkiye
2026-07-19 13:35:38 +07:00
parent 0513bf393f
commit e0ba667450
7 changed files with 679 additions and 337 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
## Features ## Features
- **xAI**: Grok Imagine video generation (`/v1/videos`) + CLI - **xAI**: Grok Imagine video generation (`/v1/videos`) + CLI
- **CLI tools**: Grok Build setup — writes `[model.9router]` to `~/.grok/config.toml` - **CLI tools**: Grok Build setup — choose separate main/general-purpose/explore/plan models and preserve each model's context window
- **GitHub Copilot**: route Claude models through Copilot's native `/v1/messages` - **GitHub Copilot**: route Claude models through Copilot's native `/v1/messages`
- **Kiro**: add GPT-5.6 model family (#2596) - **Kiro**: add GPT-5.6 model family (#2596)
- **RTK**: `X-9Router-Token-Saver` header to bypass token savers per request - **RTK**: `X-9Router-Token-Saver` header to bypass token savers per request
@@ -1,7 +1,8 @@
"use client"; "use client";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import { useModelCaps } from "@/shared/hooks/useModelCaps";
import Image from "next/image"; import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect"; import BaseUrlSelect from "./BaseUrlSelect";
import ApiKeySelect from "./ApiKeySelect"; import ApiKeySelect from "./ApiKeySelect";
@@ -9,12 +10,59 @@ import { matchKnownEndpoint } from "./cliEndpointMatch";
const ENDPOINT = "/api/cli-tools/grok-build-settings"; const ENDPOINT = "/api/cli-tools/grok-build-settings";
const MODEL_SLOT = "9router"; const MODEL_SLOT = "9router";
const SUBAGENT_TYPES = [
{ id: "general-purpose", label: "General-purpose", help: "Implementation, testing, and full-capability delegated tasks" },
{ id: "explore", label: "Explore", help: "Read-only codebase research and investigation" },
{ id: "plan", label: "Plan", help: "Architecture and implementation planning" },
];
function ModelField({ label, value, placeholder, onChange, onSelect, disabled, help }) {
return (
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<div className="sm:text-right">
<span className="text-xs font-semibold text-text-main sm:text-sm">{label}</span>
{help && <p className="mt-0.5 text-[10px] leading-tight text-text-muted">{help}</p>}
</div>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<div className="relative w-full min-w-0">
<input
type="text"
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
/>
{value && (
<button
type="button"
onClick={() => onChange("")}
className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 text-text-muted hover:text-red-500 rounded transition-colors"
title="Clear (inherit main model for subagents)"
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
)}
</div>
<button
type="button"
onClick={onSelect}
disabled={disabled}
className={`w-full sm:w-auto rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 whitespace-nowrap sm:shrink-0 ${
!disabled
? "bg-surface border-border text-text-main hover:border-primary cursor-pointer"
: "opacity-50 cursor-not-allowed border-border"
}`}
>
Select
</button>
</div>
);
}
export default function GrokBuildToolCard({ export default function GrokBuildToolCard({
tool, tool,
isExpanded, isExpanded,
onToggle, onToggle,
baseUrl,
hasActiveProviders, hasActiveProviders,
apiKeys, apiKeys,
activeProviders, activeProviders,
@@ -25,47 +73,49 @@ export default function GrokBuildToolCard({
tailscaleEnabled, tailscaleEnabled,
tailscaleUrl, tailscaleUrl,
}) { }) {
const { getCaps } = useModelCaps();
const getContextWindow = (model) => getCaps(model)?.contextWindow || null;
const initialModel = initialStatus?.settings?.model?.model || "";
const initialSubagents = Object.fromEntries(
SUBAGENT_TYPES
.map((type) => [type.id, initialStatus?.settings?.subagentModels?.[type.id]?.model])
.filter(([, model]) => Boolean(model)),
);
const [grokStatus, setGrokStatus] = useState(initialStatus || null); const [grokStatus, setGrokStatus] = useState(initialStatus || null);
const [checking, setChecking] = useState(false); const [checking, setChecking] = useState(false);
const [applying, setApplying] = useState(false); const [applying, setApplying] = useState(false);
const [restoring, setRestoring] = useState(false); const [restoring, setRestoring] = useState(false);
const [message, setMessage] = useState(null); const [message, setMessage] = useState(null);
const [selectedApiKey, setSelectedApiKey] = useState(""); const [selectedApiKey, setSelectedApiKey] = useState(apiKeys?.[0]?.key || "");
const [selectedModel, setSelectedModel] = useState(""); const [selectedModel, setSelectedModel] = useState(initialModel);
const [modalOpen, setModalOpen] = useState(false); const [subagentModels, setSubagentModels] = useState(initialSubagents);
const [modelTarget, setModelTarget] = useState(null); // "main" or subagent type
const [modelAliases, setModelAliases] = useState({}); const [modelAliases, setModelAliases] = useState({});
const [showManualConfigModal, setShowManualConfigModal] = useState(false); const [showManualConfigModal, setShowManualConfigModal] = useState(false);
const [customBaseUrl, setCustomBaseUrl] = useState(""); const [customBaseUrl, setCustomBaseUrl] = useState("");
const hasInitializedModel = useRef(false); const hasFetchedStatus = useRef(Boolean(initialStatus));
const getConfigStatus = () => { const configuredModel = grokStatus?.settings?.model;
if (!grokStatus?.installed) return null; const configStatus = !grokStatus?.installed
const cfg = grokStatus.settings?.model; ? null
if (!cfg?.base_url) return "not_configured"; : !configuredModel?.base_url
if (matchKnownEndpoint(cfg.base_url, { tunnelPublicUrl, tailscaleUrl })) return "configured"; ? "not_configured"
return "other"; : matchKnownEndpoint(configuredModel.base_url, { tunnelPublicUrl, tailscaleUrl })
}; ? "configured"
: "other";
const configStatus = getConfigStatus(); const hydrateForm = useCallback((status) => {
const mainModel = status?.settings?.model?.model || "";
const configuredSubagents = Object.fromEntries(
SUBAGENT_TYPES
.map((type) => [type.id, status?.settings?.subagentModels?.[type.id]?.model])
.filter(([, model]) => Boolean(model)),
);
setSelectedModel(mainModel);
setSubagentModels(configuredSubagents);
}, []);
useEffect(() => { const fetchModelAliases = useCallback(async () => {
if (apiKeys?.length > 0 && !selectedApiKey) {
setSelectedApiKey(apiKeys[0].key);
}
}, [apiKeys, selectedApiKey]);
useEffect(() => {
if (initialStatus) setGrokStatus(initialStatus);
}, [initialStatus]);
useEffect(() => {
if (isExpanded) {
if (!grokStatus) checkStatus();
fetchModelAliases();
}
}, [isExpanded]);
const fetchModelAliases = async () => {
try { try {
const res = await fetch("/api/models/alias"); const res = await fetch("/api/models/alias");
const data = await res.json(); const data = await res.json();
@@ -73,40 +123,38 @@ export default function GrokBuildToolCard({
} catch (error) { } catch (error) {
console.log("Error fetching model aliases:", error); console.log("Error fetching model aliases:", error);
} }
}; }, []);
useEffect(() => { const checkStatus = useCallback(async ({ hydrate = false } = {}) => {
if (grokStatus?.installed && !hasInitializedModel.current) {
hasInitializedModel.current = true;
const cfg = grokStatus.settings?.model;
if (cfg?.model) setSelectedModel(cfg.model);
}
}, [grokStatus]);
const checkStatus = async () => {
setChecking(true); setChecking(true);
try { try {
const res = await fetch(ENDPOINT); const res = await fetch(ENDPOINT);
const data = await res.json(); const status = await res.json();
setGrokStatus(data); setGrokStatus(status);
hasFetchedStatus.current = true;
if (hydrate) hydrateForm(status);
} catch (error) { } catch (error) {
setGrokStatus({ installed: false, error: error.message }); setGrokStatus({ installed: false, error: error.message });
} finally { } finally {
setChecking(false); setChecking(false);
} }
}; }, [hydrateForm]);
const normalizeLocalhost = (url) => url.replace("://localhost", "://127.0.0.1"); useEffect(() => {
if (!isExpanded) return;
const getLocalBaseUrl = () => { let cancelled = false;
if (typeof window !== "undefined") { const synchronize = async () => {
return normalizeLocalhost(window.location.origin); if (!hasFetchedStatus.current) await checkStatus({ hydrate: true });
} if (!cancelled) await fetchModelAliases();
return "http://127.0.0.1:20128"; };
}; synchronize();
return () => { cancelled = true; };
}, [isExpanded, checkStatus, fetchModelAliases]);
const getEffectiveBaseUrl = () => { const getEffectiveBaseUrl = () => {
const url = customBaseUrl || getLocalBaseUrl(); const url = customBaseUrl || (typeof window !== "undefined"
? window.location.origin.replace("://localhost", "://127.0.0.1")
: "http://127.0.0.1:20128");
return url.endsWith("/v1") ? url : `${url}/v1`; return url.endsWith("/v1") ? url : `${url}/v1`;
}; };
@@ -117,6 +165,11 @@ export default function GrokBuildToolCard({
const keyToUse = selectedApiKey?.trim() const keyToUse = selectedApiKey?.trim()
|| (apiKeys?.length > 0 ? apiKeys[0].key : null) || (apiKeys?.length > 0 ? apiKeys[0].key : null)
|| (!cloudEnabled ? "sk_9router" : null); || (!cloudEnabled ? "sk_9router" : null);
const mappedSubagents = {};
for (const type of SUBAGENT_TYPES) {
const model = subagentModels[type.id]?.trim();
if (model) mappedSubagents[type.id] = { model, contextWindow: getContextWindow(model) };
}
const res = await fetch(ENDPOINT, { const res = await fetch(ENDPOINT, {
method: "POST", method: "POST",
@@ -125,11 +178,13 @@ export default function GrokBuildToolCard({
baseUrl: getEffectiveBaseUrl(), baseUrl: getEffectiveBaseUrl(),
apiKey: keyToUse, apiKey: keyToUse,
model: selectedModel, model: selectedModel,
contextWindow: getContextWindow(selectedModel),
subagentModels: mappedSubagents,
}), }),
}); });
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
setMessage({ type: "success", text: "Settings applied successfully!" }); setMessage({ type: "success", text: "Main and subagent models applied successfully!" });
checkStatus(); checkStatus();
} else { } else {
setMessage({ type: "error", text: data.error || "Failed to apply settings" }); setMessage({ type: "error", text: data.error || "Failed to apply settings" });
@@ -150,6 +205,7 @@ export default function GrokBuildToolCard({
if (res.ok) { if (res.ok) {
setMessage({ type: "success", text: "Settings reset successfully!" }); setMessage({ type: "success", text: "Settings reset successfully!" });
setSelectedModel(""); setSelectedModel("");
setSubagentModels({});
checkStatus(); checkStatus();
} else { } else {
setMessage({ type: "error", text: data.error || "Failed to reset settings" }); setMessage({ type: "error", text: data.error || "Failed to reset settings" });
@@ -162,31 +218,33 @@ export default function GrokBuildToolCard({
}; };
const handleModelSelect = (model) => { const handleModelSelect = (model) => {
setSelectedModel(model.value); if (modelTarget === "main") {
setModalOpen(false); setSelectedModel(model.value);
} else if (modelTarget) {
setSubagentModels((current) => ({ ...current, [modelTarget]: model.value }));
}
setModelTarget(null);
}; };
const getManualConfigs = () => { const getManualConfigs = () => {
const keyToUse = (selectedApiKey && selectedApiKey.trim()) const keyToUse = selectedApiKey?.trim()
? selectedApiKey || (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>"); const baseUrl = getEffectiveBaseUrl();
const mainModel = selectedModel || "provider/model-id";
const modelId = selectedModel || "provider/model-id"; const blocks = [
const tomlContent = `[models] `[models]\ndefault = "${MODEL_SLOT}"`,
default = "${MODEL_SLOT}" `[model.${MODEL_SLOT}]\nmodel = "${mainModel}"\nbase_url = "${baseUrl}"\nname = "9Router"\ndescription = "Routed via 9Router gateway"\napi_backend = "chat_completions"\napi_key = "${keyToUse}"\ncontext_window = ${getContextWindow(mainModel) || 200000}`,
[model.${MODEL_SLOT}]
model = "${modelId}"
base_url = "${getEffectiveBaseUrl()}"
name = "9Router"
description = "Routed via 9Router gateway"
api_backend = "chat_completions"
api_key = "${keyToUse}"
`;
return [
{ filename: "~/.grok/config.toml", content: tomlContent },
]; ];
const mappings = [];
for (const type of SUBAGENT_TYPES) {
const model = subagentModels[type.id]?.trim();
if (!model) continue;
const slot = `${MODEL_SLOT}-${type.id}`;
mappings.push(`${type.id} = "${slot}"`);
blocks.push(`[model.${slot}]\nmodel = "${model}"\nbase_url = "${baseUrl}"\nname = "9Router ${type.id}"\ndescription = "Routed via 9Router gateway"\napi_backend = "chat_completions"\napi_key = "${keyToUse}"\ncontext_window = ${getContextWindow(model) || 200000}`);
}
if (mappings.length) blocks.splice(1, 0, `[subagents.models]\n${mappings.join("\n")}`);
return [{ filename: "~/.grok/config.toml", content: `${blocks.join("\n\n")}\n` }];
}; };
return ( return (
@@ -202,8 +260,8 @@ api_key = "${keyToUse}"
className="size-8 object-contain rounded-lg" className="size-8 object-contain rounded-lg"
sizes="32px" sizes="32px"
onError={(e) => { e.target.style.display = "none"; }} onError={(e) => { e.target.style.display = "none"; }}
loading="lazy" loading="lazy"
decoding="async" decoding="async"
/> />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
@@ -221,170 +279,105 @@ api_key = "${keyToUse}"
{isExpanded && ( {isExpanded && (
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4"> <div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
{checking && ( {checking && <div className="flex items-center gap-2 text-text-muted"><span className="material-symbols-outlined animate-spin">progress_activity</span><span>Checking Grok Build...</span></div>}
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking Grok Build...</span>
</div>
)}
{!checking && grokStatus && !grokStatus.installed && ( {!checking && grokStatus && !grokStatus.installed && (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-3 p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex flex-col gap-3 p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg"> <div className="flex items-start gap-3">
<div className="flex items-start gap-3"> <span className="material-symbols-outlined text-yellow-500">warning</span>
<span className="material-symbols-outlined text-yellow-500">warning</span> <div className="flex-1">
<div className="flex-1"> <p className="font-medium text-yellow-600 dark:text-yellow-400">Grok Build not detected locally</p>
<p className="font-medium text-yellow-600 dark:text-yellow-400">Grok Build not detected locally</p> <code className="block mt-2 p-2 bg-black/20 rounded text-xs font-mono">curl -fsSL https://x.ai/cli/install.sh | bash</code>
<p className="text-sm text-text-muted mt-1">Install:</p>
<code className="block mt-2 p-2 bg-black/20 rounded text-xs font-mono">curl -fsSL https://x.ai/cli/install.sh | bash</code>
<p className="text-sm text-text-muted mt-2">Manual configuration is still available if 9router is deployed on a remote server.</p>
</div>
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-2 pl-0 sm:pl-9">
<Button
variant="secondary"
size="sm"
onClick={() => setShowManualConfigModal(true)}
className="w-full sm:w-auto !bg-yellow-500/20 !border-yellow-500/40 !text-yellow-700 dark:!text-yellow-300 hover:!bg-yellow-500/30"
>
<span className="material-symbols-outlined text-[18px] mr-1">content_copy</span>
Manual Config
</Button>
</div> </div>
</div> </div>
<Button variant="secondary" size="sm" onClick={() => setShowManualConfigModal(true)} className="w-full sm:w-auto"><span className="material-symbols-outlined text-[18px] mr-1">content_copy</span>Manual Config</Button>
</div> </div>
)} )}
{!checking && grokStatus?.installed && ( {!checking && grokStatus?.installed && (
<> <>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{tool.notes && tool.notes.length > 0 && ( {tool.notes?.length > 0 && (
<div className="flex flex-col gap-2 mb-2"> <div className="mb-2 flex flex-col gap-2">
{tool.notes.map((note, idx) => ( {tool.notes.map((note, index) => (
<div <div key={index} className={`flex items-start gap-2 rounded p-2 text-xs ${note.type === "warning" ? "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400" : "bg-blue-500/10 text-blue-600 dark:text-blue-400"}`}>
key={idx} <span className="material-symbols-outlined mt-0.5 text-[14px]">{note.type === "warning" ? "warning" : "info"}</span>
className={`flex items-start gap-2 p-2 rounded text-xs ${
note.type === "warning" ? "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400" :
note.type === "error" ? "bg-red-500/10 text-red-600 dark:text-red-400" :
"bg-blue-500/10 text-blue-600 dark:text-blue-400"
}`}
>
<span className="material-symbols-outlined text-[14px] mt-0.5">
{note.type === "warning" ? "warning" : note.type === "error" ? "error" : "info"}
</span>
<span>{note.text}</span> <span>{note.text}</span>
</div> </div>
))} ))}
</div> </div>
)} )}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2"> <div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span> <span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span> <span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<BaseUrlSelect <BaseUrlSelect value={customBaseUrl || getEffectiveBaseUrl()} onChange={setCustomBaseUrl} requiresExternalUrl={tool.requiresExternalUrl} tunnelEnabled={tunnelEnabled} tunnelPublicUrl={tunnelPublicUrl} tailscaleEnabled={tailscaleEnabled} tailscaleUrl={tailscaleUrl} />
value={customBaseUrl || getEffectiveBaseUrl()}
onChange={setCustomBaseUrl}
requiresExternalUrl={tool.requiresExternalUrl}
tunnelEnabled={tunnelEnabled}
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
/>
</div> </div>
{grokStatus?.settings?.model?.base_url && ( {configuredModel?.base_url && (
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2"> <div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Current</span> <span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Current</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span> <span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5"> <span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">{configuredModel.base_url} · {configuredModel.model}{configuredModel.context_window ? ` · ${(configuredModel.context_window / 1000).toLocaleString()}K ctx` : ""}</span>
{grokStatus.settings.model.base_url}
{grokStatus.settings.model.model ? ` · ${grokStatus.settings.model.model}` : ""}
</span>
</div> </div>
)} )}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2"> <div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">API Key</span> <span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">API Key</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span> <span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} /> <ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div> </div>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2"> <ModelField label="Main Model" value={selectedModel} onChange={setSelectedModel} placeholder="provider/model-id" onSelect={() => setModelTarget("main")} disabled={!hasActiveProviders} />
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Default Model</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span> <div className="my-1 border-t border-border pt-3">
<div className="relative w-full min-w-0"> <div className="mb-2 flex items-start gap-2">
<input <span className="material-symbols-outlined text-primary text-[16px]">account_tree</span>
type="text" <div>
value={selectedModel} <p className="text-xs font-semibold text-text-main">Subagent model overrides</p>
onChange={(e) => setSelectedModel(e.target.value)} <p className="text-[10px] text-text-muted">Leave blank to inherit Main Model. Each override keeps its own context window.</p>
placeholder="provider/model-id" </div>
className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
/>
{selectedModel && (
<button
onClick={() => setSelectedModel("")}
className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 text-text-muted hover:text-red-500 rounded transition-colors"
title="Clear"
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
)}
</div> </div>
<button
onClick={() => setModalOpen(true)}
disabled={!hasActiveProviders}
className={`w-full sm:w-auto rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 whitespace-nowrap sm:shrink-0 ${
hasActiveProviders
? "bg-surface border-border text-text-main hover:border-primary cursor-pointer"
: "opacity-50 cursor-not-allowed border-border"
}`}
>
Select
</button>
</div> </div>
{SUBAGENT_TYPES.map((type) => (
<ModelField
key={type.id}
label={type.label}
help={type.help}
value={subagentModels[type.id] || ""}
onChange={(value) => setSubagentModels((current) => ({ ...current, [type.id]: value }))}
placeholder={`${selectedModel || "Main Model"} (inherit)`}
onSelect={() => setModelTarget(type.id)}
disabled={!hasActiveProviders}
/>
))}
</div> </div>
{message && ( {message && <div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}><span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span><span>{message.text}</span></div>}
<div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
<span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span>
<span>{message.text}</span>
</div>
)}
<div className="flex flex-col sm:flex-row sm:items-center gap-2"> <div className="flex flex-col sm:flex-row sm:items-center gap-2">
<Button variant="primary" size="sm" onClick={handleApply} disabled={!selectedModel} loading={applying} className="w-full sm:w-auto"> <Button variant="primary" size="sm" onClick={handleApply} disabled={!selectedModel} loading={applying} className="w-full sm:w-auto"><span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply</Button>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply <Button variant="outline" size="sm" onClick={handleReset} disabled={!grokStatus?.has9Router} loading={restoring} className="w-full sm:w-auto"><span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset</Button>
</Button> <Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)} className="w-full sm:w-auto"><span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config</Button>
<Button variant="outline" size="sm" onClick={handleReset} disabled={!grokStatus?.has9Router} loading={restoring} className="w-full sm:w-auto">
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)} className="w-full sm:w-auto">
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
</Button>
</div> </div>
</> </>
)} )}
</div> </div>
)} )}
{modalOpen && ( {modelTarget && (
<ModelSelectModal <ModelSelectModal
isOpen={modalOpen} isOpen={Boolean(modelTarget)}
onClose={() => setModalOpen(false)} onClose={() => setModelTarget(null)}
onSelect={handleModelSelect} onSelect={handleModelSelect}
selectedModel={selectedModel} selectedModel={modelTarget === "main" ? selectedModel : subagentModels[modelTarget] || ""}
activeProviders={activeProviders} activeProviders={activeProviders}
modelAliases={modelAliases} modelAliases={modelAliases}
title="Select Model for Grok Build" title={modelTarget === "main" ? "Select Main Model for Grok Build" : `Select ${SUBAGENT_TYPES.find((type) => type.id === modelTarget)?.label || "Subagent"} Model`}
/> />
)} )}
<ManualConfigModal <ManualConfigModal isOpen={showManualConfigModal} onClose={() => setShowManualConfigModal(false)} title="Grok Build - Manual Configuration" configs={getManualConfigs()} />
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="Grok Build - Manual Configuration"
configs={getManualConfigs()}
/>
</Card> </Card>
); );
} }
@@ -6,24 +6,16 @@ import { promisify } from "util";
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path"; import path from "path";
import os from "os"; import os from "os";
import { getCapabilitiesForModel } from "open-sse/providers/capabilities.js";
import {
applyGrokBuildConfig,
GROK_SUBAGENT_TYPES,
parseGrokBuildConfig,
resetGrokBuildConfig,
} from "@/lib/grokBuildConfig";
const execAsync = promisify(exec); const execAsync = promisify(exec);
const PROVIDER_NAME = "9router";
const MODEL_SLOT = "9router";
const BUILTIN_DEFAULT = "grok-build";
// [model.9router] ... until 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 previous [models].default
const PREV_DEFAULT_RE = /^# 9router-prev-default = "([^"]*)"[ \t]*\r?\n?/m;
const getGrokDir = () => path.join(os.homedir(), ".grok"); const getGrokDir = () => path.join(os.homedir(), ".grok");
const getGrokConfigPath = () => path.join(getGrokDir(), "config.toml"); const getGrokConfigPath = () => path.join(getGrokDir(), "config.toml");
const getGrokBinPath = () => path.join(getGrokDir(), "bin", "grok"); const getGrokBinPath = () => path.join(getGrokDir(), "bin", "grok");
@@ -31,21 +23,16 @@ const getGrokBinPath = () => path.join(getGrokDir(), "bin", "grok");
const checkGrokInstalled = async () => { const checkGrokInstalled = async () => {
try { try {
const isWindows = os.platform() === "win32"; const isWindows = os.platform() === "win32";
const command = isWindows ? "where grok" : "which grok"; await execAsync(isWindows ? "where grok" : "which grok", { windowsHide: true });
await execAsync(command, { windowsHide: true });
return true; return true;
} catch { } catch {
try { for (const candidate of [getGrokBinPath(), getGrokConfigPath()]) {
await fs.access(getGrokBinPath());
return true;
} catch {
try { try {
await fs.access(getGrokConfigPath()); await fs.access(candidate);
return true; return true;
} catch { } catch { /* try next */ }
return false;
}
} }
return false;
} }
}; };
@@ -58,99 +45,32 @@ const readConfigToml = async () => {
} }
}; };
const getTomlField = (body, key) => { const normalizeContextWindow = (value, model) => {
const m = body.match(new RegExp(`^[ \\t]*${key}[ \\t]*=[ \\t]*"([^"]*)"`, "m")); const explicit = Number(value);
return m ? m[1] : null; if (Number.isFinite(explicit) && explicit > 0) return Math.floor(explicit);
const slash = model.indexOf("/");
const provider = slash > 0 ? model.slice(0, slash) : null;
const modelId = slash > 0 ? model.slice(slash + 1) : model;
return getCapabilitiesForModel(provider, modelId).contextWindow;
}; };
const parseModelSection = (toml) => { const normalizeSubagentModels = (value) => {
const match = toml.match(MODEL_SECTION_RE); if (value === undefined) return undefined; // backwards-compatible callers leave current overrides untouched
if (!match) return null; if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const body = match[0].replace(/^\[model\.[^\]]+\][ \t]*\r?\n/, ""); const result = {};
return { for (const type of GROK_SUBAGENT_TYPES) {
model: getTomlField(body, "model"), const entry = value[type];
base_url: getTomlField(body, "base_url"), const model = typeof entry === "string" ? entry.trim() : entry?.model?.trim();
name: getTomlField(body, "name"), if (!model) continue; // blank means inherit the main model
api_key: getTomlField(body, "api_key"), result[type] = {
api_backend: getTomlField(body, "api_backend"), model,
}; contextWindow: normalizeContextWindow(entry?.contextWindow, model),
}; };
const parseModelsDefault = (toml) => {
const match = toml.match(MODELS_SECTION_RE);
if (!match) return null;
return getTomlField(match[1] || "", "default");
};
const buildModelSection = (model, baseUrl, apiKey) => {
const lines = [
`[model.${MODEL_SLOT}]`,
`model = "${model}"`,
`base_url = "${baseUrl}"`,
`name = "9Router"`,
`description = "Routed via 9Router gateway"`,
`api_backend = "chat_completions"`,
];
if (apiKey) lines.push(`api_key = "${apiKey}"`);
return `${lines.join("\n")}\n`;
};
const upsertModelSection = (toml, section) => {
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) =>
toml.replace(MODEL_SECTION_RE, "").replace(/\n{3,}/g, "\n\n");
// Set or insert default = "..." inside existing [models], or create the section
const setModelsDefault = (toml, value) => {
const match = toml.match(MODELS_SECTION_RE);
if (match) {
const body = match[1] || "";
let newBody;
if (/^[ \t]*default[ \t]*=/m.test(body)) {
newBody = body.replace(/^[ \t]*default[ \t]*=[ \t]*"[^"]*"/m, `default = "${value}"`);
} else {
newBody = `default = "${value}"\n${body}`;
}
return toml.replace(match[0], `[models]\n${newBody}`);
} }
const block = `[models]\ndefault = "${value}"\n\n`; return result;
return toml.length > 0 ? block + toml : block;
}; };
// Remember the previous default once (so re-Apply does not overwrite it with "9router") const has9RouterConfig = (settings) => Boolean(settings?.model?.base_url);
const rememberPrevDefault = (toml) => {
if (PREV_DEFAULT_RE.test(toml)) return toml;
const current = parseModelsDefault(toml);
if (!current || current === MODEL_SLOT) return toml;
const marker = `# 9router-prev-default = "${current}"\n`;
// Prefer placing the marker just above [model.9router] if present, else at EOF
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 default points at our slot, restore previous (or built-in) default and drop marker
const clearModelsDefaultIfOurs = (toml) => {
const prevMatch = toml.match(PREV_DEFAULT_RE);
const restoreTo = prevMatch?.[1] || BUILTIN_DEFAULT;
let next = toml.replace(PREV_DEFAULT_RE, "");
const current = parseModelsDefault(next);
if (current === MODEL_SLOT) {
next = setModelsDefault(next, restoreTo);
}
return next;
};
const has9RouterConfig = (modelCfg) => {
if (!modelCfg?.base_url) return false;
return true;
};
export async function GET() { export async function GET() {
try { try {
@@ -163,17 +83,11 @@ export async function GET() {
}); });
} }
const toml = await readConfigToml(); const settings = parseGrokBuildConfig(await readConfigToml());
const model = parseModelSection(toml);
const defaultModel = parseModelsDefault(toml);
return NextResponse.json({ return NextResponse.json({
installed: true, installed: true,
settings: { settings,
model, has9Router: has9RouterConfig(settings),
default: defaultModel,
},
has9Router: has9RouterConfig(model),
configPath: getGrokConfigPath(), configPath: getGrokConfigPath(),
}); });
} catch (error) { } catch (error) {
@@ -184,29 +98,28 @@ export async function GET() {
export async function POST(request) { export async function POST(request) {
try { try {
const { baseUrl, apiKey, model } = await request.json(); const { baseUrl, apiKey, model, contextWindow, subagentModels } = await request.json();
if (!baseUrl || !model) { const selectedModel = typeof model === "string" ? model.trim() : "";
if (!baseUrl || !selectedModel) {
return NextResponse.json({ error: "baseUrl and model are required" }, { status: 400 }); return NextResponse.json({ error: "baseUrl and model are required" }, { status: 400 });
} }
const dir = getGrokDir(); await fs.mkdir(getGrokDir(), { recursive: true });
await fs.mkdir(dir, { recursive: true });
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
const keyToWrite = apiKey || "sk_9router"; const toml = applyGrokBuildConfig(await readConfigToml(), {
baseUrl: normalizedBaseUrl,
let toml = await readConfigToml(); apiKey: apiKey || "sk_9router",
toml = rememberPrevDefault(toml); model: selectedModel,
toml = upsertModelSection(toml, buildModelSection(model, normalizedBaseUrl, keyToWrite)); contextWindow: normalizeContextWindow(contextWindow, selectedModel),
toml = setModelsDefault(toml, MODEL_SLOT); subagentModels: normalizeSubagentModels(subagentModels),
});
await fs.writeFile(getGrokConfigPath(), toml); await fs.writeFile(getGrokConfigPath(), toml);
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
message: "Grok Build settings applied successfully!", message: "Grok Build settings applied successfully!",
configPath: getGrokConfigPath(), configPath: getGrokConfigPath(),
modelSlot: MODEL_SLOT, modelSlot: "9router",
}); });
} catch (error) { } catch (error) {
console.log("Error updating grok-build settings:", error); console.log("Error updating grok-build settings:", error);
@@ -217,7 +130,7 @@ export async function POST(request) {
export async function DELETE() { export async function DELETE() {
try { try {
const configPath = getGrokConfigPath(); const configPath = getGrokConfigPath();
let toml = ""; let toml;
try { try {
toml = await fs.readFile(configPath, "utf-8"); toml = await fs.readFile(configPath, "utf-8");
} catch (error) { } catch (error) {
@@ -227,13 +140,10 @@ export async function DELETE() {
throw error; throw error;
} }
toml = removeModelSection(toml); await fs.writeFile(configPath, resetGrokBuildConfig(toml));
toml = clearModelsDefaultIfOurs(toml);
await fs.writeFile(configPath, toml);
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
message: `${PROVIDER_NAME} model slot removed from Grok Build`, message: "9router model slots removed from Grok Build",
}); });
} catch (error) { } catch (error) {
console.log("Error resetting grok-build settings:", error); console.log("Error resetting grok-build settings:", error);
+10 -1
View File
@@ -19,12 +19,21 @@ export async function GET() {
}) })
.map((m) => { .map((m) => {
const fullModel = `${m.provider}/${m.model}`; const fullModel = `${m.provider}/${m.model}`;
const providerAlias = getProviderAlias(m.provider) || m.provider;
const routedModel = `${providerAlias}/${m.model}`;
const c = getCapabilitiesForModel(m.provider, m.model); const c = getCapabilitiesForModel(m.provider, m.model);
return { return {
...m, ...m,
fullModel, fullModel,
routedModel,
alias: modelAliases[fullModel] || m.model, alias: modelAliases[fullModel] || m.model,
caps: { vision: c.vision, search: c.search, reasoning: c.reasoning }, caps: {
vision: c.vision,
search: c.search,
reasoning: c.reasoning,
contextWindow: c.contextWindow,
maxOutput: c.maxOutput,
},
}; };
}); });
+247
View File
@@ -0,0 +1,247 @@
export const GROK_MAIN_MODEL_SLOT = "9router";
export const GROK_BUILTIN_DEFAULT = "grok-build";
export const GROK_SUBAGENT_TYPES = ["general-purpose", "explore", "plan"];
const UNSET_SENTINEL = "__9router_unset__";
const MODELS_SECTION = "models";
const SUBAGENT_MODELS_SECTION = "subagents.models";
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const tomlString = (value) => JSON.stringify(String(value));
const sectionRegExp = (section) =>
new RegExp(
`^\\[${escapeRegExp(section)}\\][ \\t]*\\r?\\n((?:(?!\\[)[^\\r\\n]*\\r?\\n?)*)`,
"m",
);
const modelSlot = (type) => `${GROK_MAIN_MODEL_SLOT}-${type}`;
const previousDefaultRegExp = /^# 9router-prev-default = "([^"]*)"[ \t]*\r?\n?/m;
const previousSubagentRegExp = (type) =>
new RegExp(
`^# 9router-prev-subagent-${escapeRegExp(type)} = "([^"]*)"[ \\t]*\\r?\\n?`,
"m",
);
function getSectionField(toml, section, key) {
const match = toml.match(sectionRegExp(section));
if (!match) return null;
const field = match[1].match(
new RegExp(`^[ \\t]*${escapeRegExp(key)}[ \\t]*=[ \\t]*"([^"]*)"`, "m"),
);
return field ? field[1] : null;
}
function getSectionNumber(toml, section, key) {
const match = toml.match(sectionRegExp(section));
if (!match) return null;
const field = match[1].match(
new RegExp(`^[ \\t]*${escapeRegExp(key)}[ \\t]*=[ \\t]*([0-9]+(?:\\.[0-9]+)?)`, "m"),
);
if (!field) return null;
const value = Number(field[1]);
return Number.isFinite(value) ? value : null;
}
function setSectionField(toml, section, key, value) {
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}\n[${section}]\n${line}\n`;
}
const body = match[1] || "";
const fieldRegExp = new RegExp(
`^[ \\t]*${escapeRegExp(key)}[ \\t]*=[ \\t]*"[^"]*"`,
"m",
);
const nextBody = fieldRegExp.test(body)
? body.replace(fieldRegExp, line)
: `${line}\n${body}`;
return toml.replace(match[0], `[${section}]\n${nextBody}`);
}
function deleteSectionField(toml, section, key) {
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}`);
}
function parseModelSection(toml, slot) {
const match = toml.match(sectionRegExp(`model.${slot}`));
if (!match) return null;
const body = match[1] || "";
const contextWindow = getSectionNumber(toml, `model.${slot}`, "context_window");
return {
model: getSectionField(toml, `model.${slot}`, "model"),
base_url: getSectionField(toml, `model.${slot}`, "base_url"),
name: getSectionField(toml, `model.${slot}`, "name"),
api_key: getSectionField(toml, `model.${slot}`, "api_key"),
api_backend: getSectionField(toml, `model.${slot}`, "api_backend"),
context_window: Number.isFinite(contextWindow) && contextWindow > 0 ? contextWindow : null,
raw: body,
};
}
function buildModelSection({ slot, model, baseUrl, apiKey, contextWindow, name }) {
const lines = [
`[model.${slot}]`,
`model = ${tomlString(model)}`,
`base_url = ${tomlString(baseUrl)}`,
`name = ${tomlString(name)}`,
`description = ${tomlString("Routed via 9Router gateway")}`,
`api_backend = "chat_completions"`,
];
if (apiKey) lines.push(`api_key = ${tomlString(apiKey)}`);
if (Number.isFinite(contextWindow) && contextWindow > 0) {
lines.push(`context_window = ${Math.floor(contextWindow)}`);
}
return `${lines.join("\n")}\n`;
}
function upsertModelSection(toml, config) {
const regexp = sectionRegExp(`model.${config.slot}`);
const section = buildModelSection(config);
if (regexp.test(toml)) return toml.replace(regexp, section);
const prefix = toml.length > 0 && !toml.endsWith("\n") ? `${toml}\n` : toml;
return `${prefix}\n${section}`;
}
function removeModelSection(toml, slot) {
return toml.replace(sectionRegExp(`model.${slot}`), "").replace(/\n{3,}/g, "\n\n");
}
function insertMarker(toml, marker) {
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}`;
}
function rememberPreviousDefault(toml) {
if (previousDefaultRegExp.test(toml)) return toml;
const current = getSectionField(toml, MODELS_SECTION, "default");
if (!current || current === GROK_MAIN_MODEL_SLOT) return toml;
return insertMarker(toml, `# 9router-prev-default = ${tomlString(current)}\n`);
}
function restorePreviousDefault(toml) {
const previous = toml.match(previousDefaultRegExp)?.[1] || GROK_BUILTIN_DEFAULT;
let next = toml.replace(previousDefaultRegExp, "");
if (getSectionField(next, MODELS_SECTION, "default") === GROK_MAIN_MODEL_SLOT) {
next = setSectionField(next, MODELS_SECTION, "default", previous);
}
return next;
}
function rememberPreviousSubagent(toml, type) {
const regexp = previousSubagentRegExp(type);
if (regexp.test(toml)) return toml;
const current = getSectionField(toml, SUBAGENT_MODELS_SECTION, type);
const previous = current == null ? UNSET_SENTINEL : current;
return insertMarker(
toml,
`# 9router-prev-subagent-${type} = ${tomlString(previous)}\n`,
);
}
function restorePreviousSubagent(toml, type) {
const regexp = previousSubagentRegExp(type);
const previous = toml.match(regexp)?.[1] || UNSET_SENTINEL;
let next = toml.replace(regexp, "");
if (getSectionField(next, SUBAGENT_MODELS_SECTION, type) !== modelSlot(type)) {
return next;
}
if (previous === UNSET_SENTINEL) {
return deleteSectionField(next, SUBAGENT_MODELS_SECTION, type);
}
return setSectionField(next, SUBAGENT_MODELS_SECTION, type, previous);
}
export function parseGrokBuildConfig(toml) {
const subagentModels = {};
const subagentMappings = {};
for (const type of GROK_SUBAGENT_TYPES) {
const mapping = getSectionField(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: getSectionField(toml, MODELS_SECTION, "default"),
subagentModels,
subagentMappings,
};
}
/**
* Apply main model and optional per-type subagent overrides while preserving all unrelated TOML.
* `subagentModels === undefined` leaves existing subagent config untouched for API compatibility.
*/
export function applyGrokBuildConfig(
toml,
{ baseUrl, apiKey, model, contextWindow, subagentModels },
) {
let next = rememberPreviousDefault(toml);
next = upsertModelSection(next, {
slot: GROK_MAIN_MODEL_SLOT,
model,
baseUrl,
apiKey,
contextWindow,
name: "9Router",
});
next = setSectionField(next, MODELS_SECTION, "default", GROK_MAIN_MODEL_SLOT);
if (subagentModels && typeof subagentModels === "object") {
for (const type of GROK_SUBAGENT_TYPES) {
const selected = subagentModels[type];
const slot = modelSlot(type);
if (selected?.model) {
next = rememberPreviousSubagent(next, type);
next = upsertModelSection(next, {
slot,
model: selected.model,
baseUrl,
apiKey,
contextWindow: selected.contextWindow,
name: `9Router ${type}`,
});
next = setSectionField(next, SUBAGENT_MODELS_SECTION, type, slot);
} else {
next = restorePreviousSubagent(next, type);
next = removeModelSection(next, slot);
}
}
}
return next;
}
export function resetGrokBuildConfig(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");
}
export function getGrokSubagentSlot(type) {
return GROK_SUBAGENT_TYPES.includes(type) ? modelSlot(type) : null;
}
+8 -1
View File
@@ -13,6 +13,7 @@ function buildMaps(models) {
for (const m of models || []) { for (const m of models || []) {
if (!m.caps) continue; if (!m.caps) continue;
if (m.fullModel) byFull[m.fullModel] = m.caps; if (m.fullModel) byFull[m.fullModel] = m.caps;
if (m.routedModel) byFull[m.routedModel] = m.caps;
if (m.model) byId[m.model] = m.caps; if (m.model) byId[m.model] = m.caps;
} }
return { byFull, byId }; return { byFull, byId };
@@ -44,7 +45,13 @@ function resolveCaps(byFull, byId, key) {
if (byId[bare]) return byId[bare]; if (byId[bare]) return byId[bare];
const provider = key.includes("/") ? key.slice(0, key.indexOf("/")) : null; const provider = key.includes("/") ? key.slice(0, key.indexOf("/")) : null;
const c = getCapabilitiesForModel(provider, bare); const c = getCapabilitiesForModel(provider, bare);
return { vision: c.vision, search: c.search, reasoning: c.reasoning }; return {
vision: c.vision,
search: c.search,
reasoning: c.reasoning,
contextWindow: c.contextWindow,
maxOutput: c.maxOutput,
};
} }
export function useModelCaps() { export function useModelCaps() {
+176
View File
@@ -0,0 +1,176 @@
import { describe, expect, it } from "vitest";
import {
applyGrokBuildConfig,
getGrokSubagentSlot,
parseGrokBuildConfig,
resetGrokBuildConfig,
} from "../../src/lib/grokBuildConfig.js";
const BASE_CONFIG = `[cli]
installer = "internal"
[ui]
yolo = false
[models]
default = "grok-4.5"
default_reasoning_effort = "high"
[subagents]
enabled = true
[subagents.models]
general-purpose = "grok-4.5"
explore = "grok-build"
plan = "grok-4.5"
[mcp_servers.example]
url = "https://example.com/mcp"
enabled = true
`;
const APPLY_INPUT = {
baseUrl: "http://127.0.0.1:20128/v1",
apiKey: "sk-test",
model: "cx/gpt-5.6-sol",
contextWindow: 400000,
subagentModels: {
"general-purpose": { model: "cc/claude-sonnet-5", contextWindow: 1000000 },
explore: { model: "gemini/gemini-3-flash", contextWindow: 1048576 },
},
};
describe("grokBuildConfig", () => {
it("creates independent main and per-type subagent model slots", () => {
const result = applyGrokBuildConfig(BASE_CONFIG, APPLY_INPUT);
const parsed = parseGrokBuildConfig(result);
expect(parsed.default).toBe("9router");
expect(parsed.model).toMatchObject({
model: "cx/gpt-5.6-sol",
base_url: "http://127.0.0.1:20128/v1",
context_window: 400000,
});
expect(parsed.subagentMappings).toMatchObject({
"general-purpose": "9router-general-purpose",
explore: "9router-explore",
plan: "grok-4.5",
});
expect(parsed.subagentModels["general-purpose"]).toMatchObject({
model: "cc/claude-sonnet-5",
context_window: 1000000,
});
expect(parsed.subagentModels.explore).toMatchObject({
model: "gemini/gemini-3-flash",
context_window: 1048576,
});
expect(parsed.subagentModels.plan).toBeNull();
});
it("preserves unrelated config sections", () => {
const result = applyGrokBuildConfig(BASE_CONFIG, APPLY_INPUT);
expect(result).toContain("[cli]\ninstaller = \"internal\"");
expect(result).toContain("[ui]\nyolo = false");
expect(result).toContain("default_reasoning_effort = \"high\"");
expect(result).toContain("[mcp_servers.example]");
expect(result).toContain("url = \"https://example.com/mcp\"");
});
it("is idempotent and updates owned slots without duplicate sections", () => {
let result = applyGrokBuildConfig(BASE_CONFIG, APPLY_INPUT);
result = applyGrokBuildConfig(result, {
...APPLY_INPUT,
model: "cc/claude-opus-4.8",
contextWindow: 1000000,
subagentModels: {
...APPLY_INPUT.subagentModels,
explore: { model: "mimo/mimo", contextWindow: 262144 },
},
});
expect(result.match(/^\[model\.9router\]$/gm)).toHaveLength(1);
expect(result.match(/^\[model\.9router-general-purpose\]$/gm)).toHaveLength(1);
expect(result.match(/^\[model\.9router-explore\]$/gm)).toHaveLength(1);
expect(result.match(/^# 9router-prev-subagent-explore/gm)).toHaveLength(1);
expect(parseGrokBuildConfig(result).model).toMatchObject({
model: "cc/claude-opus-4.8",
context_window: 1000000,
});
expect(parseGrokBuildConfig(result).subagentModels.explore).toMatchObject({
model: "mimo/mimo",
context_window: 262144,
});
});
it("blank override restores previous subagent mapping and removes owned slot", () => {
let result = applyGrokBuildConfig(BASE_CONFIG, APPLY_INPUT);
result = applyGrokBuildConfig(result, {
...APPLY_INPUT,
subagentModels: {
"general-purpose": APPLY_INPUT.subagentModels["general-purpose"],
// explore omitted => inherit / restore previous
},
});
const parsed = parseGrokBuildConfig(result);
expect(parsed.subagentMappings.explore).toBe("grok-build");
expect(parsed.subagentModels.explore).toBeNull();
expect(result).not.toContain("[model.9router-explore]");
expect(parsed.subagentMappings["general-purpose"]).toBe("9router-general-purpose");
});
it("reset restores previous default and all previous subagent mappings", () => {
const applied = applyGrokBuildConfig(BASE_CONFIG, APPLY_INPUT);
const reset = resetGrokBuildConfig(applied);
const parsed = parseGrokBuildConfig(reset);
expect(parsed.default).toBe("grok-4.5");
expect(parsed.model).toBeNull();
expect(parsed.subagentMappings).toEqual({
"general-purpose": "grok-4.5",
explore: "grok-build",
plan: "grok-4.5",
});
expect(reset).not.toContain("[model.9router-");
expect(reset).not.toContain("9router-prev-");
expect(reset).toContain("[mcp_servers.example]");
});
it("removes mappings that were originally unset", () => {
const config = `[models]\ndefault = "grok-build"\n\n[mcp_servers.x]\nenabled = true\n`;
const applied = applyGrokBuildConfig(config, {
...APPLY_INPUT,
subagentModels: {
plan: { model: "cc/claude-sonnet-5", contextWindow: 1000000 },
},
});
const reset = resetGrokBuildConfig(applied);
expect(parseGrokBuildConfig(applied).subagentMappings.plan).toBe("9router-plan");
expect(parseGrokBuildConfig(reset).subagentMappings.plan).toBeNull();
expect(reset).not.toContain("[subagents.models]");
expect(reset).toContain("[mcp_servers.x]");
});
it("legacy callers without subagentModels leave existing overrides untouched", () => {
const applied = applyGrokBuildConfig(BASE_CONFIG, APPLY_INPUT);
const updatedMainOnly = applyGrokBuildConfig(applied, {
baseUrl: APPLY_INPUT.baseUrl,
apiKey: APPLY_INPUT.apiKey,
model: "gemini/gemini-3.1-pro",
contextWindow: 1048576,
});
const parsed = parseGrokBuildConfig(updatedMainOnly);
expect(parsed.model.model).toBe("gemini/gemini-3.1-pro");
expect(parsed.subagentMappings.explore).toBe("9router-explore");
expect(parsed.subagentModels.explore.model).toBe("gemini/gemini-3-flash");
});
it("returns stable slot names only for supported subagent types", () => {
expect(getGrokSubagentSlot("general-purpose")).toBe("9router-general-purpose");
expect(getGrokSubagentSlot("explore")).toBe("9router-explore");
expect(getGrokSubagentSlot("plan")).toBe("9router-plan");
expect(getGrokSubagentSlot("unknown")).toBeNull();
});
});