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

Add separate model selectors for Grok Build main, general-purpose,
explore, and plan agents. Each override gets an independent 9Router
custom-model slot and context_window derived from 9Router model
capabilities. Preserve and restore pre-existing config on reset.
This commit is contained in:
rixzkiye
2026-07-19 13:35:38 +07:00
parent 0513bf393f
commit e0ba667450
7 changed files with 679 additions and 337 deletions
@@ -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);