FN-6414: preserve built-in Z.ai models after extensions

Keep GLM-5.2 visible when user Z.ai extensions replace the provider registration.

- Add shared helpers to clone and register the built-in Z.ai provider safely.
- Re-merge missing built-in Z.ai models after extension provider registration in daemon, serve, dashboard, and pi flows.
- Cover provider replacement behavior in core and dashboard model route tests, and document the extension-preserving behavior.

Files changed:
 .changeset/FN-6414-glm-5-2-visible.md              |  5 ++
 docs/settings-reference.md                         |  2 +-
 packages/cli/src/commands/daemon.ts                | 12 +--
 packages/cli/src/commands/dashboard.ts             | 12 +--
 packages/cli/src/commands/serve.ts                 | 12 +--
 packages/core/src/__tests__/zai-provider.test.ts   | 33 +++++++-
 packages/core/src/index.ts                         |  7 +-
 packages/core/src/zai-provider.ts                  | 93 +++++++++++++++++++++
 ...register-model-routes-zai-real-registry.test.ts | 97 ++++++++++++++++++++++
 packages/engine/src/pi.ts                          | 12 +--
 10 files changed, 250 insertions(+), 35 deletions(-)

Fusion-Task-Id: FN-6414

Fusion-Task-Lineage: f0990da2-968d-4782-8c00-fbc350d6954f
This commit is contained in:
gsxdsm
2026-06-13 21:17:20 -07:00
parent 7b839069b5
commit b1ba87e599
10 changed files with 250 additions and 35 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Ensure `zai/glm-5.2` reliably appears in the model list after user Z.ai provider extensions load.

View File

@@ -801,7 +801,7 @@ Short-lived token bounds are enforced server-side:
Fusion resolves task models through workflow-backed lane values first, then global lane defaults, then the project/global default model fallback. The common workflow lanes are stored as setting values on the project's default workflow and can be edited with dropdown controls from Settings -> Project Models -> Default workflow model lanes (persisted by the Settings modal's primary Save) or from workflow editor -> Settings -> Values for declared workflow lanes and fallbacks.
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.
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.
### Planning model

View File

@@ -20,9 +20,9 @@ import {
GlobalSettingsStore,
resolveGlobalDir,
getEnabledPiExtensionPaths,
mergeBuiltInZaiProviderModels,
reconcileClaudeCliPaths,
ZAI_PROVIDER_ID,
ZAI_PROVIDER_REGISTRATION,
registerBuiltInZaiProvider,
} from "@fusion/core";
import type { AutomationRunResult, ScheduledTask } from "@fusion/core";
import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath, loadTlsCredentialsFromEnv, registerGithubTrackingHook } from "@fusion/dashboard";
@@ -554,12 +554,7 @@ export async function runDaemon(opts: DaemonOptions = {}) {
]);
const mergedAuthStorage = mergeAuthStorageReads(authStorage, [supplementalAuthStorage]);
const modelRegistry = ModelRegistry.create(mergedAuthStorage, getModelRegistryModelsPath());
try {
modelRegistry.registerProvider(ZAI_PROVIDER_ID, ZAI_PROVIDER_REGISTRATION);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.log(`[extensions] Failed to register built-in ${ZAI_PROVIDER_ID} provider: ${message}`);
}
registerBuiltInZaiProvider(modelRegistry, (message) => console.log(`[extensions] ${message}`));
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(mergedAuthStorage, modelRegistry);
// PackageManager may be used for skills adapter even if extension loading fails
@@ -674,6 +669,7 @@ export async function runDaemon(opts: DaemonOptions = {}) {
}
extensionsResult.runtime.pendingProviderRegistrations = [];
mergeBuiltInZaiProviderModels(modelRegistry, (message) => console.log(`[extensions] ${message}`));
modelRegistry.refresh();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);

View File

@@ -19,9 +19,9 @@ import {
isWorkflowColumnsEnabled,
resolveColumnFlags,
BUILTIN_CODING_WORKFLOW_IR,
mergeBuiltInZaiProviderModels,
parseWorkflowIr,
ZAI_PROVIDER_ID,
ZAI_PROVIDER_REGISTRATION,
registerBuiltInZaiProvider,
type WorkflowIrColumn,
type TraitFlags,
} from "@fusion/core";
@@ -1371,12 +1371,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
]);
const mergedAuthStorage = mergeAuthStorageReads(authStorage, [supplementalAuthStorage]);
const modelRegistry = ModelRegistry.create(mergedAuthStorage, getModelRegistryModelsPath());
try {
modelRegistry.registerProvider(ZAI_PROVIDER_ID, ZAI_PROVIDER_REGISTRATION);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logSink.log(`Failed to register built-in ${ZAI_PROVIDER_ID} provider: ${message}`, "extensions");
}
registerBuiltInZaiProvider(modelRegistry, (message) => logSink.log(message, "extensions"));
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(mergedAuthStorage, modelRegistry);
// PackageManager may be used for skills adapter even if extension loading fails.
@@ -1496,6 +1491,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
}
extensionsResult.runtime.pendingProviderRegistrations = [];
mergeBuiltInZaiProviderModels(modelRegistry, (message) => logSink.log(message, "extensions"));
modelRegistry.refresh();
try {

View File

@@ -21,8 +21,8 @@ import {
GlobalSettingsStore,
resolveGlobalDir,
getEnabledPiExtensionPaths,
ZAI_PROVIDER_ID,
ZAI_PROVIDER_REGISTRATION,
mergeBuiltInZaiProviderModels,
registerBuiltInZaiProvider,
} from "@fusion/core";
import type { AutomationRunResult, ScheduledTask } from "@fusion/core";
import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath, loadTlsCredentialsFromEnv, registerGithubTrackingHook } from "@fusion/dashboard";
@@ -604,12 +604,7 @@ export async function runServe(
]);
const mergedAuthStorage = mergeAuthStorageReads(authStorage, [supplementalAuthStorage]);
const modelRegistry = ModelRegistry.create(mergedAuthStorage, getModelRegistryModelsPath());
try {
modelRegistry.registerProvider(ZAI_PROVIDER_ID, ZAI_PROVIDER_REGISTRATION);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.log(`[extensions] Failed to register built-in ${ZAI_PROVIDER_ID} provider: ${message}`);
}
registerBuiltInZaiProvider(modelRegistry, (message) => console.log(`[extensions] ${message}`));
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(mergedAuthStorage, modelRegistry);
// PackageManager may be used for skills adapter even if extension loading fails
@@ -725,6 +720,7 @@ export async function runServe(
}
extensionsResult.runtime.pendingProviderRegistrations = [];
mergeBuiltInZaiProviderModels(modelRegistry, (message) => console.log(`[extensions] ${message}`));
modelRegistry.refresh();
try {

View File

@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { ZAI_PROVIDER_ID, ZAI_PROVIDER_REGISTRATION } from "../zai-provider.js";
import {
mergeBuiltInZaiProviderModels,
registerBuiltInZaiProvider,
ZAI_PROVIDER_ID,
ZAI_PROVIDER_REGISTRATION,
} from "../zai-provider.js";
const EXISTING_ZAI_MODELS = [
"glm-4.5-air",
@@ -45,4 +50,30 @@ describe("ZAI_PROVIDER_REGISTRATION", () => {
},
});
});
it("re-merges missing built-in models after a user zai extension replacement", () => {
const extensionModels = ZAI_PROVIDER_REGISTRATION.models
.filter((model) => model.id !== "glm-5.2")
.map((model) => ({ ...model }));
const registeredProviders = new Map<string, Partial<typeof ZAI_PROVIDER_REGISTRATION>>();
const registry = {
registeredProviders,
registerProvider(providerName: string, config: typeof ZAI_PROVIDER_REGISTRATION) {
registeredProviders.set(providerName, { ...registeredProviders.get(providerName), ...config });
},
};
registerBuiltInZaiProvider(registry);
registry.registerProvider(ZAI_PROVIDER_ID, {
...ZAI_PROVIDER_REGISTRATION,
name: "User ZAI extension",
models: extensionModels,
});
mergeBuiltInZaiProviderModels(registry);
const mergedIds = registeredProviders.get(ZAI_PROVIDER_ID)?.models?.map((model) => model.id);
expect(mergedIds).toEqual([...EXISTING_ZAI_MODELS, "glm-5.2"]);
expect(registeredProviders.get(ZAI_PROVIDER_ID)?.name).toBe("User ZAI extension");
});
});

View File

@@ -20,7 +20,12 @@ export { redactSecrets } from "./redact-secrets.js";
export * from "./frontend-ux-policy.js";
export { MOCK_PROVIDER_ID } from "./mock-provider-constants.js";
export type { MockProviderId, MockSessionPurpose } from "./mock-provider-constants.js";
export { ZAI_PROVIDER_ID, ZAI_PROVIDER_REGISTRATION } from "./zai-provider.js";
export {
ZAI_PROVIDER_ID,
ZAI_PROVIDER_REGISTRATION,
mergeBuiltInZaiProviderModels,
registerBuiltInZaiProvider,
} from "./zai-provider.js";
export type { ZaiProviderRegistration } from "./zai-provider.js";
export {
resolveWorktrunkSettings,

View File

@@ -123,3 +123,96 @@ export const ZAI_PROVIDER_REGISTRATION: ZaiProviderRegistration = {
},
],
};
type ZaiModelLike = Partial<Omit<ZaiModelRegistration, "name" | "api" | "baseUrl" | "compat">> & {
id: string;
name?: unknown;
provider?: string;
baseUrl?: unknown;
api?: unknown;
compat?: unknown;
};
interface ZaiModelRegistryLike {
registerProvider(providerName: string, config: ZaiProviderRegistration): void;
getAll?: () => ZaiModelLike[];
}
type RegistryWithProviderState = ZaiModelRegistryLike & {
registeredProviders?: Map<string, Partial<ZaiProviderRegistration>>;
};
function toZaiModelRegistration(model: ZaiModelLike): ZaiModelRegistration & { 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 ZaiModelInput[] : ["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 ZaiModelRegistration["compat"]) }
: ZAI_PROVIDER_REGISTRATION.models.find((builtInModel) => builtInModel.id === model.id)?.compat ?? {
supportsDeveloperRole: false,
thinkingFormat: "zai",
},
};
}
function cloneZaiProviderRegistration(config: ZaiProviderRegistration): ZaiProviderRegistration {
return {
...config,
models: config.models.map((model) => toZaiModelRegistration(model)),
};
}
/**
* FNXC:ModelRegistry 2026-06-13-22:04:
* 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 Z.ai models after extension registration so zai/glm-5.2 remains visible wherever the user's existing Z.ai extension models are visible, without deleting extension-supplied models.
* Always pass cloned configs because pi stores and mutates registered provider objects during later upserts.
*/
export function registerBuiltInZaiProvider(
modelRegistry: ZaiModelRegistryLike,
logWarning: (message: string) => void = () => {},
): void {
try {
modelRegistry.registerProvider(ZAI_PROVIDER_ID, cloneZaiProviderRegistration(ZAI_PROVIDER_REGISTRATION));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logWarning(`Failed to register built-in ${ZAI_PROVIDER_ID} provider: ${message}`);
}
}
export function mergeBuiltInZaiProviderModels(
modelRegistry: ZaiModelRegistryLike,
logWarning: (message: string) => void = () => {},
): void {
try {
const registryWithState = modelRegistry as RegistryWithProviderState;
const registeredProvider = registryWithState.registeredProviders?.get(ZAI_PROVIDER_ID);
if (!registeredProvider && !modelRegistry.getAll) return;
const registeredModels = registeredProvider?.models?.map((model) => toZaiModelRegistration(model)) ?? [];
const currentModels = registeredModels.length > 0
? registeredModels
: modelRegistry.getAll?.()
.filter((model) => model.provider === ZAI_PROVIDER_ID)
.map((model) => toZaiModelRegistration(model)) ?? [];
const currentModelIds = new Set(currentModels.map((model) => model.id));
const missingBuiltInModels = ZAI_PROVIDER_REGISTRATION.models.filter((model) => !currentModelIds.has(model.id));
if (missingBuiltInModels.length === 0) return;
modelRegistry.registerProvider(ZAI_PROVIDER_ID, {
...cloneZaiProviderRegistration(ZAI_PROVIDER_REGISTRATION),
...registeredProvider,
models: [...currentModels, ...missingBuiltInModels.map((model) => toZaiModelRegistration(model))],
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logWarning(`Failed to merge built-in ${ZAI_PROVIDER_ID} models: ${message}`);
}
}

View File

@@ -0,0 +1,97 @@
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
import {
mergeBuiltInZaiProviderModels,
registerBuiltInZaiProvider,
ZAI_PROVIDER_REGISTRATION,
} from "@fusion/core";
import type { Router } from "express";
import { afterEach, describe, expect, it, vi } from "vitest";
import { registerModelRoutes } from "../routes/register-model-routes.js";
const EXISTING_ZAI_MODELS = [
"glm-4.5-air",
"glm-4.7",
"glm-5-turbo",
"glm-5.1",
"glm-5v-turbo",
];
async function withTempHome() {
const originalHome = process.env.HOME;
const home = await mkdtemp(join(tmpdir(), "fusion-zai-models-"));
const authDir = join(home, ".fusion", "agent");
await mkdir(authDir, { recursive: true });
await writeFile(join(authDir, "auth.json"), JSON.stringify({ zai: { type: "api_key", key: "test-zai-key" } }));
process.env.HOME = home;
return () => {
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
};
}
function createRouterHarness(modelRegistry: ModelRegistry) {
const getHandlers = new Map<string, (req: unknown, res: { json: (body: unknown) => void }) => Promise<void>>();
const router = {
get: vi.fn((path: string, handler: (req: unknown, res: { json: (body: unknown) => void }) => Promise<void>) => {
getHandlers.set(path, handler);
}),
} as unknown as Router;
const store = {
getGlobalSettingsStore: () => ({ getSettings: vi.fn().mockResolvedValue({}) }),
getSettingsFast: vi.fn().mockResolvedValue({}),
};
const runtimeLogger = { child: vi.fn(() => ({ warn: vi.fn() })) };
registerModelRoutes({
router,
store: store as never,
runtimeLogger: runtimeLogger as never,
options: { modelRegistry },
} as never);
return getHandlers.get("/models")!;
}
describe("registerModelRoutes Z.ai real registry", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("surfaces glm-5.2 through /api/models after a user zai extension replacement", async () => {
const restoreHome = await withTempHome();
try {
const authStorage = AuthStorage.inMemory({ zai: { type: "api_key", key: "test-zai-key" } });
const modelRegistry = ModelRegistry.inMemory(authStorage);
registerBuiltInZaiProvider(modelRegistry);
modelRegistry.registerProvider("zai", {
...ZAI_PROVIDER_REGISTRATION,
name: "User ZAI extension",
models: ZAI_PROVIDER_REGISTRATION.models.filter((model) => model.id !== "glm-5.2"),
});
expect(modelRegistry.getAvailable().some((model) => model.provider === "zai" && model.id === "glm-5.2")).toBe(false);
mergeBuiltInZaiProviderModels(modelRegistry);
modelRegistry.refresh();
const allZaiIds = modelRegistry.getAll().filter((model) => model.provider === "zai").map((model) => model.id);
expect(allZaiIds).toEqual([...EXISTING_ZAI_MODELS, "glm-5.2"]);
const handler = createRouterHarness(modelRegistry);
const json = vi.fn();
await handler({}, { json });
const response = json.mock.calls[0][0] as { models: Array<{ provider: string; id: string }> };
const zaiIds = response.models.filter((model) => model.provider === "zai").map((model) => model.id);
expect(zaiIds).toEqual([...EXISTING_ZAI_MODELS, "glm-5.2"]);
} finally {
restoreHome();
}
});
});

View File

@@ -42,9 +42,9 @@ import {
getProjectRootFromWorktree,
reconcileClaudeCliPaths,
reconcileDroidCliPaths,
mergeBuiltInZaiProviderModels,
registerBuiltInZaiProvider,
resolvePiExtensionProjectRoot,
ZAI_PROVIDER_ID,
ZAI_PROVIDER_REGISTRATION,
} from "@fusion/core";
import type {
AgentPermissionPolicyActionCategory,
@@ -1364,12 +1364,7 @@ function resolveVendoredDroidCliEntry(): string | null {
}
async function registerExtensionProviders(cwd: string, modelRegistry: ModelRegistry): Promise<void> {
try {
modelRegistry.registerProvider(ZAI_PROVIDER_ID, ZAI_PROVIDER_REGISTRATION);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
extensionsLog.warn(`Failed to register built-in ${ZAI_PROVIDER_ID} provider: ${message}`);
}
registerBuiltInZaiProvider(modelRegistry, (message) => extensionsLog.warn(message));
try {
const agentDir = getPackageManagerAgentDir();
@@ -1423,6 +1418,7 @@ async function registerExtensionProviders(cwd: string, modelRegistry: ModelRegis
}
extensionsResult.runtime.pendingProviderRegistrations = [];
mergeBuiltInZaiProviderModels(modelRegistry, (message) => extensionsLog.warn(message));
modelRegistry.refresh();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);