Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
13
extensions/zai/README.md
Normal file
13
extensions/zai/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Z.AI OpenClaw provider
|
||||
|
||||
Official OpenClaw provider plugin for Z.AI.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
openclaw plugins install @openclaw/zai-provider
|
||||
```
|
||||
|
||||
## Docs
|
||||
|
||||
See `docs/providers/zai.md` in the OpenClaw repository, or the published docs at `https://docs.openclaw.ai/providers/zai`.
|
||||
13
extensions/zai/api.ts
Normal file
13
extensions/zai/api.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// Zai API module exposes the plugin public contract.
|
||||
export { applyZaiConfig, applyZaiProviderConfig, ZAI_DEFAULT_MODEL_REF } from "./onboard.js";
|
||||
export { detectZaiEndpoint, type ZaiDetectedEndpoint, type ZaiEndpointId } from "./detect.js";
|
||||
export {
|
||||
buildZaiModelDefinition,
|
||||
resolveZaiBaseUrl,
|
||||
ZAI_CN_BASE_URL,
|
||||
ZAI_CODING_CN_BASE_URL,
|
||||
ZAI_CODING_GLOBAL_BASE_URL,
|
||||
ZAI_DEFAULT_COST,
|
||||
ZAI_DEFAULT_MODEL_ID,
|
||||
ZAI_GLOBAL_BASE_URL,
|
||||
} from "./model-definitions.js";
|
||||
12
extensions/zai/config-api.ts
Normal file
12
extensions/zai/config-api.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// Narrow config barrel for core/test consumers.
|
||||
// Keep this separate from provider registration/runtime imports.
|
||||
|
||||
export { applyZaiConfig, applyZaiProviderConfig, ZAI_DEFAULT_MODEL_REF } from "./onboard.js";
|
||||
export {
|
||||
ZAI_CN_BASE_URL,
|
||||
ZAI_CODING_CN_BASE_URL,
|
||||
ZAI_CODING_GLOBAL_BASE_URL,
|
||||
ZAI_DEFAULT_COST,
|
||||
ZAI_DEFAULT_MODEL_ID,
|
||||
ZAI_GLOBAL_BASE_URL,
|
||||
} from "./model-definitions.js";
|
||||
338
extensions/zai/detect.test.ts
Normal file
338
extensions/zai/detect.test.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
// Zai tests cover detect plugin behavior.
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { detectZaiEndpoint } from "./detect.js";
|
||||
|
||||
type FetchResponse = { status: number; body?: unknown };
|
||||
|
||||
const ZAI_DETECT_ERROR_BODY_MAX_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Builds a streaming error Response whose body is far larger than the 16 MiB cap.
|
||||
* Tracks how many bytes were actually pulled and whether the consumer cancelled
|
||||
* the stream, so tests can prove the read is bounded (fail-closed) rather than
|
||||
* draining the whole untrusted body into memory.
|
||||
*/
|
||||
function makeOversizedStreamFetch(params: {
|
||||
url: string;
|
||||
status: number;
|
||||
chunkBytes?: number;
|
||||
hardCeilingBytes?: number;
|
||||
}) {
|
||||
const chunkBytes = params.chunkBytes ?? 1024 * 1024;
|
||||
const hardCeilingBytes = params.hardCeilingBytes ?? 64 * 1024 * 1024;
|
||||
const state = { enqueuedBytes: 0, cancelled: false };
|
||||
|
||||
const fetchFn = (async (url: string) => {
|
||||
if (url !== params.url) {
|
||||
throw new Error(`unexpected url: ${url}`);
|
||||
}
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (state.enqueuedBytes >= hardCeilingBytes) {
|
||||
// Safety stop: with an unbounded reader this point would be reached
|
||||
// (and the test would fail on the bounded-bytes assertion below).
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
state.enqueuedBytes += chunkBytes;
|
||||
controller.enqueue(new Uint8Array(chunkBytes));
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
});
|
||||
return new Response(body, {
|
||||
status: params.status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
return { fetchFn, state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a fetch returning a single raw (possibly non-JSON) error body, keyed by
|
||||
* `${url}::${model}`. Used to drive the new bounded decode path with small,
|
||||
* well-formed, empty, and malformed sub-cap bodies that must behave exactly as
|
||||
* the previous `res.json()` path did.
|
||||
*/
|
||||
function makeRawBodyFetch(map: Record<string, { status: number; raw: string }>) {
|
||||
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) {
|
||||
throw new Error(`unexpected url: ${url} model=${String(rawBody?.model ?? "")}`);
|
||||
}
|
||||
return new Response(entry.raw, {
|
||||
status: entry.status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
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) {
|
||||
throw new Error(`unexpected url: ${url} model=${String(rawBody?.model ?? "")}`);
|
||||
}
|
||||
const json = entry.body ?? {};
|
||||
return new Response(JSON.stringify(json), {
|
||||
status: entry.status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
describe("detectZaiEndpoint", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("resolves preferred/fallback endpoints and null when probes fail", async () => {
|
||||
const scenarios: Array<{
|
||||
endpoint?: "global" | "cn" | "coding-global" | "coding-cn";
|
||||
responses: Record<string, { status: number; body?: unknown }>;
|
||||
expected: { endpoint: string; modelId: string } | null;
|
||||
}> = [
|
||||
{
|
||||
responses: {
|
||||
"https://api.z.ai/api/paas/v4/chat/completions::glm-5.1": { status: 200 },
|
||||
},
|
||||
expected: { endpoint: "global", modelId: "glm-5.1" },
|
||||
},
|
||||
{
|
||||
responses: {
|
||||
"https://api.z.ai/api/paas/v4/chat/completions::glm-5.1": { status: 404 },
|
||||
"https://open.bigmodel.cn/api/paas/v4/chat/completions::glm-5.1": { status: 200 },
|
||||
},
|
||||
expected: { endpoint: "cn", modelId: "glm-5.1" },
|
||||
},
|
||||
{
|
||||
responses: {
|
||||
"https://api.z.ai/api/paas/v4/chat/completions::glm-5.1": { status: 404 },
|
||||
"https://open.bigmodel.cn/api/paas/v4/chat/completions::glm-5.1": { status: 404 },
|
||||
"https://api.z.ai/api/coding/paas/v4/chat/completions::glm-5.2": { status: 200 },
|
||||
},
|
||||
expected: { endpoint: "coding-global", modelId: "glm-5.2" },
|
||||
},
|
||||
{
|
||||
endpoint: "coding-global",
|
||||
responses: {
|
||||
"https://api.z.ai/api/coding/paas/v4/chat/completions::glm-5.2": {
|
||||
status: 404,
|
||||
body: { error: { message: "glm-5.2 unavailable" } },
|
||||
},
|
||||
"https://api.z.ai/api/coding/paas/v4/chat/completions::glm-5.1": {
|
||||
status: 404,
|
||||
body: { error: { message: "glm-5.1 unavailable" } },
|
||||
},
|
||||
"https://api.z.ai/api/coding/paas/v4/chat/completions::glm-4.7": { status: 200 },
|
||||
},
|
||||
expected: { endpoint: "coding-global", modelId: "glm-4.7" },
|
||||
},
|
||||
{
|
||||
endpoint: "coding-global",
|
||||
responses: {
|
||||
"https://api.z.ai/api/coding/paas/v4/chat/completions::glm-5.2": {
|
||||
status: 400,
|
||||
body: { code: 1311, msg: "model not included in the current plan" },
|
||||
},
|
||||
"https://api.z.ai/api/coding/paas/v4/chat/completions::glm-5.1": {
|
||||
status: 400,
|
||||
body: { code: 1211, msg: "model does not exist" },
|
||||
},
|
||||
"https://api.z.ai/api/coding/paas/v4/chat/completions::glm-4.7": { status: 200 },
|
||||
},
|
||||
expected: { endpoint: "coding-global", modelId: "glm-4.7" },
|
||||
},
|
||||
{
|
||||
endpoint: "coding-global",
|
||||
responses: {
|
||||
"https://api.z.ai/api/coding/paas/v4/chat/completions::glm-5.2": {
|
||||
status: 429,
|
||||
body: { error: { message: "rate limited" } },
|
||||
},
|
||||
},
|
||||
expected: null,
|
||||
},
|
||||
{
|
||||
endpoint: "coding-cn",
|
||||
responses: {
|
||||
"https://open.bigmodel.cn/api/coding/paas/v4/chat/completions::glm-5.2": {
|
||||
status: 200,
|
||||
},
|
||||
},
|
||||
expected: { endpoint: "coding-cn", modelId: "glm-5.2" },
|
||||
},
|
||||
{
|
||||
endpoint: "coding-cn",
|
||||
responses: {
|
||||
"https://open.bigmodel.cn/api/coding/paas/v4/chat/completions::glm-5.2": {
|
||||
status: 404,
|
||||
},
|
||||
"https://open.bigmodel.cn/api/coding/paas/v4/chat/completions::glm-5.1": {
|
||||
status: 200,
|
||||
},
|
||||
},
|
||||
expected: { endpoint: "coding-cn", modelId: "glm-5.1" },
|
||||
},
|
||||
{
|
||||
endpoint: "coding-cn",
|
||||
responses: {
|
||||
"https://open.bigmodel.cn/api/coding/paas/v4/chat/completions::glm-5.2": {
|
||||
status: 404,
|
||||
body: { error: { message: "glm-5.2 unavailable" } },
|
||||
},
|
||||
"https://open.bigmodel.cn/api/coding/paas/v4/chat/completions::glm-5.1": {
|
||||
status: 404,
|
||||
body: { error: { message: "glm-5.1 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.1": { status: 401 },
|
||||
"https://open.bigmodel.cn/api/paas/v4/chat/completions::glm-5.1": { status: 401 },
|
||||
"https://api.z.ai/api/coding/paas/v4/chat/completions::glm-5.2": { status: 401 },
|
||||
"https://api.z.ai/api/coding/paas/v4/chat/completions::glm-5.1": { 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.2": {
|
||||
status: 401,
|
||||
},
|
||||
"https://open.bigmodel.cn/api/coding/paas/v4/chat/completions::glm-5.1": {
|
||||
status: 401,
|
||||
},
|
||||
"https://open.bigmodel.cn/api/coding/paas/v4/chat/completions::glm-4.7": {
|
||||
status: 401,
|
||||
},
|
||||
},
|
||||
expected: null,
|
||||
},
|
||||
];
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
const detected = await detectZaiEndpoint({
|
||||
apiKey: "sk-test", // pragma: allowlist secret
|
||||
...(scenario.endpoint ? { endpoint: scenario.endpoint } : {}),
|
||||
fetchFn: makeFetch(scenario.responses),
|
||||
});
|
||||
|
||||
if (scenario.expected === null) {
|
||||
expect(detected).toBeNull();
|
||||
} else {
|
||||
expect(detected?.endpoint).toBe(scenario.expected.endpoint);
|
||||
expect(detected?.modelId).toBe(scenario.expected.modelId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("caps oversized probe timeouts before scheduling", async () => {
|
||||
const timeoutSpy = vi
|
||||
.spyOn(globalThis, "setTimeout")
|
||||
.mockReturnValue(1 as unknown as ReturnType<typeof setTimeout>);
|
||||
vi.spyOn(globalThis, "clearTimeout").mockImplementation(() => undefined);
|
||||
const fetchFn = makeFetch({
|
||||
"https://api.z.ai/api/paas/v4/chat/completions::glm-5.1": { status: 200 },
|
||||
});
|
||||
|
||||
await detectZaiEndpoint({
|
||||
apiKey: "sk-test", // pragma: allowlist secret
|
||||
fetchFn,
|
||||
timeoutMs: MAX_TIMER_TIMEOUT_MS + 1_000_000,
|
||||
});
|
||||
|
||||
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
it("still parses well-formed sub-cap error bodies to drive endpoint classification", async () => {
|
||||
// Happy path: model-not-found errors must still be decoded from the bounded
|
||||
// body so the probe classifies them as unsupported and walks to the GLM-4.7
|
||||
// fallback. The error message that drives classification lives only inside
|
||||
// the body, so a passing fallback proves the new bounded reader decoded it.
|
||||
const codingGlobal = "https://api.z.ai/api/coding/paas/v4/chat/completions";
|
||||
const detected = await detectZaiEndpoint({
|
||||
apiKey: "sk-test", // pragma: allowlist secret
|
||||
endpoint: "coding-global",
|
||||
fetchFn: makeRawBodyFetch({
|
||||
[`${codingGlobal}::glm-5.2`]: {
|
||||
status: 400,
|
||||
raw: JSON.stringify({ error: { message: "model not found for this plan" } }),
|
||||
},
|
||||
[`${codingGlobal}::glm-5.1`]: {
|
||||
status: 400,
|
||||
raw: JSON.stringify({ code: 1211, msg: "model does not exist" }),
|
||||
},
|
||||
[`${codingGlobal}::glm-4.7`]: { status: 200, raw: "{}" },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(detected?.endpoint).toBe("coding-global");
|
||||
expect(detected?.modelId).toBe("glm-4.7");
|
||||
});
|
||||
|
||||
it("swallows malformed and empty sub-cap error bodies and falls back on status", async () => {
|
||||
// Regression: a non-JSON or empty error body must not throw out of the
|
||||
// probe. JSON.parse fails, the existing try/catch swallows it, and the
|
||||
// probe degrades to status-only classification (404 => unsupported model),
|
||||
// so the GLM-4.7 fallback still resolves exactly as before.
|
||||
const codingGlobal = "https://api.z.ai/api/coding/paas/v4/chat/completions";
|
||||
const detected = await detectZaiEndpoint({
|
||||
apiKey: "sk-test", // pragma: allowlist secret
|
||||
endpoint: "coding-global",
|
||||
fetchFn: makeRawBodyFetch({
|
||||
[`${codingGlobal}::glm-5.2`]: { status: 404, raw: "<html>gateway error</html>" },
|
||||
[`${codingGlobal}::glm-5.1`]: { status: 404, raw: "" },
|
||||
[`${codingGlobal}::glm-4.7`]: { status: 200, raw: "{}" },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(detected?.endpoint).toBe("coding-global");
|
||||
expect(detected?.modelId).toBe("glm-4.7");
|
||||
});
|
||||
|
||||
it("fails closed on oversized probe error bodies without buffering unbounded", async () => {
|
||||
const { fetchFn, state } = makeOversizedStreamFetch({
|
||||
url: "https://api.z.ai/api/paas/v4/chat/completions",
|
||||
status: 400,
|
||||
});
|
||||
|
||||
const detected = await detectZaiEndpoint({
|
||||
apiKey: "sk-test", // pragma: allowlist secret
|
||||
endpoint: "global",
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
// Probe swallows the bounded-read overflow and falls back to status-only,
|
||||
// so the oversized error body cannot promote this endpoint.
|
||||
expect(detected).toBeNull();
|
||||
// The stream was cancelled (fail-closed) instead of being drained to the
|
||||
// 64 MiB safety ceiling, proving the read stops near the 16 MiB cap.
|
||||
expect(state.cancelled).toBe(true);
|
||||
expect(state.enqueuedBytes).toBeLessThanOrEqual(
|
||||
ZAI_DETECT_ERROR_BODY_MAX_BYTES + 2 * 1024 * 1024,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects oversized bodies via the shared bounded reader the probe uses", async () => {
|
||||
const { fetchFn } = makeOversizedStreamFetch({
|
||||
url: "https://api.z.ai/api/paas/v4/chat/completions",
|
||||
status: 400,
|
||||
});
|
||||
const res = await fetchFn("https://api.z.ai/api/paas/v4/chat/completions");
|
||||
|
||||
await expect(
|
||||
readResponseWithLimit(res, ZAI_DETECT_ERROR_BODY_MAX_BYTES, {
|
||||
onOverflow: ({ maxBytes }) =>
|
||||
new Error(`Z.AI probe error body exceeded size limit (${maxBytes} bytes)`),
|
||||
}),
|
||||
).rejects.toThrow(/exceeded size limit/);
|
||||
});
|
||||
});
|
||||
263
extensions/zai/detect.ts
Normal file
263
extensions/zai/detect.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
// Zai plugin module implements detect behavior.
|
||||
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
|
||||
import {
|
||||
ZAI_CN_BASE_URL,
|
||||
ZAI_CODING_CN_BASE_URL,
|
||||
ZAI_CODING_DEFAULT_MODEL_ID,
|
||||
ZAI_CODING_GLOBAL_BASE_URL,
|
||||
ZAI_DEFAULT_MODEL_ID,
|
||||
ZAI_GLOBAL_BASE_URL,
|
||||
} from "./model-definitions.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;
|
||||
};
|
||||
|
||||
type ProbeCandidate = ZaiDetectedEndpoint & {
|
||||
fallback?: boolean;
|
||||
};
|
||||
|
||||
const UNSUPPORTED_MODEL_ERROR_CODES = new Set(["1211", "1311"]);
|
||||
|
||||
/** Cap for the Z.AI probe error body; bounds untrusted error responses to avoid unbounded buffering/OOM. */
|
||||
const ZAI_DETECT_ERROR_BODY_MAX_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
function isUnsupportedModelResult(result: ProbeResult): boolean {
|
||||
if (result.ok) {
|
||||
return false;
|
||||
}
|
||||
if (result.status === 404) {
|
||||
return true;
|
||||
}
|
||||
if (result.errorCode && UNSUPPORTED_MODEL_ERROR_CODES.has(result.errorCode)) {
|
||||
return true;
|
||||
}
|
||||
if (result.status !== 400) {
|
||||
return false;
|
||||
}
|
||||
const detail = `${result.errorCode ?? ""} ${result.errorMessage ?? ""}`.toLowerCase();
|
||||
return (
|
||||
/\bmodel\b.*\b(not found|unavailable|unsupported|does not exist)\b/.test(detail) ||
|
||||
/模型.*(不存在|不支持|不可用)/.test(detail)
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchWithTimeoutLocal(
|
||||
fetchFn: typeof fetch,
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
timeoutMs: number,
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetchFn(url, { ...init, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function probeZaiChatCompletions(params: {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
modelId: string;
|
||||
timeoutMs: number;
|
||||
fetchFn?: typeof fetch;
|
||||
}): Promise<ProbeResult> {
|
||||
try {
|
||||
const fetchFn = params.fetchFn ?? globalThis.fetch;
|
||||
const res = await fetchWithTimeoutLocal(
|
||||
fetchFn,
|
||||
`${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,
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
let errorCode: string | undefined;
|
||||
let errorMessage: string | undefined;
|
||||
try {
|
||||
const bytes = await readResponseWithLimit(res, ZAI_DETECT_ERROR_BODY_MAX_BYTES, {
|
||||
onOverflow: ({ maxBytes }) =>
|
||||
new Error(`Z.AI probe error body exceeded size limit (${maxBytes} bytes)`),
|
||||
});
|
||||
const json = JSON.parse(new TextDecoder().decode(bytes)) as {
|
||||
error?: { code?: unknown; message?: unknown };
|
||||
code?: unknown;
|
||||
msg?: unknown;
|
||||
message?: unknown;
|
||||
};
|
||||
const code = json?.error?.code ?? json?.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 malformed error bodies
|
||||
}
|
||||
|
||||
return { ok: false, status: res.status, errorCode, errorMessage };
|
||||
} catch {
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
export async function detectZaiEndpoint(params: {
|
||||
apiKey: string;
|
||||
endpoint?: ZaiEndpointId;
|
||||
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 = resolveTimerTimeoutMs(params.timeoutMs, 5_000);
|
||||
const probeCandidates = (() => {
|
||||
const general: ProbeCandidate[] = [
|
||||
{
|
||||
endpoint: "global" as const,
|
||||
baseUrl: ZAI_GLOBAL_BASE_URL,
|
||||
modelId: ZAI_DEFAULT_MODEL_ID,
|
||||
note: "Verified GLM-5.1 on global endpoint.",
|
||||
},
|
||||
{
|
||||
endpoint: "cn" as const,
|
||||
baseUrl: ZAI_CN_BASE_URL,
|
||||
modelId: ZAI_DEFAULT_MODEL_ID,
|
||||
note: "Verified GLM-5.1 on cn endpoint.",
|
||||
},
|
||||
];
|
||||
const codingModels: ProbeCandidate[] = [
|
||||
{
|
||||
endpoint: "coding-global" as const,
|
||||
baseUrl: ZAI_CODING_GLOBAL_BASE_URL,
|
||||
modelId: ZAI_CODING_DEFAULT_MODEL_ID,
|
||||
note: "Verified GLM-5.2 on coding-global endpoint.",
|
||||
},
|
||||
{
|
||||
endpoint: "coding-global" as const,
|
||||
baseUrl: ZAI_CODING_GLOBAL_BASE_URL,
|
||||
modelId: "glm-5.1",
|
||||
note: "Verified GLM-5.1 on coding-global endpoint; GLM-5.2 is unavailable.",
|
||||
fallback: true,
|
||||
},
|
||||
{
|
||||
endpoint: "coding-cn" as const,
|
||||
baseUrl: ZAI_CODING_CN_BASE_URL,
|
||||
modelId: ZAI_CODING_DEFAULT_MODEL_ID,
|
||||
note: "Verified GLM-5.2 on coding-cn endpoint.",
|
||||
},
|
||||
{
|
||||
endpoint: "coding-cn" as const,
|
||||
baseUrl: ZAI_CODING_CN_BASE_URL,
|
||||
modelId: "glm-5.1",
|
||||
note: "Verified GLM-5.1 on coding-cn endpoint; GLM-5.2 is unavailable.",
|
||||
fallback: true,
|
||||
},
|
||||
];
|
||||
const codingFallback: ProbeCandidate[] = [
|
||||
{
|
||||
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.2 or GLM-5.1 there. Defaulting to GLM-4.7.",
|
||||
fallback: true,
|
||||
},
|
||||
{
|
||||
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.2 or GLM-5.1 there. Defaulting to GLM-4.7.",
|
||||
fallback: true,
|
||||
},
|
||||
];
|
||||
|
||||
switch (params.endpoint) {
|
||||
case "global":
|
||||
return general.filter((candidate) => candidate.endpoint === "global");
|
||||
case "cn":
|
||||
return general.filter((candidate) => candidate.endpoint === "cn");
|
||||
case "coding-global":
|
||||
return [
|
||||
...codingModels.filter((candidate) => candidate.endpoint === "coding-global"),
|
||||
...codingFallback.filter((candidate) => candidate.endpoint === "coding-global"),
|
||||
];
|
||||
case "coding-cn":
|
||||
return [
|
||||
...codingModels.filter((candidate) => candidate.endpoint === "coding-cn"),
|
||||
...codingFallback.filter((candidate) => candidate.endpoint === "coding-cn"),
|
||||
];
|
||||
default:
|
||||
return [...general, ...codingModels, ...codingFallback];
|
||||
}
|
||||
})();
|
||||
|
||||
const resultsByEndpoint = new Map<ZaiEndpointId, ProbeResult[]>();
|
||||
for (const candidate of probeCandidates) {
|
||||
const priorResults = resultsByEndpoint.get(candidate.endpoint) ?? [];
|
||||
if (
|
||||
candidate.fallback &&
|
||||
(priorResults.length === 0 || !priorResults.every(isUnsupportedModelResult))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const result = await probeZaiChatCompletions({
|
||||
baseUrl: candidate.baseUrl,
|
||||
apiKey: params.apiKey,
|
||||
modelId: candidate.modelId,
|
||||
timeoutMs,
|
||||
fetchFn: params.fetchFn,
|
||||
});
|
||||
if (result.ok) {
|
||||
return {
|
||||
endpoint: candidate.endpoint,
|
||||
baseUrl: candidate.baseUrl,
|
||||
modelId: candidate.modelId,
|
||||
note: candidate.note,
|
||||
};
|
||||
}
|
||||
resultsByEndpoint.set(candidate.endpoint, [...priorResults, result]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
587
extensions/zai/index.test.ts
Normal file
587
extensions/zai/index.test.ts
Normal file
@@ -0,0 +1,587 @@
|
||||
// Zai tests cover index plugin behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
|
||||
import type { Context, Model } from "openclaw/plugin-sdk/llm";
|
||||
import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { buildOpenAICompletionsParams } from "openclaw/plugin-sdk/provider-transport-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import plugin from "./index.js";
|
||||
|
||||
function createGlm47Template() {
|
||||
return {
|
||||
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"],
|
||||
cost: { input: 0.6, output: 2.2, cacheRead: 0.11, cacheWrite: 0 },
|
||||
contextWindow: 204800,
|
||||
maxTokens: 131072,
|
||||
};
|
||||
}
|
||||
|
||||
function expectReplayPolicyFields(
|
||||
policy: Record<string, unknown> | undefined,
|
||||
fields: Record<string, unknown>,
|
||||
): void {
|
||||
if (!policy) {
|
||||
throw new Error("Expected replay policy");
|
||||
}
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
expect(policy[key]).toEqual(value);
|
||||
}
|
||||
}
|
||||
|
||||
function expectModelFields(
|
||||
model: Record<string, unknown> | undefined,
|
||||
fields: Record<string, unknown>,
|
||||
): void {
|
||||
if (!model) {
|
||||
throw new Error("Expected provider model");
|
||||
}
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
expect(model[key]).toEqual(value);
|
||||
}
|
||||
}
|
||||
|
||||
describe("zai provider plugin", () => {
|
||||
it("owns replay policy for OpenAI-compatible Z.ai transports", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
|
||||
expectReplayPolicyFields(
|
||||
provider.buildReplayPolicy?.({
|
||||
provider: "zai",
|
||||
modelApi: "openai-completions",
|
||||
modelId: "glm-5.1",
|
||||
} as never) as Record<string, unknown> | undefined,
|
||||
{
|
||||
sanitizeToolCallIds: true,
|
||||
toolCallIdMode: "strict",
|
||||
applyAssistantFirstOrderingFix: true,
|
||||
validateGeminiTurns: true,
|
||||
validateAnthropicTurns: true,
|
||||
},
|
||||
);
|
||||
|
||||
expectReplayPolicyFields(
|
||||
provider.buildReplayPolicy?.({
|
||||
provider: "zai",
|
||||
modelApi: "openai-responses",
|
||||
modelId: "glm-5.1",
|
||||
} as never) as Record<string, unknown> | undefined,
|
||||
{
|
||||
sanitizeToolCallIds: true,
|
||||
toolCallIdMode: "strict",
|
||||
applyAssistantFirstOrderingFix: false,
|
||||
validateGeminiTurns: false,
|
||||
validateAnthropicTurns: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves persisted GLM-5 family models with provider-owned metadata", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
const template = createGlm47Template();
|
||||
|
||||
const cases = [
|
||||
{
|
||||
modelId: "glm-5.2",
|
||||
providerBaseUrl: "https://api.z.ai/api/coding/paas/v4",
|
||||
expected: {
|
||||
baseUrl: "https://api.z.ai/api/coding/paas/v4",
|
||||
input: ["text"],
|
||||
reasoning: true,
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 131_072,
|
||||
},
|
||||
},
|
||||
{
|
||||
modelId: "glm-5.1",
|
||||
providerBaseUrl: "https://api.z.ai/api/paas/v4",
|
||||
expected: {
|
||||
baseUrl: "https://api.z.ai/api/paas/v4",
|
||||
input: ["text"],
|
||||
reasoning: true,
|
||||
contextWindow: 202800,
|
||||
maxTokens: 131100,
|
||||
},
|
||||
},
|
||||
{
|
||||
modelId: "glm-5v-turbo",
|
||||
providerBaseUrl: "https://api.z.ai/api/paas/v4",
|
||||
expected: {
|
||||
baseUrl: "https://api.z.ai/api/paas/v4",
|
||||
input: ["text", "image"],
|
||||
reasoning: true,
|
||||
contextWindow: 202800,
|
||||
maxTokens: 131100,
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const testCase of cases) {
|
||||
const resolved = provider.resolveDynamicModel?.({
|
||||
provider: "zai",
|
||||
modelId: testCase.modelId,
|
||||
modelRegistry: {
|
||||
find: (_provider: string, modelId: string) => (modelId === "glm-4.7" ? template : null),
|
||||
},
|
||||
providerConfig: { baseUrl: testCase.providerBaseUrl },
|
||||
} as never) as Record<string, unknown> | undefined;
|
||||
expectModelFields(resolved, {
|
||||
provider: "zai",
|
||||
api: "openai-completions",
|
||||
id: testCase.modelId,
|
||||
...testCase.expected,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps selected Coding Plan and proxy endpoints for dynamic GLM-5 models", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
const template = createGlm47Template();
|
||||
|
||||
for (const baseUrl of [
|
||||
"https://open.bigmodel.cn/api/coding/paas/v4",
|
||||
"https://proxy.example.test/zai",
|
||||
]) {
|
||||
const resolved = provider.resolveDynamicModel?.({
|
||||
provider: "zai",
|
||||
modelId: "glm-5.2",
|
||||
modelRegistry: {
|
||||
find: (_provider: string, modelId: string) => (modelId === "glm-4.7" ? template : null),
|
||||
},
|
||||
providerConfig: { baseUrl },
|
||||
} as never) as Record<string, unknown> | undefined;
|
||||
expect(resolved?.baseUrl).toBe(baseUrl);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns an already-registered GLM-5 variant as-is", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
const registered = {
|
||||
id: "glm-5-turbo",
|
||||
name: "GLM-5-Turbo",
|
||||
provider: "zai",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.z.ai/api/paas/v4",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0.1, output: 0.2, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 123456,
|
||||
maxTokens: 54321,
|
||||
};
|
||||
const template = createGlm47Template();
|
||||
|
||||
expect(
|
||||
provider.resolveDynamicModel?.({
|
||||
provider: "zai",
|
||||
modelId: "glm-5-turbo",
|
||||
modelRegistry: {
|
||||
find: (_provider: string, modelId: string) =>
|
||||
modelId === "glm-5-turbo" ? registered : modelId === "glm-4.7" ? template : null,
|
||||
},
|
||||
} as never),
|
||||
).toEqual(registered);
|
||||
});
|
||||
|
||||
it("falls back to manifest baseUrl when both providerConfig and template model are unavailable", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
|
||||
const resolved = provider.resolveDynamicModel?.({
|
||||
provider: "zai",
|
||||
modelId: "glm-5-turbo",
|
||||
modelRegistry: {
|
||||
find: () => null,
|
||||
},
|
||||
} as never) as Record<string, unknown> | undefined;
|
||||
expectModelFields(resolved, {
|
||||
id: "glm-5-turbo",
|
||||
provider: "zai",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.z.ai/api/paas/v4",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
});
|
||||
});
|
||||
|
||||
it("still synthesizes unknown GLM-5 variants from the GLM-4.7 template", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
const template = createGlm47Template();
|
||||
|
||||
const resolved = provider.resolveDynamicModel?.({
|
||||
provider: "zai",
|
||||
modelId: "glm-5-turbo",
|
||||
modelRegistry: {
|
||||
find: (_provider: string, modelId: string) => (modelId === "glm-4.7" ? template : null),
|
||||
},
|
||||
} as never) as Record<string, unknown> | undefined;
|
||||
expectModelFields(resolved, {
|
||||
id: "glm-5-turbo",
|
||||
name: "GLM-5 Turbo",
|
||||
provider: "zai",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.z.ai/api/paas/v4",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
});
|
||||
});
|
||||
|
||||
it("wires tool-stream defaults through the shared stream family hook", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
let capturedPayload: Record<string, unknown> | undefined;
|
||||
const baseStreamFn: StreamFn = (model, _context, options) => {
|
||||
const payload: Record<string, unknown> = {};
|
||||
options?.onPayload?.(payload as never, model as never);
|
||||
capturedPayload = payload;
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
|
||||
const defaultWrapped = provider.wrapStreamFn?.({
|
||||
provider: "zai",
|
||||
modelId: "glm-5.1",
|
||||
extraParams: {},
|
||||
streamFn: baseStreamFn,
|
||||
} as never);
|
||||
|
||||
void defaultWrapped?.(
|
||||
{
|
||||
api: "openai-completions",
|
||||
provider: "zai",
|
||||
id: "glm-5.1",
|
||||
} as Model<"openai-completions">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(capturedPayload?.tool_stream).toBe(true);
|
||||
|
||||
const disabledWrapped = provider.wrapStreamFn?.({
|
||||
provider: "zai",
|
||||
modelId: "glm-5.1",
|
||||
extraParams: { tool_stream: false },
|
||||
streamFn: baseStreamFn,
|
||||
} as never);
|
||||
|
||||
void disabledWrapped?.(
|
||||
{
|
||||
api: "openai-completions",
|
||||
provider: "zai",
|
||||
id: "glm-5.1",
|
||||
} as Model<"openai-completions">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(capturedPayload).not.toHaveProperty("tool_stream");
|
||||
});
|
||||
|
||||
it("exposes full GLM-5.2 thinking levels while keeping older GLM models binary", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
|
||||
expect(
|
||||
provider.resolveThinkingProfile?.({
|
||||
provider: "zai",
|
||||
modelId: "glm-5.2",
|
||||
reasoning: true,
|
||||
} as never),
|
||||
).toEqual({
|
||||
levels: [
|
||||
{ id: "off", label: "off" },
|
||||
{ id: "low", label: "low" },
|
||||
{ id: "high", label: "high" },
|
||||
{ id: "max", label: "max" },
|
||||
],
|
||||
defaultLevel: "off",
|
||||
});
|
||||
|
||||
expect(
|
||||
provider.resolveThinkingProfile?.({
|
||||
provider: "zai",
|
||||
modelId: "glm-5.1",
|
||||
reasoning: true,
|
||||
} as never),
|
||||
).toEqual({
|
||||
levels: [
|
||||
{ id: "off", label: "off" },
|
||||
{ id: "low", label: "on" },
|
||||
],
|
||||
defaultLevel: "off",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps thinking off to Z.AI thinking disabled", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
let capturedPayload: Record<string, unknown> | undefined;
|
||||
const baseStreamFn: StreamFn = (model, _context, options) => {
|
||||
const payload: Record<string, unknown> = {};
|
||||
options?.onPayload?.(payload as never, model as never);
|
||||
capturedPayload = payload;
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
|
||||
const wrapped = provider.wrapStreamFn?.({
|
||||
provider: "zai",
|
||||
modelId: "glm-5.1",
|
||||
extraParams: {},
|
||||
thinkingLevel: "off",
|
||||
streamFn: baseStreamFn,
|
||||
} as never);
|
||||
|
||||
void wrapped?.(
|
||||
{
|
||||
api: "openai-completions",
|
||||
provider: "zai",
|
||||
id: "glm-5.1",
|
||||
} as Model<"openai-completions">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(capturedPayload?.tool_stream).toBe(true);
|
||||
expect(capturedPayload?.thinking).toEqual({ type: "disabled" });
|
||||
});
|
||||
|
||||
it("keeps minimal thinking enabled for binary GLM models", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
let capturedPayload: Record<string, unknown> | undefined;
|
||||
const baseStreamFn: StreamFn = (model, _context, options) => {
|
||||
const payload: Record<string, unknown> = {};
|
||||
options?.onPayload?.(payload as never, model as never);
|
||||
capturedPayload = payload;
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
|
||||
const wrapped = provider.wrapStreamFn?.({
|
||||
provider: "zai",
|
||||
modelId: "glm-5.1",
|
||||
extraParams: {},
|
||||
thinkingLevel: "minimal",
|
||||
streamFn: baseStreamFn,
|
||||
} as never);
|
||||
|
||||
void wrapped?.(
|
||||
{
|
||||
api: "openai-completions",
|
||||
provider: "zai",
|
||||
id: "glm-5.1",
|
||||
} as Model<"openai-completions">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(capturedPayload).not.toHaveProperty("thinking");
|
||||
});
|
||||
|
||||
it("maps GLM-5.2 thinking levels to Z.AI reasoning effort", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
const baseStreamFn: StreamFn = (model, _context, options) => {
|
||||
const payload: Record<string, unknown> = {};
|
||||
options?.onPayload?.(payload as never, model as never);
|
||||
return { payload } as never;
|
||||
};
|
||||
|
||||
for (const [thinkingLevel, expectedEffort] of [
|
||||
["low", "high"],
|
||||
["high", "high"],
|
||||
["max", "max"],
|
||||
] as const) {
|
||||
const wrapped = provider.wrapStreamFn?.({
|
||||
provider: "zai",
|
||||
modelId: "glm-5.2",
|
||||
extraParams: {},
|
||||
thinkingLevel,
|
||||
streamFn: baseStreamFn,
|
||||
} as never);
|
||||
|
||||
const result = wrapped?.(
|
||||
{
|
||||
api: "openai-completions",
|
||||
provider: "zai",
|
||||
id: "glm-5.2",
|
||||
} as Model<"openai-completions">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
) as unknown as { payload: Record<string, unknown> };
|
||||
|
||||
expect(result.payload.reasoning_effort).toBe(expectedEffort);
|
||||
expect(result.payload).not.toHaveProperty("thinking");
|
||||
expect(result.payload.tool_stream).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("enables Z.AI preserved thinking only when requested", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
let capturedPayload: Record<string, unknown> | undefined;
|
||||
const baseStreamFn: StreamFn = (model, _context, options) => {
|
||||
const payload: Record<string, unknown> = {};
|
||||
options?.onPayload?.(payload as never, model as never);
|
||||
capturedPayload = payload;
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
|
||||
const wrappedWithoutPreserve = provider.wrapStreamFn?.({
|
||||
provider: "zai",
|
||||
modelId: "glm-5.1",
|
||||
extraParams: {},
|
||||
thinkingLevel: "low",
|
||||
streamFn: baseStreamFn,
|
||||
} as never);
|
||||
|
||||
void wrappedWithoutPreserve?.(
|
||||
{
|
||||
api: "openai-completions",
|
||||
provider: "zai",
|
||||
id: "glm-5.1",
|
||||
} as Model<"openai-completions">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(capturedPayload?.tool_stream).toBe(true);
|
||||
expect(capturedPayload).not.toHaveProperty("thinking");
|
||||
|
||||
const wrappedWithPreserve = provider.wrapStreamFn?.({
|
||||
provider: "zai",
|
||||
modelId: "glm-5.1",
|
||||
extraParams: { preserveThinking: true },
|
||||
thinkingLevel: "low",
|
||||
streamFn: baseStreamFn,
|
||||
} as never);
|
||||
|
||||
void wrappedWithPreserve?.(
|
||||
{
|
||||
api: "openai-completions",
|
||||
provider: "zai",
|
||||
id: "glm-5.1",
|
||||
} as Model<"openai-completions">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(capturedPayload?.tool_stream).toBe(true);
|
||||
expect(capturedPayload?.thinking).toEqual({ type: "enabled", clear_thinking: false });
|
||||
});
|
||||
|
||||
it("preserves replayed reasoning_content for Z.AI preserved thinking", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
let capturedPayload: Record<string, unknown> | undefined;
|
||||
const model = {
|
||||
provider: "zai",
|
||||
id: "glm-5.1",
|
||||
name: "GLM 5.1",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.z.ai/api/paas/v4",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 131_072,
|
||||
} as Model<"openai-completions">;
|
||||
const context = {
|
||||
messages: [
|
||||
{ role: "user", content: "hi", timestamp: 1 },
|
||||
{
|
||||
role: "assistant",
|
||||
api: "openai-completions",
|
||||
provider: "zai",
|
||||
model: "glm-5.1",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "prior reasoning",
|
||||
thinkingSignature: "reasoning_content",
|
||||
},
|
||||
{ type: "text", text: "visible reply" },
|
||||
],
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: 2,
|
||||
},
|
||||
{ role: "user", content: "continue", timestamp: 3 },
|
||||
],
|
||||
} as Context;
|
||||
const baseStreamFn: StreamFn = (streamModel, streamContext, options) => {
|
||||
const payload = buildOpenAICompletionsParams(streamModel as never, streamContext, {
|
||||
reasoning: "high",
|
||||
} as never);
|
||||
options?.onPayload?.(payload as never, streamModel as never);
|
||||
capturedPayload = payload;
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
|
||||
const wrapped = provider.wrapStreamFn?.({
|
||||
provider: "zai",
|
||||
modelId: "glm-5.1",
|
||||
extraParams: { preserve_thinking: true },
|
||||
thinkingLevel: "low",
|
||||
streamFn: baseStreamFn,
|
||||
} as never);
|
||||
|
||||
void wrapped?.(model, context, {});
|
||||
|
||||
expect(capturedPayload?.thinking).toEqual({ type: "enabled", clear_thinking: false });
|
||||
const assistantMessage = (capturedPayload!.messages as Array<Record<string, unknown>>)[1];
|
||||
expect(assistantMessage?.role).toBe("assistant");
|
||||
expect(assistantMessage?.content).toBe("visible reply");
|
||||
expect(assistantMessage?.reasoning_content).toBe("prior reasoning");
|
||||
});
|
||||
|
||||
it("defaults tool_stream extra params but preserves explicit values", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
|
||||
expect(
|
||||
provider.prepareExtraParams?.({
|
||||
provider: "zai",
|
||||
modelId: "glm-4.7",
|
||||
extraParams: { endpoint: "global" },
|
||||
} as never),
|
||||
).toEqual({
|
||||
endpoint: "global",
|
||||
tool_stream: true,
|
||||
});
|
||||
|
||||
const explicit = { endpoint: "global", tool_stream: false };
|
||||
expect(
|
||||
provider.prepareExtraParams?.({
|
||||
provider: "zai",
|
||||
modelId: "glm-4.7",
|
||||
extraParams: explicit,
|
||||
} as never),
|
||||
).toBe(explicit);
|
||||
});
|
||||
|
||||
it("uses deprecated pi agent auth.json for usage auth when modern sources are empty", async () => {
|
||||
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-zai-legacy-auth-"));
|
||||
try {
|
||||
const authDir = path.join(home, ".pi", "agent");
|
||||
await fs.mkdir(authDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(authDir, "auth.json"),
|
||||
`${JSON.stringify({ "z-ai": { access: "legacy-zai-token" } }, null, 2)}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
|
||||
await expect(
|
||||
provider.resolveUsageAuth?.({
|
||||
env: { HOME: home },
|
||||
resolveApiKeyFromConfigAndStore: () => undefined,
|
||||
} as never),
|
||||
).resolves.toEqual({ token: "legacy-zai-token" });
|
||||
} finally {
|
||||
await fs.rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
440
extensions/zai/index.ts
Normal file
440
extensions/zai/index.ts
Normal file
@@ -0,0 +1,440 @@
|
||||
// Zai plugin entrypoint registers its OpenClaw integration.
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
definePluginEntry,
|
||||
type ProviderAuthContext,
|
||||
type ProviderAuthMethod,
|
||||
type ProviderAuthMethodNonInteractiveContext,
|
||||
type ProviderResolveDynamicModelContext,
|
||||
type ProviderRuntimeModel,
|
||||
type ProviderWrapStreamFnContext,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
applyAuthProfileConfig,
|
||||
buildApiKeyCredential,
|
||||
ensureApiKeyFromOptionEnvOrPrompt,
|
||||
normalizeApiKeyInput,
|
||||
normalizeOptionalSecretInput,
|
||||
type SecretInput,
|
||||
upsertAuthProfileWithLock,
|
||||
validateApiKeyInput,
|
||||
} from "openclaw/plugin-sdk/provider-auth-api-key";
|
||||
import {
|
||||
buildProviderReplayFamilyHooks,
|
||||
normalizeModelCompat,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import {
|
||||
createPayloadPatchStreamWrapper,
|
||||
createToolStreamWrapper,
|
||||
defaultToolStreamExtraParams,
|
||||
} from "openclaw/plugin-sdk/provider-stream-shared";
|
||||
import { fetchZaiUsage } from "openclaw/plugin-sdk/provider-usage";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { detectZaiEndpoint, type ZaiEndpointId } from "./detect.js";
|
||||
import { zaiMediaUnderstandingProvider } from "./media-understanding-provider.js";
|
||||
import { buildZaiModelDefinition, resolveZaiBaseUrl } from "./model-definitions.js";
|
||||
import { applyZaiConfig, applyZaiProviderConfig, resolveZaiModelId } from "./onboard.js";
|
||||
|
||||
const PROVIDER_ID = "zai";
|
||||
const GLM5_TEMPLATE_MODEL_ID = "glm-4.7";
|
||||
const PROFILE_ID = "zai:default";
|
||||
type UpsertAuthProfileParams = Parameters<typeof upsertAuthProfileWithLock>[0];
|
||||
|
||||
function resolveDeprecatedPiAgentAuthPath(env: NodeJS.ProcessEnv): string {
|
||||
const home = env.HOME?.trim() || env.USERPROFILE?.trim() || os.homedir();
|
||||
return path.join(home, ".pi", "agent", "auth.json");
|
||||
}
|
||||
|
||||
function resolveDeprecatedPiAgentAccessToken(
|
||||
env: NodeJS.ProcessEnv,
|
||||
providerIds: readonly string[],
|
||||
): string | undefined {
|
||||
try {
|
||||
const authPath = resolveDeprecatedPiAgentAuthPath(env);
|
||||
if (!fs.existsSync(authPath)) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = JSON.parse(fs.readFileSync(authPath, "utf-8")) as Record<
|
||||
string,
|
||||
{ access?: unknown }
|
||||
>;
|
||||
for (const providerId of providerIds) {
|
||||
const token = parsed[providerId]?.access;
|
||||
if (typeof token === "string" && token.trim()) {
|
||||
return token;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function upsertAuthProfileWithLockOrThrow(params: UpsertAuthProfileParams): Promise<void> {
|
||||
const updated = await upsertAuthProfileWithLock(params);
|
||||
if (!updated) {
|
||||
throw new Error(
|
||||
"Failed to update auth profile store; the auth store lock may be busy. Wait a moment and retry.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveGlm5ForwardCompatModel(
|
||||
ctx: ProviderResolveDynamicModelContext,
|
||||
): ProviderRuntimeModel | undefined {
|
||||
const trimmedModelId = ctx.modelId.trim();
|
||||
if (!normalizeLowercaseStringOrEmpty(trimmedModelId).startsWith("glm-5")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const existing = ctx.modelRegistry.find(
|
||||
PROVIDER_ID,
|
||||
trimmedModelId,
|
||||
) as ProviderRuntimeModel | null;
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const def = buildZaiModelDefinition({ id: trimmedModelId });
|
||||
const template = ctx.modelRegistry.find(
|
||||
PROVIDER_ID,
|
||||
GLM5_TEMPLATE_MODEL_ID,
|
||||
) as ProviderRuntimeModel | null;
|
||||
return normalizeModelCompat({
|
||||
...template,
|
||||
id: def.id,
|
||||
name: def.name,
|
||||
// Native models must never fall through to the OpenAI SDK's default host.
|
||||
baseUrl: ctx.providerConfig?.baseUrl ?? template?.baseUrl ?? resolveZaiBaseUrl(),
|
||||
api: "openai-completions",
|
||||
provider: PROVIDER_ID,
|
||||
reasoning: def.reasoning,
|
||||
input: def.input,
|
||||
cost: def.cost,
|
||||
contextWindow: def.contextWindow,
|
||||
maxTokens: def.maxTokens,
|
||||
} as ProviderRuntimeModel);
|
||||
}
|
||||
|
||||
function isTrueParam(value: unknown): boolean {
|
||||
return value === true;
|
||||
}
|
||||
|
||||
function shouldPreserveZaiThinking(extraParams?: Record<string, unknown>): boolean {
|
||||
return isTrueParam(extraParams?.preserveThinking) || isTrueParam(extraParams?.preserve_thinking);
|
||||
}
|
||||
|
||||
function isDisabledThinkingLevel(thinkingLevel: ProviderWrapStreamFnContext["thinkingLevel"]) {
|
||||
return thinkingLevel === "off";
|
||||
}
|
||||
|
||||
function isGlm52ModelId(modelId?: string | null): boolean {
|
||||
return normalizeLowercaseStringOrEmpty(modelId).startsWith("glm-5.2");
|
||||
}
|
||||
|
||||
function mapThinkingLevelToZaiReasoningEffort(
|
||||
thinkingLevel: ProviderWrapStreamFnContext["thinkingLevel"],
|
||||
): "high" | "max" | undefined {
|
||||
switch (thinkingLevel) {
|
||||
case "low":
|
||||
case "medium":
|
||||
case "high":
|
||||
case "adaptive":
|
||||
return "high";
|
||||
case "xhigh":
|
||||
case "max":
|
||||
return "max";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function wrapZaiStreamFn(ctx: ProviderWrapStreamFnContext) {
|
||||
let streamFn = createToolStreamWrapper(ctx.streamFn, ctx.extraParams?.tool_stream !== false);
|
||||
const preserveThinking = shouldPreserveZaiThinking(ctx.extraParams);
|
||||
const reasoningEffort = isGlm52ModelId(ctx.modelId)
|
||||
? mapThinkingLevelToZaiReasoningEffort(ctx.thinkingLevel)
|
||||
: undefined;
|
||||
|
||||
if (!isDisabledThinkingLevel(ctx.thinkingLevel) && !preserveThinking && !reasoningEffort) {
|
||||
return streamFn;
|
||||
}
|
||||
|
||||
streamFn = createPayloadPatchStreamWrapper(streamFn, ({ payload, model }) => {
|
||||
if (model.api !== "openai-completions" || model.provider !== PROVIDER_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDisabledThinkingLevel(ctx.thinkingLevel)) {
|
||||
payload.thinking = { type: "disabled" };
|
||||
return;
|
||||
}
|
||||
|
||||
if (reasoningEffort) {
|
||||
payload.reasoning_effort = reasoningEffort;
|
||||
}
|
||||
|
||||
if (preserveThinking) {
|
||||
payload.thinking = { type: "enabled", clear_thinking: false };
|
||||
}
|
||||
});
|
||||
|
||||
return streamFn;
|
||||
}
|
||||
|
||||
async function promptForZaiEndpoint(ctx: ProviderAuthContext): Promise<ZaiEndpointId> {
|
||||
return await ctx.prompter.select<ZaiEndpointId>({
|
||||
message: "Select Z.AI endpoint",
|
||||
initialValue: "global",
|
||||
options: [
|
||||
{ value: "global", label: "Global", hint: "Z.AI Global (api.z.ai)" },
|
||||
{ value: "cn", label: "CN", hint: "Z.AI CN (open.bigmodel.cn)" },
|
||||
{
|
||||
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)",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function runZaiApiKeyAuth(
|
||||
ctx: ProviderAuthContext,
|
||||
endpoint?: ZaiEndpointId,
|
||||
): Promise<{
|
||||
profiles: Array<{ profileId: string; credential: ReturnType<typeof buildApiKeyCredential> }>;
|
||||
configPatch: ReturnType<typeof applyZaiProviderConfig>;
|
||||
defaultModel: string;
|
||||
notes?: string[];
|
||||
}> {
|
||||
let capturedSecretInput: SecretInput | undefined;
|
||||
let capturedCredential = false;
|
||||
let capturedMode: "plaintext" | "ref" | undefined;
|
||||
const apiKey = await ensureApiKeyFromOptionEnvOrPrompt({
|
||||
token:
|
||||
normalizeOptionalSecretInput(ctx.opts?.zaiApiKey) ??
|
||||
normalizeOptionalSecretInput(ctx.opts?.token),
|
||||
tokenProvider: normalizeOptionalSecretInput(ctx.opts?.zaiApiKey)
|
||||
? PROVIDER_ID
|
||||
: normalizeOptionalSecretInput(ctx.opts?.tokenProvider),
|
||||
secretInputMode:
|
||||
ctx.allowSecretRefPrompt === false
|
||||
? (ctx.secretInputMode ?? "plaintext")
|
||||
: ctx.secretInputMode,
|
||||
config: ctx.config,
|
||||
expectedProviders: [PROVIDER_ID, "z-ai"],
|
||||
provider: PROVIDER_ID,
|
||||
envLabel: "ZAI_API_KEY",
|
||||
promptMessage: "Enter Z.AI API key",
|
||||
normalize: normalizeApiKeyInput,
|
||||
validate: validateApiKeyInput,
|
||||
prompter: ctx.prompter,
|
||||
setCredential: async (key, mode) => {
|
||||
capturedSecretInput = key;
|
||||
capturedCredential = true;
|
||||
capturedMode = mode;
|
||||
},
|
||||
});
|
||||
if (!capturedCredential) {
|
||||
throw new Error("Missing Z.AI API key.");
|
||||
}
|
||||
const credentialInput = capturedSecretInput ?? "";
|
||||
|
||||
const detected = await detectZaiEndpoint({ apiKey, ...(endpoint ? { endpoint } : {}) });
|
||||
const modelIdOverride = detected?.modelId;
|
||||
const nextEndpoint = detected?.endpoint ?? endpoint ?? (await promptForZaiEndpoint(ctx));
|
||||
const preset = {
|
||||
...(nextEndpoint ? { endpoint: nextEndpoint } : {}),
|
||||
...(modelIdOverride ? { modelId: modelIdOverride } : {}),
|
||||
};
|
||||
return {
|
||||
profiles: [
|
||||
{
|
||||
profileId: PROFILE_ID,
|
||||
credential: buildApiKeyCredential(
|
||||
PROVIDER_ID,
|
||||
credentialInput,
|
||||
undefined,
|
||||
capturedMode ? { secretInputMode: capturedMode } : undefined,
|
||||
),
|
||||
},
|
||||
],
|
||||
configPatch: applyZaiProviderConfig(ctx.config, preset),
|
||||
defaultModel: `zai/${resolveZaiModelId(preset)}`,
|
||||
...(detected?.note ? { notes: [detected.note] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function runZaiApiKeyAuthNonInteractive(
|
||||
ctx: ProviderAuthMethodNonInteractiveContext,
|
||||
endpoint?: ZaiEndpointId,
|
||||
) {
|
||||
const resolved = await ctx.resolveApiKey({
|
||||
provider: PROVIDER_ID,
|
||||
flagValue: normalizeOptionalSecretInput(ctx.opts.zaiApiKey),
|
||||
flagName: "--zai-api-key",
|
||||
envVar: "ZAI_API_KEY",
|
||||
});
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
const detected = await detectZaiEndpoint({
|
||||
apiKey: resolved.key,
|
||||
...(endpoint ? { endpoint } : {}),
|
||||
});
|
||||
const modelIdOverride = detected?.modelId;
|
||||
const nextEndpoint = detected?.endpoint ?? endpoint;
|
||||
|
||||
if (resolved.source !== "profile") {
|
||||
const credential = ctx.toApiKeyCredential({
|
||||
provider: PROVIDER_ID,
|
||||
resolved,
|
||||
});
|
||||
if (!credential) {
|
||||
return null;
|
||||
}
|
||||
await upsertAuthProfileWithLockOrThrow({
|
||||
profileId: PROFILE_ID,
|
||||
credential,
|
||||
agentDir: ctx.agentDir,
|
||||
});
|
||||
}
|
||||
|
||||
const next = applyAuthProfileConfig(ctx.config, {
|
||||
profileId: PROFILE_ID,
|
||||
provider: PROVIDER_ID,
|
||||
mode: "api_key",
|
||||
});
|
||||
return applyZaiConfig(next, {
|
||||
...(nextEndpoint ? { endpoint: nextEndpoint } : {}),
|
||||
...(modelIdOverride ? { modelId: modelIdOverride } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function buildZaiApiKeyMethod(params: {
|
||||
id: string;
|
||||
choiceId: string;
|
||||
choiceLabel: string;
|
||||
choiceHint?: string;
|
||||
endpoint?: ZaiEndpointId;
|
||||
}): ProviderAuthMethod {
|
||||
return {
|
||||
id: params.id,
|
||||
label: params.choiceLabel,
|
||||
hint: params.choiceHint,
|
||||
kind: "api_key",
|
||||
wizard: {
|
||||
choiceId: params.choiceId,
|
||||
choiceLabel: params.choiceLabel,
|
||||
...(params.choiceHint ? { choiceHint: params.choiceHint } : {}),
|
||||
groupId: "zai",
|
||||
groupLabel: "Z.AI",
|
||||
groupHint: "GLM Coding Plan / Global / CN",
|
||||
},
|
||||
run: async (ctx) => await runZaiApiKeyAuth(ctx, params.endpoint),
|
||||
runNonInteractive: async (ctx) => await runZaiApiKeyAuthNonInteractive(ctx, params.endpoint),
|
||||
};
|
||||
}
|
||||
|
||||
export default definePluginEntry({
|
||||
id: PROVIDER_ID,
|
||||
name: "Z.AI Provider",
|
||||
description: "Bundled Z.AI provider plugin",
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: PROVIDER_ID,
|
||||
label: "Z.AI",
|
||||
aliases: ["z-ai", "z.ai"],
|
||||
docsPath: "/providers/models",
|
||||
envVars: ["ZAI_API_KEY", "Z_AI_API_KEY"],
|
||||
auth: [
|
||||
buildZaiApiKeyMethod({
|
||||
id: "api-key",
|
||||
choiceId: "zai-api-key",
|
||||
choiceLabel: "Z.AI API key",
|
||||
}),
|
||||
buildZaiApiKeyMethod({
|
||||
id: "coding-global",
|
||||
choiceId: "zai-coding-global",
|
||||
choiceLabel: "Coding-Plan-Global",
|
||||
choiceHint: "GLM Coding Plan Global (api.z.ai)",
|
||||
endpoint: "coding-global",
|
||||
}),
|
||||
buildZaiApiKeyMethod({
|
||||
id: "coding-cn",
|
||||
choiceId: "zai-coding-cn",
|
||||
choiceLabel: "Coding-Plan-CN",
|
||||
choiceHint: "GLM Coding Plan CN (open.bigmodel.cn)",
|
||||
endpoint: "coding-cn",
|
||||
}),
|
||||
buildZaiApiKeyMethod({
|
||||
id: "global",
|
||||
choiceId: "zai-global",
|
||||
choiceLabel: "Global",
|
||||
choiceHint: "Z.AI Global (api.z.ai)",
|
||||
endpoint: "global",
|
||||
}),
|
||||
buildZaiApiKeyMethod({
|
||||
id: "cn",
|
||||
choiceId: "zai-cn",
|
||||
choiceLabel: "CN",
|
||||
choiceHint: "Z.AI CN (open.bigmodel.cn)",
|
||||
endpoint: "cn",
|
||||
}),
|
||||
],
|
||||
resolveDynamicModel: (ctx) => resolveGlm5ForwardCompatModel(ctx),
|
||||
...buildProviderReplayFamilyHooks({
|
||||
family: "openai-compatible",
|
||||
dropReasoningFromHistory: false,
|
||||
}),
|
||||
prepareExtraParams: (ctx) => defaultToolStreamExtraParams(ctx.extraParams),
|
||||
wrapStreamFn: (ctx) => wrapZaiStreamFn(ctx),
|
||||
resolveThinkingProfile: (ctx) =>
|
||||
isGlm52ModelId(ctx.modelId)
|
||||
? {
|
||||
levels: [
|
||||
{ id: "off", label: "off" },
|
||||
{ id: "low", label: "low" },
|
||||
{ id: "high", label: "high" },
|
||||
{ id: "max", label: "max" },
|
||||
],
|
||||
defaultLevel: "off",
|
||||
}
|
||||
: {
|
||||
levels: [
|
||||
{ id: "off", label: "off" },
|
||||
{ id: "low", label: "on" },
|
||||
],
|
||||
defaultLevel: "off",
|
||||
},
|
||||
isModernModelRef: ({ modelId }) => {
|
||||
const lower = normalizeLowercaseStringOrEmpty(modelId);
|
||||
return (
|
||||
lower.startsWith("glm-5") ||
|
||||
lower.startsWith("glm-4.7") ||
|
||||
lower.startsWith("glm-4.7-flash") ||
|
||||
lower.startsWith("glm-4.7-flashx")
|
||||
);
|
||||
},
|
||||
resolveUsageAuth: async (ctx) => {
|
||||
const apiKey = ctx.resolveApiKeyFromConfigAndStore({
|
||||
providerIds: [PROVIDER_ID, "z-ai"],
|
||||
envDirect: [ctx.env.ZAI_API_KEY, ctx.env.Z_AI_API_KEY],
|
||||
});
|
||||
if (apiKey) {
|
||||
return { token: apiKey };
|
||||
}
|
||||
const legacyToken = resolveDeprecatedPiAgentAccessToken(ctx.env, ["z-ai", PROVIDER_ID]);
|
||||
return legacyToken ? { token: legacyToken } : null;
|
||||
},
|
||||
fetchUsageSnapshot: async (ctx) => await fetchZaiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn),
|
||||
isCacheTtlEligible: () => true,
|
||||
});
|
||||
api.registerMediaUnderstandingProvider(zaiMediaUnderstandingProvider);
|
||||
},
|
||||
});
|
||||
15
extensions/zai/media-understanding-provider.ts
Normal file
15
extensions/zai/media-understanding-provider.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// Zai provider module implements model/runtime integration.
|
||||
import {
|
||||
describeImageWithModel,
|
||||
describeImagesWithModel,
|
||||
type MediaUnderstandingProvider,
|
||||
} from "openclaw/plugin-sdk/media-understanding";
|
||||
|
||||
export const zaiMediaUnderstandingProvider: MediaUnderstandingProvider = {
|
||||
id: "zai",
|
||||
capabilities: ["image"],
|
||||
defaultModels: { image: "glm-4.6v" },
|
||||
autoPriority: { image: 60 },
|
||||
describeImage: describeImageWithModel,
|
||||
describeImages: describeImagesWithModel,
|
||||
};
|
||||
116
extensions/zai/model-definitions.test.ts
Normal file
116
extensions/zai/model-definitions.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
// Zai tests cover model definitions plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildZaiModelDefinition, ZAI_DEFAULT_COST } from "./model-definitions.js";
|
||||
|
||||
type ExpectedZaiModelFields = {
|
||||
id: string;
|
||||
reasoning?: boolean;
|
||||
input?: string[];
|
||||
contextWindow?: number;
|
||||
maxTokens?: number;
|
||||
cost?: typeof ZAI_DEFAULT_COST;
|
||||
};
|
||||
|
||||
function expectZaiModelFields(expected: ExpectedZaiModelFields) {
|
||||
const model = buildZaiModelDefinition({ id: expected.id });
|
||||
expect(model.id).toBe(expected.id);
|
||||
if ("reasoning" in expected) {
|
||||
expect(model.reasoning).toBe(expected.reasoning);
|
||||
}
|
||||
if (expected.input) {
|
||||
expect(model.input).toEqual(expected.input);
|
||||
}
|
||||
if (expected.contextWindow !== undefined) {
|
||||
expect(model.contextWindow).toBe(expected.contextWindow);
|
||||
}
|
||||
if (expected.maxTokens !== undefined) {
|
||||
expect(model.maxTokens).toBe(expected.maxTokens);
|
||||
}
|
||||
if (expected.cost) {
|
||||
expect(model.cost).toEqual(expected.cost);
|
||||
}
|
||||
}
|
||||
|
||||
describe("zai model definitions", () => {
|
||||
it("uses official GLM-5.2 Coding Plan metadata", () => {
|
||||
expectZaiModelFields({
|
||||
id: "glm-5.2",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 131_072,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses current OpenClaw metadata for the new GLM-5.1 model", () => {
|
||||
expectZaiModelFields({
|
||||
id: "glm-5.1",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
contextWindow: 202800,
|
||||
maxTokens: 131100,
|
||||
cost: { input: 1.2, output: 4, cacheRead: 0.24, cacheWrite: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses current OpenClaw metadata for the new GLM-5V Turbo model", () => {
|
||||
expectZaiModelFields({
|
||||
id: "glm-5v-turbo",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 202800,
|
||||
maxTokens: 131100,
|
||||
cost: { input: 1.2, output: 4, cacheRead: 0.24, cacheWrite: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses current OpenClaw metadata for the GLM-5 model", () => {
|
||||
expectZaiModelFields({
|
||||
id: "glm-5",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
contextWindow: 202800,
|
||||
maxTokens: 131100,
|
||||
cost: ZAI_DEFAULT_COST,
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes newer GLM 4.5/4.6 family metadata from OpenClaw", () => {
|
||||
expectZaiModelFields({
|
||||
id: "glm-4.6v",
|
||||
input: ["text", "image"],
|
||||
contextWindow: 128000,
|
||||
maxTokens: 32768,
|
||||
cost: { input: 0.3, output: 0.9, cacheRead: 0, cacheWrite: 0 },
|
||||
});
|
||||
expectZaiModelFields({
|
||||
id: "glm-4.5-air",
|
||||
input: ["text"],
|
||||
contextWindow: 131072,
|
||||
maxTokens: 98304,
|
||||
cost: { input: 0.2, output: 1.1, cacheRead: 0.03, cacheWrite: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the remaining GLM 4.7/5 pricing and token limits aligned with OpenClaw", () => {
|
||||
expectZaiModelFields({
|
||||
id: "glm-4.7-flash",
|
||||
cost: { input: 0.07, output: 0.4, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 131072,
|
||||
});
|
||||
expectZaiModelFields({
|
||||
id: "glm-4.7-flashx",
|
||||
cost: { input: 0.06, output: 0.4, cacheRead: 0.01, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 128000,
|
||||
});
|
||||
expectZaiModelFields({
|
||||
id: "glm-5-turbo",
|
||||
contextWindow: 202800,
|
||||
maxTokens: 131100,
|
||||
cost: { input: 1.2, output: 4, cacheRead: 0.24, cacheWrite: 0 },
|
||||
});
|
||||
});
|
||||
});
|
||||
72
extensions/zai/model-definitions.ts
Normal file
72
extensions/zai/model-definitions.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
// Zai plugin module implements model definitions behavior.
|
||||
import { buildManifestModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-shared";
|
||||
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import manifest from "./openclaw.plugin.json" with { type: "json" };
|
||||
|
||||
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-5.1";
|
||||
export const ZAI_CODING_DEFAULT_MODEL_ID = "glm-5.2";
|
||||
|
||||
const ZAI_MANIFEST_CATALOG = manifest.modelCatalog.providers.zai;
|
||||
const ZAI_MANIFEST_PROVIDER = buildManifestModelProviderConfig({
|
||||
providerId: "zai",
|
||||
catalog: ZAI_MANIFEST_CATALOG,
|
||||
});
|
||||
const ZAI_MODEL_CATALOG = new Map(
|
||||
ZAI_MANIFEST_PROVIDER.models.map((model) => [model.id, model] as const),
|
||||
);
|
||||
|
||||
export const ZAI_DEFAULT_COST =
|
||||
ZAI_MODEL_CATALOG.get("glm-5")?.cost ??
|
||||
({
|
||||
input: 1,
|
||||
output: 3.2,
|
||||
cacheRead: 0.2,
|
||||
cacheWrite: 0,
|
||||
} satisfies ModelDefinitionConfig["cost"]);
|
||||
|
||||
export function resolveZaiBaseUrl(endpoint?: string): string {
|
||||
switch (endpoint) {
|
||||
case "coding-cn":
|
||||
return ZAI_CODING_CN_BASE_URL;
|
||||
case "global":
|
||||
return ZAI_GLOBAL_BASE_URL;
|
||||
case "cn":
|
||||
return ZAI_CN_BASE_URL;
|
||||
case "coding-global":
|
||||
return ZAI_CODING_GLOBAL_BASE_URL;
|
||||
default:
|
||||
return ZAI_GLOBAL_BASE_URL;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildZaiCatalogModels(): ModelDefinitionConfig[] {
|
||||
return ZAI_MANIFEST_PROVIDER.models.map((model) =>
|
||||
Object.assign({}, model, { input: [...model.input] }),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildZaiModelDefinition(params: {
|
||||
id: string;
|
||||
name?: string;
|
||||
reasoning?: boolean;
|
||||
input?: ModelDefinitionConfig["input"];
|
||||
cost?: ModelDefinitionConfig["cost"];
|
||||
contextWindow?: number;
|
||||
maxTokens?: number;
|
||||
}): ModelDefinitionConfig {
|
||||
const catalog = ZAI_MODEL_CATALOG.get(params.id);
|
||||
return {
|
||||
id: params.id,
|
||||
name: params.name ?? catalog?.name ?? `GLM ${params.id}`,
|
||||
reasoning: params.reasoning ?? catalog?.reasoning ?? true,
|
||||
input:
|
||||
params.input ?? (catalog?.input ? ([...catalog.input] as ("text" | "image")[]) : ["text"]),
|
||||
cost: params.cost ?? catalog?.cost ?? ZAI_DEFAULT_COST,
|
||||
contextWindow: params.contextWindow ?? catalog?.contextWindow ?? 202800,
|
||||
maxTokens: params.maxTokens ?? catalog?.maxTokens ?? 131100,
|
||||
};
|
||||
}
|
||||
12
extensions/zai/npm-shrinkwrap.json
generated
Normal file
12
extensions/zai/npm-shrinkwrap.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/zai-provider",
|
||||
"version": "2026.6.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/zai-provider",
|
||||
"version": "2026.6.11"
|
||||
}
|
||||
}
|
||||
}
|
||||
137
extensions/zai/onboard.test.ts
Normal file
137
extensions/zai/onboard.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
// Zai tests cover onboard plugin behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { AuthStorage, ModelRegistry } from "openclaw/plugin-sdk/agent-sessions";
|
||||
import { resolveAgentModelPrimaryValue } from "openclaw/plugin-sdk/provider-onboard";
|
||||
import { expectProviderOnboardPreservesPrimary } from "openclaw/plugin-sdk/provider-test-contracts";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
ZAI_CODING_CN_BASE_URL,
|
||||
ZAI_CODING_GLOBAL_BASE_URL,
|
||||
ZAI_GLOBAL_BASE_URL,
|
||||
} from "./model-definitions.js";
|
||||
import { applyZaiConfig, applyZaiProviderConfig } from "./onboard.js";
|
||||
|
||||
describe("zai onboard", () => {
|
||||
let defaultCfg: ReturnType<typeof applyZaiConfig>;
|
||||
let cnFlashCfg: ReturnType<typeof applyZaiConfig>;
|
||||
let cnFlashxCfg: ReturnType<typeof applyZaiConfig>;
|
||||
|
||||
beforeAll(() => {
|
||||
defaultCfg = applyZaiConfig({});
|
||||
cnFlashCfg = applyZaiConfig({}, { endpoint: "coding-cn", modelId: "glm-4.7-flash" });
|
||||
cnFlashxCfg = applyZaiConfig({}, { endpoint: "coding-cn", modelId: "glm-4.7-flashx" });
|
||||
});
|
||||
|
||||
it("adds zai provider with correct settings", () => {
|
||||
expect(defaultCfg.models?.providers?.zai?.baseUrl).toBe(ZAI_GLOBAL_BASE_URL);
|
||||
expect(defaultCfg.models?.providers?.zai?.api).toBe("openai-completions");
|
||||
const ids = defaultCfg.models?.providers?.zai?.models?.map((m) => m.id);
|
||||
expect(ids).toEqual([
|
||||
"glm-5.2",
|
||||
"glm-5.1",
|
||||
"glm-5",
|
||||
"glm-5-turbo",
|
||||
"glm-5v-turbo",
|
||||
"glm-4.7",
|
||||
"glm-4.7-flash",
|
||||
"glm-4.7-flashx",
|
||||
"glm-4.6",
|
||||
"glm-4.6v",
|
||||
"glm-4.5",
|
||||
"glm-4.5-air",
|
||||
"glm-4.5-flash",
|
||||
"glm-4.5v",
|
||||
]);
|
||||
expect(
|
||||
defaultCfg.models?.providers?.zai?.models?.find((model) => model.id === "glm-5.2"),
|
||||
).toMatchObject({
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 131_072,
|
||||
});
|
||||
expect(
|
||||
defaultCfg.models?.providers?.zai?.models?.find((model) => model.id === "glm-5.2"),
|
||||
).not.toHaveProperty("baseUrl");
|
||||
});
|
||||
|
||||
it("resolves GLM-5.2 through the selected Coding Plan or custom endpoint", async () => {
|
||||
for (const [name, cfg, expectedBaseUrl] of [
|
||||
["coding-cn", applyZaiConfig({}, { endpoint: "coding-cn" }), ZAI_CODING_CN_BASE_URL],
|
||||
[
|
||||
"custom",
|
||||
applyZaiConfig({
|
||||
models: {
|
||||
providers: {
|
||||
zai: {
|
||||
baseUrl: "https://proxy.example.test/zai",
|
||||
api: "openai-completions",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
"https://proxy.example.test/zai",
|
||||
],
|
||||
] as const) {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), `openclaw-zai-${name}-`));
|
||||
try {
|
||||
const modelsPath = path.join(dir, "models.json");
|
||||
await fs.writeFile(
|
||||
modelsPath,
|
||||
JSON.stringify({
|
||||
...cfg.models,
|
||||
providers: {
|
||||
...cfg.models?.providers,
|
||||
zai: { ...cfg.models?.providers?.zai, apiKey: "test-key" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const registry = ModelRegistry.create(AuthStorage.inMemory(), modelsPath);
|
||||
expect(registry.getError()).toBeUndefined();
|
||||
expect(registry.find("zai", "glm-5.2")?.baseUrl).toBe(expectedBaseUrl);
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("supports CN endpoint for supported coding models", () => {
|
||||
for (const [modelId, cfg] of [
|
||||
["glm-4.7-flash", cnFlashCfg],
|
||||
["glm-4.7-flashx", cnFlashxCfg],
|
||||
] as const) {
|
||||
expect(cfg.models?.providers?.zai?.baseUrl).toBe(ZAI_CODING_CN_BASE_URL);
|
||||
expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(`zai/${modelId}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults Coding Plan endpoints to GLM-5.2 without changing the general API default", () => {
|
||||
const codingCfg = applyZaiConfig({}, { endpoint: "coding-global" });
|
||||
const existingCodingCfg = applyZaiConfig({
|
||||
models: {
|
||||
providers: {
|
||||
zai: {
|
||||
baseUrl: `${ZAI_CODING_GLOBAL_BASE_URL}/`,
|
||||
api: "openai-completions",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolveAgentModelPrimaryValue(defaultCfg.agents?.defaults?.model)).toBe("zai/glm-5.1");
|
||||
expect(codingCfg.models?.providers?.zai?.baseUrl).toBe(ZAI_CODING_GLOBAL_BASE_URL);
|
||||
expect(resolveAgentModelPrimaryValue(codingCfg.agents?.defaults?.model)).toBe("zai/glm-5.2");
|
||||
expect(resolveAgentModelPrimaryValue(existingCodingCfg.agents?.defaults?.model)).toBe(
|
||||
"zai/glm-5.2",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not overwrite existing primary model in provider-only mode", () => {
|
||||
expectProviderOnboardPreservesPrimary({
|
||||
applyProviderConfig: applyZaiProviderConfig,
|
||||
primaryModelRef: "anthropic/claude-opus-4-5",
|
||||
});
|
||||
});
|
||||
});
|
||||
74
extensions/zai/onboard.ts
Normal file
74
extensions/zai/onboard.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
// Zai setup module handles plugin onboarding behavior.
|
||||
import {
|
||||
applyProviderConfigWithModelCatalogPreset,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/provider-onboard";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
buildZaiCatalogModels,
|
||||
resolveZaiBaseUrl,
|
||||
ZAI_CODING_CN_BASE_URL,
|
||||
ZAI_CODING_DEFAULT_MODEL_ID,
|
||||
ZAI_CODING_GLOBAL_BASE_URL,
|
||||
ZAI_DEFAULT_MODEL_ID,
|
||||
} from "./model-definitions.js";
|
||||
|
||||
export const ZAI_DEFAULT_MODEL_REF = `zai/${ZAI_DEFAULT_MODEL_ID}`;
|
||||
|
||||
function resolveZaiPresetBaseUrl(cfg: OpenClawConfig, endpoint?: string): string {
|
||||
const existingProvider = cfg.models?.providers?.zai;
|
||||
const existingBaseUrl = normalizeOptionalString(existingProvider?.baseUrl) ?? "";
|
||||
return endpoint ? resolveZaiBaseUrl(endpoint) : existingBaseUrl || resolveZaiBaseUrl();
|
||||
}
|
||||
|
||||
export function resolveZaiModelId(params?: {
|
||||
endpoint?: string;
|
||||
modelId?: string;
|
||||
baseUrl?: string;
|
||||
}): string {
|
||||
const explicitModelId = normalizeOptionalString(params?.modelId);
|
||||
if (explicitModelId) {
|
||||
return explicitModelId;
|
||||
}
|
||||
const baseUrl = normalizeOptionalString(params?.baseUrl)?.replace(/\/+$/, "");
|
||||
const usesCodingEndpoint =
|
||||
params?.endpoint?.startsWith("coding-") ||
|
||||
baseUrl === ZAI_CODING_GLOBAL_BASE_URL ||
|
||||
baseUrl === ZAI_CODING_CN_BASE_URL;
|
||||
return usesCodingEndpoint ? ZAI_CODING_DEFAULT_MODEL_ID : ZAI_DEFAULT_MODEL_ID;
|
||||
}
|
||||
|
||||
function applyZaiPreset(
|
||||
cfg: OpenClawConfig,
|
||||
params?: { endpoint?: string; modelId?: string },
|
||||
primaryModelRef?: string,
|
||||
): OpenClawConfig {
|
||||
const baseUrl = resolveZaiPresetBaseUrl(cfg, params?.endpoint);
|
||||
const modelId = resolveZaiModelId({ ...params, baseUrl });
|
||||
const modelRef = `zai/${modelId}`;
|
||||
return applyProviderConfigWithModelCatalogPreset(cfg, {
|
||||
providerId: "zai",
|
||||
api: "openai-completions",
|
||||
baseUrl,
|
||||
catalogModels: buildZaiCatalogModels(),
|
||||
aliases: [{ modelRef, alias: "GLM" }],
|
||||
primaryModelRef,
|
||||
});
|
||||
}
|
||||
|
||||
export function applyZaiProviderConfig(
|
||||
cfg: OpenClawConfig,
|
||||
params?: { endpoint?: string; modelId?: string },
|
||||
): OpenClawConfig {
|
||||
return applyZaiPreset(cfg, params);
|
||||
}
|
||||
|
||||
export function applyZaiConfig(
|
||||
cfg: OpenClawConfig,
|
||||
params?: { endpoint?: string; modelId?: string },
|
||||
): OpenClawConfig {
|
||||
const baseUrl = resolveZaiPresetBaseUrl(cfg, params?.endpoint);
|
||||
const modelId = resolveZaiModelId({ ...params, baseUrl });
|
||||
const modelRef = modelId === ZAI_DEFAULT_MODEL_ID ? ZAI_DEFAULT_MODEL_REF : `zai/${modelId}`;
|
||||
return applyZaiPreset(cfg, params, modelRef);
|
||||
}
|
||||
349
extensions/zai/openclaw.plugin.json
Normal file
349
extensions/zai/openclaw.plugin.json
Normal file
@@ -0,0 +1,349 @@
|
||||
{
|
||||
"id": "zai",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
"enabledByDefault": true,
|
||||
"providers": ["zai"],
|
||||
"providerEndpoints": [
|
||||
{
|
||||
"endpointClass": "zai-native",
|
||||
"hosts": ["api.z.ai"]
|
||||
}
|
||||
],
|
||||
"providerRequest": {
|
||||
"providers": {
|
||||
"zai": {
|
||||
"family": "zai"
|
||||
}
|
||||
}
|
||||
},
|
||||
"setup": {
|
||||
"providers": [
|
||||
{
|
||||
"id": "zai",
|
||||
"authMethods": ["api-key"],
|
||||
"envVars": ["ZAI_API_KEY", "Z_AI_API_KEY"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"modelCatalog": {
|
||||
"providers": {
|
||||
"zai": {
|
||||
"baseUrl": "https://api.z.ai/api/paas/v4",
|
||||
"api": "openai-completions",
|
||||
"models": [
|
||||
{
|
||||
"id": "glm-5.2",
|
||||
"name": "GLM-5.2",
|
||||
"reasoning": true,
|
||||
"input": ["text"],
|
||||
"contextWindow": 1000000,
|
||||
"maxTokens": 131072,
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "glm-5.1",
|
||||
"name": "GLM-5.1",
|
||||
"reasoning": true,
|
||||
"input": ["text"],
|
||||
"contextWindow": 202800,
|
||||
"maxTokens": 131100,
|
||||
"cost": {
|
||||
"input": 1.2,
|
||||
"output": 4,
|
||||
"cacheRead": 0.24,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "glm-5",
|
||||
"name": "GLM-5",
|
||||
"reasoning": true,
|
||||
"input": ["text"],
|
||||
"contextWindow": 202800,
|
||||
"maxTokens": 131100,
|
||||
"cost": {
|
||||
"input": 1,
|
||||
"output": 3.2,
|
||||
"cacheRead": 0.2,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "glm-5-turbo",
|
||||
"name": "GLM-5 Turbo",
|
||||
"reasoning": true,
|
||||
"input": ["text"],
|
||||
"contextWindow": 202800,
|
||||
"maxTokens": 131100,
|
||||
"cost": {
|
||||
"input": 1.2,
|
||||
"output": 4,
|
||||
"cacheRead": 0.24,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "glm-5v-turbo",
|
||||
"name": "GLM-5V Turbo",
|
||||
"reasoning": true,
|
||||
"input": ["text", "image"],
|
||||
"contextWindow": 202800,
|
||||
"maxTokens": 131100,
|
||||
"cost": {
|
||||
"input": 1.2,
|
||||
"output": 4,
|
||||
"cacheRead": 0.24,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "glm-4.7",
|
||||
"name": "GLM-4.7",
|
||||
"reasoning": true,
|
||||
"input": ["text"],
|
||||
"contextWindow": 204800,
|
||||
"maxTokens": 131072,
|
||||
"cost": {
|
||||
"input": 0.6,
|
||||
"output": 2.2,
|
||||
"cacheRead": 0.11,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "glm-4.7-flash",
|
||||
"name": "GLM-4.7 Flash",
|
||||
"reasoning": true,
|
||||
"input": ["text"],
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 131072,
|
||||
"cost": {
|
||||
"input": 0.07,
|
||||
"output": 0.4,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "glm-4.7-flashx",
|
||||
"name": "GLM-4.7 FlashX",
|
||||
"reasoning": true,
|
||||
"input": ["text"],
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 128000,
|
||||
"cost": {
|
||||
"input": 0.06,
|
||||
"output": 0.4,
|
||||
"cacheRead": 0.01,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "glm-4.6",
|
||||
"name": "GLM-4.6",
|
||||
"reasoning": true,
|
||||
"input": ["text"],
|
||||
"contextWindow": 204800,
|
||||
"maxTokens": 131072,
|
||||
"cost": {
|
||||
"input": 0.6,
|
||||
"output": 2.2,
|
||||
"cacheRead": 0.11,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "glm-4.6v",
|
||||
"name": "GLM-4.6V",
|
||||
"reasoning": true,
|
||||
"input": ["text", "image"],
|
||||
"contextWindow": 128000,
|
||||
"maxTokens": 32768,
|
||||
"cost": {
|
||||
"input": 0.3,
|
||||
"output": 0.9,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "glm-4.5",
|
||||
"name": "GLM-4.5",
|
||||
"reasoning": true,
|
||||
"input": ["text"],
|
||||
"contextWindow": 131072,
|
||||
"maxTokens": 98304,
|
||||
"cost": {
|
||||
"input": 0.6,
|
||||
"output": 2.2,
|
||||
"cacheRead": 0.11,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "glm-4.5-air",
|
||||
"name": "GLM-4.5 Air",
|
||||
"reasoning": true,
|
||||
"input": ["text"],
|
||||
"contextWindow": 131072,
|
||||
"maxTokens": 98304,
|
||||
"cost": {
|
||||
"input": 0.2,
|
||||
"output": 1.1,
|
||||
"cacheRead": 0.03,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "glm-4.5-flash",
|
||||
"name": "GLM-4.5 Flash",
|
||||
"reasoning": true,
|
||||
"input": ["text"],
|
||||
"contextWindow": 131072,
|
||||
"maxTokens": 98304,
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "glm-4.5v",
|
||||
"name": "GLM-4.5V",
|
||||
"reasoning": true,
|
||||
"input": ["text", "image"],
|
||||
"contextWindow": 64000,
|
||||
"maxTokens": 16384,
|
||||
"cost": {
|
||||
"input": 0.6,
|
||||
"output": 1.8,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"aliases": {
|
||||
"z.ai": {
|
||||
"provider": "zai"
|
||||
},
|
||||
"z-ai": {
|
||||
"provider": "zai"
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
"zai": "static"
|
||||
}
|
||||
},
|
||||
"modelPricing": {
|
||||
"providers": {
|
||||
"zai": {
|
||||
"openRouter": {
|
||||
"provider": "z-ai"
|
||||
},
|
||||
"liteLLM": {
|
||||
"provider": "zai"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"providerAuthChoices": [
|
||||
{
|
||||
"provider": "zai",
|
||||
"method": "api-key",
|
||||
"choiceId": "zai-api-key",
|
||||
"choiceLabel": "Z.AI API key",
|
||||
"groupId": "zai",
|
||||
"groupLabel": "Z.AI",
|
||||
"groupHint": "GLM Coding Plan / Global / CN",
|
||||
"optionKey": "zaiApiKey",
|
||||
"cliFlag": "--zai-api-key",
|
||||
"cliOption": "--zai-api-key <key>",
|
||||
"cliDescription": "Z.AI API key"
|
||||
},
|
||||
{
|
||||
"provider": "zai",
|
||||
"method": "coding-global",
|
||||
"choiceId": "zai-coding-global",
|
||||
"choiceLabel": "Coding-Plan-Global",
|
||||
"choiceHint": "GLM Coding Plan Global (api.z.ai)",
|
||||
"groupId": "zai",
|
||||
"groupLabel": "Z.AI",
|
||||
"groupHint": "GLM Coding Plan / Global / CN",
|
||||
"optionKey": "zaiApiKey",
|
||||
"cliFlag": "--zai-api-key",
|
||||
"cliOption": "--zai-api-key <key>",
|
||||
"cliDescription": "Z.AI API key"
|
||||
},
|
||||
{
|
||||
"provider": "zai",
|
||||
"method": "coding-cn",
|
||||
"choiceId": "zai-coding-cn",
|
||||
"choiceLabel": "Coding-Plan-CN",
|
||||
"choiceHint": "GLM Coding Plan CN (open.bigmodel.cn)",
|
||||
"groupId": "zai",
|
||||
"groupLabel": "Z.AI",
|
||||
"groupHint": "GLM Coding Plan / Global / CN",
|
||||
"optionKey": "zaiApiKey",
|
||||
"cliFlag": "--zai-api-key",
|
||||
"cliOption": "--zai-api-key <key>",
|
||||
"cliDescription": "Z.AI API key"
|
||||
},
|
||||
{
|
||||
"provider": "zai",
|
||||
"method": "global",
|
||||
"choiceId": "zai-global",
|
||||
"choiceLabel": "Global",
|
||||
"choiceHint": "Z.AI Global (api.z.ai)",
|
||||
"groupId": "zai",
|
||||
"groupLabel": "Z.AI",
|
||||
"groupHint": "GLM Coding Plan / Global / CN",
|
||||
"optionKey": "zaiApiKey",
|
||||
"cliFlag": "--zai-api-key",
|
||||
"cliOption": "--zai-api-key <key>",
|
||||
"cliDescription": "Z.AI API key"
|
||||
},
|
||||
{
|
||||
"provider": "zai",
|
||||
"method": "cn",
|
||||
"choiceId": "zai-cn",
|
||||
"choiceLabel": "CN",
|
||||
"choiceHint": "Z.AI CN (open.bigmodel.cn)",
|
||||
"groupId": "zai",
|
||||
"groupLabel": "Z.AI",
|
||||
"groupHint": "GLM Coding Plan / Global / CN",
|
||||
"optionKey": "zaiApiKey",
|
||||
"cliFlag": "--zai-api-key",
|
||||
"cliOption": "--zai-api-key <key>",
|
||||
"cliDescription": "Z.AI API key"
|
||||
}
|
||||
],
|
||||
"contracts": {
|
||||
"mediaUnderstandingProviders": ["zai"]
|
||||
},
|
||||
"mediaUnderstandingProviderMetadata": {
|
||||
"zai": {
|
||||
"capabilities": ["image"],
|
||||
"defaultModels": {
|
||||
"image": "glm-4.6v"
|
||||
},
|
||||
"autoPriority": {
|
||||
"image": 60
|
||||
}
|
||||
}
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
35
extensions/zai/package.json
Normal file
35
extensions/zai/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@openclaw/zai-provider",
|
||||
"version": "2026.6.11",
|
||||
"description": "OpenClaw Z.AI provider plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"install": {
|
||||
"clawhubSpec": "clawhub:@openclaw/zai-provider",
|
||||
"npmSpec": "@openclaw/zai-provider",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.6.9"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.11"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.11",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
}
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
}
|
||||
}
|
||||
4
extensions/zai/provider-runtime.contract.test.ts
Normal file
4
extensions/zai/provider-runtime.contract.test.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Zai tests cover provider runtime.contract plugin behavior.
|
||||
import { describeZAIProviderRuntimeContract } from "openclaw/plugin-sdk/provider-test-contracts";
|
||||
|
||||
describeZAIProviderRuntimeContract(() => import("./index.js"));
|
||||
2
extensions/zai/runtime-api.ts
Normal file
2
extensions/zai/runtime-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Zai API module exposes the plugin public contract.
|
||||
export { detectZaiEndpoint, type ZaiDetectedEndpoint, type ZaiEndpointId } from "./detect.js";
|
||||
2
extensions/zai/test-api.ts
Normal file
2
extensions/zai/test-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Zai API module exposes the plugin public contract.
|
||||
export { zaiMediaUnderstandingProvider } from "./media-understanding-provider.js";
|
||||
16
extensions/zai/tsconfig.json
Normal file
16
extensions/zai/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user