diff --git a/CHANGELOG.md b/CHANGELOG.md
index fb1f69ad..5932bb12 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,7 @@
## Features
- **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`
- **Kiro**: add GPT-5.6 model family (#2596)
- **RTK**: `X-9Router-Token-Saver` header to bypass token savers per request
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/GrokBuildToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/GrokBuildToolCard.js
index 4b37e47c..f1ea3c22 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/GrokBuildToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/GrokBuildToolCard.js
@@ -1,7 +1,8 @@
"use client";
-import { useState, useEffect, useRef } from "react";
+import { useState, useEffect, useRef, useCallback } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
+import { useModelCaps } from "@/shared/hooks/useModelCaps";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
import ApiKeySelect from "./ApiKeySelect";
@@ -9,12 +10,59 @@ import { matchKnownEndpoint } from "./cliEndpointMatch";
const ENDPOINT = "/api/cli-tools/grok-build-settings";
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 (
+
+
+
{label}
+ {help &&
{help}
}
+
+
arrow_forward
+
+ 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 && (
+
+ )}
+
+
+
+ );
+}
export default function GrokBuildToolCard({
tool,
isExpanded,
onToggle,
- baseUrl,
hasActiveProviders,
apiKeys,
activeProviders,
@@ -25,47 +73,49 @@ export default function GrokBuildToolCard({
tailscaleEnabled,
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 [checking, setChecking] = useState(false);
const [applying, setApplying] = useState(false);
const [restoring, setRestoring] = useState(false);
const [message, setMessage] = useState(null);
- const [selectedApiKey, setSelectedApiKey] = useState("");
- const [selectedModel, setSelectedModel] = useState("");
- const [modalOpen, setModalOpen] = useState(false);
+ const [selectedApiKey, setSelectedApiKey] = useState(apiKeys?.[0]?.key || "");
+ const [selectedModel, setSelectedModel] = useState(initialModel);
+ const [subagentModels, setSubagentModels] = useState(initialSubagents);
+ const [modelTarget, setModelTarget] = useState(null); // "main" or subagent type
const [modelAliases, setModelAliases] = useState({});
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
const [customBaseUrl, setCustomBaseUrl] = useState("");
- const hasInitializedModel = useRef(false);
+ const hasFetchedStatus = useRef(Boolean(initialStatus));
- const getConfigStatus = () => {
- if (!grokStatus?.installed) return null;
- const cfg = grokStatus.settings?.model;
- if (!cfg?.base_url) return "not_configured";
- if (matchKnownEndpoint(cfg.base_url, { tunnelPublicUrl, tailscaleUrl })) return "configured";
- return "other";
- };
+ const configuredModel = grokStatus?.settings?.model;
+ const configStatus = !grokStatus?.installed
+ ? null
+ : !configuredModel?.base_url
+ ? "not_configured"
+ : 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(() => {
- 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 () => {
+ const fetchModelAliases = useCallback(async () => {
try {
const res = await fetch("/api/models/alias");
const data = await res.json();
@@ -73,40 +123,38 @@ export default function GrokBuildToolCard({
} catch (error) {
console.log("Error fetching model aliases:", error);
}
- };
+ }, []);
- useEffect(() => {
- if (grokStatus?.installed && !hasInitializedModel.current) {
- hasInitializedModel.current = true;
- const cfg = grokStatus.settings?.model;
- if (cfg?.model) setSelectedModel(cfg.model);
- }
- }, [grokStatus]);
-
- const checkStatus = async () => {
+ const checkStatus = useCallback(async ({ hydrate = false } = {}) => {
setChecking(true);
try {
const res = await fetch(ENDPOINT);
- const data = await res.json();
- setGrokStatus(data);
+ const status = await res.json();
+ setGrokStatus(status);
+ hasFetchedStatus.current = true;
+ if (hydrate) hydrateForm(status);
} catch (error) {
setGrokStatus({ installed: false, error: error.message });
} finally {
setChecking(false);
}
- };
+ }, [hydrateForm]);
- const normalizeLocalhost = (url) => url.replace("://localhost", "://127.0.0.1");
-
- const getLocalBaseUrl = () => {
- if (typeof window !== "undefined") {
- return normalizeLocalhost(window.location.origin);
- }
- return "http://127.0.0.1:20128";
- };
+ useEffect(() => {
+ if (!isExpanded) return;
+ let cancelled = false;
+ const synchronize = async () => {
+ if (!hasFetchedStatus.current) await checkStatus({ hydrate: true });
+ if (!cancelled) await fetchModelAliases();
+ };
+ synchronize();
+ return () => { cancelled = true; };
+ }, [isExpanded, checkStatus, fetchModelAliases]);
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`;
};
@@ -117,6 +165,11 @@ export default function GrokBuildToolCard({
const keyToUse = selectedApiKey?.trim()
|| (apiKeys?.length > 0 ? apiKeys[0].key : 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, {
method: "POST",
@@ -125,11 +178,13 @@ export default function GrokBuildToolCard({
baseUrl: getEffectiveBaseUrl(),
apiKey: keyToUse,
model: selectedModel,
+ contextWindow: getContextWindow(selectedModel),
+ subagentModels: mappedSubagents,
}),
});
const data = await res.json();
if (res.ok) {
- setMessage({ type: "success", text: "Settings applied successfully!" });
+ setMessage({ type: "success", text: "Main and subagent models applied successfully!" });
checkStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
@@ -150,6 +205,7 @@ export default function GrokBuildToolCard({
if (res.ok) {
setMessage({ type: "success", text: "Settings reset successfully!" });
setSelectedModel("");
+ setSubagentModels({});
checkStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
@@ -162,31 +218,33 @@ export default function GrokBuildToolCard({
};
const handleModelSelect = (model) => {
- setSelectedModel(model.value);
- setModalOpen(false);
+ if (modelTarget === "main") {
+ setSelectedModel(model.value);
+ } else if (modelTarget) {
+ setSubagentModels((current) => ({ ...current, [modelTarget]: model.value }));
+ }
+ setModelTarget(null);
};
const getManualConfigs = () => {
- const keyToUse = (selectedApiKey && selectedApiKey.trim())
- ? selectedApiKey
- : (!cloudEnabled ? "sk_9router" : "");
-
- const modelId = selectedModel || "provider/model-id";
- const tomlContent = `[models]
-default = "${MODEL_SLOT}"
-
-[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 keyToUse = selectedApiKey?.trim()
+ || (!cloudEnabled ? "sk_9router" : "");
+ const baseUrl = getEffectiveBaseUrl();
+ const mainModel = selectedModel || "provider/model-id";
+ const blocks = [
+ `[models]\ndefault = "${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}`,
];
+ 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 (
@@ -202,8 +260,8 @@ api_key = "${keyToUse}"
className="size-8 object-contain rounded-lg"
sizes="32px"
onError={(e) => { e.target.style.display = "none"; }}
- loading="lazy"
- decoding="async"
+ loading="lazy"
+ decoding="async"
/>
@@ -221,170 +279,105 @@ api_key = "${keyToUse}"
{isExpanded && (
- {checking && (
-
- progress_activity
- Checking Grok Build...
-
- )}
+ {checking &&
progress_activityChecking Grok Build...
}
{!checking && grokStatus && !grokStatus.installed && (
-
-
-
-
warning
-
-
Grok Build not detected locally
-
Install:
-
curl -fsSL https://x.ai/cli/install.sh | bash
-
Manual configuration is still available if 9router is deployed on a remote server.
-
-
-
-
+
+
+
warning
+
+
Grok Build not detected locally
+
curl -fsSL https://x.ai/cli/install.sh | bash
+
)}
{!checking && grokStatus?.installed && (
<>
- {tool.notes && tool.notes.length > 0 && (
-
- {tool.notes.map((note, idx) => (
-
-
- {note.type === "warning" ? "warning" : note.type === "error" ? "error" : "info"}
-
+ {tool.notes?.length > 0 && (
+
+ {tool.notes.map((note, index) => (
+
+ {note.type === "warning" ? "warning" : "info"}
{note.text}
))}
)}
-
Select Endpoint
arrow_forward
-
+
- {grokStatus?.settings?.model?.base_url && (
-
+ {configuredModel?.base_url && (
+
Current
arrow_forward
-
- {grokStatus.settings.model.base_url}
- {grokStatus.settings.model.model ? ` · ${grokStatus.settings.model.model}` : ""}
-
+ {configuredModel.base_url} · {configuredModel.model}{configuredModel.context_window ? ` · ${(configuredModel.context_window / 1000).toLocaleString()}K ctx` : ""}
)}
-
+
-
-
Default Model
-
arrow_forward
-
-
setSelectedModel(e.target.value)}
- placeholder="provider/model-id"
- 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 && (
-
- )}
+
setModelTarget("main")} disabled={!hasActiveProviders} />
+
+
+
+
account_tree
+
+
Subagent model overrides
+
Leave blank to inherit Main Model. Each override keeps its own context window.
+
-
+
+ {SUBAGENT_TYPES.map((type) => (
+ setSubagentModels((current) => ({ ...current, [type.id]: value }))}
+ placeholder={`${selectedModel || "Main Model"} (inherit)`}
+ onSelect={() => setModelTarget(type.id)}
+ disabled={!hasActiveProviders}
+ />
+ ))}
- {message && (
-
- {message.type === "success" ? "check_circle" : "error"}
- {message.text}
-
- )}
+ {message &&
{message.type === "success" ? "check_circle" : "error"}{message.text}
}
-
-
-
+
+
+
>
)}
)}
- {modalOpen && (
+ {modelTarget && (
setModalOpen(false)}
+ isOpen={Boolean(modelTarget)}
+ onClose={() => setModelTarget(null)}
onSelect={handleModelSelect}
- selectedModel={selectedModel}
+ selectedModel={modelTarget === "main" ? selectedModel : subagentModels[modelTarget] || ""}
activeProviders={activeProviders}
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`}
/>
)}
- setShowManualConfigModal(false)}
- title="Grok Build - Manual Configuration"
- configs={getManualConfigs()}
- />
+ setShowManualConfigModal(false)} title="Grok Build - Manual Configuration" configs={getManualConfigs()} />
);
}
diff --git a/src/app/api/cli-tools/grok-build-settings/route.js b/src/app/api/cli-tools/grok-build-settings/route.js
index afc747ef..02299a3b 100644
--- a/src/app/api/cli-tools/grok-build-settings/route.js
+++ b/src/app/api/cli-tools/grok-build-settings/route.js
@@ -6,24 +6,16 @@ import { promisify } from "util";
import fs from "fs/promises";
import path from "path";
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 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 getGrokConfigPath = () => path.join(getGrokDir(), "config.toml");
const getGrokBinPath = () => path.join(getGrokDir(), "bin", "grok");
@@ -31,21 +23,16 @@ const getGrokBinPath = () => path.join(getGrokDir(), "bin", "grok");
const checkGrokInstalled = async () => {
try {
const isWindows = os.platform() === "win32";
- const command = isWindows ? "where grok" : "which grok";
- await execAsync(command, { windowsHide: true });
+ await execAsync(isWindows ? "where grok" : "which grok", { windowsHide: true });
return true;
} catch {
- try {
- await fs.access(getGrokBinPath());
- return true;
- } catch {
+ for (const candidate of [getGrokBinPath(), getGrokConfigPath()]) {
try {
- await fs.access(getGrokConfigPath());
+ await fs.access(candidate);
return true;
- } catch {
- return false;
- }
+ } catch { /* try next */ }
}
+ return false;
}
};
@@ -58,99 +45,32 @@ const readConfigToml = async () => {
}
};
-const getTomlField = (body, key) => {
- const m = body.match(new RegExp(`^[ \\t]*${key}[ \\t]*=[ \\t]*"([^"]*)"`, "m"));
- return m ? m[1] : null;
+const normalizeContextWindow = (value, model) => {
+ const explicit = Number(value);
+ 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 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) => {
- 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 normalizeSubagentModels = (value) => {
+ if (value === undefined) return undefined; // backwards-compatible callers leave current overrides untouched
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
+ const result = {};
+ for (const type of GROK_SUBAGENT_TYPES) {
+ const entry = value[type];
+ const model = typeof entry === "string" ? entry.trim() : entry?.model?.trim();
+ if (!model) continue; // blank means inherit the main model
+ result[type] = {
+ model,
+ contextWindow: normalizeContextWindow(entry?.contextWindow, model),
+ };
}
- const block = `[models]\ndefault = "${value}"\n\n`;
- return toml.length > 0 ? block + toml : block;
+ return result;
};
-// Remember the previous default once (so re-Apply does not overwrite it with "9router")
-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;
-};
+const has9RouterConfig = (settings) => Boolean(settings?.model?.base_url);
export async function GET() {
try {
@@ -163,17 +83,11 @@ export async function GET() {
});
}
- const toml = await readConfigToml();
- const model = parseModelSection(toml);
- const defaultModel = parseModelsDefault(toml);
-
+ const settings = parseGrokBuildConfig(await readConfigToml());
return NextResponse.json({
installed: true,
- settings: {
- model,
- default: defaultModel,
- },
- has9Router: has9RouterConfig(model),
+ settings,
+ has9Router: has9RouterConfig(settings),
configPath: getGrokConfigPath(),
});
} catch (error) {
@@ -184,29 +98,28 @@ export async function GET() {
export async function POST(request) {
try {
- const { baseUrl, apiKey, model } = await request.json();
- if (!baseUrl || !model) {
+ const { baseUrl, apiKey, model, contextWindow, subagentModels } = await request.json();
+ const selectedModel = typeof model === "string" ? model.trim() : "";
+ if (!baseUrl || !selectedModel) {
return NextResponse.json({ error: "baseUrl and model are required" }, { status: 400 });
}
- const dir = getGrokDir();
- await fs.mkdir(dir, { recursive: true });
-
+ await fs.mkdir(getGrokDir(), { recursive: true });
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
- const keyToWrite = apiKey || "sk_9router";
-
- let toml = await readConfigToml();
- toml = rememberPrevDefault(toml);
- toml = upsertModelSection(toml, buildModelSection(model, normalizedBaseUrl, keyToWrite));
- toml = setModelsDefault(toml, MODEL_SLOT);
-
+ const toml = applyGrokBuildConfig(await readConfigToml(), {
+ baseUrl: normalizedBaseUrl,
+ apiKey: apiKey || "sk_9router",
+ model: selectedModel,
+ contextWindow: normalizeContextWindow(contextWindow, selectedModel),
+ subagentModels: normalizeSubagentModels(subagentModels),
+ });
await fs.writeFile(getGrokConfigPath(), toml);
return NextResponse.json({
success: true,
message: "Grok Build settings applied successfully!",
configPath: getGrokConfigPath(),
- modelSlot: MODEL_SLOT,
+ modelSlot: "9router",
});
} catch (error) {
console.log("Error updating grok-build settings:", error);
@@ -217,7 +130,7 @@ export async function POST(request) {
export async function DELETE() {
try {
const configPath = getGrokConfigPath();
- let toml = "";
+ let toml;
try {
toml = await fs.readFile(configPath, "utf-8");
} catch (error) {
@@ -227,13 +140,10 @@ export async function DELETE() {
throw error;
}
- toml = removeModelSection(toml);
- toml = clearModelsDefaultIfOurs(toml);
- await fs.writeFile(configPath, toml);
-
+ await fs.writeFile(configPath, resetGrokBuildConfig(toml));
return NextResponse.json({
success: true,
- message: `${PROVIDER_NAME} model slot removed from Grok Build`,
+ message: "9router model slots removed from Grok Build",
});
} catch (error) {
console.log("Error resetting grok-build settings:", error);
diff --git a/src/app/api/models/route.js b/src/app/api/models/route.js
index a4e833a9..b2e4fe0a 100644
--- a/src/app/api/models/route.js
+++ b/src/app/api/models/route.js
@@ -19,12 +19,21 @@ export async function GET() {
})
.map((m) => {
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);
return {
...m,
fullModel,
+ routedModel,
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,
+ },
};
});
diff --git a/src/lib/grokBuildConfig.js b/src/lib/grokBuildConfig.js
new file mode 100644
index 00000000..c11147f1
--- /dev/null
+++ b/src/lib/grokBuildConfig.js
@@ -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;
+}
diff --git a/src/shared/hooks/useModelCaps.js b/src/shared/hooks/useModelCaps.js
index e6fb4c52..92bcec21 100644
--- a/src/shared/hooks/useModelCaps.js
+++ b/src/shared/hooks/useModelCaps.js
@@ -13,6 +13,7 @@ function buildMaps(models) {
for (const m of models || []) {
if (!m.caps) continue;
if (m.fullModel) byFull[m.fullModel] = m.caps;
+ if (m.routedModel) byFull[m.routedModel] = m.caps;
if (m.model) byId[m.model] = m.caps;
}
return { byFull, byId };
@@ -44,7 +45,13 @@ function resolveCaps(byFull, byId, key) {
if (byId[bare]) return byId[bare];
const provider = key.includes("/") ? key.slice(0, key.indexOf("/")) : null;
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() {
diff --git a/tests/unit/grok-build-config.test.js b/tests/unit/grok-build-config.test.js
new file mode 100644
index 00000000..6da793a9
--- /dev/null
+++ b/tests/unit/grok-build-config.test.js
@@ -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();
+ });
+});