fix(zai): align explicit coding endpoint setup with detected model defaults (#45969)

* fix: align Z.AI coding onboarding with endpoint docs

* fix: align Z.AI coding onboarding with endpoint docs (#45969)
This commit is contained in:
Ayaan Zaidi 2026-03-14 16:20:37 +05:30 committed by GitHub
parent 439c21e078
commit c79c4ffbfb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 212 additions and 60 deletions

View File

@ -9,6 +9,10 @@ Docs: https://docs.openclaw.ai
- Placeholder: replace with the first 2026.3.14 user-facing change. - Placeholder: replace with the first 2026.3.14 user-facing change.
- Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob. - Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob.
### Fixes
- Z.AI/onboarding: detect a working default model even for explicit `zai-coding-*` endpoint choices, so Coding Plan setup can keep the selected endpoint while defaulting to `glm-5` when available or `glm-4.7` as fallback. (#45969)
## 2026.3.13 ## 2026.3.13
### Changes ### Changes

View File

@ -14,7 +14,17 @@ models are accessed via the `zai` provider and model IDs like `zai/glm-5`.
## CLI setup ## CLI setup
```bash ```bash
openclaw onboard --auth-choice zai-api-key # Coding Plan Global, recommended for Coding Plan users
openclaw onboard --auth-choice zai-coding-global
# Coding Plan CN (China region), recommended for Coding Plan users
openclaw onboard --auth-choice zai-coding-cn
# General API
openclaw onboard --auth-choice zai-global
# General API CN (China region)
openclaw onboard --auth-choice zai-cn
``` ```
## Config snippet ## Config snippet

View File

@ -15,9 +15,17 @@ with a Z.AI API key.
## CLI setup ## CLI setup
```bash ```bash
openclaw onboard --auth-choice zai-api-key # Coding Plan Global, recommended for Coding Plan users
# or non-interactive openclaw onboard --auth-choice zai-coding-global
openclaw onboard --zai-api-key "$ZAI_API_KEY"
# Coding Plan CN (China region), recommended for Coding Plan users
openclaw onboard --auth-choice zai-coding-cn
# General API
openclaw onboard --auth-choice zai-global
# General API CN (China region)
openclaw onboard --auth-choice zai-cn
``` ```
## Config snippet ## Config snippet

View File

@ -245,9 +245,15 @@ export async function applyAuthChoiceApiProviders(
setZaiApiKey(apiKey, params.agentDir, { secretInputMode: mode }), setZaiApiKey(apiKey, params.agentDir, { secretInputMode: mode }),
}); });
// zai-api-key: auto-detect endpoint + choose a working default model.
let modelIdOverride: string | undefined; let modelIdOverride: string | undefined;
if (!endpoint) { if (endpoint) {
const detected = await detectZaiEndpoint({ apiKey, endpoint });
if (detected) {
modelIdOverride = detected.modelId;
await params.prompter.note(detected.note, "Z.AI endpoint");
}
} else {
// zai-api-key: auto-detect endpoint + choose a working default model.
const detected = await detectZaiEndpoint({ apiKey }); const detected = await detectZaiEndpoint({ apiKey });
if (detected) { if (detected) {
endpoint = detected.endpoint; endpoint = detected.endpoint;

View File

@ -285,7 +285,7 @@ describe("applyAuthChoice", () => {
expectedBaseUrl: string; expectedBaseUrl: string;
expectedModel?: string; expectedModel?: string;
shouldPromptForEndpoint: boolean; shouldPromptForEndpoint: boolean;
shouldAssertDetectCall?: boolean; expectedDetectCall?: { apiKey: string; endpoint?: "coding-global" | "coding-cn" };
}> = [ }> = [
{ {
authChoice: "zai-api-key", authChoice: "zai-api-key",
@ -298,8 +298,16 @@ describe("applyAuthChoice", () => {
{ {
authChoice: "zai-coding-global", authChoice: "zai-coding-global",
token: "zai-test-key", token: "zai-test-key",
detectResult: {
endpoint: "coding-global",
modelId: "glm-4.7",
baseUrl: ZAI_CODING_GLOBAL_BASE_URL,
note: "Detected coding-global endpoint with GLM-4.7 fallback",
},
expectedBaseUrl: ZAI_CODING_GLOBAL_BASE_URL, expectedBaseUrl: ZAI_CODING_GLOBAL_BASE_URL,
expectedModel: "zai/glm-4.7",
shouldPromptForEndpoint: false, shouldPromptForEndpoint: false,
expectedDetectCall: { apiKey: "zai-test-key", endpoint: "coding-global" },
}, },
{ {
authChoice: "zai-api-key", authChoice: "zai-api-key",
@ -313,7 +321,7 @@ describe("applyAuthChoice", () => {
expectedBaseUrl: ZAI_CODING_GLOBAL_BASE_URL, expectedBaseUrl: ZAI_CODING_GLOBAL_BASE_URL,
expectedModel: "zai/glm-4.5", expectedModel: "zai/glm-4.5",
shouldPromptForEndpoint: false, shouldPromptForEndpoint: false,
shouldAssertDetectCall: true, expectedDetectCall: { apiKey: "zai-detected-key" },
}, },
]; ];
for (const scenario of scenarios) { for (const scenario of scenarios) {
@ -344,8 +352,8 @@ describe("applyAuthChoice", () => {
setDefaultModel: true, setDefaultModel: true,
}); });
if (scenario.shouldAssertDetectCall) { if (scenario.expectedDetectCall) {
expect(detectZaiEndpoint).toHaveBeenCalledWith({ apiKey: scenario.token }); expect(detectZaiEndpoint).toHaveBeenCalledWith(scenario.expectedDetectCall);
} }
if (scenario.shouldPromptForEndpoint) { if (scenario.shouldPromptForEndpoint) {
expect(select).toHaveBeenCalledWith( expect(select).toHaveBeenCalledWith(

View File

@ -1,7 +1,7 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { setTimeout as delay } from "node:timers/promises"; import { setTimeout as delay } from "node:timers/promises";
import { beforeAll, describe, expect, it, vi } from "vitest"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { makeTempWorkspace } from "../test-helpers/workspace.js"; import { makeTempWorkspace } from "../test-helpers/workspace.js";
import { withEnvAsync } from "../test-utils/env.js"; import { withEnvAsync } from "../test-utils/env.js";
import { MINIMAX_API_BASE_URL, MINIMAX_CN_API_BASE_URL } from "./onboard-auth.js"; import { MINIMAX_API_BASE_URL, MINIMAX_CN_API_BASE_URL } from "./onboard-auth.js";
@ -18,6 +18,8 @@ type OnboardEnv = {
}; };
const ensureWorkspaceAndSessionsMock = vi.hoisted(() => vi.fn(async (..._args: unknown[]) => {})); const ensureWorkspaceAndSessionsMock = vi.hoisted(() => vi.fn(async (..._args: unknown[]) => {}));
type DetectZaiEndpoint = typeof import("./zai-endpoint-detect.js").detectZaiEndpoint;
const detectZaiEndpoint = vi.hoisted(() => vi.fn<DetectZaiEndpoint>(async () => null));
vi.mock("./onboard-helpers.js", async (importOriginal) => { vi.mock("./onboard-helpers.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./onboard-helpers.js")>(); const actual = await importOriginal<typeof import("./onboard-helpers.js")>();
@ -27,6 +29,10 @@ vi.mock("./onboard-helpers.js", async (importOriginal) => {
}; };
}); });
vi.mock("./zai-endpoint-detect.js", () => ({
detectZaiEndpoint,
}));
const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js"); const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js");
const NON_INTERACTIVE_DEFAULT_OPTIONS = { const NON_INTERACTIVE_DEFAULT_OPTIONS = {
@ -180,6 +186,11 @@ describe("onboard (non-interactive): provider auth", () => {
({ ensureAuthProfileStore, upsertAuthProfile } = await import("../agents/auth-profiles.js")); ({ ensureAuthProfileStore, upsertAuthProfile } = await import("../agents/auth-profiles.js"));
}); });
beforeEach(() => {
detectZaiEndpoint.mockReset();
detectZaiEndpoint.mockResolvedValue(null);
});
it("stores MiniMax API key and uses global baseUrl by default", async () => { it("stores MiniMax API key and uses global baseUrl by default", async () => {
await withOnboardEnv("openclaw-onboard-minimax-", async (env) => { await withOnboardEnv("openclaw-onboard-minimax-", async (env) => {
const cfg = await runOnboardingAndReadConfig(env, { const cfg = await runOnboardingAndReadConfig(env, {
@ -220,6 +231,12 @@ describe("onboard (non-interactive): provider auth", () => {
it("stores Z.AI API key and uses global baseUrl by default", async () => { it("stores Z.AI API key and uses global baseUrl by default", async () => {
await withOnboardEnv("openclaw-onboard-zai-", async (env) => { await withOnboardEnv("openclaw-onboard-zai-", async (env) => {
detectZaiEndpoint.mockResolvedValueOnce({
endpoint: "global",
baseUrl: "https://api.z.ai/api/paas/v4",
modelId: "glm-5",
note: "Verified GLM-5 on global endpoint.",
});
const cfg = await runOnboardingAndReadConfig(env, { const cfg = await runOnboardingAndReadConfig(env, {
authChoice: "zai-api-key", authChoice: "zai-api-key",
zaiApiKey: "zai-test-key", // pragma: allowlist secret zaiApiKey: "zai-test-key", // pragma: allowlist secret
@ -235,6 +252,12 @@ describe("onboard (non-interactive): provider auth", () => {
it("supports Z.AI CN coding endpoint auth choice", async () => { it("supports Z.AI CN coding endpoint auth choice", async () => {
await withOnboardEnv("openclaw-onboard-zai-cn-", async (env) => { await withOnboardEnv("openclaw-onboard-zai-cn-", async (env) => {
detectZaiEndpoint.mockResolvedValueOnce({
endpoint: "coding-cn",
baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4",
modelId: "glm-4.7",
note: "Coding Plan CN endpoint verified, but this key/plan does not expose GLM-5 there. Defaulting to GLM-4.7.",
});
const cfg = await runOnboardingAndReadConfig(env, { const cfg = await runOnboardingAndReadConfig(env, {
authChoice: "zai-coding-cn", authChoice: "zai-coding-cn",
zaiApiKey: "zai-test-key", // pragma: allowlist secret zaiApiKey: "zai-test-key", // pragma: allowlist secret
@ -243,6 +266,25 @@ describe("onboard (non-interactive): provider auth", () => {
expect(cfg.models?.providers?.zai?.baseUrl).toBe( expect(cfg.models?.providers?.zai?.baseUrl).toBe(
"https://open.bigmodel.cn/api/coding/paas/v4", "https://open.bigmodel.cn/api/coding/paas/v4",
); );
expect(cfg.agents?.defaults?.model?.primary).toBe("zai/glm-4.7");
});
});
it("supports Z.AI Coding Plan global endpoint with GLM-5 when available", async () => {
await withOnboardEnv("openclaw-onboard-zai-coding-global-", async (env) => {
detectZaiEndpoint.mockResolvedValueOnce({
endpoint: "coding-global",
baseUrl: "https://api.z.ai/api/coding/paas/v4",
modelId: "glm-5",
note: "Verified GLM-5 on coding-global endpoint.",
});
const cfg = await runOnboardingAndReadConfig(env, {
authChoice: "zai-coding-global",
zaiApiKey: "zai-test-key", // pragma: allowlist secret
});
expect(cfg.models?.providers?.zai?.baseUrl).toBe("https://api.z.ai/api/coding/paas/v4");
expect(cfg.agents?.defaults?.model?.primary).toBe("zai/glm-5");
}); });
}); });

View File

@ -291,6 +291,13 @@ export async function applyNonInteractiveAuthChoice(params: {
endpoint = "global"; endpoint = "global";
} else if (authChoice === "zai-cn") { } else if (authChoice === "zai-cn") {
endpoint = "cn"; endpoint = "cn";
}
if (endpoint) {
const detected = await detectZaiEndpoint({ apiKey: resolved.key, endpoint });
if (detected) {
modelIdOverride = detected.modelId;
}
} else { } else {
const detected = await detectZaiEndpoint({ apiKey: resolved.key }); const detected = await detectZaiEndpoint({ apiKey: resolved.key });
if (detected) { if (detected) {

View File

@ -1,11 +1,14 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { detectZaiEndpoint } from "./zai-endpoint-detect.js"; import { detectZaiEndpoint } from "./zai-endpoint-detect.js";
function makeFetch(map: Record<string, { status: number; body?: unknown }>) { type FetchResponse = { status: number; body?: unknown };
return (async (url: string) => {
const entry = map[url]; function makeFetch(map: Record<string, FetchResponse>) {
return (async (url: string, init?: RequestInit) => {
const rawBody = typeof init?.body === "string" ? JSON.parse(init.body) : null;
const entry = map[`${url}::${rawBody?.model ?? ""}`] ?? map[url];
if (!entry) { if (!entry) {
throw new Error(`unexpected url: ${url}`); throw new Error(`unexpected url: ${url} model=${String(rawBody?.model ?? "")}`);
} }
const json = entry.body ?? {}; const json = entry.body ?? {};
return new Response(JSON.stringify(json), { return new Response(JSON.stringify(json), {
@ -18,39 +21,71 @@ function makeFetch(map: Record<string, { status: number; body?: unknown }>) {
describe("detectZaiEndpoint", () => { describe("detectZaiEndpoint", () => {
it("resolves preferred/fallback endpoints and null when probes fail", async () => { it("resolves preferred/fallback endpoints and null when probes fail", async () => {
const scenarios: Array<{ const scenarios: Array<{
endpoint?: "global" | "cn" | "coding-global" | "coding-cn";
responses: Record<string, { status: number; body?: unknown }>; responses: Record<string, { status: number; body?: unknown }>;
expected: { endpoint: string; modelId: string } | null; expected: { endpoint: string; modelId: string } | null;
}> = [ }> = [
{ {
responses: { responses: {
"https://api.z.ai/api/paas/v4/chat/completions": { status: 200 }, "https://api.z.ai/api/paas/v4/chat/completions::glm-5": { status: 200 },
}, },
expected: { endpoint: "global", modelId: "glm-5" }, expected: { endpoint: "global", modelId: "glm-5" },
}, },
{ {
responses: { responses: {
"https://api.z.ai/api/paas/v4/chat/completions": { "https://api.z.ai/api/paas/v4/chat/completions::glm-5": {
status: 404, status: 404,
body: { error: { message: "not found" } }, body: { error: { message: "not found" } },
}, },
"https://open.bigmodel.cn/api/paas/v4/chat/completions": { status: 200 }, "https://open.bigmodel.cn/api/paas/v4/chat/completions::glm-5": { status: 200 },
}, },
expected: { endpoint: "cn", modelId: "glm-5" }, expected: { endpoint: "cn", modelId: "glm-5" },
}, },
{ {
responses: { responses: {
"https://api.z.ai/api/paas/v4/chat/completions": { status: 404 }, "https://api.z.ai/api/paas/v4/chat/completions::glm-5": { status: 404 },
"https://open.bigmodel.cn/api/paas/v4/chat/completions": { status: 404 }, "https://open.bigmodel.cn/api/paas/v4/chat/completions::glm-5": { status: 404 },
"https://api.z.ai/api/coding/paas/v4/chat/completions": { status: 200 }, "https://api.z.ai/api/coding/paas/v4/chat/completions::glm-5": { status: 200 },
},
expected: { endpoint: "coding-global", modelId: "glm-5" },
},
{
endpoint: "coding-global",
responses: {
"https://api.z.ai/api/coding/paas/v4/chat/completions::glm-5": {
status: 404,
body: { error: { message: "glm-5 unavailable" } },
},
"https://api.z.ai/api/coding/paas/v4/chat/completions::glm-4.7": { status: 200 },
}, },
expected: { endpoint: "coding-global", modelId: "glm-4.7" }, expected: { endpoint: "coding-global", modelId: "glm-4.7" },
}, },
{ {
endpoint: "coding-cn",
responses: { responses: {
"https://api.z.ai/api/paas/v4/chat/completions": { status: 401 }, "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions::glm-5": { status: 200 },
"https://open.bigmodel.cn/api/paas/v4/chat/completions": { status: 401 }, },
"https://api.z.ai/api/coding/paas/v4/chat/completions": { status: 401 }, expected: { endpoint: "coding-cn", modelId: "glm-5" },
"https://open.bigmodel.cn/api/coding/paas/v4/chat/completions": { status: 401 }, },
{
endpoint: "coding-cn",
responses: {
"https://open.bigmodel.cn/api/coding/paas/v4/chat/completions::glm-5": {
status: 404,
body: { error: { message: "glm-5 unavailable" } },
},
"https://open.bigmodel.cn/api/coding/paas/v4/chat/completions::glm-4.7": { status: 200 },
},
expected: { endpoint: "coding-cn", modelId: "glm-4.7" },
},
{
responses: {
"https://api.z.ai/api/paas/v4/chat/completions::glm-5": { status: 401 },
"https://open.bigmodel.cn/api/paas/v4/chat/completions::glm-5": { status: 401 },
"https://api.z.ai/api/coding/paas/v4/chat/completions::glm-5": { status: 401 },
"https://api.z.ai/api/coding/paas/v4/chat/completions::glm-4.7": { status: 401 },
"https://open.bigmodel.cn/api/coding/paas/v4/chat/completions::glm-5": { status: 401 },
"https://open.bigmodel.cn/api/coding/paas/v4/chat/completions::glm-4.7": { status: 401 },
}, },
expected: null, expected: null,
}, },
@ -59,6 +94,7 @@ describe("detectZaiEndpoint", () => {
for (const scenario of scenarios) { for (const scenario of scenarios) {
const detected = await detectZaiEndpoint({ const detected = await detectZaiEndpoint({
apiKey: "sk-test", // pragma: allowlist secret apiKey: "sk-test", // pragma: allowlist secret
...(scenario.endpoint ? { endpoint: scenario.endpoint } : {}),
fetchFn: makeFetch(scenario.responses), fetchFn: makeFetch(scenario.responses),
}); });

View File

@ -88,6 +88,7 @@ async function probeZaiChatCompletions(params: {
export async function detectZaiEndpoint(params: { export async function detectZaiEndpoint(params: {
apiKey: string; apiKey: string;
endpoint?: ZaiEndpointId;
timeoutMs?: number; timeoutMs?: number;
fetchFn?: typeof fetch; fetchFn?: typeof fetch;
}): Promise<ZaiDetectedEndpoint | null> { }): Promise<ZaiDetectedEndpoint | null> {
@ -97,50 +98,80 @@ export async function detectZaiEndpoint(params: {
} }
const timeoutMs = params.timeoutMs ?? 5_000; const timeoutMs = params.timeoutMs ?? 5_000;
const probeCandidates = (() => {
// Prefer GLM-5 on the general API endpoints. const general = [
const glm5: Array<{ endpoint: ZaiEndpointId; baseUrl: string }> = [ {
{ endpoint: "global", baseUrl: ZAI_GLOBAL_BASE_URL }, endpoint: "global" as const,
{ endpoint: "cn", baseUrl: ZAI_CN_BASE_URL }, baseUrl: ZAI_GLOBAL_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", modelId: "glm-5",
note: `Verified GLM-5 on ${candidate.endpoint} endpoint.`, note: "Verified GLM-5 on global endpoint.",
}; },
} {
} endpoint: "cn" as const,
baseUrl: ZAI_CN_BASE_URL,
modelId: "glm-5",
note: "Verified GLM-5 on cn endpoint.",
},
];
const codingGlm5 = [
{
endpoint: "coding-global" as const,
baseUrl: ZAI_CODING_GLOBAL_BASE_URL,
modelId: "glm-5",
note: "Verified GLM-5 on coding-global endpoint.",
},
{
endpoint: "coding-cn" as const,
baseUrl: ZAI_CODING_CN_BASE_URL,
modelId: "glm-5",
note: "Verified GLM-5 on coding-cn endpoint.",
},
];
const codingFallback = [
{
endpoint: "coding-global" as const,
baseUrl: ZAI_CODING_GLOBAL_BASE_URL,
modelId: "glm-4.7",
note: "Coding Plan endpoint verified, but this key/plan does not expose GLM-5 there. Defaulting to GLM-4.7.",
},
{
endpoint: "coding-cn" as const,
baseUrl: ZAI_CODING_CN_BASE_URL,
modelId: "glm-4.7",
note: "Coding Plan CN endpoint verified, but this key/plan does not expose GLM-5 there. Defaulting to GLM-4.7.",
},
];
// Fallback: Coding Plan endpoint (GLM-5 not available there). switch (params.endpoint) {
const coding: Array<{ endpoint: ZaiEndpointId; baseUrl: string }> = [ case "global":
{ endpoint: "coding-global", baseUrl: ZAI_CODING_GLOBAL_BASE_URL }, return general.filter((candidate) => candidate.endpoint === "global");
{ endpoint: "coding-cn", baseUrl: ZAI_CODING_CN_BASE_URL }, case "cn":
]; return general.filter((candidate) => candidate.endpoint === "cn");
for (const candidate of coding) { case "coding-global":
return [
...codingGlm5.filter((candidate) => candidate.endpoint === "coding-global"),
...codingFallback.filter((candidate) => candidate.endpoint === "coding-global"),
];
case "coding-cn":
return [
...codingGlm5.filter((candidate) => candidate.endpoint === "coding-cn"),
...codingFallback.filter((candidate) => candidate.endpoint === "coding-cn"),
];
default:
return [...general, ...codingGlm5, ...codingFallback];
}
})();
for (const candidate of probeCandidates) {
const result = await probeZaiChatCompletions({ const result = await probeZaiChatCompletions({
baseUrl: candidate.baseUrl, baseUrl: candidate.baseUrl,
apiKey: params.apiKey, apiKey: params.apiKey,
modelId: "glm-4.7", modelId: candidate.modelId,
timeoutMs, timeoutMs,
fetchFn: params.fetchFn, fetchFn: params.fetchFn,
}); });
if (result.ok) { if (result.ok) {
return { return candidate;
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.",
};
} }
} }