From 60f089c7d6b27eca6052e443186d97512c89b017 Mon Sep 17 00:00:00 2001 From: troybrave Date: Thu, 20 Aug 2026 22:39:39 -0400 Subject: [PATCH] fix(server): discover Grok models via CLI, not a full ACP session The Grok provider health check spawned grok agent stdio and session/new under a 15s deadline. That boots every workspace MCP server, so a healthy Grok install is marked error and cached even while chats work. Use grok models for model list and login state instead. --- .../src/provider/Layers/GrokProvider.test.ts | 120 ++++++++++- .../src/provider/Layers/GrokProvider.ts | 191 ++++++++++++------ 2 files changed, 241 insertions(+), 70 deletions(-) diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 1c9bf1f26de7..8b5dd89ae79e 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -6,7 +6,11 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { GrokSettings } from "@t3tools/contracts"; -import { buildInitialGrokProviderSnapshot, checkGrokProviderStatus } from "./GrokProvider.ts"; +import { + buildInitialGrokProviderSnapshot, + checkGrokProviderStatus, + parseGrokModelsCliOutput, +} from "./GrokProvider.ts"; const decodeGrokSettings = Schema.decodeSync(GrokSettings); @@ -91,7 +95,7 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { }), ); - it.effect("reports an error when ACP model discovery is unavailable", () => + it.effect("does not mark Grok broken when the CLI is installed but lists no models", () => Effect.gen(function* () { const snapshot = yield* Effect.scoped( Effect.gen(function* () { @@ -111,10 +115,118 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { }), ); - expect(snapshot.status).toBe("error"); + expect(snapshot.status).toBe("ready"); expect(snapshot.installed).toBe(true); expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-build"]); - expect(snapshot.message).toContain("ACP startup failed"); }), ); + + it.effect("discovers models and auth from `grok models` instead of an ACP session", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-models-" }); + const grokPath = path.join(dir, "grok"); + yield* fs.writeFileString( + grokPath, + [ + "#!/bin/sh", + 'if [ "$1" = "--version" ]; then', + ' printf "grok 1.0.5\\n"', + " exit 0", + "fi", + 'if [ "$1" = "models" ]; then', + " cat <<'EOF'", + "You are logged in with grok.com.", + "", + "Default model: grok-4.6", + "", + "Available models:", + " * grok-4.6 (default)", + " - grok-4.5", + "EOF", + " exit 0", + "fi", + "exit 1", + "", + ].join("\n"), + ); + yield* fs.chmod(grokPath, 0o755); + + return yield* checkGrokProviderStatus( + decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + ); + }), + ); + + expect(snapshot.status).toBe("ready"); + expect(snapshot.installed).toBe(true); + expect(snapshot.version).toBe("1.0.5"); + expect(snapshot.auth).toEqual({ + status: "authenticated", + type: "cached_token", + label: "Grok Subscription", + }); + expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-4.6", "grok-4.5"]); + }), + ); + + it.effect("warns instead of erroring when Grok is installed but not logged in", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-nologin-" }); + const grokPath = path.join(dir, "grok"); + yield* fs.writeFileString( + grokPath, + [ + "#!/bin/sh", + 'if [ "$1" = "--version" ]; then', + ' printf "grok 1.0.5\\n"', + " exit 0", + "fi", + 'printf "You are not logged in.\\nPlease run grok login.\\n"', + "exit 0", + "", + ].join("\n"), + ); + yield* fs.chmod(grokPath, 0o755); + + return yield* checkGrokProviderStatus( + decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + ); + }), + ); + + expect(snapshot.status).toBe("warning"); + expect(snapshot.installed).toBe(true); + expect(snapshot.auth.status).toBe("unauthenticated"); + expect(snapshot.message).toContain("grok login"); + }), + ); +}); + +describe("parseGrokModelsCliOutput", () => { + it("reads login state and bullet model ids from grok models", () => { + const parsed = parseGrokModelsCliOutput(`You are logged in with grok.com. + +Default model: grok-4.6 + +Available models: + * grok-4.6 (default) + - grok-4.5 +`); + expect(parsed.authenticated).toBe(true); + expect(parsed.models.map((model) => model.slug)).toEqual(["grok-4.6", "grok-4.5"]); + }); + + it("detects a logged-out CLI without treating it as a missing install", () => { + const parsed = parseGrokModelsCliOutput("You are not logged in.\nPlease run grok login.\n"); + expect(parsed.authenticated).toBe(false); + expect(parsed.models).toEqual([]); + }); }); diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 934eecdb5ae6..76026454898b 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -2,14 +2,12 @@ import { type GrokSettings, type ModelCapabilities, type ServerProvider, + type ServerProviderAuth, type ServerProviderModel, } from "@t3tools/contracts"; -import type * as EffectAcpSchema from "effect-acp/schema"; import { causeErrorTag } from "@t3tools/shared/observability"; -import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; import { HttpClient } from "effect/unstable/http"; @@ -18,6 +16,7 @@ import { createModelCapabilities } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { + AUTH_PROBE_TIMEOUT_MS, buildServerProvider, isCommandMissingCause, parseGenericCliVersion, @@ -29,7 +28,7 @@ import { enrichProviderSnapshotWithVersionAdvisory, type ProviderMaintenanceCapabilities, } from "../providerMaintenance.ts"; -import { makeGrokAcpRuntime, resolveGrokAcpBaseModelId } from "../acp/GrokAcpSupport.ts"; +import { resolveGrokAcpBaseModelId } from "../acp/GrokAcpSupport.ts"; const GROK_PRESENTATION = { displayName: "Grok", @@ -42,7 +41,6 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ }); const VERSION_PROBE_TIMEOUT_MS = 4_000; -const GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; const GROK_BUILT_IN_MODELS: ReadonlyArray = [ { @@ -99,54 +97,79 @@ function grokModelsFromSettings( return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); } -function buildGrokDiscoveredModelsFromSessionModelState( - modelState: EffectAcpSchema.SessionModelState | null | undefined, -): ReadonlyArray { - if (!modelState || modelState.availableModels.length === 0) { - return []; - } +export function parseGrokModelsCliOutput(output: string): { + readonly authenticated: boolean | null; + readonly models: ReadonlyArray; +} { + const authenticated = /you are logged in/i.test(output) + ? true + : /not logged in|please (?:run )?grok login|unauthenticated/i.test(output) + ? false + : null; + const seen = new Set(); - return modelState.availableModels - .map((model): ServerProviderModel | undefined => { - const slug = resolveGrokAcpBaseModelId(model.modelId); - if (!slug || seen.has(slug)) { - return undefined; - } - seen.add(slug); - return { - slug, - name: model.name.trim() || slug, - isCustom: false, - capabilities: EMPTY_CAPABILITIES, - }; - }) - .filter((model): model is ServerProviderModel => model !== undefined); + const models: ServerProviderModel[] = []; + const addModel = (rawSlug: string) => { + const slug = resolveGrokAcpBaseModelId(rawSlug.replace(/[(),]/g, "")); + if (!slug || seen.has(slug)) { + return; + } + seen.add(slug); + models.push({ + slug, + name: displayNameFromGrokModelSlug(slug), + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }); + }; + + const defaultMatch = output.match(/^\s*Default model:\s+(\S+)/im); + if (defaultMatch?.[1]) { + addModel(defaultMatch[1]); + } + for (const line of output.split(/\r?\n/)) { + const bullet = line.match(/^\s*[*\-]\s+(\S+)/); + if (bullet?.[1]) { + addModel(bullet[1]); + } + } + + return { authenticated, models }; } -const discoverGrokModelsViaAcp = ( - grokSettings: GrokSettings, - environment: NodeJS.ProcessEnv = process.env, -) => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const acp = yield* makeGrokAcpRuntime({ - grokSettings, - environment, - childProcessSpawner, - cwd: process.cwd(), - clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, - }); - const started = yield* acp.start(); - return buildGrokDiscoveredModelsFromSessionModelState(started.sessionSetupResult.models); - }).pipe(Effect.scoped); +function displayNameFromGrokModelSlug(slug: string): string { + return slug + .split(/[-_]/g) + .map((part) => (part.toLowerCase() === "grok" ? "Grok" : part)) + .join(" "); +} -const runGrokVersionCommand = ( +function grokAuthFromModelsCli(authenticated: boolean | null): ServerProviderAuth { + if (authenticated === true) { + return { + status: "authenticated", + type: "cached_token", + label: "Grok Subscription", + }; + } + if (authenticated === false) { + return { + status: "unauthenticated", + type: "cached_token", + label: "Grok Subscription", + }; + } + return { status: "unknown" }; +} + +const runGrokCliCommand = ( grokSettings: GrokSettings, + args: ReadonlyArray, environment: NodeJS.ProcessEnv = process.env, ) => Effect.gen(function* () { const command = grokSettings.binaryPath || "grok"; - const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { + const spawnCommand = yield* resolveSpawnCommand(command, args, { env: environment, }); return yield* spawnAndCollect( @@ -161,11 +184,7 @@ const runGrokVersionCommand = ( export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( grokSettings: GrokSettings, environment: NodeJS.ProcessEnv = process.env, -): Effect.fn.Return< - ServerProviderDraft, - never, - ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto -> { +): Effect.fn.Return { const checkedAt = DateTime.formatIso(yield* DateTime.now); const fallbackModels = grokModelsFromSettings(grokSettings.customModels); @@ -185,7 +204,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func }); } - const versionResult = yield* runGrokVersionCommand(grokSettings, environment).pipe( + const versionResult = yield* runGrokCliCommand(grokSettings, ["--version"], environment).pipe( Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), Effect.result, ); @@ -251,13 +270,14 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func }); } - const discoveryExit = yield* discoverGrokModelsViaAcp(grokSettings, environment).pipe( - Effect.timeoutOption(GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS), - Effect.exit, + const modelsResult = yield* runGrokCliCommand(grokSettings, ["models"], environment).pipe( + Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS), + Effect.result, ); - if (Exit.isFailure(discoveryExit)) { - yield* Effect.logWarning("Grok ACP model discovery failed", { - errorTag: causeErrorTag(discoveryExit.cause), + + if (Result.isFailure(modelsResult)) { + yield* Effect.logWarning("Grok CLI model listing failed.", { + errorTag: modelsResult.failure._tag, }); return buildServerProvider({ presentation: GROK_PRESENTATION, @@ -267,15 +287,16 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func probe: { installed: true, version, - status: "error", + status: "warning", auth: { status: "unknown" }, - message: "Grok CLI is installed but ACP startup failed. Check server logs for details.", + message: "Grok CLI is installed but `grok models` failed. Chats may still work.", }, }); } - if (Option.isNone(discoveryExit.value)) { + + if (Option.isNone(modelsResult.success)) { yield* Effect.logWarning( - `Grok ACP model discovery timed out after ${GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`, + `Grok CLI model listing timed out after ${AUTH_PROBE_TIMEOUT_MS}ms.`, ); return buildServerProvider({ presentation: GROK_PRESENTATION, @@ -285,17 +306,55 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func probe: { installed: true, version, - status: "error", + status: "warning", + auth: { status: "unknown" }, + message: `Grok CLI is installed but \`grok models\` timed out after ${AUTH_PROBE_TIMEOUT_MS}ms. Chats may still work.`, + }, + }); + } + + const modelsOutput = modelsResult.success.value; + if (modelsOutput.code !== 0) { + yield* Effect.logWarning("Grok CLI model listing exited with a non-zero status.", { + exitCode: modelsOutput.code, + }); + return buildServerProvider({ + presentation: GROK_PRESENTATION, + enabled: grokSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "warning", auth: { status: "unknown" }, - message: `Grok CLI is installed but ACP startup timed out after ${GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`, + message: "Grok CLI is installed but `grok models` failed. Chats may still work.", }, }); } - const discoveredModels = discoveryExit.value.value; + + const parsedModels = parseGrokModelsCliOutput(`${modelsOutput.stdout}\n${modelsOutput.stderr}`); const models = - discoveredModels.length > 0 - ? grokModelsFromSettings(grokSettings.customModels, discoveredModels) + parsedModels.models.length > 0 + ? grokModelsFromSettings(grokSettings.customModels, parsedModels.models) : fallbackModels; + const auth = grokAuthFromModelsCli(parsedModels.authenticated); + + if (auth.status === "unauthenticated") { + return buildServerProvider({ + presentation: GROK_PRESENTATION, + enabled: grokSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version, + status: "warning", + auth, + message: "Grok CLI is installed but not logged in. Run `grok login`.", + }, + }); + } return buildServerProvider({ presentation: GROK_PRESENTATION, @@ -306,7 +365,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func installed: true, version, status: "ready", - auth: { status: "unknown" }, + auth, }, }); });