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:
5
.changeset/FN-6414-glm-5-2-visible.md
Normal file
5
.changeset/FN-6414-glm-5-2-visible.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user