mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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:
committed by
GitHub
parent
2b5df1dfea
commit
5e7842a41d
@@ -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 \
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
66
src/commands/zai-endpoint-detect.test.ts
Normal file
66
src/commands/zai-endpoint-detect.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
148
src/commands/zai-endpoint-detect.ts
Normal file
148
src/commands/zai-endpoint-detect.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user