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:
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<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({
|
||||
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" : "<API_KEY_FROM_DASHBOARD>");
|
||||
|
||||
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" : "<API_KEY_FROM_DASHBOARD>");
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
@@ -221,170 +279,105 @@ api_key = "${keyToUse}"
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
|
||||
{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>
|
||||
)}
|
||||
{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>}
|
||||
|
||||
{!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 items-start gap-3">
|
||||
<span className="material-symbols-outlined text-yellow-500">warning</span>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-yellow-600 dark:text-yellow-400">Grok Build not detected locally</p>
|
||||
<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 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">
|
||||
<span className="material-symbols-outlined text-yellow-500">warning</span>
|
||||
<div className="flex-1">
|
||||
<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>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{!checking && grokStatus?.installed && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
{tool.notes && tool.notes.length > 0 && (
|
||||
<div className="flex flex-col gap-2 mb-2">
|
||||
{tool.notes.map((note, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
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>
|
||||
{tool.notes?.length > 0 && (
|
||||
<div className="mb-2 flex flex-col gap-2">
|
||||
{tool.notes.map((note, index) => (
|
||||
<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"}`}>
|
||||
<span className="material-symbols-outlined mt-0.5 text-[14px]">{note.type === "warning" ? "warning" : "info"}</span>
|
||||
<span>{note.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<BaseUrlSelect
|
||||
value={customBaseUrl || getEffectiveBaseUrl()}
|
||||
onChange={setCustomBaseUrl}
|
||||
requiresExternalUrl={tool.requiresExternalUrl}
|
||||
tunnelEnabled={tunnelEnabled}
|
||||
tunnelPublicUrl={tunnelPublicUrl}
|
||||
tailscaleEnabled={tailscaleEnabled}
|
||||
tailscaleUrl={tailscaleUrl}
|
||||
/>
|
||||
<BaseUrlSelect value={customBaseUrl || getEffectiveBaseUrl()} onChange={setCustomBaseUrl} requiresExternalUrl={tool.requiresExternalUrl} tunnelEnabled={tunnelEnabled} tunnelPublicUrl={tunnelPublicUrl} tailscaleEnabled={tailscaleEnabled} tailscaleUrl={tailscaleUrl} />
|
||||
</div>
|
||||
|
||||
{grokStatus?.settings?.model?.base_url && (
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
|
||||
{configuredModel?.base_url && (
|
||||
<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="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">
|
||||
{grokStatus.settings.model.base_url}
|
||||
{grokStatus.settings.model.model ? ` · ${grokStatus.settings.model.model}` : ""}
|
||||
</span>
|
||||
<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>
|
||||
</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="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
|
||||
<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="relative w-full min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
value={selectedModel}
|
||||
onChange={(e) => 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 && (
|
||||
<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>
|
||||
)}
|
||||
<ModelField label="Main Model" value={selectedModel} onChange={setSelectedModel} placeholder="provider/model-id" onSelect={() => setModelTarget("main")} disabled={!hasActiveProviders} />
|
||||
|
||||
<div className="my-1 border-t border-border pt-3">
|
||||
<div className="mb-2 flex items-start gap-2">
|
||||
<span className="material-symbols-outlined text-primary text-[16px]">account_tree</span>
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-text-main">Subagent model overrides</p>
|
||||
<p className="text-[10px] text-text-muted">Leave blank to inherit Main Model. Each override keeps its own context window.</p>
|
||||
</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>
|
||||
|
||||
{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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
{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 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">
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{modalOpen && (
|
||||
{modelTarget && (
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => 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`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ManualConfigModal
|
||||
isOpen={showManualConfigModal}
|
||||
onClose={() => setShowManualConfigModal(false)}
|
||||
title="Grok Build - Manual Configuration"
|
||||
configs={getManualConfigs()}
|
||||
/>
|
||||
<ManualConfigModal isOpen={showManualConfigModal} onClose={() => setShowManualConfigModal(false)} title="Grok Build - Manual Configuration" configs={getManualConfigs()} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
247
src/lib/grokBuildConfig.js
Normal file
247
src/lib/grokBuildConfig.js
Normal 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;
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
176
tests/unit/grok-build-config.test.js
Normal file
176
tests/unit/grok-build-config.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user