From 5e7842a41d34848e12ea3d9c0747eab505b9cd95 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 12 Feb 2026 19:16:04 +0100 Subject: [PATCH] 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 --- docs/cli/onboard.md | 3 + docs/providers/glm.md | 6 +- docs/providers/zai.md | 4 +- src/agents/pi-embedded-runner.test.ts | 2 +- src/agents/pi-embedded-runner/model.test.ts | 35 +++++ src/agents/pi-embedded-runner/model.ts | 49 ++++++ .../auth-choice.apply.api-providers.ts | 118 ++++++++------ src/commands/auth-choice.test.ts | 4 +- src/commands/onboard-auth.credentials.ts | 2 +- src/commands/onboard-auth.models.ts | 5 +- src/commands/onboard-auth.test.ts | 5 +- ...oard-non-interactive.provider-auth.test.ts | 6 +- .../local/auth-choice.ts | 19 ++- src/commands/zai-endpoint-detect.test.ts | 66 ++++++++ src/commands/zai-endpoint-detect.ts | 148 ++++++++++++++++++ 15 files changed, 406 insertions(+), 66 deletions(-) create mode 100644 src/commands/zai-endpoint-detect.test.ts create mode 100644 src/commands/zai-endpoint-detect.ts diff --git a/docs/cli/onboard.md b/docs/cli/onboard.md index d2b43bac181..ee6f147f288 100644 --- a/docs/cli/onboard.md +++ b/docs/cli/onboard.md @@ -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 \ diff --git a/docs/providers/glm.md b/docs/providers/glm.md index 4b342667c0a..f65ea81f9da 100644 --- a/docs/providers/glm.md +++ b/docs/providers/glm.md @@ -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). diff --git a/docs/providers/zai.md b/docs/providers/zai.md index b71e8ff90bc..07b8171936a 100644 --- a/docs/providers/zai.md +++ b/docs/providers/zai.md @@ -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/` (example: `zai/glm-4.7`). +- GLM models are available as `zai/` (example: `zai/glm-5`). - See [/providers/glm](/providers/glm) for the model family overview. - Z.AI uses Bearer auth with your API key. diff --git a/src/agents/pi-embedded-runner.test.ts b/src/agents/pi-embedded-runner.test.ts index 205524e1a21..0877412f93a 100644 --- a/src/agents/pi-embedded-runner.test.ts +++ b/src/agents/pi-embedded-runner.test.ts @@ -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) { diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index 9603f1e1e98..5f9ba96a69b 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -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); + + 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(); diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index 4c01aa3dba4..e4b3d5c950f 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -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 | 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 | null; + if (!template) { + continue; + } + return normalizeModelCompat({ + ...template, + id: trimmed, + name: trimmed, + reasoning: true, + } as Model); + } + + 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); +} + // 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 = normalizeModelCompat({ diff --git a/src/commands/auth-choice.apply.api-providers.ts b/src/commands/auth-choice.apply.api-providers.ts index eaad175178a..73cf6d887d3 100644 --- a/src/commands/auth-choice.apply.api-providers.ts +++ b/src/commands/auth-choice.apply.api-providers.ts @@ -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 }; } diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index 9cae3219ee1..1854e5e3a6e 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -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"); diff --git a/src/commands/onboard-auth.credentials.ts b/src/commands/onboard-auth.credentials.ts index ee88ef6b36c..9ffb2626361 100644 --- a/src/commands/onboard-auth.credentials.ts +++ b/src/commands/onboard-auth.credentials.ts @@ -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"; diff --git a/src/commands/onboard-auth.models.ts b/src/commands/onboard-auth.models.ts index 5feed468315..a6ef9b7fea4 100644 --- a/src/commands/onboard-auth.models.ts +++ b/src/commands/onboard-auth.models.ts @@ -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; } } diff --git a/src/commands/onboard-auth.test.ts b/src/commands/onboard-auth.test.ts index 35aa30c857a..eaa1658fa3f 100644 --- a/src/commands/onboard-auth.test.ts +++ b/src/commands/onboard-auth.test.ts @@ -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); diff --git a/src/commands/onboard-non-interactive.provider-auth.test.ts b/src/commands/onboard-non-interactive.provider-auth.test.ts index 6a7f1a94f20..692320eb1fa 100644 --- a/src/commands/onboard-non-interactive.provider-auth.test.ts +++ b/src/commands/onboard-non-interactive.provider-auth.test.ts @@ -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); diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index 5de48199085..b29f44edfc7 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -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") { diff --git a/src/commands/zai-endpoint-detect.test.ts b/src/commands/zai-endpoint-detect.test.ts new file mode 100644 index 00000000000..f1a16eaaaaa --- /dev/null +++ b/src/commands/zai-endpoint-detect.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { detectZaiEndpoint } from "./zai-endpoint-detect.js"; + +function makeFetch(map: Record) { + 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); + }); +}); diff --git a/src/commands/zai-endpoint-detect.ts b/src/commands/zai-endpoint-detect.ts new file mode 100644 index 00000000000..6f53c6c58cc --- /dev/null +++ b/src/commands/zai-endpoint-detect.ts @@ -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 { + 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 { + // 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; +}