From 540996f10f2a7bf67479bce58d3111c0c9fa4e33 Mon Sep 17 00:00:00 2001 From: Tomsun28 Date: Thu, 12 Feb 2026 21:01:48 +0800 Subject: [PATCH] feat(provider): Z.AI endpoints + model catalog (#13456) (thanks @tomsun28) (#13456) Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- .gitignore | 1 + CHANGELOG.md | 1 + docs/cli/onboard.md | 14 +++ src/agents/live-model-filter.ts | 2 +- src/agents/zai.live.test.ts | 22 +++++ src/cli/program/register.onboard.ts | 2 +- src/commands/auth-choice-options.ts | 28 +++++- .../auth-choice.apply.api-providers.ts | 69 +++++++++---- .../auth-choice.preferred-provider.ts | 4 + src/commands/auth-choice.test.ts | 96 +++++++++++++++++++ src/commands/onboard-auth.config-core.ts | 84 ++++++++++++++-- src/commands/onboard-auth.models.ts | 56 +++++++++++ src/commands/onboard-auth.test.ts | 45 +++++++++ src/commands/onboard-auth.ts | 8 ++ ...oard-non-interactive.provider-auth.test.ts | 54 +++++++++++ .../local/auth-choice.ts | 24 ++++- src/commands/onboard-types.ts | 4 + 17 files changed, 482 insertions(+), 32 deletions(-) diff --git a/.gitignore b/.gitignore index 6667c67095..55f905293c 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,7 @@ docs/.local/ IDENTITY.md USER.md .tgz +.idea # local tooling .serena/ diff --git a/CHANGELOG.md b/CHANGELOG.md index bedf9ebaa4..6c7b80f9cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai - Slack: detect control commands when channel messages start with bot mention prefixes (for example, `@Bot /new`). (#14142) Thanks @beefiker. - Discord tests: use a partial @buape/carbon mock in slash command coverage. (#13262) Thanks @arosstale. - CLI/Wizard: exit with code 1 when `configure`, `agents add`, or interactive `onboard` wizards are canceled, so `set -e` automation stops correctly. (#14156) Thanks @0xRaini. +- Onboarding/Providers: add Z.AI endpoint-specific auth choices (`zai-coding-global`, `zai-coding-cn`, `zai-global`, `zai-cn`) and expand default Z.AI model wiring. (#13456) Thanks @tomsun28. - Feishu: pass `Buffer` directly to the Feishu SDK upload APIs instead of `Readable.from(...)` to avoid form-data upload failures. (#10345) Thanks @youngerstyle. - Feishu: trigger mention-gated group handling only when the bot itself is mentioned (not just any mention). (#11088) Thanks @openperf. - Feishu: probe status uses the resolved account context for multi-account credential checks. (#11233) Thanks @onevcat. diff --git a/docs/cli/onboard.md b/docs/cli/onboard.md index 2b4c97b1cf..d2b43bac18 100644 --- a/docs/cli/onboard.md +++ b/docs/cli/onboard.md @@ -39,6 +39,20 @@ openclaw onboard --non-interactive \ `--custom-api-key` is optional in non-interactive mode. If omitted, onboarding checks `CUSTOM_API_KEY`. +Non-interactive Z.AI endpoint choices: + +```bash +# Promptless endpoint selection +openclaw onboard --non-interactive \ + --auth-choice zai-coding-global \ + --zai-api-key "$ZAI_API_KEY" + +# Other Z.AI endpoint choices: +# --auth-choice zai-coding-cn +# --auth-choice zai-global +# --auth-choice zai-cn +``` + Flow notes: - `quickstart`: minimal prompts, auto-generates a gateway token. diff --git a/src/agents/live-model-filter.ts b/src/agents/live-model-filter.ts index 4ce4e7d732..0b43187e6b 100644 --- a/src/agents/live-model-filter.ts +++ b/src/agents/live-model-filter.ts @@ -19,7 +19,7 @@ const CODEX_MODELS = [ "gpt-5.1-codex-max", ]; const GOOGLE_PREFIXES = ["gemini-3"]; -const ZAI_PREFIXES = ["glm-4.7"]; +const ZAI_PREFIXES = ["glm-5", "glm-4.7", "glm-4.7-flash", "glm-4.7-flashx"]; const MINIMAX_PREFIXES = ["minimax-m2.1"]; const XAI_PREFIXES = ["grok-4"]; diff --git a/src/agents/zai.live.test.ts b/src/agents/zai.live.test.ts index 2cff4a6630..c75a6b7a8a 100644 --- a/src/agents/zai.live.test.ts +++ b/src/agents/zai.live.test.ts @@ -29,4 +29,26 @@ describeLive("zai live", () => { .join(" "); expect(text.length).toBeGreaterThan(0); }, 20000); + + it("glm-4.7-flashx returns assistant text", async () => { + const model = getModel("zai", "glm-4.7-flashx" as "glm-4.7"); + const res = await completeSimple( + model, + { + messages: [ + { + role: "user", + content: "Reply with the word ok.", + timestamp: Date.now(), + }, + ], + }, + { apiKey: ZAI_KEY, maxTokens: 64 }, + ); + const text = res.content + .filter((block) => block.type === "text") + .map((block) => block.text.trim()) + .join(" "); + expect(text.length).toBeGreaterThan(0); + }, 20000); }); diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index df8d241830..5fd5e5bdcf 100644 --- a/src/cli/program/register.onboard.ts +++ b/src/cli/program/register.onboard.ts @@ -58,7 +58,7 @@ export function registerOnboardCommand(program: Command) { .option("--mode ", "Wizard mode: local|remote") .option( "--auth-choice ", - "Auth: setup-token|token|chutes|openai-codex|openai-api-key|xai-api-key|qianfan-api-key|openrouter-api-key|litellm-api-key|ai-gateway-api-key|cloudflare-ai-gateway-api-key|moonshot-api-key|moonshot-api-key-cn|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|xiaomi-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|custom-api-key|skip|together-api-key", + "Auth: setup-token|token|chutes|openai-codex|openai-api-key|xai-api-key|qianfan-api-key|openrouter-api-key|litellm-api-key|ai-gateway-api-key|cloudflare-ai-gateway-api-key|moonshot-api-key|moonshot-api-key-cn|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|zai-coding-global|zai-coding-cn|zai-global|zai-cn|xiaomi-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|custom-api-key|skip|together-api-key", ) .option( "--token-provider ", diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 3d27077cb0..612a7a0022 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -92,9 +92,9 @@ const AUTH_CHOICE_GROUP_DEFS: { }, { value: "zai", - label: "Z.AI (GLM 4.7)", - hint: "API key", - choices: ["zai-api-key"], + label: "Z.AI", + hint: "GLM Coding Plan / Global / CN", + choices: ["zai-coding-global", "zai-coding-cn", "zai-global", "zai-cn"], }, { value: "qianfan", @@ -242,7 +242,27 @@ export function buildAuthChoiceOptions(params: { label: "Google Gemini CLI OAuth", hint: "Uses the bundled Gemini CLI auth plugin", }); - options.push({ value: "zai-api-key", label: "Z.AI (GLM 4.7) API key" }); + options.push({ value: "zai-api-key", label: "Z.AI API key" }); + options.push({ + value: "zai-coding-global", + label: "Coding-Plan-Global", + hint: "GLM Coding Plan Global (api.z.ai)", + }); + options.push({ + value: "zai-coding-cn", + label: "Coding-Plan-CN", + hint: "GLM Coding Plan CN (open.bigmodel.cn)", + }); + options.push({ + value: "zai-global", + label: "Global", + hint: "Z.AI Global (api.z.ai)", + }); + options.push({ + value: "zai-cn", + label: "CN", + hint: "Z.AI CN (open.bigmodel.cn)", + }); options.push({ value: "xiaomi-api-key", label: "Xiaomi API key", diff --git a/src/commands/auth-choice.apply.api-providers.ts b/src/commands/auth-choice.apply.api-providers.ts index 8f7705d568..eaad175178 100644 --- a/src/commands/auth-choice.apply.api-providers.ts +++ b/src/commands/auth-choice.apply.api-providers.ts @@ -40,6 +40,7 @@ import { applyXiaomiConfig, applyXiaomiProviderConfig, applyZaiConfig, + applyZaiProviderConfig, CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, LITELLM_DEFAULT_MODEL_REF, QIANFAN_DEFAULT_MODEL_REF, @@ -619,7 +620,54 @@ export async function applyAuthChoiceApiProviders( return { config: nextConfig, agentModelOverride }; } - if (authChoice === "zai-api-key") { + if ( + authChoice === "zai-api-key" || + authChoice === "zai-coding-global" || + authChoice === "zai-coding-cn" || + authChoice === "zai-global" || + authChoice === "zai-cn" + ) { + // Determine endpoint from authChoice or prompt + let endpoint: string; + if (authChoice === "zai-coding-global") { + endpoint = "coding-global"; + } else if (authChoice === "zai-coding-cn") { + endpoint = "coding-cn"; + } else if (authChoice === "zai-global") { + endpoint = "global"; + } else if (authChoice === "zai-cn") { + endpoint = "cn"; + } else { + // zai-api-key: prompt for endpoint selection + endpoint = await params.prompter.select({ + message: "Select Z.AI endpoint", + options: [ + { + value: "coding-global", + label: "Coding-Plan-Global", + hint: "GLM Coding Plan Global (api.z.ai)", + }, + { + value: "coding-cn", + label: "Coding-Plan-CN", + hint: "GLM Coding Plan CN (open.bigmodel.cn)", + }, + { + value: "global", + label: "Global", + hint: "Z.AI Global (api.z.ai)", + }, + { + value: "cn", + label: "CN", + hint: "Z.AI CN (open.bigmodel.cn)", + }, + ], + initialValue: "coding-global", + }); + } + + // Input API key let hasCredential = false; if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "zai") { @@ -655,23 +703,8 @@ export async function applyAuthChoiceApiProviders( config: nextConfig, setDefaultModel: params.setDefaultModel, defaultModel: ZAI_DEFAULT_MODEL_REF, - applyDefaultConfig: applyZaiConfig, - applyProviderConfig: (config) => ({ - ...config, - agents: { - ...config.agents, - defaults: { - ...config.agents?.defaults, - models: { - ...config.agents?.defaults?.models, - [ZAI_DEFAULT_MODEL_REF]: { - ...config.agents?.defaults?.models?.[ZAI_DEFAULT_MODEL_REF], - alias: config.agents?.defaults?.models?.[ZAI_DEFAULT_MODEL_REF]?.alias ?? "GLM", - }, - }, - }, - }, - }), + applyDefaultConfig: (config) => applyZaiConfig(config, { endpoint }), + applyProviderConfig: (config) => applyZaiProviderConfig(config, { endpoint }), noteDefault: ZAI_DEFAULT_MODEL_REF, noteAgentModel, prompter: params.prompter, diff --git a/src/commands/auth-choice.preferred-provider.ts b/src/commands/auth-choice.preferred-provider.ts index 2cfbcdbf4a..8cd4480253 100644 --- a/src/commands/auth-choice.preferred-provider.ts +++ b/src/commands/auth-choice.preferred-provider.ts @@ -20,6 +20,10 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial> = { "google-antigravity": "google-antigravity", "google-gemini-cli": "google-gemini-cli", "zai-api-key": "zai", + "zai-coding-global": "zai", + "zai-coding-cn": "zai", + "zai-global": "zai", + "zai-cn": "zai", "xiaomi-api-key": "xiaomi", "synthetic-api-key": "synthetic", "venice-api-key": "venice", diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index 2445a598ff..9cae3219ee 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -6,6 +6,7 @@ import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import type { AuthChoice } from "./onboard-types.js"; import { applyAuthChoice, resolvePreferredProviderForAuthChoice } from "./auth-choice.js"; +import { ZAI_CODING_CN_BASE_URL, ZAI_CODING_GLOBAL_BASE_URL } from "./onboard-auth.js"; vi.mock("../providers/github-copilot-auth.js", () => ({ githubCopilotLoginCommand: vi.fn(async () => {}), @@ -199,6 +200,101 @@ describe("applyAuthChoice", () => { expect(parsed.profiles?.["synthetic:default"]?.key).toBe("sk-synthetic-test"); }); + it("prompts for Z.AI endpoint when selecting zai-api-key", async () => { + tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); + process.env.OPENCLAW_STATE_DIR = tempStateDir; + process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent"); + process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR; + + const text = vi.fn().mockResolvedValue("zai-test-key"); + const select = vi.fn(async (params: { message: string }) => { + if (params.message === "Select Z.AI endpoint") { + return "coding-cn"; + } + return "default"; + }); + const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []); + const prompter: WizardPrompter = { + intro: vi.fn(noopAsync), + outro: vi.fn(noopAsync), + note: vi.fn(noopAsync), + select: select as WizardPrompter["select"], + multiselect, + text, + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: noop, stop: noop })), + }; + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }), + }; + + const result = await applyAuthChoice({ + authChoice: "zai-api-key", + config: {}, + prompter, + runtime, + setDefaultModel: true, + }); + + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ message: "Select Z.AI endpoint", initialValue: "coding-global" }), + ); + expect(result.config.models?.providers?.zai?.baseUrl).toBe(ZAI_CODING_CN_BASE_URL); + expect(result.config.agents?.defaults?.model?.primary).toBe("zai/glm-4.7"); + + const authProfilePath = authProfilePathFor(requireAgentDir()); + const raw = await fs.readFile(authProfilePath, "utf8"); + const parsed = JSON.parse(raw) as { + profiles?: Record; + }; + expect(parsed.profiles?.["zai:default"]?.key).toBe("zai-test-key"); + }); + + it("uses endpoint-specific auth choice without prompting for Z.AI endpoint", async () => { + tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); + process.env.OPENCLAW_STATE_DIR = tempStateDir; + process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent"); + process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR; + + const text = vi.fn().mockResolvedValue("zai-test-key"); + const select = vi.fn(async () => "default"); + const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []); + const prompter: WizardPrompter = { + intro: vi.fn(noopAsync), + outro: vi.fn(noopAsync), + note: vi.fn(noopAsync), + select: select as WizardPrompter["select"], + multiselect, + text, + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: noop, stop: noop })), + }; + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }), + }; + + const result = await applyAuthChoice({ + authChoice: "zai-coding-global", + config: {}, + prompter, + runtime, + setDefaultModel: true, + }); + + expect(select).not.toHaveBeenCalledWith( + expect.objectContaining({ message: "Select Z.AI endpoint" }), + ); + expect(result.config.models?.providers?.zai?.baseUrl).toBe(ZAI_CODING_GLOBAL_BASE_URL); + }); + it("does not override the global default model when selecting xai-api-key without setDefaultModel", async () => { tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); process.env.OPENCLAW_STATE_DIR = tempStateDir; diff --git a/src/commands/onboard-auth.config-core.ts b/src/commands/onboard-auth.config-core.ts index 966402753d..1fb2d23025 100644 --- a/src/commands/onboard-auth.config-core.ts +++ b/src/commands/onboard-auth.config-core.ts @@ -38,6 +38,7 @@ import { XAI_DEFAULT_MODEL_REF, } from "./onboard-auth.credentials.js"; import { + buildZaiModelDefinition, buildMoonshotModelDefinition, buildXaiModelDefinition, QIANFAN_BASE_URL, @@ -47,18 +48,65 @@ import { MOONSHOT_CN_BASE_URL, MOONSHOT_DEFAULT_MODEL_ID, MOONSHOT_DEFAULT_MODEL_REF, + ZAI_DEFAULT_MODEL_ID, + resolveZaiBaseUrl, XAI_BASE_URL, XAI_DEFAULT_MODEL_ID, } from "./onboard-auth.models.js"; -export function applyZaiConfig(cfg: OpenClawConfig): OpenClawConfig { +export function applyZaiProviderConfig( + cfg: OpenClawConfig, + params?: { endpoint?: string; modelId?: string }, +): OpenClawConfig { + const modelId = params?.modelId?.trim() || ZAI_DEFAULT_MODEL_ID; + const modelRef = `zai/${modelId}`; + const models = { ...cfg.agents?.defaults?.models }; - models[ZAI_DEFAULT_MODEL_REF] = { - ...models[ZAI_DEFAULT_MODEL_REF], - alias: models[ZAI_DEFAULT_MODEL_REF]?.alias ?? "GLM", + models[modelRef] = { + ...models[modelRef], + alias: models[modelRef]?.alias ?? "GLM", + }; + + const providers = { ...cfg.models?.providers }; + const existingProvider = providers.zai; + const existingModels = Array.isArray(existingProvider?.models) ? existingProvider.models : []; + + const defaultModels = [ + buildZaiModelDefinition({ id: "glm-5" }), + buildZaiModelDefinition({ id: "glm-4.7" }), + buildZaiModelDefinition({ id: "glm-4.7-flash" }), + buildZaiModelDefinition({ id: "glm-4.7-flashx" }), + ]; + + const mergedModels = [...existingModels]; + const seen = new Set(existingModels.map((m) => m.id)); + for (const model of defaultModels) { + if (!seen.has(model.id)) { + mergedModels.push(model); + seen.add(model.id); + } + } + + const { apiKey: existingApiKey, ...existingProviderRest } = (existingProvider ?? {}) as Record< + string, + unknown + > as { apiKey?: string }; + const resolvedApiKey = typeof existingApiKey === "string" ? existingApiKey : undefined; + const normalizedApiKey = resolvedApiKey?.trim(); + + const baseUrl = params?.endpoint + ? resolveZaiBaseUrl(params.endpoint) + : (typeof existingProvider?.baseUrl === "string" ? existingProvider.baseUrl : "") || + resolveZaiBaseUrl(); + + providers.zai = { + ...existingProviderRest, + baseUrl, + api: "openai-completions", + ...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}), + models: mergedModels.length > 0 ? mergedModels : defaultModels, }; - const existingModel = cfg.agents?.defaults?.model; return { ...cfg, agents: { @@ -66,13 +114,37 @@ export function applyZaiConfig(cfg: OpenClawConfig): OpenClawConfig { defaults: { ...cfg.agents?.defaults, models, + }, + }, + models: { + mode: cfg.models?.mode ?? "merge", + providers, + }, + }; +} + +export function applyZaiConfig( + cfg: OpenClawConfig, + params?: { endpoint?: string; modelId?: string }, +): OpenClawConfig { + const modelId = params?.modelId?.trim() || ZAI_DEFAULT_MODEL_ID; + const modelRef = modelId === ZAI_DEFAULT_MODEL_ID ? ZAI_DEFAULT_MODEL_REF : `zai/${modelId}`; + const next = applyZaiProviderConfig(cfg, params); + + const existingModel = next.agents?.defaults?.model; + return { + ...next, + agents: { + ...next.agents, + defaults: { + ...next.agents?.defaults, model: { ...(existingModel && "fallbacks" in (existingModel as Record) ? { fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks, } : undefined), - primary: ZAI_DEFAULT_MODEL_REF, + primary: modelRef, }, }, }, diff --git a/src/commands/onboard-auth.models.ts b/src/commands/onboard-auth.models.ts index 611a7cb8ea..5feed46831 100644 --- a/src/commands/onboard-auth.models.ts +++ b/src/commands/onboard-auth.models.ts @@ -20,6 +20,26 @@ export const KIMI_CODING_MODEL_REF = `kimi-coding/${KIMI_CODING_MODEL_ID}`; export { QIANFAN_BASE_URL, QIANFAN_DEFAULT_MODEL_ID }; export const QIANFAN_DEFAULT_MODEL_REF = `qianfan/${QIANFAN_DEFAULT_MODEL_ID}`; +export const ZAI_CODING_GLOBAL_BASE_URL = "https://api.z.ai/api/coding/paas/v4"; +export const ZAI_CODING_CN_BASE_URL = "https://open.bigmodel.cn/api/coding/paas/v4"; +export const ZAI_GLOBAL_BASE_URL = "https://api.z.ai/api/paas/v4"; +export const ZAI_CN_BASE_URL = "https://open.bigmodel.cn/api/paas/v4"; +export const ZAI_DEFAULT_MODEL_ID = "glm-4.7"; + +export function resolveZaiBaseUrl(endpoint?: string): string { + switch (endpoint) { + case "coding-cn": + return ZAI_CODING_CN_BASE_URL; + case "global": + return ZAI_GLOBAL_BASE_URL; + case "cn": + return ZAI_CN_BASE_URL; + case "coding-global": + default: + return ZAI_CODING_GLOBAL_BASE_URL; + } +} + // Pricing: MiniMax doesn't publish public rates. Override in models.json for accurate costs. export const MINIMAX_API_COST = { input: 15, @@ -46,6 +66,13 @@ export const MOONSHOT_DEFAULT_COST = { cacheWrite: 0, }; +export const ZAI_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + const MINIMAX_MODEL_CATALOG = { "MiniMax-M2.1": { name: "MiniMax M2.1", reasoning: false }, "MiniMax-M2.1-lightning": { @@ -56,6 +83,15 @@ const MINIMAX_MODEL_CATALOG = { type MinimaxCatalogId = keyof typeof MINIMAX_MODEL_CATALOG; +const ZAI_MODEL_CATALOG = { + "glm-5": { name: "GLM-5", reasoning: true }, + "glm-4.7": { name: "GLM-4.7", reasoning: true }, + "glm-4.7-flash": { name: "GLM-4.7 Flash", reasoning: true }, + "glm-4.7-flashx": { name: "GLM-4.7 FlashX", reasoning: true }, +} as const; + +type ZaiCatalogId = keyof typeof ZAI_MODEL_CATALOG; + export function buildMinimaxModelDefinition(params: { id: string; name?: string; @@ -97,6 +133,26 @@ export function buildMoonshotModelDefinition(): ModelDefinitionConfig { }; } +export function buildZaiModelDefinition(params: { + id: string; + name?: string; + reasoning?: boolean; + cost?: ModelDefinitionConfig["cost"]; + contextWindow?: number; + maxTokens?: number; +}): ModelDefinitionConfig { + const catalog = ZAI_MODEL_CATALOG[params.id as ZaiCatalogId]; + return { + id: params.id, + name: params.name ?? catalog?.name ?? `GLM ${params.id}`, + reasoning: params.reasoning ?? catalog?.reasoning ?? true, + input: ["text"], + cost: params.cost ?? ZAI_DEFAULT_COST, + contextWindow: params.contextWindow ?? 204800, + maxTokens: params.maxTokens ?? 131072, + }; +} + export const XAI_BASE_URL = "https://api.x.ai/v1"; export const XAI_DEFAULT_MODEL_ID = "grok-4"; export const XAI_DEFAULT_MODEL_REF = `xai/${XAI_DEFAULT_MODEL_ID}`; diff --git a/src/commands/onboard-auth.test.ts b/src/commands/onboard-auth.test.ts index 27a8460de1..35aa30c857 100644 --- a/src/commands/onboard-auth.test.ts +++ b/src/commands/onboard-auth.test.ts @@ -18,12 +18,16 @@ import { applyXaiProviderConfig, applyXiaomiConfig, applyXiaomiProviderConfig, + applyZaiConfig, + applyZaiProviderConfig, OPENROUTER_DEFAULT_MODEL_REF, SYNTHETIC_DEFAULT_MODEL_ID, SYNTHETIC_DEFAULT_MODEL_REF, XAI_DEFAULT_MODEL_REF, setMinimaxApiKey, writeOAuthCredentials, + ZAI_CODING_CN_BASE_URL, + ZAI_CODING_GLOBAL_BASE_URL, } from "./onboard-auth.js"; const authProfilePathFor = (agentDir: string) => path.join(agentDir, "auth-profiles.json"); @@ -303,6 +307,47 @@ describe("applyMinimaxApiProviderConfig", () => { }); }); +describe("applyZaiConfig", () => { + it("adds zai provider with correct settings", () => { + const cfg = applyZaiConfig({}); + expect(cfg.models?.providers?.zai).toMatchObject({ + baseUrl: ZAI_CODING_GLOBAL_BASE_URL, + api: "openai-completions", + }); + const ids = cfg.models?.providers?.zai?.models?.map((m) => m.id); + expect(ids).toContain("glm-5"); + expect(ids).toContain("glm-4.7"); + expect(ids).toContain("glm-4.7-flash"); + expect(ids).toContain("glm-4.7-flashx"); + }); + + it("sets correct primary model", () => { + const cfg = applyZaiConfig({}, { modelId: "glm-5" }); + expect(cfg.agents?.defaults?.model?.primary).toBe("zai/glm-5"); + }); + + it("supports CN endpoint", () => { + const cfg = applyZaiConfig({}, { endpoint: "coding-cn", modelId: "glm-4.7-flash" }); + expect(cfg.models?.providers?.zai?.baseUrl).toBe(ZAI_CODING_CN_BASE_URL); + expect(cfg.agents?.defaults?.model?.primary).toBe("zai/glm-4.7-flash"); + }); + + it("supports CN endpoint with glm-4.7-flashx", () => { + const cfg = applyZaiConfig({}, { endpoint: "coding-cn", modelId: "glm-4.7-flashx" }); + expect(cfg.models?.providers?.zai?.baseUrl).toBe(ZAI_CODING_CN_BASE_URL); + expect(cfg.agents?.defaults?.model?.primary).toBe("zai/glm-4.7-flashx"); + }); +}); + +describe("applyZaiProviderConfig", () => { + it("does not overwrite existing primary model", () => { + const cfg = applyZaiProviderConfig({ + agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } }, + }); + expect(cfg.agents?.defaults?.model?.primary).toBe("anthropic/claude-opus-4-5"); + }); +}); + describe("applySyntheticConfig", () => { it("adds synthetic provider with correct settings", () => { const cfg = applySyntheticConfig({}); diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts index f0abdb9877..71c287d7fd 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -32,6 +32,7 @@ export { applyXiaomiConfig, applyXiaomiProviderConfig, applyZaiConfig, + applyZaiProviderConfig, } from "./onboard-auth.config-core.js"; export { applyMinimaxApiConfig, @@ -78,6 +79,7 @@ export { buildMinimaxApiModelDefinition, buildMinimaxModelDefinition, buildMoonshotModelDefinition, + buildZaiModelDefinition, DEFAULT_MINIMAX_BASE_URL, MOONSHOT_CN_BASE_URL, QIANFAN_BASE_URL, @@ -91,4 +93,10 @@ export { MOONSHOT_BASE_URL, MOONSHOT_DEFAULT_MODEL_ID, MOONSHOT_DEFAULT_MODEL_REF, + resolveZaiBaseUrl, + ZAI_CODING_CN_BASE_URL, + ZAI_DEFAULT_MODEL_ID, + ZAI_CODING_GLOBAL_BASE_URL, + ZAI_CN_BASE_URL, + ZAI_GLOBAL_BASE_URL, } from "./onboard-auth.models.js"; diff --git a/src/commands/onboard-non-interactive.provider-auth.test.ts b/src/commands/onboard-non-interactive.provider-auth.test.ts index 246c65c0ab..aeb64ff777 100644 --- a/src/commands/onboard-non-interactive.provider-auth.test.ts +++ b/src/commands/onboard-non-interactive.provider-auth.test.ts @@ -139,6 +139,60 @@ async function expectApiKeyProfile(params: { } describe("onboard (non-interactive): provider auth", () => { + it("stores Z.AI API key and uses coding-global baseUrl by default", async () => { + await withOnboardEnv("openclaw-onboard-zai-", async ({ configPath, runtime }) => { + await runNonInteractive( + { + nonInteractive: true, + authChoice: "zai-api-key", + zaiApiKey: "zai-test-key", + skipHealth: true, + skipChannels: true, + skipSkills: true, + json: true, + }, + runtime, + ); + + const cfg = await readJsonFile<{ + auth?: { profiles?: Record }; + agents?: { defaults?: { model?: { primary?: string } } }; + models?: { providers?: Record }; + }>(configPath); + + expect(cfg.auth?.profiles?.["zai:default"]?.provider).toBe("zai"); + expect(cfg.auth?.profiles?.["zai:default"]?.mode).toBe("api_key"); + expect(cfg.models?.providers?.zai?.baseUrl).toBe("https://api.z.ai/api/coding/paas/v4"); + expect(cfg.agents?.defaults?.model?.primary).toBe("zai/glm-4.7"); + await expectApiKeyProfile({ profileId: "zai:default", provider: "zai", key: "zai-test-key" }); + }); + }, 60_000); + + it("supports Z.AI CN coding endpoint auth choice", async () => { + await withOnboardEnv("openclaw-onboard-zai-cn-", async ({ configPath, runtime }) => { + await runNonInteractive( + { + nonInteractive: true, + authChoice: "zai-coding-cn", + zaiApiKey: "zai-test-key", + skipHealth: true, + skipChannels: true, + skipSkills: true, + json: true, + }, + runtime, + ); + + const cfg = await readJsonFile<{ + models?: { providers?: Record }; + }>(configPath); + + expect(cfg.models?.providers?.zai?.baseUrl).toBe( + "https://open.bigmodel.cn/api/coding/paas/v4", + ); + }); + }, 60_000); + it("stores xAI API key and sets default model", async () => { await withOnboardEnv("openclaw-onboard-xai-", async ({ configPath, runtime }) => { await runNonInteractive( diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index a2744b56cd..5de4819908 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -187,7 +187,13 @@ export async function applyNonInteractiveAuthChoice(params: { return applyGoogleGeminiModelDefault(nextConfig).next; } - if (authChoice === "zai-api-key") { + if ( + authChoice === "zai-api-key" || + authChoice === "zai-coding-global" || + authChoice === "zai-coding-cn" || + authChoice === "zai-global" || + authChoice === "zai-cn" + ) { const resolved = await resolveNonInteractiveApiKey({ provider: "zai", cfg: baseConfig, @@ -207,7 +213,21 @@ export async function applyNonInteractiveAuthChoice(params: { provider: "zai", mode: "api_key", }); - return applyZaiConfig(nextConfig); + + // Determine endpoint from authChoice or opts + let endpoint: "global" | "cn" | "coding-global" | "coding-cn" | undefined; + if (authChoice === "zai-coding-global") { + endpoint = "coding-global"; + } else if (authChoice === "zai-coding-cn") { + endpoint = "coding-cn"; + } else if (authChoice === "zai-global") { + endpoint = "global"; + } else if (authChoice === "zai-cn") { + endpoint = "cn"; + } else { + endpoint = "coding-global"; + } + return applyZaiConfig(nextConfig, { endpoint }); } if (authChoice === "xiaomi-api-key") { diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index 70102902e1..84cf9e8247 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -27,6 +27,10 @@ export type AuthChoice = | "google-antigravity" | "google-gemini-cli" | "zai-api-key" + | "zai-coding-global" + | "zai-coding-cn" + | "zai-global" + | "zai-cn" | "xiaomi-api-key" | "minimax-cloud" | "minimax"