diff --git a/.changeset/fn-7711-grok-cli-model-registry.md b/.changeset/fn-7711-grok-cli-model-registry.md new file mode 100644 index 0000000000..fb99a406f1 --- /dev/null +++ b/.changeset/fn-7711-grok-cli-model-registry.md @@ -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). diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 64e85dfb23..df498ca96f 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -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. +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/` 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 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. diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index f0c72701c4..c63c0cf18b 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -20,8 +20,10 @@ import { GlobalSettingsStore, resolveGlobalDir, getEnabledPiExtensionPaths, + mergeBuiltInGrokProviderModels, mergeBuiltInZaiProviderModels, reconcileClaudeCliPaths, + registerBuiltInGrokProvider, registerBuiltInZaiProvider, } from "@fusion/core"; import type { AutomationRunResult, ScheduledTask } from "@fusion/core"; @@ -550,6 +552,7 @@ export async function runDaemon(opts: DaemonOptions = {}) { const authStorage = createFusionAuthStorage(); const modelRegistry = ModelRegistry.create(authStorage, getModelRegistryModelsPath()); registerBuiltInZaiProvider(modelRegistry, (message) => console.log(`[extensions] ${message}`)); + registerBuiltInGrokProvider(modelRegistry, (message) => console.log(`[extensions] ${message}`)); const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry); // 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 = []; mergeBuiltInZaiProviderModels(modelRegistry, (message) => console.log(`[extensions] ${message}`)); + mergeBuiltInGrokProviderModels(modelRegistry, (message) => console.log(`[extensions] ${message}`)); modelRegistry.refresh(); try { diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index fa5d0a09d6..d86adc0e0a 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -20,8 +20,10 @@ import { isWorkspaceTask, resolveColumnFlags, BUILTIN_CODING_WORKFLOW_IR, + mergeBuiltInGrokProviderModels, mergeBuiltInZaiProviderModels, parseWorkflowIr, + registerBuiltInGrokProvider, registerBuiltInZaiProvider, type WorkflowIrColumn, type TraitFlags, @@ -1485,6 +1487,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: const authStorage = createFusionAuthStorage(); const modelRegistry = ModelRegistry.create(authStorage, getModelRegistryModelsPath()); registerBuiltInZaiProvider(modelRegistry, (message) => logSink.log(message, "extensions")); + registerBuiltInGrokProvider(modelRegistry, (message) => logSink.log(message, "extensions")); const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry); // 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 = []; mergeBuiltInZaiProviderModels(modelRegistry, (message) => logSink.log(message, "extensions")); + mergeBuiltInGrokProviderModels(modelRegistry, (message) => logSink.log(message, "extensions")); modelRegistry.refresh(); try { diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 441f4d8a28..6e8db5f946 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -21,7 +21,9 @@ import { GlobalSettingsStore, resolveGlobalDir, getEnabledPiExtensionPaths, + mergeBuiltInGrokProviderModels, mergeBuiltInZaiProviderModels, + registerBuiltInGrokProvider, registerBuiltInZaiProvider, } from "@fusion/core"; import type { AutomationRunResult, ScheduledTask } from "@fusion/core"; @@ -599,6 +601,7 @@ export async function runServe( const authStorage = createFusionAuthStorage(); const modelRegistry = ModelRegistry.create(authStorage, getModelRegistryModelsPath()); registerBuiltInZaiProvider(modelRegistry, (message) => console.log(`[extensions] ${message}`)); + registerBuiltInGrokProvider(modelRegistry, (message) => console.log(`[extensions] ${message}`)); const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry); // PackageManager may be used for skills adapter even if extension loading fails @@ -715,6 +718,7 @@ export async function runServe( extensionsResult.runtime.pendingProviderRegistrations = []; mergeBuiltInZaiProviderModels(modelRegistry, (message) => console.log(`[extensions] ${message}`)); + mergeBuiltInGrokProviderModels(modelRegistry, (message) => console.log(`[extensions] ${message}`)); modelRegistry.refresh(); try { diff --git a/packages/core/src/__tests__/grok-provider.test.ts b/packages/core/src/__tests__/grok-provider.test.ts new file mode 100644 index 0000000000..cdc41114f5 --- /dev/null +++ b/packages/core/src/__tests__/grok-provider.test.ts @@ -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(); + 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(); + 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>(); + 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>(); + 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); + }); +}); diff --git a/packages/core/src/grok-provider.ts b/packages/core/src/grok-provider.ts new file mode 100644 index 0000000000..22902854b3 --- /dev/null +++ b/packages/core/src/grok-provider.ts @@ -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> & { + 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>; +}; + +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}`); + } +} diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 3cd12e6727..59772b722e 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -86,6 +86,13 @@ export { registerBuiltInZaiProvider, } 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 { resolveWorktrunkSettings, requiresWorktrunkInstallVerification, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b9f245e52c..ea605f3292 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -53,6 +53,13 @@ export { registerBuiltInZaiProvider, } 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 { resolveWorktrunkSettings, requiresWorktrunkInstallVerification, diff --git a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts index 563cd67222..8cce7063aa 100644 --- a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts +++ b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts @@ -1537,7 +1537,12 @@ describe("createFnAgent", () => { expect(registerProviderMock).toHaveBeenNthCalledWith(1, "zai", expect.objectContaining({ 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" }], })); expect(refreshMock).toHaveBeenCalled(); @@ -1786,6 +1791,77 @@ describe("createFnAgent", () => { 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 () => { const { createFnAgent } = await import("../pi.js"); diff --git a/packages/engine/src/__tests__/provider-registration.test.ts b/packages/engine/src/__tests__/provider-registration.test.ts index 788f5af241..5ce68242c0 100644 --- a/packages/engine/src/__tests__/provider-registration.test.ts +++ b/packages/engine/src/__tests__/provider-registration.test.ts @@ -104,7 +104,7 @@ describe("seedDashboardProviders", () => { const { authStorage: wrapped } = await seedDashboardProviders({ store, authStorage, modelRegistry }); 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 () => { @@ -115,7 +115,7 @@ describe("seedDashboardProviders", () => { const { authStorage: wrapped } = await seedDashboardProviders({ store, authStorage, modelRegistry }); 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 () => { diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index bb39a7084b..f5e01ac520 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -42,8 +42,10 @@ import { getProjectRootFromWorktree, reconcileClaudeCliPaths, reconcileDroidCliPaths, + mergeBuiltInGrokProviderModels, mergeBuiltInZaiProviderModels, mergeSupplementalAnthropicModels, + registerBuiltInGrokProvider, registerBuiltInZaiProvider, resolvePiExtensionProjectRoot, } from "@fusion/core"; @@ -1463,6 +1465,7 @@ function resolveVendoredDroidCliEntry(): string | null { async function registerExtensionProviders(cwd: string, modelRegistry: ModelRegistry): Promise { registerBuiltInZaiProvider(modelRegistry, (message) => extensionsLog.warn(message)); + registerBuiltInGrokProvider(modelRegistry, (message) => extensionsLog.warn(message)); try { const agentDir = getPackageManagerAgentDir(); @@ -1525,6 +1528,7 @@ async function registerExtensionProviders(cwd: string, modelRegistry: ModelRegis extensionsResult.runtime.pendingProviderRegistrations = []; mergeBuiltInZaiProviderModels(modelRegistry, (message) => extensionsLog.warn(message)); + mergeBuiltInGrokProviderModels(modelRegistry, (message) => extensionsLog.warn(message)); modelRegistry.refresh(); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/packages/engine/src/provider-registration.ts b/packages/engine/src/provider-registration.ts index b24890157e..cc7fe90a83 100644 --- a/packages/engine/src/provider-registration.ts +++ b/packages/engine/src/provider-registration.ts @@ -14,7 +14,9 @@ DashboardAuthStorage callers MUST pass to `createServer(...)` (not the raw authS disposer to unsubscribe the settings listener on shutdown. */ import { + mergeBuiltInGrokProviderModels, mergeBuiltInZaiProviderModels, + registerBuiltInGrokProvider, registerBuiltInZaiProvider, type CustomProvider, type TaskStore, @@ -62,9 +64,11 @@ export async function seedDashboardProviders( const log = options.log ?? (() => {}); registerBuiltInZaiProvider(modelRegistry, (message) => log("extensions", message)); + registerBuiltInGrokProvider(modelRegistry, (message) => log("extensions", message)); const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry); mergeBuiltInZaiProviderModels(modelRegistry, (message) => log("extensions", message)); + mergeBuiltInGrokProviderModels(modelRegistry, (message) => log("extensions", message)); modelRegistry.refresh(); try {