FN-7758: route Grok CLI fallback models through the CLI runtime when no API key is visible

Fix Grok CLI 'missing API key' false-positive by extending the no-visible-key routing invariant to configured fallback models, not just the default provider.
- deriveGrokRuntimeHintForNoVisibleKey now also triggers when the fallback provider is grok-cli
- new applyGrokCliNoKeyRuntimeOptions promotes a grok-cli fallback model into the primary session (stripping the provider-qualified model prefix) instead of leaving it as an unused fallback
- docs/grok-cli-contract.md updated to describe the fallback-routing contract
- added regression tests in grok-runtime-routing.test.ts, chat-manager.test.ts, and chat-manager-room-hybrid.test.ts
- added changeset fn-7758-grok-cli-no-key-routing.md (patch)

Files changed:
 .changeset/fn-7758-grok-cli-no-key-routing.md      |   7 ++
 docs/grok-cli-contract.md                          |  29 +++--
 .../src/__tests__/chat-manager-room-hybrid.test.ts |  36 +++++++
 .../dashboard/src/__tests__/chat-manager.test.ts   |  41 +++++++
 packages/dashboard/src/chat.ts                     |  16 ++-
 .../src/__tests__/grok-runtime-routing.test.ts     | 118 +++++++++++++++++++++
 packages/engine/src/agent-session-helpers.ts       |  36 ++++++-
 7 files changed, 266 insertions(+), 17 deletions(-)

Fusion-Task-Id: FN-7758

Fusion-Task-Lineage: 6b564b21-50ab-4c3e-b0d7-7fded2091d90

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-09 22:19:22 -07:00
parent b4b183fcd0
commit 2be6040b02
7 changed files with 266 additions and 17 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Route no-key Grok CLI chat and fallback model selections through the bundled CLI runtime.
category: fix
dev: Extends grok-cli no-visible-key routing to dashboard chat defaults, room responders, and fallback models.

View File

@@ -134,7 +134,7 @@ Notes:
`GrokCliProviderCard.tsx`) is out of scope for this task and is not `GrokCliProviderCard.tsx`) is out of scope for this task and is not
modified here. modified here.
## Wiring (resolved — FN-7725, extended by FN-7753) ## Wiring (resolved — FN-7725, extended by FN-7753/FN-7758)
<!-- <!--
FNXC:GrokCli 2026-07-09-00:00: FNXC:GrokCli 2026-07-09-00:00:
@@ -160,17 +160,25 @@ via the dashboard's agent **Runtime Source → Runtime** picker
plugin runtime, including the bundled Grok Runtime plugin's `runtimeId: plugin runtime, including the bundled Grok Runtime plugin's `runtimeId:
"grok"`, with no Grok-specific code required). "grok"`, with no Grok-specific code required).
**Automatic no-key fallback (FN-7753):** when `createResolvedAgentSession()` sees **Automatic no-key fallback (FN-7753/FN-7758):** when `createResolvedAgentSession()` sees
all of the following, it derives the same effective `runtimeHint: "grok"` before all of the following, it derives the same effective `runtimeHint: "grok"` before
calling `resolveRuntime()`: calling `resolveRuntime()`:
1. no explicit runtime hint was supplied (explicit hints, including `"pi"`, 1. no explicit runtime hint was supplied (explicit hints, including `"pi"`,
always win); always win);
2. the resolved execution provider is `grok-cli`; 2. the resolved primary/default provider is `grok-cli`, or the configured
fallback provider is `grok-cli`;
3. Fusion cannot see a non-empty `GROK_API_KEY` either in the environment or in 3. Fusion cannot see a non-empty `GROK_API_KEY` either in the environment or in
`~/.grok/user-settings.json`'s `apiKey` field; and `~/.grok/user-settings.json`'s `apiKey` field; and
4. the bundled Grok Runtime plugin has registered runtime id `"grok"`. 4. the bundled Grok Runtime plugin has registered runtime id `"grok"`.
FN-7758 also requires dashboard Chat/QuickChat and room responders to forward
the configured default provider/model into this same session seam when a send has
no explicit model and no bound agent runtime model. That keeps the no-key routing
invariant identical across executor, reviewer/validator/merger-adjacent, single
chat, QuickChat, and room responder surfaces instead of letting model-less chat
bypass the auto-derive by omitting `defaultProvider`.
If a Fusion-visible key exists, the direct xAI OpenAI-compatible endpoint remains If a Fusion-visible key exists, the direct xAI OpenAI-compatible endpoint remains
the default. If the Grok runtime is not registered, Fusion leaves the session on the default. If the Grok runtime is not registered, Fusion leaves the session on
the existing PI/direct path rather than inventing a separate routing mode. the existing PI/direct path rather than inventing a separate routing mode.
@@ -207,13 +215,14 @@ additive change," formalizing + testing + documenting the already-working
path is lower risk and closes the actual gap (an *exercised* path, not just path is lower risk and closes the actual gap (an *exercised* path, not just
an implemented adapter) without adding new user-facing config surface. an implemented adapter) without adding new user-facing config surface.
**Model plumbing (FN-7753):** for the automatic no-key fallback, the selected **Model plumbing (FN-7753/FN-7758):** for the automatic no-key fallback, the selected
`grok-cli/*` model id is preserved through `AgentRuntimeOptions.defaultModelId`, `grok-cli/*` model id is preserved through `AgentRuntimeOptions.defaultModelId`
normalized by stripping a leading `grok-cli/` (or `grok/`) prefix, and passed to (or promoted from `fallbackModelId` when the fallback provider is the grok-cli
the CLI as `grok --model <id>` alongside `--prompt` and `--format json`. selection), normalized by stripping a leading `grok-cli/` (or `grok/`) prefix,
Runtime-mode remains model-agnostic when chosen explicitly from the dashboard; and passed to the CLI as `grok --model <id>` alongside `--prompt` and
that no-model path still uses the adapter's historical `"grok/default"` session `--format json`. Runtime-mode remains model-agnostic when chosen explicitly from
fallback and omits `--model`. the dashboard; that no-model path still uses the adapter's historical
`"grok/default"` session fallback and omits `--model`.
**Why the direct xAI endpoint stays default:** a `grok-cli/*` **model** selection **Why the direct xAI endpoint stays default:** a `grok-cli/*` **model** selection
continues to route through the direct xAI OpenAI-compatible endpoint continues to route through the direct xAI OpenAI-compatible endpoint

View File

@@ -110,6 +110,42 @@ describe("ChatManager room hybrid responder resolution", () => {
expect(result.ambient.map((agent: any) => agent.id)).toEqual(["agent-a"]); expect(result.ambient.map((agent: any) => agent.id)).toEqual(["agent-a"]);
}); });
it("passes configured default grok-cli model to model-less room responders", async () => {
mockChatStore.listRoomMembers.mockReturnValue([
{ roomId: "room-1", agentId: "agent-a", role: "member", addedAt: "2026-01-01" },
]);
mockAgentStore.listAgents.mockResolvedValue([{ id: "agent-a", name: "Alpha", role: "executor", runtimeConfig: {} }]);
let createOptions: any;
__setCreateResolvedAgentSession(async (options: any) => {
createOptions = options;
return {
session: {
prompt: vi.fn(),
dispose: vi.fn(),
state: {
messages: [{ role: "assistant", content: "Room reply" }],
},
},
} as any;
});
const manager = new ChatManager(
mockChatStore as any,
"/tmp",
mockAgentStore as any,
undefined,
async () => ({ defaultProvider: "grok-cli", defaultModelId: "grok-cli/grok-4.5" }),
);
await manager.sendRoomMessage("room-1", "hello room");
expect(createOptions).toMatchObject({
sessionPurpose: "heartbeat",
defaultProvider: "grok-cli",
defaultModelId: "grok-cli/grok-4.5",
});
});
it("persists assistant room replies for resolved responders", async () => { it("persists assistant room replies for resolved responders", async () => {
mockChatStore.listRoomMembers.mockReturnValue([ mockChatStore.listRoomMembers.mockReturnValue([
{ roomId: "room-1", agentId: "agent-a", role: "member", addedAt: "2026-01-01" }, { roomId: "room-1", agentId: "agent-a", role: "member", addedAt: "2026-01-01" },

View File

@@ -201,6 +201,47 @@ describe("ChatManager.sendMessage", () => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
it("routes model-less QuickChat through configured default grok-cli provider", async () => {
let createOptions: any;
__setCreateResolvedAgentSession(async (options: any) => {
createOptions = options;
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
model: { provider: "grok-cli", id: "grok-4.5" },
state: { messages: [{ role: "assistant", content: "Grok response" }] },
},
runtimeId: "grok",
wasConfigured: true,
} as any;
});
mockChatStore.getSession.mockReturnValue({
id: "chat-001",
status: "active",
projectId: "project-a",
});
mockChatStore.addMessage.mockImplementation((_sessionId, input) => ({
id: input.role === "assistant" ? "assistant-msg" : "user-msg",
sessionId: "chat-001",
role: input.role,
content: input.content,
createdAt: "2026-07-09T00:00:00.000Z",
}));
const chatManager = createChatManagerWithSettings({
defaultProvider: "grok-cli",
defaultModelId: "grok-cli/grok-4.5",
});
await chatManager.sendMessage("chat-001", "Hello Grok");
expect(createOptions).toMatchObject({
sessionPurpose: "executor",
defaultProvider: "grok-cli",
defaultModelId: "grok-cli/grok-4.5",
});
});
it("records successful chat session token usage from provider stats", async () => { it("records successful chat session token usage from provider stats", async () => {
__setCreateResolvedAgentSession(async () => ({ __setCreateResolvedAgentSession(async () => ({
session: { session: {

View File

@@ -1708,9 +1708,13 @@ export class ChatManager {
const roomPrompt = roomPromptParts.join("\n\n"); const roomPrompt = roomPromptParts.join("\n\n");
const responderRuntimeModel = extractRuntimeModel(input.responder.runtimeConfig); const responderRuntimeModel = extractRuntimeModel(input.responder.runtimeConfig);
const effectiveModelProvider = input.modelProvider ?? responderRuntimeModel.provider;
const effectiveModelId = input.modelId ?? responderRuntimeModel.modelId;
const chatModelSettings = await this.getChatModelSettings(); const chatModelSettings = await this.getChatModelSettings();
/*
* FNXC:GrokCliRouting 2026-07-09-22:10:
* Room responders with no explicit send-time or responder runtime model still need the configured chat/project default to reach createResolvedAgentSession. Without forwarding a defaultProvider of grok-cli, the no-visible-key auto-derive seam cannot route to the Grok CLI runtime and pi can surface the direct xAI missing-key error.
*/
const effectiveModelProvider = input.modelProvider ?? responderRuntimeModel.provider ?? chatModelSettings.defaultProvider;
const effectiveModelId = input.modelId ?? responderRuntimeModel.modelId ?? chatModelSettings.defaultModelId;
/* /*
* FNXC:ChatModels 2026-07-01-16:42: * FNXC:ChatModels 2026-07-01-16:42:
* Room responders should pass configured fallback models even when the room send chose an explicit model. The engine still swaps only for retryable provider/model-selection failures, so an unavailable Sonnet 5 can recover without making ordinary prompt errors ambiguous. * Room responders should pass configured fallback models even when the room send chose an explicit model. The engine still swaps only for retryable provider/model-selection failures, so an unavailable Sonnet 5 can recover without making ordinary prompt errors ambiguous.
@@ -2177,6 +2181,14 @@ export class ChatManager {
} }
const chatModelSettings = await this.getChatModelSettings(); const chatModelSettings = await this.getChatModelSettings();
/*
* FNXC:GrokCliRouting 2026-07-09-22:10:
* Model-less Chat/QuickChat sessions must pass the configured default model into the shared engine session helper. This keeps grok-cli defaults on the Grok CLI runtime when Fusion has no visible key instead of bypassing FN-7753/FN-7758 routing by omitting defaultProvider entirely.
*/
effectiveModelProvider ??= chatModelSettings.defaultProvider;
effectiveModelId ??= chatModelSettings.defaultModelId;
failureContextProvider = effectiveModelProvider;
failureContextModelId = effectiveModelId;
const usesConfiguredDefaultModel = const usesConfiguredDefaultModel =
requestedModelProvider === chatModelSettings.defaultProvider requestedModelProvider === chatModelSettings.defaultProvider
&& requestedModelId === chatModelSettings.defaultModelId && requestedModelId === chatModelSettings.defaultModelId

View File

@@ -318,6 +318,124 @@ describe("Grok CLI runtime routing (FN-7725)", () => {
})); }));
}); });
it("auto-routes heartbeat/room responder grok-cli defaults to the Grok runtime when no Fusion-visible key exists", async () => {
vi.mocked(fusionCore.isGrokApiKeyFusionVisible).mockReturnValue(false);
const spawn = vi.fn().mockReturnValue(makeFakeGrokProcess().proc);
const grokRegistration = await createGrokRegistration(spawn);
const pluginRunner = createMockPluginRunner({
getRuntimeById: vi.fn().mockReturnValue(grokRegistration),
});
const audit = { database: vi.fn().mockResolvedValue(undefined) };
const result = await createResolvedAgentSession({
sessionPurpose: "heartbeat",
pluginRunner,
runAuditor: audit as never,
cwd: "/tmp/project",
defaultProvider: "grok-cli",
defaultModelId: "grok-4.5",
systemPrompt: "room-responder",
});
expect(result.runtimeId).toBe("grok");
expect(result.wasConfigured).toBe(true);
expect(result.session).toMatchObject({ model: "grok-4.5" });
expect(audit.database).toHaveBeenCalledWith(expect.objectContaining({
type: "session:runtime-resolved",
target: "grok",
metadata: expect.objectContaining({
sessionPurpose: "heartbeat",
runtimeHint: "grok",
reason: "grok-cli-no-visible-key",
}),
}));
});
it("auto-routes a grok-cli fallback model to the Grok runtime when no Fusion-visible key exists", async () => {
vi.mocked(fusionCore.isGrokApiKeyFusionVisible).mockReturnValue(false);
const spawn = vi.fn().mockReturnValue(makeFakeGrokProcess().proc);
const grokRegistration = await createGrokRegistration(spawn);
const pluginRunner = createMockPluginRunner({
getRuntimeById: vi.fn().mockReturnValue(grokRegistration),
});
const audit = { database: vi.fn().mockResolvedValue(undefined) };
const result = await createResolvedAgentSession({
sessionPurpose: "executor",
pluginRunner,
runAuditor: audit as never,
cwd: "/tmp/project",
defaultProvider: "openai",
defaultModelId: "gpt-4o",
fallbackProvider: "grok-cli",
fallbackModelId: "grok-cli/grok-4.5",
systemPrompt: "fallback-selection-only",
});
expect(result.runtimeId).toBe("grok");
expect(result.wasConfigured).toBe(true);
expect(mockCreateFnAgent).not.toHaveBeenCalled();
expect(result.session).toMatchObject({ model: "grok-4.5" });
expect(audit.database).toHaveBeenCalledWith(expect.objectContaining({
type: "session:runtime-resolved",
target: "grok",
metadata: expect.objectContaining({
runtimeHint: "grok",
reason: "grok-cli-no-visible-key",
provider: "openai",
modelId: "gpt-4o",
}),
}));
});
it("auto-routes a bare grok-cli fallback model id without adding a provider prefix", async () => {
vi.mocked(fusionCore.isGrokApiKeyFusionVisible).mockReturnValue(false);
const spawn = vi.fn().mockReturnValue(makeFakeGrokProcess().proc);
const grokRegistration = await createGrokRegistration(spawn);
const pluginRunner = createMockPluginRunner({
getRuntimeById: vi.fn().mockReturnValue(grokRegistration),
});
const result = await createResolvedAgentSession({
sessionPurpose: "validation",
pluginRunner,
cwd: "/tmp/project",
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
fallbackProvider: "grok-cli",
fallbackModelId: "grok-4.5",
systemPrompt: "fallback-bare-model",
});
expect(result.runtimeId).toBe("grok");
expect(result.session).toMatchObject({ model: "grok-4.5" });
});
it("keeps mock/test-mode provider routing on the mock runtime when grok-cli fallback is configured", async () => {
vi.mocked(fusionCore.isGrokApiKeyFusionVisible).mockReturnValue(false);
const spawn = vi.fn().mockReturnValue(makeFakeGrokProcess().proc);
const grokRegistration = await createGrokRegistration(spawn);
const pluginRunner = createMockPluginRunner({
getRuntimeById: vi.fn().mockReturnValue(grokRegistration),
});
const result = await createResolvedAgentSession({
sessionPurpose: "executor",
pluginRunner,
cwd: "/tmp/project",
defaultProvider: "mock",
defaultModelId: "scripted",
fallbackProvider: "grok-cli",
fallbackModelId: "grok-4.5",
systemPrompt: "mock-mode",
});
expect(result.runtimeId).toBe("mock");
expect(result.wasConfigured).toBe(true);
expect(pluginRunner.getRuntimeById).not.toHaveBeenCalledWith("grok");
expect(mockCreateFnAgent).not.toHaveBeenCalled();
});
it("honors explicit runtime hints over the no-key grok-cli auto-derivation", async () => { it("honors explicit runtime hints over the no-key grok-cli auto-derivation", async () => {
vi.mocked(fusionCore.isGrokApiKeyFusionVisible).mockReturnValue(false); vi.mocked(fusionCore.isGrokApiKeyFusionVisible).mockReturnValue(false);
const spawn = vi.fn().mockReturnValue(makeFakeGrokProcess().proc); const spawn = vi.fn().mockReturnValue(makeFakeGrokProcess().proc);

View File

@@ -167,7 +167,8 @@ function deriveGrokRuntimeHintForNoVisibleKey(
runtimeOptions: AgentRuntimeOptions, runtimeOptions: AgentRuntimeOptions,
pluginRunner: PluginRunner | undefined, pluginRunner: PluginRunner | undefined,
): string | undefined { ): string | undefined {
if (runtimeOptions.defaultProvider !== GROK_CLI_PROVIDER_ID) return undefined; if (runtimeOptions.defaultProvider !== GROK_CLI_PROVIDER_ID
&& runtimeOptions.fallbackProvider !== GROK_CLI_PROVIDER_ID) return undefined;
if (isGrokApiKeyFusionVisible()) return undefined; if (isGrokApiKeyFusionVisible()) return undefined;
try { try {
return pluginRunner?.getRuntimeById("grok") ? "grok" : undefined; return pluginRunner?.getRuntimeById("grok") ? "grok" : undefined;
@@ -176,6 +177,29 @@ function deriveGrokRuntimeHintForNoVisibleKey(
} }
} }
function applyGrokCliNoKeyRuntimeOptions(
runtimeOptions: AgentRuntimeOptions,
): AgentRuntimeOptions {
if (runtimeOptions.defaultProvider === GROK_CLI_PROVIDER_ID) {
return {
...runtimeOptions,
defaultModelId: stripGrokCliModelProviderPrefix(runtimeOptions.defaultModelId),
};
}
if (runtimeOptions.fallbackProvider === GROK_CLI_PROVIDER_ID) {
return {
...runtimeOptions,
defaultProvider: runtimeOptions.fallbackProvider,
defaultModelId: stripGrokCliModelProviderPrefix(runtimeOptions.fallbackModelId),
fallbackProvider: undefined,
fallbackModelId: undefined,
};
}
return runtimeOptions;
}
function pickSettingsThenRuntimeModel( function pickSettingsThenRuntimeModel(
settingsModel: ResolvedModelSelection, settingsModel: ResolvedModelSelection,
assignedAgentRuntimeConfig?: Record<string, unknown>, assignedAgentRuntimeConfig?: Record<string, unknown>,
@@ -406,16 +430,18 @@ export async function createResolvedAgentSession(
Explicit runtime hints always win, visible keys keep the direct xAI endpoint default, and mock/test-mode Explicit runtime hints always win, visible keys keep the direct xAI endpoint default, and mock/test-mode
provider routing stays on the mock runtime. Strip only the provider-qualified model prefix so the CLI provider routing stays on the mock runtime. Strip only the provider-qualified model prefix so the CLI
receives the concrete selected model via GrokRuntimeAdapter without changing non-grok sessions. receives the concrete selected model via GrokRuntimeAdapter without changing non-grok sessions.
FNXC:GrokCliRouting 2026-07-09-22:10:
FN-7758 extends the no-visible-key invariant to configured fallback models. Pi resolves fallback models
during session creation and prompt-time swaps through the key-requiring provider registry, so a grok-cli
fallback must select the Grok CLI runtime up front and promote the fallback model into the CLI session.
*/ */
const autoGrokRuntimeHint = !useMockRuntime && !runtimeHint const autoGrokRuntimeHint = !useMockRuntime && !runtimeHint
? deriveGrokRuntimeHintForNoVisibleKey(runtimeOptions, pluginRunner) ? deriveGrokRuntimeHintForNoVisibleKey(runtimeOptions, pluginRunner)
: undefined; : undefined;
const effectiveRuntimeHint = autoGrokRuntimeHint ?? runtimeHint; const effectiveRuntimeHint = autoGrokRuntimeHint ?? runtimeHint;
const effectiveRuntimeOptionsWithModel: AgentRuntimeOptions = autoGrokRuntimeHint const effectiveRuntimeOptionsWithModel: AgentRuntimeOptions = autoGrokRuntimeHint
? { ? applyGrokCliNoKeyRuntimeOptions(effectiveRuntimeOptions)
...effectiveRuntimeOptions,
defaultModelId: stripGrokCliModelProviderPrefix(effectiveRuntimeOptions.defaultModelId),
}
: effectiveRuntimeOptions; : effectiveRuntimeOptions;
const resolved = useMockRuntime const resolved = useMockRuntime