diff --git a/.changeset/FN-6414-glm-5-2-visible.md b/.changeset/FN-6414-glm-5-2-visible.md new file mode 100644 index 0000000000..5223763e71 --- /dev/null +++ b/.changeset/FN-6414-glm-5-2-visible.md @@ -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. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index daeeed0dcb..c5edf6a02c 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -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 diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index dbcd7a8b88..97dda060aa 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -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); diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 5b7fd4a23c..b6ea752456 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -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 { diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index ac36dab835..be332a35d3 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -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 { diff --git a/packages/core/src/__tests__/zai-provider.test.ts b/packages/core/src/__tests__/zai-provider.test.ts index 42a89a1336..a28d908048 100644 --- a/packages/core/src/__tests__/zai-provider.test.ts +++ b/packages/core/src/__tests__/zai-provider.test.ts @@ -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>(); + 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"); + }); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5ce09c5516..23a35ef3c5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/core/src/zai-provider.ts b/packages/core/src/zai-provider.ts index 121e709ae7..c8c2020805 100644 --- a/packages/core/src/zai-provider.ts +++ b/packages/core/src/zai-provider.ts @@ -123,3 +123,96 @@ export const ZAI_PROVIDER_REGISTRATION: ZaiProviderRegistration = { }, ], }; + +type ZaiModelLike = Partial> & { + 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>; +}; + +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}`); + } +} diff --git a/packages/dashboard/src/__tests__/register-model-routes-zai-real-registry.test.ts b/packages/dashboard/src/__tests__/register-model-routes-zai-real-registry.test.ts new file mode 100644 index 0000000000..ff45e21263 --- /dev/null +++ b/packages/dashboard/src/__tests__/register-model-routes-zai-real-registry.test.ts @@ -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 void }) => Promise>(); + const router = { + get: vi.fn((path: string, handler: (req: unknown, res: { json: (body: unknown) => void }) => Promise) => { + 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(); + } + }); +}); diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index 85e3567b53..cb9bd2ebfa 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -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 { - 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);