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` : ""}
)} -
+
API Key arrow_forward
-
- 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(); + }); +});