Skip to content
Source
Sign inSign up

status.ts

609 lines20.3 KB

1import { resolveProviderProbeTimeoutMs } from "@t3tools/provider-core/server/snapshotProbe";
2import {
3 type CustomModelSetting,
4 type ModelCapabilities,
5 type ServerProvider,
6 type ServerProviderAuth,
7 type ServerProviderModel,
8 type ServerProviderSlashCommand,
9} from "@t3tools/contracts";
10import type { GrokSettings } from "../settings.ts";
11import type * as EffectAcpSchema from "effect-acp/compat";
12import { causeErrorTag } from "@t3tools/shared/observability";
13import * as SecureRandom from "effect/crypto/SecureRandom";
14import * as DateTime from "effect/DateTime";
15import * as Effect from "effect/Effect";
16import * as Exit from "effect/Exit";
17import * as Option from "effect/Option";
18import * as Result from "effect/Result";
19import * as Schema from "effect/Schema";
20import * as HttpClient from "effect/http/HttpClient";
21import * as ChildProcess from "effect/process/ChildProcess";
22import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
23import { createModelCapabilities } from "@t3tools/shared/model";
24import { resolveSpawnCommand } from "@t3tools/shared/shell";
25​
26import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions";
27import {
28 AUTH_PROBE_TIMEOUT_MS,
29 buildServerProvider,
30 COMPACT_SLASH_COMMAND,
31 isCommandMissingCause,
32 parseCliVersionResult,
33 providerModelsFromSettings,
34 spawnAndCollect,
35 type ServerProviderDraft,
36} from "@t3tools/provider-core/server/snapshotProbe";
37import {
38 enrichProviderSnapshotWithVersionAdvisory,
39 type ProviderMaintenanceCapabilities,
40} from "@t3tools/provider-core/server/maintenanceResolver";
41import {
42 GROK_DEFAULT_MODEL_SLUG,
43 GROK_SUPPORTED_RUNTIME_MODES,
44 isValidGrokReasoningEffortToken,
45 makeGrokAcpRuntime,
46 resolveGrokAcpBaseModelId,
47} from "./acpSupport.ts";
48import { sessionModelStateFromInitialize } from "@t3tools/provider-acp/server/runtimeModel";
49import { discoverGrokSkills } from "./skills.ts";
50import { grokProbeEnvironment } from "./probeEnvironment.ts";
51​
52const GROK_PRESENTATION = {
53 displayName: "Grok",
54 supportsConversationRollback: false,
55 showInteractionModeToggle: false,
56 supportedRuntimeModes: GROK_SUPPORTED_RUNTIME_MODES,
57} as const;
58const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({
59 optionDescriptors: [],
60});
61​
62const VERSION_PROBE_TIMEOUT_MS = 10_000;
63// Grok writes the initialize result only after startup work (settings,
64// skill discovery, file watching). From a home directory that work measured
65// 9-14s on CLI 1.0.52, so an 8s cap records a healthy CLI as a failed probe.
66const GROK_ACP_INITIALIZE_TIMEOUT_MS = 20_000;
67const GROK_API_KEY_ENV = "XAI_API_KEY";
68​
69const GROK_BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
70 {
71 slug: GROK_DEFAULT_MODEL_SLUG,
72 name: "Grok Build",
73 isCustom: false,
74 capabilities: EMPTY_CAPABILITIES,
75 },
76];
77​
78export const buildInitialGrokProviderSnapshot = Effect.fn(
79 "GrokProviderStatus.buildInitialGrokProviderSnapshot",
80)(function* (grokSettings: GrokSettings): Effect.fn.Return<ServerProviderDraft> {
81 const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso);
82 const models = grokModelsFromSettings(grokSettings.customModels);
83​
84 if (!grokSettings.enabled) {
85 return buildServerProvider({
86 presentation: GROK_PRESENTATION,
87 enabled: false,
88 checkedAt,
89 models,
90 probe: {
91 installed: false,
92 version: null,
93 status: "warning",
94 auth: { status: "unknown" },
95 message: "Grok is disabled in T3 Code settings.",
96 },
97 });
98 }
99​
100 return buildServerProvider({
101 presentation: GROK_PRESENTATION,
102 enabled: true,
103 checkedAt,
104 models,
105 probe: {
106 installed: true,
107 version: null,
108 status: "warning",
109 auth: { status: "unknown" },
110 message: "Checking Grok CLI availability...",
111 },
112 });
113});
114​
115function grokModelsFromSettings(
116 customModels: ReadonlyArray<CustomModelSetting> | undefined,
117 builtInModels: ReadonlyArray<ServerProviderModel> = GROK_BUILT_IN_MODELS,
118): ReadonlyArray<ServerProviderModel> {
119 return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES);
121​
122function nonEmptyString(value: unknown): string | undefined {
123 return typeof value === "string" ? value.trim() || undefined : undefined;
125​
126function isRecord(value: unknown): value is Record<string, unknown> {
127 return typeof value === "object" && value !== null && !Array.isArray(value);
129​
130const GROK_EFFORT_RANK = ["none", "minimal", "low", "medium", "high", "xhigh", "max"];
131​
132export function grokReasoningOptionsFromModel(model: EffectAcpSchema.ModelInfo): {
133 readonly options: ReadonlyArray<{
134 value: string;
135 label: string;
136 description?: string;
137 isDefault?: boolean;
138 }>;
139 readonly currentValue: string | undefined;
140} {
141 const meta = model._meta;
142 if (!meta || meta.supportsReasoningEffort === false) {
143 return { options: [], currentValue: undefined };
144 }
145​
146 const currentEffort = nonEmptyString(meta.reasoningEffort);
147 const advertisedOptions = Array.isArray(meta.reasoningEfforts) ? meta.reasoningEfforts : [];
148 const seen = new Set<string>();
149 const options: Array<{
150 value: string;
151 label: string;
152 description?: string;
153 advertisedDefault: boolean;
154 }> = [];
155​
156 for (const entry of advertisedOptions) {
157 if (!isRecord(entry)) {
158 continue;
159 }
160 const rawValue = nonEmptyString(entry.value);
161 const rawId = nonEmptyString(entry.id);
162 const value =
163 rawValue && isValidGrokReasoningEffortToken(rawValue)
164 ? rawValue
165 : rawId && isValidGrokReasoningEffortToken(rawId)
166 ? rawId
167 : undefined;
168 if (value === undefined || seen.has(value)) {
169 continue;
170 }
171 seen.add(value);
172 const description = nonEmptyString(entry.description);
173 options.push({
174 value,
175 label: nonEmptyString(entry.label) ?? value,
176 ...(description ? { description } : {}),
177 advertisedDefault: entry.default === true || entry.isDefault === true,
178 });
179 }
180​
181 const currentValue =
182 currentEffort && options.some((option) => option.value === currentEffort)
183 ? currentEffort
184 : undefined;
185 const advertisedDefaults = options.filter((option) => option.advertisedDefault);
186 const selectedDefault =
187 advertisedDefaults.find((option) => option.value === currentValue)?.value ??
188 advertisedDefaults[0]?.value;
189 // Grok advertises its strongest level first; pickers and effort bars read lowest to highest.
190 const rank = (option: { value: string }) => GROK_EFFORT_RANK.indexOf(option.value);
191 if (options.every((option) => rank(option) >= 0)) {
192 options.sort((left, right) => rank(left) - rank(right));
193 }
194 return {
195 options: options.map(({ value, label, description }) => ({
196 value,
197 label,
198 ...(description ? { description } : {}),
199 ...(value === selectedDefault ? { isDefault: true } : {}),
200 })),
201 currentValue: currentValue ?? selectedDefault,
202 };
204​
205export function buildGrokModelCapabilities(model: EffectAcpSchema.ModelInfo): ModelCapabilities {
206 const reasoning = grokReasoningOptionsFromModel(model);
207 return reasoning.options.length > 0
208 ? createModelCapabilities({
209 optionDescriptors: [
210 {
211 id: "reasoningEffort",
212 label: "Reasoning",
213 type: "select",
214 options: reasoning.options.map((option) => ({
215 id: option.value,
216 label: option.label,
217 ...(option.description ? { description: option.description } : {}),
218 ...(option.isDefault ? { isDefault: true } : {}),
219 })),
220 ...(reasoning.currentValue ? { currentValue: reasoning.currentValue } : {}),
221 },
222 ],
223 })
224 : EMPTY_CAPABILITIES;
226​
227function buildGrokModelsFromSessionModelState(
228 modelState: EffectAcpSchema.SessionModelState | null | undefined,
229): ReadonlyArray<ServerProviderModel> {
230 if (!modelState || modelState.availableModels.length === 0) {
231 return [];
232 }
233 const currentModelId = modelState.currentModelId.trim();
234 const seen = new Set<string>();
235 return modelState.availableModels.flatMap((model): ServerProviderModel[] => {
236 const slug = resolveGrokAcpBaseModelId(model.modelId);
237 if (!slug || seen.has(slug)) {
238 return [];
239 }
240 seen.add(slug);
241 return [
242 {
243 slug,
244 name: model.name.trim() || slug,
245 isCustom: false,
246 ...(model.modelId.trim() === currentModelId ? { isDefault: true } : {}),
247 capabilities: buildGrokModelCapabilities(model),
248 },
249 ];
250 });
252​
253export interface GrokModelsCliOutput {
254 readonly authenticated: boolean | null;
255 readonly models: ReadonlyArray<ServerProviderModel>;
257​
258/** Exit status is always 0; only output text indicates login state. */
259export function parseGrokModelsCliOutput(output: string): GrokModelsCliOutput {
260 const authenticated = /you are logged in/i.test(output)
261 ? true
262 : /not authenticated|not logged in/i.test(output)
263 ? false
264 : null;
265​
266 const seen = new Set<string>();
267 const models: ServerProviderModel[] = [];
268 for (const line of output.split(/\r?\n/)) {
269 const bullet = line.match(/^\s*[*-]\s+(\S+)(.*)$/);
270 if (!bullet?.[1]) {
271 continue;
272 }
273 const slug = resolveGrokAcpBaseModelId(bullet[1]);
274 if (seen.has(slug)) {
275 continue;
276 }
277 seen.add(slug);
278 models.push({
279 slug,
280 name: displayNameFromGrokModelSlug(slug),
281 isCustom: false,
282 ...(/\(default\)/i.test(bullet[2] ?? "") ? { isDefault: true } : {}),
283 capabilities: EMPTY_CAPABILITIES,
284 });
285 }
286 return { authenticated, models };
288​
289function displayNameFromGrokModelSlug(slug: string): string {
290 return slug
291 .split(/[-_]/g)
292 .map((part) => (part.toLowerCase() === "grok" ? "Grok" : part))
293 .join(" ");
295​
296const runGrokCliCommand = Effect.fn("GrokProviderStatus.runGrokCliCommand")(function* (
297 grokSettings: GrokSettings,
298 args: ReadonlyArray<string>,
299 environment: NodeJS.ProcessEnv,
300) {
301 const command = grokSettings.binaryPath || "grok";
302 const spawnCommand = yield* resolveSpawnCommand(command, args, { env: environment });
303 return yield* spawnAndCollect(
304 command,
305 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
306 env: environment,
307 shell: spawnCommand.shell,
308 }),
309 );
310});
311​
312const probeGrokModels = Effect.fn("GrokProviderStatus.probeGrokModels")(function* (
313 grokSettings: GrokSettings,
314 environment: NodeJS.ProcessEnv,
315) {
316 const modelsResult = yield* runGrokCliCommand(grokSettings, ["models"], environment).pipe(
317 Effect.timeoutOption(resolveProviderProbeTimeoutMs("grok", AUTH_PROBE_TIMEOUT_MS, environment)),
318 Effect.result,
319 );
320 // Only a clean exit is parsed. Failed invocations print help or error text that
321 // must not be read as model slugs or as a login verdict.
322 const modelsOutput =
323 Result.isSuccess(modelsResult) &&
324 Option.isSome(modelsResult.success) &&
325 modelsResult.success.value.code === 0
326 ? modelsResult.success.value
327 : undefined;
328 const cliModels: GrokModelsCliOutput = modelsOutput
329 ? parseGrokModelsCliOutput(`${modelsOutput.stdout}\n${modelsOutput.stderr}`)
330 : { authenticated: null, models: [] };
331 if (!modelsOutput) {
332 yield* Effect.logWarning("Grok CLI model listing failed or timed out.", {
333 errorTag: Result.isFailure(modelsResult)
334 ? modelsResult.failure._tag
335 : Option.isNone(modelsResult.success)
336 ? "Timeout"
337 : `ExitCode${modelsResult.success.value.code}`,
338 });
339 }
340 return cliModels;
341});
342​
343const decodeAvailableCommands = Schema.decodeUnknownOption(Schema.Array(Schema.Unknown));
344const decodeAvailableCommand = Schema.decodeUnknownOption(
345 Schema.Struct({
346 name: Schema.String,
347 description: Schema.String,
348 input: Schema.optional(Schema.NullOr(Schema.Struct({ hint: Schema.String }))),
349 }),
350);
351​
352export function grokSlashCommandsFromInitialize(
353 initialized: EffectAcpSchema.InitializeResponse,
354): ReadonlyArray<ServerProviderSlashCommand> {
355 const commands = decodeAvailableCommands(initialized._meta?.availableCommands);
356 const byName = new Map<string, ServerProviderSlashCommand>([
357 [COMPACT_SLASH_COMMAND.name, COMPACT_SLASH_COMMAND],
358 ]);
359 for (const entry of Option.getOrElse(commands, () => [])) {
360 const decoded = decodeAvailableCommand(entry);
361 if (Option.isNone(decoded)) continue;
362 const command = decoded.value;
363 const name = command.name.trim();
364 if (!name || name.toLowerCase() === "always-approve") continue;
365 // Grok advertises /context, but its ACP handler completes without emitting output.
366 if (name.toLowerCase() === "context") continue;
367 const description = command.description.trim();
368 const hint = command.input?.hint.trim();
369 byName.set(name, {
370 name,
371 ...(description ? { description } : {}),
372 ...(hint ? { input: { hint } } : {}),
373 });
374 }
375 return [...byName.values()];
377​
378/**
379 * This never calls `authenticate` or `session/new`, so it cannot open a browser login or
380 * boot the workspace's MCP servers.
381 */
382const discoverGrokMetadataViaAcpInitialize = Effect.fn(
383 "GrokProviderStatus.discoverGrokMetadataViaAcpInitialize",
384)(function* (grokSettings: GrokSettings, environment: NodeJS.ProcessEnv) {
385 const acp = yield* makeGrokAcpRuntime({
386 grokSettings,
387 environment,
388 cwd: process.cwd(),
389 clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" },
390 });
391 const initialized = yield* acp.initialize();
392 return {
393 models: buildGrokModelsFromSessionModelState(sessionModelStateFromInitialize(initialized)),
394 slashCommands: grokSlashCommandsFromInitialize(initialized),
395 };
396}, Effect.scoped);
397​
398export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* (
399 grokSettings: GrokSettings,
400 environment: NodeJS.ProcessEnv = process.env,
401 cwd?: string,
402): Effect.fn.Return<
403 ServerProviderDraft,
404 never,
405 ChildProcessSpawner.ChildProcessSpawner | SecureRandom.SecureRandom
406> {
407 const checkedAt = DateTime.formatIso(yield* DateTime.now);
408 const fallbackModels = grokModelsFromSettings(grokSettings.customModels);
409​
410 if (!grokSettings.enabled) {
411 return buildServerProvider({
412 presentation: GROK_PRESENTATION,
413 enabled: false,
414 checkedAt,
415 models: fallbackModels,
416 probe: {
417 installed: false,
418 version: null,
419 status: "warning",
420 auth: { status: "unknown" },
421 message: "Grok is disabled in T3 Code settings.",
422 },
423 });
424 }
425​
426 const probeEnvironment = grokProbeEnvironment(environment);
427 const apiKey = environment[GROK_API_KEY_ENV]?.trim();
428​
429 const versionResult = yield* runGrokCliCommand(
430 grokSettings,
431 ["--version"],
432 probeEnvironment,
433 ).pipe(
434 Effect.timeoutOption(
435 resolveProviderProbeTimeoutMs("grok", VERSION_PROBE_TIMEOUT_MS, environment),
436 ),
437 Effect.result,
438 );
439​
440 if (Result.isFailure(versionResult)) {
441 const error = versionResult.failure;
442 yield* Effect.logWarning("Grok CLI health check failed.", {
443 errorTag: error._tag,
444 });
445 return buildServerProvider({
446 presentation: GROK_PRESENTATION,
447 enabled: grokSettings.enabled,
448 checkedAt,
449 models: fallbackModels,
450 probe: {
451 installed: !isCommandMissingCause(error),
452 version: null,
453 status: "error",
454 auth: { status: "unknown" },
455 message: isCommandMissingCause(error)
456 ? "Grok CLI (`grok`) is not installed or not on PATH."
457 : "Failed to execute Grok CLI health check.",
458 },
459 });
460 }
461​
462 if (Option.isNone(versionResult.success)) {
463 return buildServerProvider({
464 presentation: GROK_PRESENTATION,
465 enabled: grokSettings.enabled,
466 checkedAt,
467 models: fallbackModels,
468 probe: {
469 installed: true,
470 version: null,
471 status: "error",
472 auth: { status: "unknown" },
473 message: "Grok CLI is installed but timed out while running `grok --version`.",
474 },
475 });
476 }
477​
478 const versionOutput = versionResult.success.value;
479 const version = parseCliVersionResult(versionOutput);
480 if (versionOutput.code !== 0) {
481 yield* Effect.logWarning("Grok CLI version probe exited with a non-zero status.", {
482 exitCode: versionOutput.code,
483 stdoutLength: versionOutput.stdout.length,
484 stderrLength: versionOutput.stderr.length,
485 });
486 return buildServerProvider({
487 presentation: GROK_PRESENTATION,
488 enabled: grokSettings.enabled,
489 checkedAt,
490 models: fallbackModels,
491 probe: {
492 installed: true,
493 version,
494 status: "error",
495 auth: { status: "unknown" },
496 message: "Grok CLI is installed but failed to run.",
497 },
498 });
499 }
500​
501 // `grok models` reports login state and model slugs without starting the agent.
502 let cliModels = yield* probeGrokModels(grokSettings, probeEnvironment);
503 // An expired access token prints the logged-out line, then the CLI refreshes it in the same
504 // process. Probing again after that process exited tells a refresh from a real logout.
505 if (cliModels.authenticated === false && !apiKey) {
506 const recheck = yield* probeGrokModels(grokSettings, probeEnvironment);
507 if (recheck.authenticated !== null) {
508 cliModels = recheck;
509 }
510 }
511​
512 const auth: ServerProviderAuth = apiKey
513 ? { status: "authenticated", type: "api_key", label: "xAI API key" }
514 : cliModels.authenticated === true
515 ? { status: "authenticated", type: "cached_token", label: "Grok account" }
516 : cliModels.authenticated === false
517 ? { status: "unauthenticated" }
518 : { status: "unknown" };
519​
520 const skills = yield* discoverGrokSkills(grokSettings, environment, cwd).pipe(
521 Effect.tapError((cause) => Effect.logDebug("Grok skill discovery failed.", { cause })),
522 Effect.orElseSucceed(() => []),
523 );
524​
525 const acpExit = yield* discoverGrokMetadataViaAcpInitialize(grokSettings, probeEnvironment).pipe(
526 Effect.timeoutOption(
527 resolveProviderProbeTimeoutMs("grok", GROK_ACP_INITIALIZE_TIMEOUT_MS, environment),
528 ),
529 Effect.exit,
530 );
531 const acpMetadata = Exit.isSuccess(acpExit) ? Option.getOrUndefined(acpExit.value) : undefined;
532 const acpModels = acpMetadata?.models ?? [];
533 const acpFailed = Exit.isFailure(acpExit) || Option.isNone(acpExit.value);
534 if (acpFailed) {
535 yield* Effect.logWarning("Grok ACP initialize probe failed or timed out.", {
536 errorTag: Exit.isFailure(acpExit) ? causeErrorTag(acpExit.cause) : "Timeout",
537 });
538 }
539​
540 const discoveredModels = acpModels.length > 0 ? acpModels : cliModels.models;
541 const models =
542 discoveredModels.length > 0
543 ? grokModelsFromSettings(grokSettings.customModels, discoveredModels)
544 : fallbackModels;
545 // The registry replaces cached models on a ready probe, so a fallback-only list must not be ready.
546 const incompleteMessage = acpFailed
547 ? "Grok CLI is installed but ACP initialize failed. Model options may be incomplete."
548 : discoveredModels.length === 0
549 ? "Grok CLI did not list any models. Model options may be incomplete."
550 : undefined;
551​
552 if (auth.status === "unauthenticated") {
553 return buildServerProvider({
554 presentation: GROK_PRESENTATION,
555 enabled: grokSettings.enabled,
556 checkedAt,
557 models,
558 skills,
559 probe: {
560 installed: true,
561 version,
562 status: "error",
563 auth,
564 message: "Grok CLI is installed but not logged in. Run `grok login`.",
565 },
566 });
567 }
568​
569 return buildServerProvider({
570 presentation: GROK_PRESENTATION,
571 enabled: grokSettings.enabled,
572 checkedAt,
573 models,
574 skills,
575 slashCommands: acpMetadata?.slashCommands ?? [COMPACT_SLASH_COMMAND],
576 probe: {
577 installed: true,
578 version,
579 status: incompleteMessage ? "warning" : "ready",
580 auth,
581 ...(incompleteMessage ? { message: incompleteMessage } : {}),
582 },
583 });
584});
585​
586export const enrichGrokSnapshot = (input: {
587 readonly snapshot: ServerProvider;
588 readonly maintenanceCapabilities: ProviderMaintenanceCapabilities;
589 readonly enableProviderUpdateChecks?: boolean;
590 readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>;
591}): Effect.Effect<
592 void,
593 never,
594 HttpClient.HttpClient | ProviderLatestVersions.ProviderLatestVersions
595> => {
596 const { snapshot, publishSnapshot } = input;
597​
598 return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, {
599 enableProviderUpdateChecks: input.enableProviderUpdateChecks,
600 }).pipe(
601 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
602 Effect.catchCause((cause) =>
603 Effect.logWarning("Grok version advisory enrichment failed", {
604 errorTag: causeErrorTag(cause),
605 }),
606 ),
607 Effect.asVoid,
608 );
609};