FN-7711: add built-in Grok CLI provider to fix pi model registry lookup

Registers a built-in grok-cli provider so Grok CLI model executions no longer hard-fail with "not found in the pi model registry".

- Add packages/core/src/grok-provider.ts: built-in grok-cli provider config (xAI OpenAI-compatible endpoint https://api.x.ai/v1, api openai-completions, apiKey $GROK_API_KEY), mirroring the existing Z.ai provider
- Register the provider in packages/engine/src/pi.ts (registerExtensionProviders) and packages/engine/src/provider-registration.ts (seedDashboardProviders)
- Wire the provider into CLI entrypoints: packages/cli/src/commands/daemon.ts, dashboard.ts, serve.ts
- Export grok-provider from packages/core/src/index.ts and packages/core/src/index.gate.ts
- Add unit tests: packages/core/src/__tests__/grok-provider.test.ts, and extend packages/engine/src/__tests__/pi-create-fn-agent.test.ts and provider-registration.test.ts
- Document the new provider in docs/settings-reference.md
- Add changeset .changeset/fn-7711-grok-cli-model-registry.md (patch, category: fix)

Note: Grok CLI binary remains discovery/probe only; GrokRuntimeAdapter streaming is a stub (tracked follow-up).

Files changed:
 .changeset/fn-7711-grok-cli-model-registry.md      |   7 +
 docs/settings-reference.md                         |   2 +
 packages/cli/src/commands/daemon.ts                |   4 +
 packages/cli/src/commands/dashboard.ts             |   4 +
 packages/cli/src/commands/serve.ts                 |   4 +
 packages/core/src/__tests__/grok-provider.test.ts  | 130 ++++++++++++
 packages/core/src/grok-provider.ts                 | 224 +++++++++++++++++++++
 packages/core/src/index.gate.ts                    |   7 +
 packages/core/src/index.ts                         |   7 +
 .../src/__tests__/pi-create-fn-agent.test.ts       |  78 ++++++-
 .../src/__tests__/provider-registration.test.ts    |   4 +-
 packages/engine/src/pi.ts                          |   4 +
 packages/engine/src/provider-registration.ts       |   4 +
 13 files changed, 476 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-7711

Fusion-Task-Lineage: ae90b54f-206e-46fd-8365-b0a4488ceb84

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-09 00:18:03 -07:00
parent 6cff782308
commit 7dc271027f
13 changed files with 476 additions and 3 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Grok CLI models now run instead of failing with "not found in the pi model registry".
category: fix
dev: Adds a built-in grok-cli provider (packages/core/src/grok-provider.ts) — xAI OpenAI-compatible endpoint https://api.x.ai/v1, api openai-completions, apiKey $GROK_API_KEY — registered into the execution registry (pi.ts registerExtensionProviders), seedDashboardProviders, and CLI serve/daemon/dashboard, mirroring the built-in Z.ai provider. Grok CLI binary remains discovery/probe only; GrokRuntimeAdapter streaming is still a stub (tracked follow-up).

View File

@@ -966,6 +966,8 @@ Fusion resolves task models through workflow-backed lane values first, then glob
Z.ai's built-in provider uses the existing `zai` auth entry / `ZAI_API_KEY` environment variable and includes `zai/glm-5.2` as a selectable model in the same dropdowns and workflow lane controls as the other built-in GLM models. If a pi extension also registers the `zai` provider, Fusion preserves the extension's models and re-adds any missing built-in Z.ai models so built-in GLM choices remain available. Z.ai's built-in provider uses the existing `zai` auth entry / `ZAI_API_KEY` environment variable and includes `zai/glm-5.2` as a selectable model in the same dropdowns and workflow lane controls as the other built-in GLM models. If a pi extension also registers the `zai` provider, Fusion preserves the extension's models and re-adds any missing built-in Z.ai models so built-in GLM choices remain available.
Grok (`grok-cli`) is likewise seeded as a built-in provider — xAI's OpenAI-compatible endpoint (`https://api.x.ai/v1`, api type `openai-completions`), API key `GROK_API_KEY` — into every model registry Fusion seeds (task execution, dashboard `/api/models`, and CLI `serve`/`daemon`/`dashboard`), mirroring the Z.ai pattern above. This makes `grok-cli/<model>` selections (e.g. `grok-cli/grok-4.5`) resolvable for execution even before the `grok` CLI binary is discovered or the picker surfaces additional Grok models (see the CLI-discovery paragraph below); a missing `GROK_API_KEY` surfaces only as a normal auth error at stream time, not a model-resolution failure.
When the Hermes Runtime plugin (`fusion-plugin-hermes-runtime`) is installed and the local `hermes` CLI has configured profiles (`hermes profile list`), those profiles are surfaced additively in `/api/models` under the `hermes` provider — one row per profile, id/name derived from the profile name and its configured model. This surfacing is read-only (Fusion does not create or edit Hermes profiles) and is fetched through a short-TTL, single-flight cache so the model picker never spawns the `hermes` CLI on every request; a missing/failed `hermes` binary simply yields zero Hermes rows without affecting other providers. When the Hermes Runtime plugin (`fusion-plugin-hermes-runtime`) is installed and the local `hermes` CLI has configured profiles (`hermes profile list`), those profiles are surfaced additively in `/api/models` under the `hermes` provider — one row per profile, id/name derived from the profile name and its configured model. This surfacing is read-only (Fusion does not create or edit Hermes profiles) and is fetched through a short-TTL, single-flight cache so the model picker never spawns the `hermes` CLI on every request; a missing/failed `hermes` binary simply yields zero Hermes rows without affecting other providers.
When the Cursor Runtime plugin (`fusion-plugin-cursor-runtime`) is installed and the `useCursorCli` toggle is enabled (Settings → Authentication), Cursor CLI-discovered models (`cursor-agent models --json`, with text/`model list` fallbacks) are surfaced additively in `/api/models` under the `cursor-cli` provider — id/name derived from the discovered model id/label. This surfacing is fetched through a short-TTL, single-flight cache so the model picker never spawns `cursor-agent` on every request; a missing/failed/unavailable Cursor CLI binary simply yields zero `cursor-cli` rows without affecting other providers. Disabling `useCursorCli` hides all `cursor-cli` rows. When the Cursor Runtime plugin (`fusion-plugin-cursor-runtime`) is installed and the `useCursorCli` toggle is enabled (Settings → Authentication), Cursor CLI-discovered models (`cursor-agent models --json`, with text/`model list` fallbacks) are surfaced additively in `/api/models` under the `cursor-cli` provider — id/name derived from the discovered model id/label. This surfacing is fetched through a short-TTL, single-flight cache so the model picker never spawns `cursor-agent` on every request; a missing/failed/unavailable Cursor CLI binary simply yields zero `cursor-cli` rows without affecting other providers. Disabling `useCursorCli` hides all `cursor-cli` rows.

View File

@@ -20,8 +20,10 @@ import {
GlobalSettingsStore, GlobalSettingsStore,
resolveGlobalDir, resolveGlobalDir,
getEnabledPiExtensionPaths, getEnabledPiExtensionPaths,
mergeBuiltInGrokProviderModels,
mergeBuiltInZaiProviderModels, mergeBuiltInZaiProviderModels,
reconcileClaudeCliPaths, reconcileClaudeCliPaths,
registerBuiltInGrokProvider,
registerBuiltInZaiProvider, registerBuiltInZaiProvider,
} from "@fusion/core"; } from "@fusion/core";
import type { AutomationRunResult, ScheduledTask } from "@fusion/core"; import type { AutomationRunResult, ScheduledTask } from "@fusion/core";
@@ -550,6 +552,7 @@ export async function runDaemon(opts: DaemonOptions = {}) {
const authStorage = createFusionAuthStorage(); const authStorage = createFusionAuthStorage();
const modelRegistry = ModelRegistry.create(authStorage, getModelRegistryModelsPath()); const modelRegistry = ModelRegistry.create(authStorage, getModelRegistryModelsPath());
registerBuiltInZaiProvider(modelRegistry, (message) => console.log(`[extensions] ${message}`)); registerBuiltInZaiProvider(modelRegistry, (message) => console.log(`[extensions] ${message}`));
registerBuiltInGrokProvider(modelRegistry, (message) => console.log(`[extensions] ${message}`));
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry); const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry);
// PackageManager may be used for skills adapter even if extension loading fails // PackageManager may be used for skills adapter even if extension loading fails
@@ -665,6 +668,7 @@ export async function runDaemon(opts: DaemonOptions = {}) {
extensionsResult.runtime.pendingProviderRegistrations = []; extensionsResult.runtime.pendingProviderRegistrations = [];
mergeBuiltInZaiProviderModels(modelRegistry, (message) => console.log(`[extensions] ${message}`)); mergeBuiltInZaiProviderModels(modelRegistry, (message) => console.log(`[extensions] ${message}`));
mergeBuiltInGrokProviderModels(modelRegistry, (message) => console.log(`[extensions] ${message}`));
modelRegistry.refresh(); modelRegistry.refresh();
try { try {

View File

@@ -20,8 +20,10 @@ import {
isWorkspaceTask, isWorkspaceTask,
resolveColumnFlags, resolveColumnFlags,
BUILTIN_CODING_WORKFLOW_IR, BUILTIN_CODING_WORKFLOW_IR,
mergeBuiltInGrokProviderModels,
mergeBuiltInZaiProviderModels, mergeBuiltInZaiProviderModels,
parseWorkflowIr, parseWorkflowIr,
registerBuiltInGrokProvider,
registerBuiltInZaiProvider, registerBuiltInZaiProvider,
type WorkflowIrColumn, type WorkflowIrColumn,
type TraitFlags, type TraitFlags,
@@ -1485,6 +1487,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
const authStorage = createFusionAuthStorage(); const authStorage = createFusionAuthStorage();
const modelRegistry = ModelRegistry.create(authStorage, getModelRegistryModelsPath()); const modelRegistry = ModelRegistry.create(authStorage, getModelRegistryModelsPath());
registerBuiltInZaiProvider(modelRegistry, (message) => logSink.log(message, "extensions")); registerBuiltInZaiProvider(modelRegistry, (message) => logSink.log(message, "extensions"));
registerBuiltInGrokProvider(modelRegistry, (message) => logSink.log(message, "extensions"));
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry); const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry);
// PackageManager may be used for skills adapter even if extension loading fails. // PackageManager may be used for skills adapter even if extension loading fails.
@@ -1605,6 +1608,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
extensionsResult.runtime.pendingProviderRegistrations = []; extensionsResult.runtime.pendingProviderRegistrations = [];
mergeBuiltInZaiProviderModels(modelRegistry, (message) => logSink.log(message, "extensions")); mergeBuiltInZaiProviderModels(modelRegistry, (message) => logSink.log(message, "extensions"));
mergeBuiltInGrokProviderModels(modelRegistry, (message) => logSink.log(message, "extensions"));
modelRegistry.refresh(); modelRegistry.refresh();
try { try {

View File

@@ -21,7 +21,9 @@ import {
GlobalSettingsStore, GlobalSettingsStore,
resolveGlobalDir, resolveGlobalDir,
getEnabledPiExtensionPaths, getEnabledPiExtensionPaths,
mergeBuiltInGrokProviderModels,
mergeBuiltInZaiProviderModels, mergeBuiltInZaiProviderModels,
registerBuiltInGrokProvider,
registerBuiltInZaiProvider, registerBuiltInZaiProvider,
} from "@fusion/core"; } from "@fusion/core";
import type { AutomationRunResult, ScheduledTask } from "@fusion/core"; import type { AutomationRunResult, ScheduledTask } from "@fusion/core";
@@ -599,6 +601,7 @@ export async function runServe(
const authStorage = createFusionAuthStorage(); const authStorage = createFusionAuthStorage();
const modelRegistry = ModelRegistry.create(authStorage, getModelRegistryModelsPath()); const modelRegistry = ModelRegistry.create(authStorage, getModelRegistryModelsPath());
registerBuiltInZaiProvider(modelRegistry, (message) => console.log(`[extensions] ${message}`)); registerBuiltInZaiProvider(modelRegistry, (message) => console.log(`[extensions] ${message}`));
registerBuiltInGrokProvider(modelRegistry, (message) => console.log(`[extensions] ${message}`));
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry); const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry);
// PackageManager may be used for skills adapter even if extension loading fails // PackageManager may be used for skills adapter even if extension loading fails
@@ -715,6 +718,7 @@ export async function runServe(
extensionsResult.runtime.pendingProviderRegistrations = []; extensionsResult.runtime.pendingProviderRegistrations = [];
mergeBuiltInZaiProviderModels(modelRegistry, (message) => console.log(`[extensions] ${message}`)); mergeBuiltInZaiProviderModels(modelRegistry, (message) => console.log(`[extensions] ${message}`));
mergeBuiltInGrokProviderModels(modelRegistry, (message) => console.log(`[extensions] ${message}`));
modelRegistry.refresh(); modelRegistry.refresh();
try { try {

View File

@@ -0,0 +1,130 @@
import { describe, expect, it } from "vitest";
import {
GROK_CLI_PROVIDER_ID,
GROK_PROVIDER_REGISTRATION,
mergeBuiltInGrokProviderModels,
registerBuiltInGrokProvider,
} from "../grok-provider.js";
import { ZAI_PROVIDER_ID, ZAI_PROVIDER_REGISTRATION, registerBuiltInZaiProvider } from "../zai-provider.js";
const EXPECTED_GROK_MODELS = ["grok-4.5", "grok-4", "grok-code-fast-1", "grok-3", "grok-3-mini"];
describe("GROK_PROVIDER_REGISTRATION", () => {
it("uses the xAI OpenAI-compatible endpoint and API-key auth", () => {
expect(GROK_CLI_PROVIDER_ID).toBe("grok-cli");
expect(GROK_PROVIDER_REGISTRATION).toMatchObject({
name: "Grok",
baseUrl: "https://api.x.ai/v1",
apiKey: "$GROK_API_KEY",
api: "openai-completions",
});
});
it("seeds the reported default model plus a conservative catalog", () => {
const modelIds = GROK_PROVIDER_REGISTRATION.models.map((model) => model.id);
expect(modelIds).toEqual(EXPECTED_GROK_MODELS);
expect(modelIds).toContain("grok-4.5");
});
it("does not copy Z.ai's thinkingFormat compat field", () => {
for (const model of GROK_PROVIDER_REGISTRATION.models) {
expect(model.compat).not.toHaveProperty("thinkingFormat");
}
});
});
describe("registerBuiltInGrokProvider", () => {
it("registers grok-cli with the expected baseUrl/api/models", () => {
const registeredProviders = new Map<string, unknown>();
const registry = {
registeredProviders,
registerProvider(providerName: string, config: unknown) {
registeredProviders.set(providerName, config);
},
};
registerBuiltInGrokProvider(registry);
const registered = registeredProviders.get(GROK_CLI_PROVIDER_ID) as typeof GROK_PROVIDER_REGISTRATION;
expect(registered).toMatchObject({
name: "Grok",
baseUrl: "https://api.x.ai/v1",
apiKey: "$GROK_API_KEY",
api: "openai-completions",
});
expect(registered.models.map((model) => model.id)).toEqual(EXPECTED_GROK_MODELS);
});
it("is additive — does not displace a pre-existing zai provider registration", () => {
const registeredProviders = new Map<string, unknown>();
const registry = {
registeredProviders,
registerProvider(providerName: string, config: unknown) {
registeredProviders.set(providerName, config);
},
};
registerBuiltInZaiProvider(registry);
registerBuiltInGrokProvider(registry);
expect(registeredProviders.has(ZAI_PROVIDER_ID)).toBe(true);
expect(registeredProviders.has(GROK_CLI_PROVIDER_ID)).toBe(true);
expect((registeredProviders.get(ZAI_PROVIDER_ID) as typeof ZAI_PROVIDER_REGISTRATION).models.map((m) => m.id))
.toEqual(ZAI_PROVIDER_REGISTRATION.models.map((m) => m.id));
});
it("does not throw when registerProvider throws", () => {
const registry = {
registerProvider() {
throw new Error("boom");
},
};
const warnings: string[] = [];
expect(() => registerBuiltInGrokProvider(registry, (message) => warnings.push(message))).not.toThrow();
expect(warnings[0]).toContain("Failed to register built-in grok-cli provider");
});
});
describe("mergeBuiltInGrokProviderModels", () => {
it("re-adds missing built-in models after a user grok extension replacement", () => {
const extensionModels = GROK_PROVIDER_REGISTRATION.models
.filter((model) => model.id !== "grok-4.5")
.map((model) => ({ ...model }));
const registeredProviders = new Map<string, Partial<typeof GROK_PROVIDER_REGISTRATION>>();
const registry = {
registeredProviders,
registerProvider(providerName: string, config: typeof GROK_PROVIDER_REGISTRATION) {
registeredProviders.set(providerName, { ...registeredProviders.get(providerName), ...config });
},
};
registerBuiltInGrokProvider(registry);
registry.registerProvider(GROK_CLI_PROVIDER_ID, {
...GROK_PROVIDER_REGISTRATION,
name: "User Grok extension",
models: extensionModels,
});
mergeBuiltInGrokProviderModels(registry);
const mergedIds = registeredProviders.get(GROK_CLI_PROVIDER_ID)?.models?.map((model) => model.id);
expect(mergedIds).toEqual(expect.arrayContaining(EXPECTED_GROK_MODELS));
expect(mergedIds).toContain("grok-4.5");
expect(registeredProviders.get(GROK_CLI_PROVIDER_ID)?.name).toBe("User Grok extension");
});
it("is a no-op when all built-in models are already present", () => {
const registeredProviders = new Map<string, Partial<typeof GROK_PROVIDER_REGISTRATION>>();
const registry = {
registeredProviders,
registerProvider(providerName: string, config: typeof GROK_PROVIDER_REGISTRATION) {
registeredProviders.set(providerName, { ...registeredProviders.get(providerName), ...config });
},
};
registerBuiltInGrokProvider(registry);
expect(() => mergeBuiltInGrokProviderModels(registry)).not.toThrow();
const mergedIds = registeredProviders.get(GROK_CLI_PROVIDER_ID)?.models?.map((model) => model.id);
expect(mergedIds).toEqual(EXPECTED_GROK_MODELS);
});
});

View File

@@ -0,0 +1,224 @@
/**
* FNXC:ModelRegistry 2026-07-09-00:00:
* FN-7711 registers `grok-cli` as a built-in, OpenAI-compatible xAI provider so the
* execution model registry (packages/engine/src/pi.ts `createFnAgent`) can resolve
* `grok-cli/*` model selections. Before this, selecting a `grok-cli` model hard-failed
* at session creation with "not found in the pi model registry" because FN-7705/FN-7710
* only made Grok models appear in the `/api/models` picker — they were never registered
* into the execution registry that `resolveConfiguredModel()` reads from.
*
* The operator-installed `grok` CLI binary (landed by FN-7705) remains discovery/probe
* only — this module does not shell out to it. The Grok plugin's `GrokRuntimeAdapter` is
* a no-op streaming stub, so once this provider exists, execution streams against xAI's
* OpenAI-compatible endpoint (`https://api.x.ai/v1`, api type `openai-completions`) via
* the standard pi/openai-completions path, not the plugin runtime.
*
* This module mirrors `zai-provider.ts` (the canonical built-in-provider pattern):
* a static provider registration, `registerBuiltInGrokProvider` to seed it, and
* `mergeBuiltInGrokProviderModels` to re-add any built-in models an extension's
* provider registration may have dropped (pi's `registerProvider()` replaces the
* provider's model list wholesale rather than merging).
*/
export const GROK_CLI_PROVIDER_ID = "grok-cli";
type GrokModelInput = "text" | "image";
interface GrokModelRegistration {
id: string;
name: string;
reasoning: boolean;
input: GrokModelInput[];
cost: {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
};
contextWindow: number;
maxTokens: number;
compat: {
supportsDeveloperRole: boolean;
};
}
export interface GrokProviderRegistration {
name: string;
baseUrl: string;
apiKey: string;
api: "openai-completions";
models: GrokModelRegistration[];
}
// pi registerProvider() replaces the provider's model list, so keep every
// currently built-in Grok model here and append new models as xAI ships them.
export const GROK_PROVIDER_REGISTRATION: GrokProviderRegistration = {
name: "Grok",
baseUrl: "https://api.x.ai/v1",
apiKey: "$GROK_API_KEY",
api: "openai-completions",
models: [
{
id: "grok-4.5",
name: "Grok 4.5",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 256000,
maxTokens: 65536,
compat: {
supportsDeveloperRole: false,
},
},
{
id: "grok-4",
name: "Grok 4",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 256000,
maxTokens: 65536,
compat: {
supportsDeveloperRole: false,
},
},
{
id: "grok-code-fast-1",
name: "Grok Code Fast 1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 131072,
maxTokens: 32768,
compat: {
supportsDeveloperRole: false,
},
},
{
id: "grok-3",
name: "Grok 3",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 131072,
maxTokens: 32768,
compat: {
supportsDeveloperRole: false,
},
},
{
id: "grok-3-mini",
name: "Grok 3 Mini",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 131072,
maxTokens: 32768,
compat: {
supportsDeveloperRole: false,
},
},
],
};
type GrokModelLike = Partial<Omit<GrokModelRegistration, "name" | "api" | "baseUrl" | "compat">> & {
id: string;
name?: unknown;
provider?: string;
baseUrl?: unknown;
api?: unknown;
compat?: unknown;
};
interface GrokModelRegistryLike {
registerProvider(providerName: string, config: GrokProviderRegistration): void;
getAll?: () => GrokModelLike[];
}
type RegistryWithProviderState = GrokModelRegistryLike & {
registeredProviders?: Map<string, Partial<GrokProviderRegistration>>;
};
function toGrokModelRegistration(model: GrokModelLike): GrokModelRegistration & { baseUrl?: string; api?: string } {
return {
id: model.id,
name: String(model.name ?? model.id),
api: typeof model.api === "string" ? model.api : undefined,
baseUrl: typeof model.baseUrl === "string" ? model.baseUrl : undefined,
reasoning: model.reasoning === true,
input: Array.isArray(model.input) ? model.input as GrokModelInput[] : ["text"],
cost: model.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: Number(model.contextWindow ?? 0),
maxTokens: Number(model.maxTokens ?? 0),
compat: typeof model.compat === "object" && model.compat !== null
? { ...(model.compat as GrokModelRegistration["compat"]) }
: GROK_PROVIDER_REGISTRATION.models.find((builtInModel) => builtInModel.id === model.id)?.compat ?? {
supportsDeveloperRole: false,
},
};
}
function cloneGrokProviderRegistration(config: GrokProviderRegistration): GrokProviderRegistration {
return {
...config,
models: config.models.map((model) => toGrokModelRegistration(model)),
};
}
/**
* FNXC:ModelRegistry 2026-07-09-00:00:
* Seeds the built-in `grok-cli` provider (mirrors `registerBuiltInZaiProvider`). Registration
* is unconditional and harmless without a `GROK_API_KEY` — a missing key only surfaces as a
* normal downstream auth error at stream time, never as the "not found in the pi model
* registry" hard-fail this task fixes. Always pass a cloned config because pi's
* registerProvider() stores and mutates the config object during later upserts.
*/
export function registerBuiltInGrokProvider(
modelRegistry: GrokModelRegistryLike,
logWarning: (message: string) => void = () => {},
): void {
try {
modelRegistry.registerProvider(GROK_CLI_PROVIDER_ID, cloneGrokProviderRegistration(GROK_PROVIDER_REGISTRATION));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logWarning(`Failed to register built-in ${GROK_CLI_PROVIDER_ID} provider: ${message}`);
}
}
/**
* FNXC:ModelRegistry 2026-07-09-00:00:
* pi's registerProvider() treats a provider config with models as a full provider replacement,
* and user extensions load after Fusion's built-in provider registration. Re-merge missing
* built-in Grok models after extension registration so grok-4.5 remains visible wherever the
* user's existing Grok extension models are visible, without deleting extension-supplied models
* (mirrors `mergeBuiltInZaiProviderModels`).
*/
export function mergeBuiltInGrokProviderModels(
modelRegistry: GrokModelRegistryLike,
logWarning: (message: string) => void = () => {},
): void {
try {
const registryWithState = modelRegistry as RegistryWithProviderState;
const registeredProvider = registryWithState.registeredProviders?.get(GROK_CLI_PROVIDER_ID);
if (!registeredProvider && !modelRegistry.getAll) return;
const registeredModels = registeredProvider?.models?.map((model) => toGrokModelRegistration(model)) ?? [];
const currentModels = registeredModels.length > 0
? registeredModels
: modelRegistry.getAll?.()
.filter((model) => model.provider === GROK_CLI_PROVIDER_ID)
.map((model) => toGrokModelRegistration(model)) ?? [];
const currentModelIds = new Set(currentModels.map((model) => model.id));
const missingBuiltInModels = GROK_PROVIDER_REGISTRATION.models.filter((model) => !currentModelIds.has(model.id));
if (missingBuiltInModels.length === 0) return;
modelRegistry.registerProvider(GROK_CLI_PROVIDER_ID, {
...cloneGrokProviderRegistration(GROK_PROVIDER_REGISTRATION),
...registeredProvider,
models: [...currentModels, ...missingBuiltInModels.map((model) => toGrokModelRegistration(model))],
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logWarning(`Failed to merge built-in ${GROK_CLI_PROVIDER_ID} models: ${message}`);
}
}

View File

@@ -86,6 +86,13 @@ export {
registerBuiltInZaiProvider, registerBuiltInZaiProvider,
} from "./zai-provider.js"; } from "./zai-provider.js";
export type { ZaiProviderRegistration } from "./zai-provider.js"; export type { ZaiProviderRegistration } from "./zai-provider.js";
export {
GROK_CLI_PROVIDER_ID,
GROK_PROVIDER_REGISTRATION,
mergeBuiltInGrokProviderModels,
registerBuiltInGrokProvider,
} from "./grok-provider.js";
export type { GrokProviderRegistration } from "./grok-provider.js";
export { export {
resolveWorktrunkSettings, resolveWorktrunkSettings,
requiresWorktrunkInstallVerification, requiresWorktrunkInstallVerification,

View File

@@ -53,6 +53,13 @@ export {
registerBuiltInZaiProvider, registerBuiltInZaiProvider,
} from "./zai-provider.js"; } from "./zai-provider.js";
export type { ZaiProviderRegistration } from "./zai-provider.js"; export type { ZaiProviderRegistration } from "./zai-provider.js";
export {
GROK_CLI_PROVIDER_ID,
GROK_PROVIDER_REGISTRATION,
mergeBuiltInGrokProviderModels,
registerBuiltInGrokProvider,
} from "./grok-provider.js";
export type { GrokProviderRegistration } from "./grok-provider.js";
export { export {
resolveWorktrunkSettings, resolveWorktrunkSettings,
requiresWorktrunkInstallVerification, requiresWorktrunkInstallVerification,

View File

@@ -1537,7 +1537,12 @@ describe("createFnAgent", () => {
expect(registerProviderMock).toHaveBeenNthCalledWith(1, "zai", expect.objectContaining({ expect(registerProviderMock).toHaveBeenNthCalledWith(1, "zai", expect.objectContaining({
models: expect.arrayContaining([expect.objectContaining({ id: "glm-5.2" })]), models: expect.arrayContaining([expect.objectContaining({ id: "glm-5.2" })]),
})); }));
expect(registerProviderMock).toHaveBeenNthCalledWith(2, "zai", expect.objectContaining({ // FN-7711: registerBuiltInGrokProvider seeds grok-cli immediately after the built-in zai
// registration, before the extension's pending provider registrations replay.
expect(registerProviderMock).toHaveBeenNthCalledWith(2, "grok-cli", expect.objectContaining({
models: expect.arrayContaining([expect.objectContaining({ id: "grok-4.5" })]),
}));
expect(registerProviderMock).toHaveBeenNthCalledWith(3, "zai", expect.objectContaining({
models: [{ id: "glm-5.1" }], models: [{ id: "glm-5.1" }],
})); }));
expect(refreshMock).toHaveBeenCalled(); expect(refreshMock).toHaveBeenCalled();
@@ -1786,6 +1791,77 @@ describe("createFnAgent", () => {
expect(createAgentSessionMock).not.toHaveBeenCalled(); expect(createAgentSessionMock).not.toHaveBeenCalled();
}); });
// FNXC:ModelRegistry 2026-07-09-00:00:
// FN-7711 symptom verification: selecting grok-cli/grok-4.5 used to hard-fail at session
// creation with "not found in the pi model registry" because the grok-cli provider was never
// registered into the execution registry (see registerExtensionProviders in ../pi.js). This
// mirrors the zai/glm-5.1 throw test above to reproduce the exact original failure, then proves
// it is gone once the registry resolves a grok-cli model (mirroring how registerBuiltInGrokProvider
// makes the provider resolvable), and that an unlisted grok-cli id also resolves via the
// provider-base-model on-the-fly fallback.
it("reproduces then proves gone the grok-cli/grok-4.5 'not found in the pi model registry' hard-fail", async () => {
// Without any grok-cli provider registration, find() returns nothing and getAll() has no
// grok-cli models — resolveConfiguredModel must throw the exact original error message.
findMock.mockImplementation((provider: string, modelId: string) => (
provider === "grok-cli" && modelId === "grok-4.5" ? undefined : { provider, id: modelId }
));
getAllMock.mockReturnValue([]);
const { createFnAgent } = await import("../pi.js");
await expect(createFnAgent({
cwd: "/tmp",
systemPrompt: "test",
tools: "readonly",
defaultProvider: "grok-cli",
defaultModelId: "grok-4.5",
})).rejects.toThrow("Configured model grok-cli/grok-4.5 (primary selection) was not found in the pi model registry");
expect(createAgentSessionMock).not.toHaveBeenCalled();
// Once the grok-cli provider is resolvable (as it is after registerBuiltInGrokProvider seeds
// it into the real execution registry via registerExtensionProviders), find() returns a model
// and the session is created successfully — the hard-fail is gone.
findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId }));
await createFnAgent({
cwd: "/tmp",
systemPrompt: "test",
tools: "readonly",
defaultProvider: "grok-cli",
defaultModelId: "grok-4.5",
});
expect(createAgentSessionMock).toHaveBeenCalledTimes(1);
expect(createAgentSessionMock.mock.calls[0]?.[0]).toMatchObject({
model: { provider: "grok-cli", id: "grok-4.5" },
});
});
it("resolves an unlisted grok-cli model id via the provider-base-model on-the-fly fallback", async () => {
// find() has no entry for the unlisted id, but getAll() reports the provider has at least one
// registered grok-cli model (as it does once registerBuiltInGrokProvider seeds the provider) —
// resolveConfiguredModel should synthesize a model from the base model rather than throwing.
findMock.mockImplementation((provider: string, modelId: string) => (
provider === "grok-cli" && modelId === "grok-4-fast" ? undefined : { provider, id: modelId }
));
getAllMock.mockReturnValue([{ provider: "grok-cli", id: "grok-4.5", name: "Grok 4.5" }]);
const { createFnAgent } = await import("../pi.js");
await createFnAgent({
cwd: "/tmp",
systemPrompt: "test",
tools: "readonly",
defaultProvider: "grok-cli",
defaultModelId: "grok-4-fast",
});
expect(createAgentSessionMock).toHaveBeenCalledTimes(1);
expect(createAgentSessionMock.mock.calls[0]?.[0]).toMatchObject({
model: { provider: "grok-cli", id: "grok-4-fast" },
});
});
it("creates a session when configured models resolve successfully", async () => { it("creates a session when configured models resolve successfully", async () => {
const { createFnAgent } = await import("../pi.js"); const { createFnAgent } = await import("../pi.js");

View File

@@ -104,7 +104,7 @@ describe("seedDashboardProviders", () => {
const { authStorage: wrapped } = await seedDashboardProviders({ store, authStorage, modelRegistry }); const { authStorage: wrapped } = await seedDashboardProviders({ store, authStorage, modelRegistry });
const providerIds = wrapped.getApiKeyProviders().map((p) => p.id); const providerIds = wrapped.getApiKeyProviders().map((p) => p.id);
expect(providerIds).toEqual(expect.arrayContaining(["zai", "openrouter", "kimi-coding"])); expect(providerIds).toEqual(expect.arrayContaining(["zai", "openrouter", "kimi-coding", "grok-cli"]));
}); });
it("registers built-in API-key providers with an empty customProviders array", async () => { it("registers built-in API-key providers with an empty customProviders array", async () => {
@@ -115,7 +115,7 @@ describe("seedDashboardProviders", () => {
const { authStorage: wrapped } = await seedDashboardProviders({ store, authStorage, modelRegistry }); const { authStorage: wrapped } = await seedDashboardProviders({ store, authStorage, modelRegistry });
const providerIds = wrapped.getApiKeyProviders().map((p) => p.id); const providerIds = wrapped.getApiKeyProviders().map((p) => p.id);
expect(providerIds).toEqual(expect.arrayContaining(["zai", "openrouter", "kimi-coding"])); expect(providerIds).toEqual(expect.arrayContaining(["zai", "openrouter", "kimi-coding", "grok-cli"]));
}); });
it("registers one custom provider alongside built-ins", async () => { it("registers one custom provider alongside built-ins", async () => {

View File

@@ -42,8 +42,10 @@ import {
getProjectRootFromWorktree, getProjectRootFromWorktree,
reconcileClaudeCliPaths, reconcileClaudeCliPaths,
reconcileDroidCliPaths, reconcileDroidCliPaths,
mergeBuiltInGrokProviderModels,
mergeBuiltInZaiProviderModels, mergeBuiltInZaiProviderModels,
mergeSupplementalAnthropicModels, mergeSupplementalAnthropicModels,
registerBuiltInGrokProvider,
registerBuiltInZaiProvider, registerBuiltInZaiProvider,
resolvePiExtensionProjectRoot, resolvePiExtensionProjectRoot,
} from "@fusion/core"; } from "@fusion/core";
@@ -1463,6 +1465,7 @@ function resolveVendoredDroidCliEntry(): string | null {
async function registerExtensionProviders(cwd: string, modelRegistry: ModelRegistry): Promise<void> { async function registerExtensionProviders(cwd: string, modelRegistry: ModelRegistry): Promise<void> {
registerBuiltInZaiProvider(modelRegistry, (message) => extensionsLog.warn(message)); registerBuiltInZaiProvider(modelRegistry, (message) => extensionsLog.warn(message));
registerBuiltInGrokProvider(modelRegistry, (message) => extensionsLog.warn(message));
try { try {
const agentDir = getPackageManagerAgentDir(); const agentDir = getPackageManagerAgentDir();
@@ -1525,6 +1528,7 @@ async function registerExtensionProviders(cwd: string, modelRegistry: ModelRegis
extensionsResult.runtime.pendingProviderRegistrations = []; extensionsResult.runtime.pendingProviderRegistrations = [];
mergeBuiltInZaiProviderModels(modelRegistry, (message) => extensionsLog.warn(message)); mergeBuiltInZaiProviderModels(modelRegistry, (message) => extensionsLog.warn(message));
mergeBuiltInGrokProviderModels(modelRegistry, (message) => extensionsLog.warn(message));
modelRegistry.refresh(); modelRegistry.refresh();
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);

View File

@@ -14,7 +14,9 @@ DashboardAuthStorage callers MUST pass to `createServer(...)` (not the raw authS
disposer to unsubscribe the settings listener on shutdown. disposer to unsubscribe the settings listener on shutdown.
*/ */
import { import {
mergeBuiltInGrokProviderModels,
mergeBuiltInZaiProviderModels, mergeBuiltInZaiProviderModels,
registerBuiltInGrokProvider,
registerBuiltInZaiProvider, registerBuiltInZaiProvider,
type CustomProvider, type CustomProvider,
type TaskStore, type TaskStore,
@@ -62,9 +64,11 @@ export async function seedDashboardProviders(
const log = options.log ?? (() => {}); const log = options.log ?? (() => {});
registerBuiltInZaiProvider(modelRegistry, (message) => log("extensions", message)); registerBuiltInZaiProvider(modelRegistry, (message) => log("extensions", message));
registerBuiltInGrokProvider(modelRegistry, (message) => log("extensions", message));
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry); const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry);
mergeBuiltInZaiProviderModels(modelRegistry, (message) => log("extensions", message)); mergeBuiltInZaiProviderModels(modelRegistry, (message) => log("extensions", message));
mergeBuiltInGrokProviderModels(modelRegistry, (message) => log("extensions", message));
modelRegistry.refresh(); modelRegistry.refresh();
try { try {