diff --git a/.changeset/FN-6401-glm-5-2.md b/.changeset/FN-6401-glm-5-2.md new file mode 100644 index 0000000000..a2892108a9 --- /dev/null +++ b/.changeset/FN-6401-glm-5-2.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Enable Z.ai GLM-5.2 model selection. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index cfc7f7172e..80b942e2fc 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -801,6 +801,8 @@ 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. + ### Planning model 1. Per-task `planningModelProvider` + `planningModelId` diff --git a/packages/cli/src/commands/__tests__/daemon.test.ts b/packages/cli/src/commands/__tests__/daemon.test.ts index 21f70d75a1..bb51b2c341 100644 --- a/packages/cli/src/commands/__tests__/daemon.test.ts +++ b/packages/cli/src/commands/__tests__/daemon.test.ts @@ -682,6 +682,17 @@ describe("runDaemon", () => { await runDaemon({}); expect(mockSyncStartupModels).toHaveBeenCalledTimes(1); }); + + it("registers built-in zai GLM-5.2 before refreshing models", async () => { + await runDaemon({}); + + expect(mocks.modelRegistry.registerProvider).toHaveBeenCalledWith("zai", expect.objectContaining({ + models: expect.arrayContaining([expect.objectContaining({ id: "glm-5.2" })]), + })); + expect(mocks.modelRegistry.refresh).toHaveBeenCalled(); + + await triggerSignal("SIGINT"); + }); const originalCwd = process.cwd; const originalExit = process.exit; diff --git a/packages/cli/src/commands/__tests__/dashboard.test.ts b/packages/cli/src/commands/__tests__/dashboard.test.ts index 19e9327970..37dc7badfd 100644 --- a/packages/cli/src/commands/__tests__/dashboard.test.ts +++ b/packages/cli/src/commands/__tests__/dashboard.test.ts @@ -832,10 +832,23 @@ async function runDashboard(...args: Parameters): Retur // ── Tests ─────────────────────────────────────────────────────────── describe("runDashboard — startup model sync", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("invokes shared startup model sync", async () => { await runDashboard(0, { open: false }); expect(mockSyncStartupModels).toHaveBeenCalledTimes(1); }); + + it("registers built-in zai GLM-5.2 before refreshing models", async () => { + await runDashboard(0, { open: false }); + + expect(mockModelRegistry.registerProvider).toHaveBeenCalledWith("zai", expect.objectContaining({ + models: expect.arrayContaining([expect.objectContaining({ id: "glm-5.2" })]), + })); + expect(mockModelRegistry.refresh).toHaveBeenCalled(); + }); }); function resetGitHubMocks() { diff --git a/packages/cli/src/commands/__tests__/serve.test.ts b/packages/cli/src/commands/__tests__/serve.test.ts index d1c7a9bf7d..29114ab488 100644 --- a/packages/cli/src/commands/__tests__/serve.test.ts +++ b/packages/cli/src/commands/__tests__/serve.test.ts @@ -752,6 +752,17 @@ describe("runServe", () => { await runServe(4040, {}); expect(mockSyncStartupModels).toHaveBeenCalledTimes(1); }); + + it("registers built-in zai GLM-5.2 before refreshing models", async () => { + await runServe(0, {}); + + expect(mocks.modelRegistry.registerProvider).toHaveBeenCalledWith("zai", expect.objectContaining({ + models: expect.arrayContaining([expect.objectContaining({ id: "glm-5.2" })]), + })); + expect(mocks.modelRegistry.refresh).toHaveBeenCalled(); + + await triggerSignal("SIGINT"); + }); const originalCwd = process.cwd; const originalOn = process.on; const originalExit = process.exit; diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index 4d3ba603ae..dbcd7a8b88 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -21,6 +21,8 @@ import { resolveGlobalDir, getEnabledPiExtensionPaths, reconcileClaudeCliPaths, + ZAI_PROVIDER_ID, + ZAI_PROVIDER_REGISTRATION, } from "@fusion/core"; import type { AutomationRunResult, ScheduledTask } from "@fusion/core"; import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath, loadTlsCredentialsFromEnv, registerGithubTrackingHook } from "@fusion/dashboard"; @@ -552,6 +554,12 @@ 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}`); + } const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(mergedAuthStorage, modelRegistry); // PackageManager may be used for skills adapter even if extension loading fails diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 4ce90ae0ee..5b7fd4a23c 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -20,6 +20,8 @@ import { resolveColumnFlags, BUILTIN_CODING_WORKFLOW_IR, parseWorkflowIr, + ZAI_PROVIDER_ID, + ZAI_PROVIDER_REGISTRATION, type WorkflowIrColumn, type TraitFlags, } from "@fusion/core"; @@ -1369,6 +1371,12 @@ 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"); + } const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(mergedAuthStorage, modelRegistry); // PackageManager may be used for skills adapter even if extension loading fails. diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 5945d98672..ac36dab835 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -21,6 +21,8 @@ import { GlobalSettingsStore, resolveGlobalDir, getEnabledPiExtensionPaths, + ZAI_PROVIDER_ID, + ZAI_PROVIDER_REGISTRATION, } from "@fusion/core"; import type { AutomationRunResult, ScheduledTask } from "@fusion/core"; import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath, loadTlsCredentialsFromEnv, registerGithubTrackingHook } from "@fusion/dashboard"; @@ -602,6 +604,12 @@ 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}`); + } const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(mergedAuthStorage, modelRegistry); // PackageManager may be used for skills adapter even if extension loading fails diff --git a/packages/core/src/__tests__/zai-provider.test.ts b/packages/core/src/__tests__/zai-provider.test.ts new file mode 100644 index 0000000000..42a89a1336 --- /dev/null +++ b/packages/core/src/__tests__/zai-provider.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { ZAI_PROVIDER_ID, ZAI_PROVIDER_REGISTRATION } from "../zai-provider.js"; + +const EXISTING_ZAI_MODELS = [ + "glm-4.5-air", + "glm-4.7", + "glm-5-turbo", + "glm-5.1", + "glm-5v-turbo", +]; + +describe("ZAI_PROVIDER_REGISTRATION", () => { + it("uses the existing zai auth surface and API endpoint", () => { + expect(ZAI_PROVIDER_ID).toBe("zai"); + expect(ZAI_PROVIDER_REGISTRATION).toMatchObject({ + name: "ZAI", + baseUrl: "https://api.z.ai/api/coding/paas/v4", + apiKey: "$ZAI_API_KEY", + api: "openai-completions", + }); + }); + + it("preserves existing built-in models and appends glm-5.2", () => { + const modelIds = ZAI_PROVIDER_REGISTRATION.models.map((model) => model.id); + + expect(modelIds).toEqual([...EXISTING_ZAI_MODELS, "glm-5.2"]); + for (const id of EXISTING_ZAI_MODELS) { + expect(modelIds).toContain(id); + } + }); + + it("registers GLM-5.2 with upstream model capabilities", () => { + expect(ZAI_PROVIDER_REGISTRATION.models.find((model) => model.id === "glm-5.2")).toMatchObject({ + id: "glm-5.2", + name: "GLM-5.2", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1_000_000, + maxTokens: 131_072, + compat: { + supportsDeveloperRole: false, + thinkingFormat: "zai", + zaiToolStream: true, + }, + }); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6441dbe401..5ce09c5516 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -20,6 +20,8 @@ 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 type { ZaiProviderRegistration } from "./zai-provider.js"; export { resolveWorktrunkSettings, requiresWorktrunkInstallVerification, diff --git a/packages/core/src/zai-provider.ts b/packages/core/src/zai-provider.ts new file mode 100644 index 0000000000..121e709ae7 --- /dev/null +++ b/packages/core/src/zai-provider.ts @@ -0,0 +1,125 @@ +export const ZAI_PROVIDER_ID = "zai"; + +type ZaiModelInput = "text" | "image"; + +interface ZaiModelRegistration { + id: string; + name: string; + reasoning: boolean; + input: ZaiModelInput[]; + cost: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + }; + contextWindow: number; + maxTokens: number; + compat: { + supportsDeveloperRole: boolean; + thinkingFormat: "zai"; + zaiToolStream?: boolean; + }; +} + +export interface ZaiProviderRegistration { + name: string; + baseUrl: string; + apiKey: string; + api: "openai-completions"; + models: ZaiModelRegistration[]; +} + +// pi registerProvider() replaces the provider's model list, so keep every +// currently built-in Z.ai model here and append new models such as GLM-5.2. +export const ZAI_PROVIDER_REGISTRATION: ZaiProviderRegistration = { + name: "ZAI", + baseUrl: "https://api.z.ai/api/coding/paas/v4", + apiKey: "$ZAI_API_KEY", + api: "openai-completions", + models: [ + { + id: "glm-4.5-air", + name: "GLM-4.5-Air", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 131072, + maxTokens: 98304, + compat: { + supportsDeveloperRole: false, + thinkingFormat: "zai", + }, + }, + { + id: "glm-4.7", + name: "GLM-4.7", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 204800, + maxTokens: 131072, + compat: { + supportsDeveloperRole: false, + thinkingFormat: "zai", + zaiToolStream: true, + }, + }, + { + id: "glm-5-turbo", + name: "GLM-5-Turbo", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 131072, + compat: { + supportsDeveloperRole: false, + thinkingFormat: "zai", + zaiToolStream: true, + }, + }, + { + id: "glm-5.1", + name: "GLM-5.1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 131072, + compat: { + supportsDeveloperRole: false, + thinkingFormat: "zai", + zaiToolStream: true, + }, + }, + { + id: "glm-5v-turbo", + name: "GLM-5V-Turbo", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 131072, + compat: { + supportsDeveloperRole: false, + thinkingFormat: "zai", + zaiToolStream: true, + }, + }, + { + id: "glm-5.2", + name: "GLM-5.2", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000000, + maxTokens: 131072, + compat: { + supportsDeveloperRole: false, + thinkingFormat: "zai", + zaiToolStream: true, + }, + }, + ], +}; 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 5ffa69f693..321358c385 100644 --- a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts +++ b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts @@ -1336,7 +1336,10 @@ describe("createFnAgent", () => { "/tmp", "/tmp/.fusion/disabled-auto-extension-discovery", ); - expect(registerProviderMock).toHaveBeenCalledWith("zai", expect.objectContaining({ + expect(registerProviderMock).toHaveBeenNthCalledWith(1, "zai", expect.objectContaining({ + models: expect.arrayContaining([expect.objectContaining({ id: "glm-5.2" })]), + })); + expect(registerProviderMock).toHaveBeenNthCalledWith(2, "zai", expect.objectContaining({ models: [{ id: "glm-5.1" }], })); expect(refreshMock).toHaveBeenCalled(); diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index 790c863501..85e3567b53 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -34,7 +34,18 @@ import { type AgentSession, type ToolDefinition, } from "@earendil-works/pi-coding-agent"; -import { customProviderRegistryKey, getEnabledPiExtensionPaths, getFusionAgentDir, getLegacyPiAgentDir, getProjectRootFromWorktree, reconcileClaudeCliPaths, reconcileDroidCliPaths, resolvePiExtensionProjectRoot } from "@fusion/core"; +import { + customProviderRegistryKey, + getEnabledPiExtensionPaths, + getFusionAgentDir, + getLegacyPiAgentDir, + getProjectRootFromWorktree, + reconcileClaudeCliPaths, + reconcileDroidCliPaths, + resolvePiExtensionProjectRoot, + ZAI_PROVIDER_ID, + ZAI_PROVIDER_REGISTRATION, +} from "@fusion/core"; import type { AgentPermissionPolicyActionCategory, PermanentAgentActionCategory, @@ -1353,6 +1364,13 @@ 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}`); + } + try { const agentDir = getPackageManagerAgentDir(); const packageManager = new DefaultPackageManager({