mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 11:08:37 +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:
|
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
|
```bash
|
||||||
# Promptless endpoint selection
|
# Promptless endpoint selection
|
||||||
openclaw onboard --non-interactive \
|
openclaw onboard --non-interactive \
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ title: "GLM Models"
|
|||||||
# GLM models
|
# GLM models
|
||||||
|
|
||||||
GLM is a **model family** (not a company) available through the Z.AI platform. In OpenClaw, GLM
|
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
|
## CLI setup
|
||||||
|
|
||||||
@@ -22,12 +22,12 @@ openclaw onboard --auth-choice zai-api-key
|
|||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
env: { ZAI_API_KEY: "sk-..." },
|
env: { ZAI_API_KEY: "sk-..." },
|
||||||
agents: { defaults: { model: { primary: "zai/glm-4.7" } } },
|
agents: { defaults: { model: { primary: "zai/glm-5" } } },
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- GLM versions and availability can change; check Z.AI's docs for the latest.
|
- 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).
|
- For provider details, see [/providers/zai](/providers/zai).
|
||||||
|
|||||||
@@ -25,12 +25,12 @@ openclaw onboard --zai-api-key "$ZAI_API_KEY"
|
|||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
env: { ZAI_API_KEY: "sk-..." },
|
env: { ZAI_API_KEY: "sk-..." },
|
||||||
agents: { defaults: { model: { primary: "zai/glm-4.7" } } },
|
agents: { defaults: { model: { primary: "zai/glm-5" } } },
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Notes
|
## 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.
|
- See [/providers/glm](/providers/glm) for the model family overview.
|
||||||
- Z.AI uses Bearer auth with your API key.
|
- Z.AI uses Bearer auth with your API key.
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ beforeAll(async () => {
|
|||||||
workspaceDir = path.join(tempRoot, "workspace");
|
workspaceDir = path.join(tempRoot, "workspace");
|
||||||
await fs.mkdir(agentDir, { recursive: true });
|
await fs.mkdir(agentDir, { recursive: true });
|
||||||
await fs.mkdir(workspaceDir, { recursive: true });
|
await fs.mkdir(workspaceDir, { recursive: true });
|
||||||
}, 20_000);
|
}, 60_000);
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
if (!tempRoot) {
|
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", () => {
|
it("keeps unknown-model errors for non-gpt-5 openai-codex ids", () => {
|
||||||
const result = resolveModel("openai-codex", "gpt-4.1-mini", "/tmp/agent");
|
const result = resolveModel("openai-codex", "gpt-4.1-mini", "/tmp/agent");
|
||||||
expect(result.model).toBeUndefined();
|
expect(result.model).toBeUndefined();
|
||||||
|
|||||||
@@ -114,6 +114,51 @@ function resolveAnthropicOpus46ForwardCompatModel(
|
|||||||
return undefined;
|
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.
|
// 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
|
// 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
|
// in the registry yet, clone the opus-4-5 template so the correct api
|
||||||
@@ -242,6 +287,10 @@ export function resolveModel(
|
|||||||
if (antigravityForwardCompat) {
|
if (antigravityForwardCompat) {
|
||||||
return { model: antigravityForwardCompat, authStorage, modelRegistry };
|
return { model: antigravityForwardCompat, authStorage, modelRegistry };
|
||||||
}
|
}
|
||||||
|
const zaiForwardCompat = resolveZaiGlm5ForwardCompatModel(provider, modelId, modelRegistry);
|
||||||
|
if (zaiForwardCompat) {
|
||||||
|
return { model: zaiForwardCompat, authStorage, modelRegistry };
|
||||||
|
}
|
||||||
const providerCfg = providers[provider];
|
const providerCfg = providers[provider];
|
||||||
if (providerCfg || modelId.startsWith("mock-")) {
|
if (providerCfg || modelId.startsWith("mock-")) {
|
||||||
const fallbackModel: Model<Api> = normalizeModelCompat({
|
const fallbackModel: Model<Api> = normalizeModelCompat({
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ import {
|
|||||||
ZAI_DEFAULT_MODEL_REF,
|
ZAI_DEFAULT_MODEL_REF,
|
||||||
} from "./onboard-auth.js";
|
} from "./onboard-auth.js";
|
||||||
import { OPENCODE_ZEN_DEFAULT_MODEL } from "./opencode-zen-model-default.js";
|
import { OPENCODE_ZEN_DEFAULT_MODEL } from "./opencode-zen-model-default.js";
|
||||||
|
import { detectZaiEndpoint } from "./zai-endpoint-detect.js";
|
||||||
|
|
||||||
export async function applyAuthChoiceApiProviders(
|
export async function applyAuthChoiceApiProviders(
|
||||||
params: ApplyAuthChoiceParams,
|
params: ApplyAuthChoiceParams,
|
||||||
@@ -627,8 +628,7 @@ export async function applyAuthChoiceApiProviders(
|
|||||||
authChoice === "zai-global" ||
|
authChoice === "zai-global" ||
|
||||||
authChoice === "zai-cn"
|
authChoice === "zai-cn"
|
||||||
) {
|
) {
|
||||||
// Determine endpoint from authChoice or prompt
|
let endpoint: "global" | "cn" | "coding-global" | "coding-cn" | undefined;
|
||||||
let endpoint: string;
|
|
||||||
if (authChoice === "zai-coding-global") {
|
if (authChoice === "zai-coding-global") {
|
||||||
endpoint = "coding-global";
|
endpoint = "coding-global";
|
||||||
} else if (authChoice === "zai-coding-cn") {
|
} else if (authChoice === "zai-coding-cn") {
|
||||||
@@ -637,41 +637,15 @@ export async function applyAuthChoiceApiProviders(
|
|||||||
endpoint = "global";
|
endpoint = "global";
|
||||||
} else if (authChoice === "zai-cn") {
|
} else if (authChoice === "zai-cn") {
|
||||||
endpoint = "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
|
// Input API key
|
||||||
let hasCredential = false;
|
let hasCredential = false;
|
||||||
|
let apiKey = "";
|
||||||
|
|
||||||
if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "zai") {
|
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;
|
hasCredential = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -682,7 +656,8 @@ export async function applyAuthChoiceApiProviders(
|
|||||||
initialValue: true,
|
initialValue: true,
|
||||||
});
|
});
|
||||||
if (useExisting) {
|
if (useExisting) {
|
||||||
await setZaiApiKey(envKey.apiKey, params.agentDir);
|
apiKey = envKey.apiKey;
|
||||||
|
await setZaiApiKey(apiKey, params.agentDir);
|
||||||
hasCredential = true;
|
hasCredential = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -691,27 +666,76 @@ export async function applyAuthChoiceApiProviders(
|
|||||||
message: "Enter Z.AI API key",
|
message: "Enter Z.AI API key",
|
||||||
validate: validateApiKeyInput,
|
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, {
|
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||||
profileId: "zai:default",
|
profileId: "zai:default",
|
||||||
provider: "zai",
|
provider: "zai",
|
||||||
mode: "api_key",
|
mode: "api_key",
|
||||||
});
|
});
|
||||||
{
|
|
||||||
const applied = await applyDefaultModelChoice({
|
const defaultModel = modelIdOverride ? `zai/${modelIdOverride}` : ZAI_DEFAULT_MODEL_REF;
|
||||||
config: nextConfig,
|
const applied = await applyDefaultModelChoice({
|
||||||
setDefaultModel: params.setDefaultModel,
|
config: nextConfig,
|
||||||
defaultModel: ZAI_DEFAULT_MODEL_REF,
|
setDefaultModel: params.setDefaultModel,
|
||||||
applyDefaultConfig: (config) => applyZaiConfig(config, { endpoint }),
|
defaultModel,
|
||||||
applyProviderConfig: (config) => applyZaiProviderConfig(config, { endpoint }),
|
applyDefaultConfig: (config) =>
|
||||||
noteDefault: ZAI_DEFAULT_MODEL_REF,
|
applyZaiConfig(config, {
|
||||||
noteAgentModel,
|
endpoint,
|
||||||
prompter: params.prompter,
|
...(modelIdOverride ? { modelId: modelIdOverride } : {}),
|
||||||
});
|
}),
|
||||||
nextConfig = applied.config;
|
applyProviderConfig: (config) =>
|
||||||
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
|
applyZaiProviderConfig(config, {
|
||||||
}
|
endpoint,
|
||||||
|
...(modelIdOverride ? { modelId: modelIdOverride } : {}),
|
||||||
|
}),
|
||||||
|
noteDefault: defaultModel,
|
||||||
|
noteAgentModel,
|
||||||
|
prompter: params.prompter,
|
||||||
|
});
|
||||||
|
nextConfig = applied.config;
|
||||||
|
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
|
||||||
|
|
||||||
return { config: nextConfig, agentModelOverride };
|
return { config: nextConfig, agentModelOverride };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -241,10 +241,10 @@ describe("applyAuthChoice", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(select).toHaveBeenCalledWith(
|
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.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 authProfilePath = authProfilePathFor(requireAgentDir());
|
||||||
const raw = await fs.readFile(authProfilePath, "utf8");
|
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 XIAOMI_DEFAULT_MODEL_REF = "xiaomi/mimo-v2-flash";
|
||||||
export const OPENROUTER_DEFAULT_MODEL_REF = "openrouter/auto";
|
export const OPENROUTER_DEFAULT_MODEL_REF = "openrouter/auto";
|
||||||
export const TOGETHER_DEFAULT_MODEL_REF = "together/moonshotai/Kimi-K2.5";
|
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_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_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_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 {
|
export function resolveZaiBaseUrl(endpoint?: string): string {
|
||||||
switch (endpoint) {
|
switch (endpoint) {
|
||||||
@@ -35,8 +35,9 @@ export function resolveZaiBaseUrl(endpoint?: string): string {
|
|||||||
case "cn":
|
case "cn":
|
||||||
return ZAI_CN_BASE_URL;
|
return ZAI_CN_BASE_URL;
|
||||||
case "coding-global":
|
case "coding-global":
|
||||||
default:
|
|
||||||
return ZAI_CODING_GLOBAL_BASE_URL;
|
return ZAI_CODING_GLOBAL_BASE_URL;
|
||||||
|
default:
|
||||||
|
return ZAI_GLOBAL_BASE_URL;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
setMinimaxApiKey,
|
setMinimaxApiKey,
|
||||||
writeOAuthCredentials,
|
writeOAuthCredentials,
|
||||||
ZAI_CODING_CN_BASE_URL,
|
ZAI_CODING_CN_BASE_URL,
|
||||||
ZAI_CODING_GLOBAL_BASE_URL,
|
ZAI_GLOBAL_BASE_URL,
|
||||||
} from "./onboard-auth.js";
|
} from "./onboard-auth.js";
|
||||||
|
|
||||||
const authProfilePathFor = (agentDir: string) => path.join(agentDir, "auth-profiles.json");
|
const authProfilePathFor = (agentDir: string) => path.join(agentDir, "auth-profiles.json");
|
||||||
@@ -311,7 +311,8 @@ describe("applyZaiConfig", () => {
|
|||||||
it("adds zai provider with correct settings", () => {
|
it("adds zai provider with correct settings", () => {
|
||||||
const cfg = applyZaiConfig({});
|
const cfg = applyZaiConfig({});
|
||||||
expect(cfg.models?.providers?.zai).toMatchObject({
|
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",
|
api: "openai-completions",
|
||||||
});
|
});
|
||||||
const ids = cfg.models?.providers?.zai?.models?.map((m) => m.id);
|
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", () => {
|
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 withOnboardEnv("openclaw-onboard-zai-", async ({ configPath, runtime }) => {
|
||||||
await runNonInteractive(
|
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"]?.provider).toBe("zai");
|
||||||
expect(cfg.auth?.profiles?.["zai:default"]?.mode).toBe("api_key");
|
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.models?.providers?.zai?.baseUrl).toBe("https://api.z.ai/api/paas/v4");
|
||||||
expect(cfg.agents?.defaults?.model?.primary).toBe("zai/glm-4.7");
|
expect(cfg.agents?.defaults?.model?.primary).toBe("zai/glm-5");
|
||||||
await expectApiKeyProfile({ profileId: "zai:default", provider: "zai", key: "zai-test-key" });
|
await expectApiKeyProfile({ profileId: "zai:default", provider: "zai", key: "zai-test-key" });
|
||||||
});
|
});
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import {
|
|||||||
resolveCustomProviderId,
|
resolveCustomProviderId,
|
||||||
} from "../../onboard-custom.js";
|
} from "../../onboard-custom.js";
|
||||||
import { applyOpenAIConfig } from "../../openai-model-default.js";
|
import { applyOpenAIConfig } from "../../openai-model-default.js";
|
||||||
|
import { detectZaiEndpoint } from "../../zai-endpoint-detect.js";
|
||||||
import { resolveNonInteractiveApiKey } from "../api-keys.js";
|
import { resolveNonInteractiveApiKey } from "../api-keys.js";
|
||||||
|
|
||||||
export async function applyNonInteractiveAuthChoice(params: {
|
export async function applyNonInteractiveAuthChoice(params: {
|
||||||
@@ -214,8 +215,10 @@ export async function applyNonInteractiveAuthChoice(params: {
|
|||||||
mode: "api_key",
|
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 endpoint: "global" | "cn" | "coding-global" | "coding-cn" | undefined;
|
||||||
|
let modelIdOverride: string | undefined;
|
||||||
|
|
||||||
if (authChoice === "zai-coding-global") {
|
if (authChoice === "zai-coding-global") {
|
||||||
endpoint = "coding-global";
|
endpoint = "coding-global";
|
||||||
} else if (authChoice === "zai-coding-cn") {
|
} else if (authChoice === "zai-coding-cn") {
|
||||||
@@ -225,9 +228,19 @@ export async function applyNonInteractiveAuthChoice(params: {
|
|||||||
} else if (authChoice === "zai-cn") {
|
} else if (authChoice === "zai-cn") {
|
||||||
endpoint = "cn";
|
endpoint = "cn";
|
||||||
} else {
|
} 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") {
|
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