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:
@@ -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
|
||||
|
||||
|
||||
@@ -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" }));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user