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:
7
.changeset/fn-7711-grok-cli-model-registry.md
Normal file
7
.changeset/fn-7711-grok-cli-model-registry.md
Normal 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).
|
||||
@@ -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/<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 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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
130
packages/core/src/__tests__/grok-provider.test.ts
Normal file
130
packages/core/src/__tests__/grok-provider.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
224
packages/core/src/grok-provider.ts
Normal file
224
packages/core/src/grok-provider.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<void> {
|
||||
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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user