feat(zai): auto-detect endpoint + default glm-5 (#14786)

* feat(zai): auto-detect endpoint + default glm-5

* test: fix Z.AI default endpoint expectation (#14786)

* test: bump embedded runner beforeAll timeout

* chore: update changelog for Z.AI GLM-5 autodetect (#14786)

* chore: resolve changelog merge conflict with main (#14786)

* chore: append changelog note for #14786 without merge conflict

* chore: sync changelog with main to resolve merge conflict
This commit is contained in:
Peter Steinberger
2026-02-12 19:16:04 +01:00
committed by GitHub
parent 2b5df1dfea
commit 5e7842a41d
15 changed files with 406 additions and 66 deletions

View File

@@ -41,6 +41,9 @@ openclaw onboard --non-interactive \
Non-interactive Z.AI endpoint choices:
Note: `--auth-choice zai-api-key` now auto-detects the best Z.AI endpoint for your key (prefers the general API with `zai/glm-5`).
If you specifically want the GLM Coding Plan endpoints, pick `zai-coding-global` or `zai-coding-cn`.
```bash
# Promptless endpoint selection
openclaw onboard --non-interactive \

View File

@@ -9,7 +9,7 @@ title: "GLM Models"
# GLM models
GLM is a **model family** (not a company) available through the Z.AI platform. In OpenClaw, GLM
models are accessed via the `zai` provider and model IDs like `zai/glm-4.7`.
models are accessed via the `zai` provider and model IDs like `zai/glm-5`.
## CLI setup
@@ -22,12 +22,12 @@ openclaw onboard --auth-choice zai-api-key
```json5
{
env: { ZAI_API_KEY: "sk-..." },
agents: { defaults: { model: { primary: "zai/glm-4.7" } } },
agents: { defaults: { model: { primary: "zai/glm-5" } } },
}
```
## Notes
- GLM versions and availability can change; check Z.AI's docs for the latest.
- Example model IDs include `glm-4.7` and `glm-4.6`.
- Example model IDs include `glm-5`, `glm-4.7`, and `glm-4.6`.
- For provider details, see [/providers/zai](/providers/zai).

View File

@@ -25,12 +25,12 @@ openclaw onboard --zai-api-key "$ZAI_API_KEY"
```json5
{
env: { ZAI_API_KEY: "sk-..." },
agents: { defaults: { model: { primary: "zai/glm-4.7" } } },
agents: { defaults: { model: { primary: "zai/glm-5" } } },
}
```
## Notes
- GLM models are available as `zai/<model>` (example: `zai/glm-4.7`).
- GLM models are available as `zai/<model>` (example: `zai/glm-5`).
- See [/providers/glm](/providers/glm) for the model family overview.
- Z.AI uses Bearer auth with your API key.

View File

@@ -104,7 +104,7 @@ beforeAll(async () => {
workspaceDir = path.join(tempRoot, "workspace");
await fs.mkdir(agentDir, { recursive: true });
await fs.mkdir(workspaceDir, { recursive: true });
}, 20_000);
}, 60_000);
afterAll(async () => {
if (!tempRoot) {

View File

@@ -242,6 +242,41 @@ describe("resolveModel", () => {
});
});
it("builds a zai forward-compat fallback for glm-5", () => {
const templateModel = {
id: "glm-4.7",
name: "GLM-4.7",
provider: "zai",
api: "openai-completions",
baseUrl: "https://api.z.ai/api/paas/v4",
reasoning: true,
input: ["text"] as const,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 131072,
};
vi.mocked(discoverModels).mockReturnValue({
find: vi.fn((provider: string, modelId: string) => {
if (provider === "zai" && modelId === "glm-4.7") {
return templateModel;
}
return null;
}),
} as unknown as ReturnType<typeof discoverModels>);
const result = resolveModel("zai", "glm-5", "/tmp/agent");
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
provider: "zai",
id: "glm-5",
api: "openai-completions",
baseUrl: "https://api.z.ai/api/paas/v4",
reasoning: true,
});
});
it("keeps unknown-model errors for non-gpt-5 openai-codex ids", () => {
const result = resolveModel("openai-codex", "gpt-4.1-mini", "/tmp/agent");
expect(result.model).toBeUndefined();

View File

@@ -114,6 +114,51 @@ function resolveAnthropicOpus46ForwardCompatModel(
return undefined;
}
// Z.ai's GLM-5 may not be present in pi-ai's built-in model catalog yet.
// When a user configures zai/glm-5 without a models.json entry, clone glm-4.7 as a forward-compat fallback.
const ZAI_GLM5_MODEL_ID = "glm-5";
const ZAI_GLM5_TEMPLATE_MODEL_IDS = ["glm-4.7"] as const;
function resolveZaiGlm5ForwardCompatModel(
provider: string,
modelId: string,
modelRegistry: ModelRegistry,
): Model<Api> | undefined {
if (normalizeProviderId(provider) !== "zai") {
return undefined;
}
const trimmed = modelId.trim();
const lower = trimmed.toLowerCase();
if (lower !== ZAI_GLM5_MODEL_ID && !lower.startsWith(`${ZAI_GLM5_MODEL_ID}-`)) {
return undefined;
}
for (const templateId of ZAI_GLM5_TEMPLATE_MODEL_IDS) {
const template = modelRegistry.find("zai", templateId) as Model<Api> | null;
if (!template) {
continue;
}
return normalizeModelCompat({
...template,
id: trimmed,
name: trimmed,
reasoning: true,
} as Model<Api>);
}
return normalizeModelCompat({
id: trimmed,
name: trimmed,
api: "openai-completions",
provider: "zai",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: DEFAULT_CONTEXT_TOKENS,
maxTokens: DEFAULT_CONTEXT_TOKENS,
} as Model<Api>);
}
// google-antigravity's model catalog in pi-ai can lag behind the actual platform.
// When a google-antigravity model ID contains "opus-4-6" (or "opus-4.6") but isn't
// in the registry yet, clone the opus-4-5 template so the correct api
@@ -242,6 +287,10 @@ export function resolveModel(
if (antigravityForwardCompat) {
return { model: antigravityForwardCompat, authStorage, modelRegistry };
}
const zaiForwardCompat = resolveZaiGlm5ForwardCompatModel(provider, modelId, modelRegistry);
if (zaiForwardCompat) {
return { model: zaiForwardCompat, authStorage, modelRegistry };
}
const providerCfg = providers[provider];
if (providerCfg || modelId.startsWith("mock-")) {
const fallbackModel: Model<Api> = normalizeModelCompat({

View File

@@ -69,6 +69,7 @@ import {
ZAI_DEFAULT_MODEL_REF,
} from "./onboard-auth.js";
import { OPENCODE_ZEN_DEFAULT_MODEL } from "./opencode-zen-model-default.js";
import { detectZaiEndpoint } from "./zai-endpoint-detect.js";
export async function applyAuthChoiceApiProviders(
params: ApplyAuthChoiceParams,
@@ -627,8 +628,7 @@ export async function applyAuthChoiceApiProviders(
authChoice === "zai-global" ||
authChoice === "zai-cn"
) {
// Determine endpoint from authChoice or prompt
let endpoint: string;
let endpoint: "global" | "cn" | "coding-global" | "coding-cn" | undefined;
if (authChoice === "zai-coding-global") {
endpoint = "coding-global";
} else if (authChoice === "zai-coding-cn") {
@@ -637,41 +637,15 @@ export async function applyAuthChoiceApiProviders(
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;
let apiKey = "";
if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "zai") {
await setZaiApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir);
apiKey = normalizeApiKeyInput(params.opts.token);
await setZaiApiKey(apiKey, params.agentDir);
hasCredential = true;
}
@@ -682,7 +656,8 @@ export async function applyAuthChoiceApiProviders(
initialValue: true,
});
if (useExisting) {
await setZaiApiKey(envKey.apiKey, params.agentDir);
apiKey = envKey.apiKey;
await setZaiApiKey(apiKey, params.agentDir);
hasCredential = true;
}
}
@@ -691,27 +666,76 @@ export async function applyAuthChoiceApiProviders(
message: "Enter Z.AI API key",
validate: validateApiKeyInput,
});
await setZaiApiKey(normalizeApiKeyInput(String(key)), params.agentDir);
apiKey = normalizeApiKeyInput(String(key));
await setZaiApiKey(apiKey, params.agentDir);
}
// zai-api-key: auto-detect endpoint + choose a working default model.
let modelIdOverride: string | undefined;
if (!endpoint) {
const detected = await detectZaiEndpoint({ apiKey });
if (detected) {
endpoint = detected.endpoint;
modelIdOverride = detected.modelId;
await params.prompter.note(detected.note, "Z.AI endpoint");
} else {
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: "global",
});
}
}
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "zai:default",
provider: "zai",
mode: "api_key",
});
{
const applied = await applyDefaultModelChoice({
config: nextConfig,
setDefaultModel: params.setDefaultModel,
defaultModel: ZAI_DEFAULT_MODEL_REF,
applyDefaultConfig: (config) => applyZaiConfig(config, { endpoint }),
applyProviderConfig: (config) => applyZaiProviderConfig(config, { endpoint }),
noteDefault: ZAI_DEFAULT_MODEL_REF,
noteAgentModel,
prompter: params.prompter,
});
nextConfig = applied.config;
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
}
const defaultModel = modelIdOverride ? `zai/${modelIdOverride}` : ZAI_DEFAULT_MODEL_REF;
const applied = await applyDefaultModelChoice({
config: nextConfig,
setDefaultModel: params.setDefaultModel,
defaultModel,
applyDefaultConfig: (config) =>
applyZaiConfig(config, {
endpoint,
...(modelIdOverride ? { modelId: modelIdOverride } : {}),
}),
applyProviderConfig: (config) =>
applyZaiProviderConfig(config, {
endpoint,
...(modelIdOverride ? { modelId: modelIdOverride } : {}),
}),
noteDefault: defaultModel,
noteAgentModel,
prompter: params.prompter,
});
nextConfig = applied.config;
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
return { config: nextConfig, agentModelOverride };
}

View File

@@ -241,10 +241,10 @@ describe("applyAuthChoice", () => {
});
expect(select).toHaveBeenCalledWith(
expect.objectContaining({ message: "Select Z.AI endpoint", initialValue: "coding-global" }),
expect.objectContaining({ message: "Select Z.AI endpoint", initialValue: "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");
expect(result.config.agents?.defaults?.model?.primary).toBe("zai/glm-5");
const authProfilePath = authProfilePathFor(requireAgentDir());
const raw = await fs.readFile(authProfilePath, "utf8");

View File

@@ -115,7 +115,7 @@ export async function setVeniceApiKey(key: string, agentDir?: string) {
});
}
export const ZAI_DEFAULT_MODEL_REF = "zai/glm-4.7";
export const ZAI_DEFAULT_MODEL_REF = "zai/glm-5";
export const XIAOMI_DEFAULT_MODEL_REF = "xiaomi/mimo-v2-flash";
export const OPENROUTER_DEFAULT_MODEL_REF = "openrouter/auto";
export const TOGETHER_DEFAULT_MODEL_REF = "together/moonshotai/Kimi-K2.5";

View File

@@ -24,7 +24,7 @@ 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 const ZAI_DEFAULT_MODEL_ID = "glm-5";
export function resolveZaiBaseUrl(endpoint?: string): string {
switch (endpoint) {
@@ -35,8 +35,9 @@ export function resolveZaiBaseUrl(endpoint?: string): string {
case "cn":
return ZAI_CN_BASE_URL;
case "coding-global":
default:
return ZAI_CODING_GLOBAL_BASE_URL;
default:
return ZAI_GLOBAL_BASE_URL;
}
}

View File

@@ -27,7 +27,7 @@ import {
setMinimaxApiKey,
writeOAuthCredentials,
ZAI_CODING_CN_BASE_URL,
ZAI_CODING_GLOBAL_BASE_URL,
ZAI_GLOBAL_BASE_URL,
} from "./onboard-auth.js";
const authProfilePathFor = (agentDir: string) => path.join(agentDir, "auth-profiles.json");
@@ -311,7 +311,8 @@ describe("applyZaiConfig", () => {
it("adds zai provider with correct settings", () => {
const cfg = applyZaiConfig({});
expect(cfg.models?.providers?.zai).toMatchObject({
baseUrl: ZAI_CODING_GLOBAL_BASE_URL,
// Default: general (non-coding) endpoint. Coding Plan endpoint is detected during onboarding.
baseUrl: ZAI_GLOBAL_BASE_URL,
api: "openai-completions",
});
const ids = cfg.models?.providers?.zai?.models?.map((m) => m.id);

View File

@@ -156,7 +156,7 @@ async function expectApiKeyProfile(params: {
}
describe("onboard (non-interactive): provider auth", () => {
it("stores Z.AI API key and uses coding-global baseUrl by default", async () => {
it("stores Z.AI API key and uses global baseUrl by default", async () => {
await withOnboardEnv("openclaw-onboard-zai-", async ({ configPath, runtime }) => {
await runNonInteractive(
{
@@ -179,8 +179,8 @@ describe("onboard (non-interactive): provider auth", () => {
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");
expect(cfg.models?.providers?.zai?.baseUrl).toBe("https://api.z.ai/api/paas/v4");
expect(cfg.agents?.defaults?.model?.primary).toBe("zai/glm-5");
await expectApiKeyProfile({ profileId: "zai:default", provider: "zai", key: "zai-test-key" });
});
}, 60_000);

View File

@@ -53,6 +53,7 @@ import {
resolveCustomProviderId,
} from "../../onboard-custom.js";
import { applyOpenAIConfig } from "../../openai-model-default.js";
import { detectZaiEndpoint } from "../../zai-endpoint-detect.js";
import { resolveNonInteractiveApiKey } from "../api-keys.js";
export async function applyNonInteractiveAuthChoice(params: {
@@ -214,8 +215,10 @@ export async function applyNonInteractiveAuthChoice(params: {
mode: "api_key",
});
// Determine endpoint from authChoice or opts
// Determine endpoint from authChoice or detect from the API key.
let endpoint: "global" | "cn" | "coding-global" | "coding-cn" | undefined;
let modelIdOverride: string | undefined;
if (authChoice === "zai-coding-global") {
endpoint = "coding-global";
} else if (authChoice === "zai-coding-cn") {
@@ -225,9 +228,19 @@ export async function applyNonInteractiveAuthChoice(params: {
} else if (authChoice === "zai-cn") {
endpoint = "cn";
} else {
endpoint = "coding-global";
const detected = await detectZaiEndpoint({ apiKey: resolved.key });
if (detected) {
endpoint = detected.endpoint;
modelIdOverride = detected.modelId;
} else {
endpoint = "global";
}
}
return applyZaiConfig(nextConfig, { endpoint });
return applyZaiConfig(nextConfig, {
endpoint,
...(modelIdOverride ? { modelId: modelIdOverride } : {}),
});
}
if (authChoice === "xiaomi-api-key") {

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { detectZaiEndpoint } from "./zai-endpoint-detect.js";
function makeFetch(map: Record<string, { status: number; body?: unknown }>) {
return (async (url: string) => {
const entry = map[url];
if (!entry) {
throw new Error(`unexpected url: ${url}`);
}
const json = entry.body ?? {};
return new Response(JSON.stringify(json), {
status: entry.status,
headers: { "content-type": "application/json" },
});
}) as typeof fetch;
}
describe("detectZaiEndpoint", () => {
it("prefers global glm-5 when it works", async () => {
const fetchFn = makeFetch({
"https://api.z.ai/api/paas/v4/chat/completions": { status: 200 },
});
const detected = await detectZaiEndpoint({ apiKey: "sk-test", fetchFn });
expect(detected?.endpoint).toBe("global");
expect(detected?.modelId).toBe("glm-5");
});
it("falls back to cn glm-5 when global fails", async () => {
const fetchFn = makeFetch({
"https://api.z.ai/api/paas/v4/chat/completions": {
status: 404,
body: { error: { message: "not found" } },
},
"https://open.bigmodel.cn/api/paas/v4/chat/completions": { status: 200 },
});
const detected = await detectZaiEndpoint({ apiKey: "sk-test", fetchFn });
expect(detected?.endpoint).toBe("cn");
expect(detected?.modelId).toBe("glm-5");
});
it("falls back to coding endpoint with glm-4.7", async () => {
const fetchFn = makeFetch({
"https://api.z.ai/api/paas/v4/chat/completions": { status: 404 },
"https://open.bigmodel.cn/api/paas/v4/chat/completions": { status: 404 },
"https://api.z.ai/api/coding/paas/v4/chat/completions": { status: 200 },
});
const detected = await detectZaiEndpoint({ apiKey: "sk-test", fetchFn });
expect(detected?.endpoint).toBe("coding-global");
expect(detected?.modelId).toBe("glm-4.7");
});
it("returns null when nothing works", async () => {
const fetchFn = makeFetch({
"https://api.z.ai/api/paas/v4/chat/completions": { status: 401 },
"https://open.bigmodel.cn/api/paas/v4/chat/completions": { status: 401 },
"https://api.z.ai/api/coding/paas/v4/chat/completions": { status: 401 },
"https://open.bigmodel.cn/api/coding/paas/v4/chat/completions": { status: 401 },
});
const detected = await detectZaiEndpoint({ apiKey: "sk-test", fetchFn });
expect(detected).toBe(null);
});
});

View File

@@ -0,0 +1,148 @@
import { fetchWithTimeout } from "../utils/fetch-timeout.js";
import {
ZAI_CN_BASE_URL,
ZAI_CODING_CN_BASE_URL,
ZAI_CODING_GLOBAL_BASE_URL,
ZAI_GLOBAL_BASE_URL,
} from "./onboard-auth.models.js";
export type ZaiEndpointId = "global" | "cn" | "coding-global" | "coding-cn";
export type ZaiDetectedEndpoint = {
endpoint: ZaiEndpointId;
/** Provider baseUrl to store in config. */
baseUrl: string;
/** Recommended default model id for that endpoint. */
modelId: string;
/** Human-readable note explaining the choice. */
note: string;
};
type ProbeResult =
| { ok: true }
| {
ok: false;
status?: number;
errorCode?: string;
errorMessage?: string;
};
async function probeZaiChatCompletions(params: {
baseUrl: string;
apiKey: string;
modelId: string;
timeoutMs: number;
fetchFn?: typeof fetch;
}): Promise<ProbeResult> {
try {
const res = await fetchWithTimeout(
`${params.baseUrl}/chat/completions`,
{
method: "POST",
headers: {
authorization: `Bearer ${params.apiKey}`,
"content-type": "application/json",
},
body: JSON.stringify({
model: params.modelId,
stream: false,
max_tokens: 1,
messages: [{ role: "user", content: "ping" }],
}),
},
params.timeoutMs,
params.fetchFn,
);
if (res.ok) {
return { ok: true };
}
let errorCode: string | undefined;
let errorMessage: string | undefined;
try {
const json = (await res.json()) as {
error?: { code?: unknown; message?: unknown };
msg?: unknown;
message?: unknown;
};
const code = json?.error?.code;
const msg = json?.error?.message ?? json?.msg ?? json?.message;
if (typeof code === "string") {
errorCode = code;
} else if (typeof code === "number") {
errorCode = String(code);
}
if (typeof msg === "string") {
errorMessage = msg;
}
} catch {
// ignore
}
return { ok: false, status: res.status, errorCode, errorMessage };
} catch {
return { ok: false };
}
}
export async function detectZaiEndpoint(params: {
apiKey: string;
timeoutMs?: number;
fetchFn?: typeof fetch;
}): Promise<ZaiDetectedEndpoint | null> {
// Never auto-probe in vitest; it would create flaky network behavior.
if (process.env.VITEST && !params.fetchFn) {
return null;
}
const timeoutMs = params.timeoutMs ?? 5_000;
// Prefer GLM-5 on the general API endpoints.
const glm5: Array<{ endpoint: ZaiEndpointId; baseUrl: string }> = [
{ endpoint: "global", baseUrl: ZAI_GLOBAL_BASE_URL },
{ endpoint: "cn", baseUrl: ZAI_CN_BASE_URL },
];
for (const candidate of glm5) {
const result = await probeZaiChatCompletions({
baseUrl: candidate.baseUrl,
apiKey: params.apiKey,
modelId: "glm-5",
timeoutMs,
fetchFn: params.fetchFn,
});
if (result.ok) {
return {
endpoint: candidate.endpoint,
baseUrl: candidate.baseUrl,
modelId: "glm-5",
note: `Verified GLM-5 on ${candidate.endpoint} endpoint.`,
};
}
}
// Fallback: Coding Plan endpoint (GLM-5 not available there).
const coding: Array<{ endpoint: ZaiEndpointId; baseUrl: string }> = [
{ endpoint: "coding-global", baseUrl: ZAI_CODING_GLOBAL_BASE_URL },
{ endpoint: "coding-cn", baseUrl: ZAI_CODING_CN_BASE_URL },
];
for (const candidate of coding) {
const result = await probeZaiChatCompletions({
baseUrl: candidate.baseUrl,
apiKey: params.apiKey,
modelId: "glm-4.7",
timeoutMs,
fetchFn: params.fetchFn,
});
if (result.ok) {
return {
endpoint: candidate.endpoint,
baseUrl: candidate.baseUrl,
modelId: "glm-4.7",
note: "Coding Plan endpoint detected; GLM-5 is not available there. Defaulting to GLM-4.7.",
};
}
}
return null;
}