FN-7753: route grok-cli execution through the grok CLI when no Fusion-visible GROK_API_KEY resolves

Route grok-cli model selections through the grok CLI runtime when no Fusion-visible GROK_API_KEY is available.

- Add read-only isGrokApiKeyFusionVisible() in packages/core/src/grok-provider.ts, refactored to share user-settings-file reading with hydrateGrokApiKeyFromUserSettings without mutating process.env or logging key material.
- In packages/engine/src/agent-session-helpers.ts, auto-derive the existing "grok" runtimeHint when defaultProvider is grok-cli, no key is Fusion-visible, and the grok plugin runtime is registered; explicit runtime hints and mock/test-mode routing remain unchanged, and the provider-qualified model prefix is stripped before handoff.
- Normalize provider-qualified model ids (grok-cli/<id>, grok/<id>) in the grok-runtime plugin's runtime-adapter and CLI stream spawn so the concrete model reaches `grok --model`, with the historical grok/default fallback preserved for the no-model path.
- Update docs (grok-cli-contract.md, settings-reference.md, plugin README) and add/extend tests covering the new fallback behavior, model normalization, and CLI streaming.
- Add changeset fn-7753-grok-cli-no-key-fallback.md (patch, fix).

Files changed:
 .changeset/fn-7753-grok-cli-no-key-fallback.md     |   7 ++
 docs/grok-cli-contract.md                          |  83 ++++++++++------
 docs/settings-reference.md                         |   6 +-
 .../__tests__/grok-provider-user-settings.test.ts  |  46 +++++++++
 packages/core/src/grok-provider.ts                 |  39 +++++++-
 packages/core/src/index.gate.ts                    |   1 +
 packages/core/src/index.ts                         |   1 +
 .../src/__tests__/grok-runtime-routing.test.ts     | 107 +++++++++++++++++++--
 packages/engine/src/agent-session-helpers.ts       |  52 +++++++++-
 plugins/fusion-plugin-grok-runtime/README.md       |  46 +++++----
 .../src/__tests__/cli-stream.test.ts               |  70 ++++++++++++++
 .../src/__tests__/runtime-adapter.test.ts          |  28 ++++++
 .../fusion-plugin-grok-runtime/src/cli-stream.ts   |   6 ++
 .../src/runtime-adapter.ts                         |  24 ++++-
 14 files changed, 443 insertions(+), 73 deletions(-)

Fusion-Task-Id: FN-7753

Fusion-Task-Lineage: 30ef7265-1ba9-47fd-8c4e-87b02f6a1d78

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-09 21:36:14 -07:00
parent 28c82331e7
commit f7c6f560c0
14 changed files with 441 additions and 71 deletions

View File

@@ -76,18 +76,23 @@ grok --prompt "<text>" --format json
`~/.grok/user-settings.json`), a CLI-routed selection needs **no
Fusion-visible `GROK_API_KEY`** — unlike the direct xAI
OpenAI-compatible streaming path (`https://api.x.ai/v1`), which still
requires one.
- This adapter is only reached when an agent's
`runtimeConfig.runtimeHint === "grok"`. See "Routing Grok through the CLI
runtime (FN-7725)" below for how to set that, and
requires one. When Fusion auto-routes a no-key `grok-cli/*` model selection
through this adapter (FN-7753), the selected model id is passed to the CLI
with `--model <id>`.
- This adapter is reached either when an agent explicitly sets
`runtimeConfig.runtimeHint === "grok"` or when FN-7753's no-visible-key
`grok-cli/*` fallback derives that hint automatically. See "Routing Grok
through the CLI runtime (FN-7725 / FN-7753)" below and
`docs/grok-cli-contract.md` for the full contract and decision record.
## Routing Grok through the CLI runtime (FN-7725)
## Routing Grok through the CLI runtime (FN-7725 / FN-7753)
By default, selecting a `grok-cli/*` **model** for an agent/task still routes
execution through the **direct xAI OpenAI-compatible endpoint**
(`https://api.x.ai/v1`, FN-7711/FN-7714) — this default is unchanged by this
plugin.
By default, selecting a `grok-cli/*` **model** for an agent/task routes through
the **direct xAI OpenAI-compatible endpoint** (`https://api.x.ai/v1`,
FN-7711/FN-7714) whenever Fusion can see a `GROK_API_KEY` (environment or
`~/.grok/user-settings.json` `apiKey`). If no Fusion-visible key resolves and
the Grok Runtime plugin is registered, Fusion automatically routes that session
through the `grok` CLI runtime instead, letting the CLI own auth end-to-end.
To route a specific agent's execution through the `grok` CLI's own
non-interactive streaming mode (`grok --prompt --format json`) instead:
@@ -104,16 +109,19 @@ non-interactive streaming mode (`grok --prompt --format json`) instead:
child agent) resolves through `packages/engine/src/runtime-resolution.ts`
to this plugin's `GrokRuntimeAdapter` instead of the default pi runtime.
**Known limitation:** Runtime-mode is model-agnostic — it does not carry a
specific `grok-cli/*` model id through to the adapter, so
`GrokRuntimeAdapter.createSession()` always falls back to `"grok/default"`.
If you need a specific Grok model honored end-to-end, use the direct xAI
endpoint path (**Built-in Model** → a `grok-cli/*` model) instead — that
path does preserve model selection, just not via the CLI binary.
**Automatic fallback precedence (FN-7753):** explicit runtime hint >
Fusion-visible key/direct endpoint > automatic CLI fallback. The fallback is
only derived when no explicit runtime hint is set, the provider is `grok-cli`,
no Fusion-visible key resolves, and runtime id `"grok"` is registered. The
selected model is normalized from `grok-cli/<id>` (or `grok/<id>`) to `<id>`
and sent as `--model <id>`.
This routing is opt-in and per-agent; it does not change any other agent's
or task's execution path, and it does not change what a `grok-cli/*` model
selection does under **Built-in Model** mode.
**Known limitation:** explicit Runtime-mode is still model-agnostic — it does
not carry a specific `grok-cli/*` model id through to the adapter, so
`GrokRuntimeAdapter.createSession()` falls back to `"grok/default"` and omits
`--model`. Built-in Model selections preserve the model either through the
direct endpoint (when a key is visible) or through the FN-7753 automatic CLI
fallback (when no key is visible).
## Enable via Settings → Authentication

View File

@@ -0,0 +1,70 @@
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("node:child_process", () => ({ spawn: vi.fn() }));
import { spawn } from "node:child_process";
import { spawnGrokStream } from "../cli-stream.js";
function mockPlatform(platform: NodeJS.Platform) {
return vi.spyOn(process, "platform", "get").mockReturnValue(platform);
}
function createMockChild() {
const child = new EventEmitter() as EventEmitter & {
stdout: PassThrough;
stderr: PassThrough;
kill: ReturnType<typeof vi.fn>;
};
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.kill = vi.fn();
vi.mocked(spawn).mockReturnValue(child as never);
return child;
}
describe("spawnGrokStream", () => {
beforeEach(() => {
vi.clearAllMocks();
mockPlatform("darwin");
createMockChild();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("passes the selected model to grok --model when provided", () => {
spawnGrokStream("grok", "hello", { cwd: "/tmp/project", model: "grok-4.5" });
expect(spawn).toHaveBeenCalledWith("grok", [
"--prompt",
"hello",
"--format",
"json",
"--model",
"grok-4.5",
"--directory",
"/tmp/project",
], {
cwd: "/tmp/project",
stdio: ["ignore", "pipe", "pipe"],
shell: false,
signal: undefined,
});
});
it("omits --model when no model is provided", () => {
spawnGrokStream("grok", "hello", { cwd: "/tmp/project" });
expect(spawn).toHaveBeenCalledWith("grok", [
"--prompt",
"hello",
"--format",
"json",
"--directory",
"/tmp/project",
], expect.objectContaining({ cwd: "/tmp/project" }));
});
});

View File

@@ -31,6 +31,34 @@ describe("GrokRuntimeAdapter", () => {
expect(result.session.systemPrompt).toBe("sys");
});
it("passes the normalized selected model to the CLI spawn seam", async () => {
const { proc } = makeFakeProc();
const spawn = vi.fn().mockReturnValue(proc);
const adapter = new GrokRuntimeAdapter({ spawn });
const { session } = await adapter.createSession({ defaultModelId: "grok-cli/grok-4.5" });
const promise = adapter.promptWithFallback(session, "hello grok");
proc.emit("close", 0, null);
await promise;
expect(session.model).toBe("grok-4.5");
expect(spawn).toHaveBeenCalledWith("grok", "hello grok", expect.objectContaining({ model: "grok-4.5" }));
});
it("omits --model for the no-model grok/default fallback", async () => {
const { proc } = makeFakeProc();
const spawn = vi.fn().mockReturnValue(proc);
const adapter = new GrokRuntimeAdapter({ spawn });
const { session } = await adapter.createSession({});
const promise = adapter.promptWithFallback(session, "hello grok");
proc.emit("close", 0, null);
await promise;
expect(session.model).toBe("grok/default");
expect(spawn).toHaveBeenCalledWith("grok", "hello grok", expect.objectContaining({ model: undefined }));
});
it("streams onText for each text NDJSON event in order and resolves on close", async () => {
const { proc, stdout } = makeFakeProc();
const spawn = vi.fn().mockReturnValue(proc);

View File

@@ -18,6 +18,7 @@ export type GrokStreamProcess = ChildProcessByStdio<null, Readable, Readable>;
export interface SpawnGrokStreamOptions {
cwd?: string;
model?: string;
signal?: AbortSignal;
}
@@ -30,6 +31,11 @@ export interface SpawnGrokStreamOptions {
*/
export function spawnGrokStream(binary: string, prompt: string, options?: SpawnGrokStreamOptions): GrokStreamProcess {
const args: string[] = ["--prompt", prompt, "--format", "json"];
const model = options?.model?.trim();
if (model) {
// FNXC:GrokCliRouting 2026-07-09-00:00: FN-7753 preserves a selected `grok-cli/*` model when auto-routing through the CLI; upstream verifies `--model <model>` alongside `--prompt`/`--format json`.
args.push("--model", model);
}
if (options?.cwd) {
args.push("--directory", options.cwd);
}

View File

@@ -32,6 +32,9 @@ does, same `streamEnded`-guarded (via the existing `settled` flag)
resolve-never-reject lifecycle as before. This adapter is only reached when
an agent's `runtimeConfig.runtimeHint === "grok"` (wired end-to-end by
FN-7725).
FNXC:GrokCliRouting 2026-07-09-00:00:
FN-7753: auto-derived `grok` runtime routing from a `grok-cli/*` model selection must preserve the concrete model. Normalize provider-qualified ids (`grok-cli/<id>` or `grok/<id>`) at session creation/prompt time and pass only the concrete id to `grok --model`; the no-model Runtime-mode path keeps the historical `grok/default` session fallback and omits `--model`.
*/
/**
@@ -71,6 +74,23 @@ function parseToolArguments(raw: string | undefined): unknown {
}
}
function normalizeGrokCliModel(model: string | undefined): string | undefined {
const normalized = model?.trim();
if (!normalized) return undefined;
for (const prefix of ["grok-cli/", "grok/"]) {
if (normalized.startsWith(prefix)) {
const stripped = normalized.slice(prefix.length).trim();
return stripped.length > 0 ? stripped : undefined;
}
}
return normalized;
}
function modelForCli(model: string | undefined): string | undefined {
const normalized = normalizeGrokCliModel(model);
return normalized && normalized !== "default" ? normalized : undefined;
}
export interface GrokRuntimeAdapterOptions {
/** Binary name/path to invoke. Defaults to "grok" (PATH resolution). */
binary?: string;
@@ -99,7 +119,7 @@ export class GrokRuntimeAdapter implements AgentRuntime {
onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void;
} = {},
): Promise<AgentSessionResult> {
const model = options.defaultModelId ?? "grok/default";
const model = normalizeGrokCliModel(options.defaultModelId) ?? "grok/default";
const session: GrokSession = {
model,
systemPrompt: options.systemPrompt,
@@ -124,7 +144,7 @@ export class GrokRuntimeAdapter implements AgentRuntime {
return new Promise<void>((resolve) => {
let proc: GrokStreamProcess;
try {
proc = this.spawnFn(this.binary, prompt, { cwd, signal });
proc = this.spawnFn(this.binary, prompt, { cwd, model: modelForCli(grokSession.model), signal });
} catch {
// Spawn threw synchronously (e.g. binary not found without shell
// resolution) — resolve, never reject, matching the CLI-adapter