diff --git a/docs/development/validation.md b/docs/development/validation.md index 79ea4315..71e2d2b5 100644 --- a/docs/development/validation.md +++ b/docs/development/validation.md @@ -251,6 +251,17 @@ CREATION_API_URL=http://127.0.0.1:8019 CREATION_WEB_URL=http://127.0.0.1:5179 no Python 用例使用模拟 HTTP 服务及真实 PNG、MP3,检查请求字段、音色、执行快照、文件探测、工程重开和下载恢复不重复生成。通用错误分类和安全提示由现有错误测试负责。浏览器用例检查两种语言下的提供商及型号列表、获取模型失败的具体原因、音色必填、保存重开和实际音频播放;播放器使用隔离服务的 WAV 替身,不代表 MOSS 已生成语音。真实验收另需有效 MOSS 凭证和音色 ID,并保留实际音频、模型标识及播放器截图。 +Zhipu BigModel chat access uses the following checks. Browser commands connect to the isolated API and Vite described earlier in this document. Environment variables can override the ports. + +```sh +.venv/bin/python -m pytest tests/test_zhipu.py tests/test_model_configs.py -q +node --test tests/providerPresets.test.ts +CREATION_API_URL=http://127.0.0.1:8019 CREATION_WEB_URL=http://127.0.0.1:5179 node tests/browser/zhipu_provider.mjs +``` + +The Python cases check how the shared thinking levels map to GLM reasoning_effort. GLM accepts only low, high and max for reasoning_effort. The auto level omits the parameter so the provider default applies, and an unknown level falls back to high as in the DeepSeek branch. Other hosts and the DeepSeek behavior stay unchanged. + +The browser case checks both display languages for the provider list and names, canonical URL prefill, GLM model preselection without remote discovery, manual addition of other model IDs, the saved connection reuse hint, save and reopen, key privacy in public model responses, and the assistant agent picker listing and keeping the selected Zhipu model. The browser never contacts open.bigmodel.cn and the API key is always a fixture value. Real streaming conversations, tool call execution and thinking level effects additionally require valid Zhipu credentials. Keep the conversation transcript, tool receipts and screenshots as evidence. ### Voice library / 音色库 diff --git a/packages/core/src/opendirector/integrations/chat_completions.py b/packages/core/src/opendirector/integrations/chat_completions.py index f350fc5a..20dd8957 100644 --- a/packages/core/src/opendirector/integrations/chat_completions.py +++ b/packages/core/src/opendirector/integrations/chat_completions.py @@ -1,4 +1,4 @@ -"""Chat Completions streaming, including DeepSeek's thinking parameters.""" +"""Chat Completions streaming, including provider-specific thinking parameters.""" import json from urllib.parse import urlsplit @@ -14,8 +14,14 @@ def chat_payload(config: dict, messages: list[dict], thinking: str) -> dict: value = {"model": config["modelId"], "messages": messages, "stream": True, "max_tokens": 8192} thinking_map = {"auto": "high", "low": "low", "medium": "high", "high": "max"} - if urlsplit(config["url"]).hostname == "api.deepseek.com": + hostname = urlsplit(config["url"]).hostname + if hostname == "api.deepseek.com": value.update(thinking={"type": "enabled"}, reasoning_effort=thinking_map.get(thinking, thinking_map["auto"])) + elif hostname == "open.bigmodel.cn": + # GLM reasoning_effort accepts only low, high and max. Omit the parameter for + # auto so the provider default applies, and fall back to high for unknown levels. + if thinking != "auto": + value["reasoning_effort"] = thinking_map.get(thinking, thinking_map["auto"]) elif thinking != "auto": value["reasoning_effort"] = thinking if len(json.dumps(value, ensure_ascii=False).encode()) > 48 * 1024 * 1024: diff --git a/src/canvas/providerPresets.ts b/src/canvas/providerPresets.ts index 4845cc42..037a9ffe 100644 --- a/src/canvas/providerPresets.ts +++ b/src/canvas/providerPresets.ts @@ -6,6 +6,7 @@ import { getActiveLocale } from "../i18n/messages.ts"; const EN_NAMES: Record = { "OpenAI 兼容": "OpenAI compatible", "MiniMax 官方视频": "MiniMax official video", + "智谱 BigModel": "Zhipu BigModel", "可灵官方视频": "Kling official video", "MOSS 开放平台": "MOSS Open Platform", "火山方舟 / Agent Plan": "Volcano Ark / Agent Plan", @@ -118,6 +119,19 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ connectionType: "api", authMode: "bearer", }, + { + id: "zhipu", + logoOrganization: "zai-org", + name: "智谱 BigModel", + kinds: ["agent"], + url: "https://open.bigmodel.cn/api/paas/v4", + protocol: "openai", + connectionType: "api", + authMode: "bearer", + modelsByKind: { + agent: ["glm-5.3", "glm-5.3-flash", "glm-5.2"], + }, + }, { id: "moss", logoOrganization: "OpenMOSS-Team", diff --git a/tests/browser/manifest.json b/tests/browser/manifest.json index a6e8c1ab..de411641 100644 --- a/tests/browser/manifest.json +++ b/tests/browser/manifest.json @@ -468,6 +468,13 @@ "gate" ], "note": "Runs with voice_library_server.py in the Release checks voice library phase." + }, + { + "file": "zhipu_provider.mjs", + "previewSafe": true, + "lanes": [ + "extended" + ] } ] } diff --git a/tests/browser/zhipu_provider.mjs b/tests/browser/zhipu_provider.mjs new file mode 100644 index 00000000..ca43876b --- /dev/null +++ b/tests/browser/zhipu_provider.mjs @@ -0,0 +1,249 @@ +/** Isolated Zhipu BigModel provider UI proof. Saves a fixture key and never calls a model service. + * Start creation_server.py on 8029 and Vite on 5189 with its API proxy on 8029. + * CREATION_WEB_URL, CREATION_API_URL and ZHIPU_SCREENSHOT_DIR override defaults. + */ +import assert from "node:assert/strict"; +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { launchBrowser } from "./engine.mjs"; + +const base = process.env.CREATION_WEB_URL ?? "http://127.0.0.1:5189"; +const api = process.env.CREATION_API_URL ?? "http://127.0.0.1:8029"; +const output = process.env.ZHIPU_SCREENSHOT_DIR ?? "/tmp/opendirector-zhipu-evidence"; +const modelIds = ["glm-5.3", "glm-5.3-flash", "glm-5.2"]; +const canonicalURL = "https://open.bigmodel.cn/api/paas/v4"; +const fixtureKey = "zhipu-browser-fixture-only-no-real-service"; +const headers = { "X-OpenDirector-Request": "1", "Content-Type": "application/json" }; +async function call(path, { method = "GET", body } = {}) { + const response = await fetch(api + path, { + method, + headers, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + assert.ok(response.ok, `Fixture API ${method} ${path}: ${response.status}`); + return response.status === 204 ? null : response.json(); +} +const health = await call("/api/creation-test-health"); +assert.equal(health.isolated, true, "Run only against creation_server.py"); +assert.equal(health.provider, "fixture"); +const webHealth = await (await fetch(base + "/api/creation-test-health", { headers })).json(); +assert.equal(webHealth.root, health.root, "Web proxy must use the same isolated API"); +const removedServices = new Set(); +for (const config of await call("/api/model-configs")) { + const serviceId = config.providerId ?? config.id; + if (config.url.replace(/\/+$/, "") === canonicalURL && modelIds.includes(config.modelId) && !removedServices.has(serviceId)) { + await call(`/api/model-services/${config.id}`, { method: "DELETE" }); + removedServices.add(serviceId); + } +} +await mkdir(output, { recursive: true }); +const projectId = crypto.randomUUID(); +async function createPickerProject(locale) { + const id = crypto.randomUUID(); + await call("/api/creation/projects/" + id + "/commands", { + method: "POST", + body: { commandId: crypto.randomUUID(), projectId: id, type: "project.create", + payload: { name: "Zhipu picker " + locale + " " + id.slice(0, 8) } }, + }); + return id; +} +await call(`/api/creation/projects/${projectId}/commands`, { + method: "POST", + body: { commandId: crypto.randomUUID(), projectId, type: "project.create", + payload: { name: `Zhipu provider UI fixture ${projectId.slice(0, 8)}` } }, +}); + +const copy = { + "zh-CN": { + settings: "设置", models: "模型接入", agentOverview: "文本模型", chat: "对话", + add: "添加服务", provider: "提供商", zhipu: "智谱 BigModel", url: "服务地址(Base URL)", + reload: "载入支持的模型", save: "保存服务", cancel: "取消", manage: "管理", close: "关闭设置", + chatEntry: "与 AI 对话", manual: "手动添加", manualAdd: "添加", manualId: "Model ID(服务的 served-model-name)", + }, + "en-US": { + settings: "Settings", models: "Model connections", agentOverview: "Text model", chat: "Chat", + add: "Add service", provider: "Provider", zhipu: "Zhipu BigModel", url: "Service URL (Base URL)", + reload: "Load supported models", save: "Save service", cancel: "Cancel", manage: "Manage", close: "Close settings", + chatEntry: "Chat with AI", manual: "Add manually", manualAdd: "Add", manualId: "Model ID (the service's served-model-name)", + }, +}; +const browser = await launchBrowser(); +const context = await browser.newContext({ viewport: { width: 1500, height: 1000 } }); +const page = await context.newPage(); +page.setDefaultTimeout(15000); +await page.addInitScript(() => { + localStorage.setItem("opendirector.modelSetup.v1", "dismissed"); + localStorage.setItem("opendirector.locale.v1", "zh-CN"); +}); +const requests = []; +const errors = []; +page.on("request", request => requests.push(request.url())); +page.on("pageerror", error => errors.push(error.message)); +const shot = name => page.screenshot({ path: join(output, name), fullPage: true }); +const provider = labels => page.getByRole("combobox", { name: labels.provider, exact: true }); +// Filter an option card by its checkbox so glm-5.3 does not substring-match glm-5.3-flash. +const optionRow = modelId => + page.locator(".cw-model-option").filter({ has: page.getByRole("checkbox", { name: modelId, exact: true }) }); +async function brandLogo(image) { + await image.waitFor(); + await image.evaluate(element => element.decode()); + assert.ok(await image.evaluate(element => element.naturalWidth > 0)); + const source = await image.getAttribute("src"); + assert.ok(source.startsWith("data:image/svg+xml") || new URL(source, base).origin === new URL(base).origin); +} +async function openAgentCategory(labels) { + await page.locator(".cw-model-setup-row") + .filter({ has: page.locator("strong", { hasText: new RegExp(`^${labels.agentOverview}$`) }) }) + .getByRole("button").click(); +} +async function provePreset(labels, locale) { + await page.getByRole("button", { name: labels.add, exact: true }).click(); + const start = requests.length; + await provider(labels).click(); + const option = page.getByRole("option", { name: labels.zhipu, exact: true }); + await brandLogo(option.locator("img.cw-model-logo")); + await option.click(); + assert.equal(await provider(labels).innerText(), labels.zhipu); + assert.equal(await page.getByLabel(labels.url, { exact: true }).inputValue(), canonicalURL); + assert.equal(await page.locator(".cw-model-option").count(), modelIds.length); + await brandLogo(provider(labels).locator("img.cw-model-logo")); + for (const modelId of modelIds) { + assert.equal(await page.getByRole("checkbox", { name: modelId, exact: true }).isChecked(), true); + await brandLogo(optionRow(modelId).locator("img.cw-model-logo")); + } + await page.getByLabel("API Key", { exact: true }).fill(fixtureKey); + await page.getByRole("button", { name: labels.reload, exact: true }).click(); + for (const modelId of modelIds) + assert.equal(await page.getByRole("checkbox", { name: modelId, exact: true }).isChecked(), true); + assert.ok(!requests.slice(start).some(url => url.includes("/model-configs/discover")), "Preset selection or reload must not discover remote models"); + await shot(`${locale}-mock-zhipu-provider.png`); +} +async function proveReopen(labels, locale) { + const service = page.locator(".cw-model-service").filter({ hasText: canonicalURL }); + assert.equal(await service.count(), 1); + assert.ok((await service.innerText()).includes(labels.zhipu)); + for (const logo of await service.locator("img.cw-model-logo").all()) + await brandLogo(logo); + await service.getByRole("button", { name: labels.manage, exact: true }).click(); + assert.equal(await provider(labels).innerText(), labels.zhipu); + for (const modelId of modelIds) + assert.equal(await page.getByRole("checkbox", { name: modelId, exact: true }).isChecked(), true); + assert.equal(await page.getByLabel("API Key", { exact: true }).inputValue(), "", "Saved key stays private"); + await brandLogo(provider(labels).locator("img.cw-model-logo")); + await shot(`${locale}-mock-zhipu-reopened.png`); + await page.getByRole("button", { name: labels.cancel, exact: true }).click(); +} +async function proveManualAdd(labels, locale) { + // The preset ships a curated list, but manual model IDs stay available like DeepSeek. + await page.getByRole("button", { name: labels.add, exact: true }).click(); + await provider(labels).click(); + await page.getByRole("option", { name: labels.zhipu, exact: true }).click(); + const start = requests.length; + await page.getByRole("button", { name: labels.manual, exact: true }).click(); + await page.getByLabel(labels.manualId, { exact: true }).fill("glm-4.7"); + await page.locator(".cw-model-manual").getByRole("button", { name: labels.manualAdd, exact: true }).click(); + assert.equal(await page.locator(".cw-model-option").count(), modelIds.length + 1); + assert.equal(await page.getByRole("checkbox", { name: "glm-4.7", exact: true }).isChecked(), true); + assert.ok(!requests.slice(start).some(url => url.includes("/model-configs/discover")), "Manual add must not discover remote models"); + await shot(`${locale}-mock-zhipu-manual-add.png`); + await page.getByRole("button", { name: labels.cancel, exact: true }).click(); + await page.getByRole("button", { name: labels.add, exact: true }).waitFor(); +} +async function proveAgentPicker(labels, locale) { + // Each locale gets a fresh page. The browser context still shares the saved + // model configuration. A separate project avoids competing for the current + // project's single editing lease. + const pickerProjectId = await createPickerProject(locale); + const pickerPage = await context.newPage(); + pickerPage.setDefaultTimeout(15000); + pickerPage.on("request", request => requests.push(request.url())); + pickerPage.on("pageerror", error => errors.push(error.message)); + await pickerPage.addInitScript(({ locale: pickerLocale }) => { + localStorage.setItem("opendirector.modelSetup.v1", "dismissed"); + localStorage.setItem("opendirector.locale.v1", pickerLocale); + }, { locale }); + + try { + // The picker runtime inventory is a response fixture. The Default group lists + // the saved agent model configs from the real API, so no provider endpoint is contacted. + await pickerPage.route(/\/assistant\/runtimes$/, route => route.fulfill({ + json: [{ + id: "default", label: "Default", group: "default", modelSource: "settings", + available: true, status: "ready", modelOverride: false, + }], + })); + await pickerPage.goto(base + "/ai-tool/ai-canvas/" + pickerProjectId + "?creation=unified"); + await pickerPage.locator('.cw-workspace[aria-busy="false"]').waitFor(); + const pickerRow = pickerPage.getByRole("menuitemradio", { name: "glm-5.3", exact: true }); + const chatButton = pickerPage.getByRole("button", { name: labels.chatEntry, exact: true }); + if (await chatButton.getAttribute("aria-expanded") !== "true") + await chatButton.click(); + const agentButton = pickerPage.getByRole("button", { name: /^Agent[::]/ }); + await agentButton.click(); + await brandLogo(pickerRow.locator("img.cw-model-logo")); + await pickerRow.click(); + await agentButton.click(); + assert.equal( + await pickerRow.getAttribute("aria-checked"), + "true", "Saved Zhipu model stays selected in the assistant picker", + ); + await pickerPage.screenshot({ + path: join(output, locale + "-mock-zhipu-agent-picker.png"), + fullPage: true, + }); + await pickerPage.keyboard.press("Escape"); + if (await chatButton.getAttribute("aria-expanded") === "true") + await chatButton.click({ force: true }); + } finally { + await pickerPage.close().catch(() => {}); + } +} + +try { + await page.goto(`${base}/ai-tool/ai-canvas/${projectId}?creation=unified`); + const zh = copy["zh-CN"]; + await page.getByRole("button", { name: zh.settings, exact: true }).click(); + await page.getByRole("button", { name: zh.models, exact: true }).click(); + await openAgentCategory(zh); + await provePreset(zh, "zh-CN"); + await page.getByRole("button", { name: zh.save, exact: true }).click(); + await page.getByRole("button", { name: zh.add, exact: true }).waitFor(); + const saved = (await call("/api/model-configs")).filter(config => config.url === canonicalURL); + assert.deepEqual(saved.map(config => config.modelId).sort(), [...modelIds].sort()); + assert.equal(new Set(saved.map(config => config.providerId)).size, 1); + for (const model of saved) { + assert.equal(model.kind, "agent"); + assert.ok(!JSON.stringify(model).includes(fixtureKey)); + assert.equal(Object.hasOwn(model, "apiKey"), false); + } + await proveReopen(zh, "zh-CN"); + await proveManualAdd(zh, "zh-CN"); + await page.getByRole("button", { name: zh.close, exact: true }).click(); + await proveAgentPicker(zh, "zh-CN"); + + await page.getByRole("button", { name: zh.settings, exact: true }).click(); + await page.getByRole("button", { name: zh.models, exact: true }).click(); + await page.getByRole("button", { name: "通用", exact: true }).click(); + await page.locator("#settings-language").selectOption("en-US"); + const en = copy["en-US"]; + await page.getByRole("button", { name: en.models, exact: true }).click(); + await openAgentCategory(en); + await provePreset(en, "en-US"); + await page.getByRole("button", { name: en.cancel, exact: true }).click(); + await proveReopen(en, "en-US"); + await proveManualAdd(en, "en-US"); + await page.getByRole("button", { name: en.close, exact: true }).click(); + await proveAgentPicker(en, "en-US"); + assert.ok(!requests.some(url => new URL(url).hostname === "open.bigmodel.cn"), "Browser must not contact the model provider"); + assert.deepEqual(errors, []); + console.log(JSON.stringify({ passed: true, fixture: true, generation: "not submitted", base, api, + projectURL: `${base}/ai-tool/ai-canvas/${projectId}?creation=unified`, screenshotDirectory: output, + modelConfigIds: saved.map(config => config.id), checks: ["Chinese and English", "Z.ai brand logo", + "canonical URL", "GLM models preselected without discovery", "save and reopen", "public models have no key", + "manual model IDs stay available", "assistant picker lists and keeps the Zhipu model", "no provider request from the browser"] })); +} catch (error) { + await shot("mock-zhipu-provider-failure.png").catch(() => {}); + throw error; +} finally { + await browser.close(); +} diff --git a/tests/providerPresets.test.ts b/tests/providerPresets.test.ts index 77c00b4a..ac1c8b5a 100644 --- a/tests/providerPresets.test.ts +++ b/tests/providerPresets.test.ts @@ -71,6 +71,48 @@ test("MOSS provider labels follow the display language", () => { } }); +test("Zhipu BigModel preset offers agent chat models by kind", () => { + const preset = PROVIDER_PRESETS.find((item) => item.id === "zhipu"); + assert.ok(preset); + assert.equal(preset.url, "https://open.bigmodel.cn/api/paas/v4"); + assert.equal(preset.protocol, "openai"); + assert.equal(preset.connectionType, "api"); + assert.equal(preset.authMode, "bearer"); + assert.equal(preset.logoOrganization, "zai-org"); + assert.deepEqual(preset.kinds, ["agent"]); + assert.deepEqual(preset.modelsByKind, { + agent: ["glm-5.3", "glm-5.3-flash", "glm-5.2"], + }); + assert.ok(providerOptions("agent").some((item) => item.id === "zhipu")); + for (const kind of ["image", "video", "audio", "vision"] as const) + assert.ok(!providerOptions(kind).some((item) => item.id === "zhipu")); +}); + +test("Zhipu provider labels follow the display language", () => { + const previous = getActiveLocale(); + try { + setActiveLocale("zh-CN"); + assert.equal(providerOptions("agent").find((item) => item.id === "zhipu")?.name, "智谱 BigModel"); + setActiveLocale("en-US"); + assert.equal(providerOptions("agent").find((item) => item.id === "zhipu")?.name, "Zhipu BigModel"); + } finally { + setActiveLocale(previous); + } +}); + +test("a saved Zhipu agent connection reopens with its provider identity", () => { + const preset = PROVIDER_PRESETS.find((item) => item.id === "zhipu")!; + for (const modelId of preset.modelsByKind?.agent ?? []) { + const model: ModelConfig = { + ...preset, id: "saved-zhipu", kind: "agent", modelId, + url: `${preset.url}/`, + inputRules: { profile: "agent", allowedKinds: [], maxImages: 0, maxVideos: 0, maxAudios: 0, videoModes: [] }, + }; + assert.equal(providerPresetFor(model), "zhipu"); + assert.equal(providerPresetFor({ ...model, url: "https://other.test/v1" }), "custom"); + } +}); + test("a saved MOSS image connection reopens with its provider identity", () => { const preset = PROVIDER_PRESETS.find((item) => item.id === "moss"); assert.ok(preset); diff --git a/tests/test_zhipu.py b/tests/test_zhipu.py new file mode 100644 index 00000000..37988c16 --- /dev/null +++ b/tests/test_zhipu.py @@ -0,0 +1,31 @@ +"""Zhipu BigModel chat requests map the shared thinking setting to GLM reasoning_effort.""" +from opendirector.integrations.chat_completions import chat_payload + + +ZHIPU = {"modelId": "glm-5.3", "url": "https://open.bigmodel.cn/api/paas/v4"} + + +def test_zhipu_reasoning_effort_accepts_only_glm_levels(): + assert chat_payload(ZHIPU, [], "low")["reasoning_effort"] == "low" + assert chat_payload(ZHIPU, [], "medium")["reasoning_effort"] == "high" + assert chat_payload(ZHIPU, [], "high")["reasoning_effort"] == "max" + assert chat_payload(ZHIPU, [], "xhigh")["reasoning_effort"] == "high" + + +def test_zhipu_auto_keeps_provider_default(): + payload = chat_payload(ZHIPU, [], "auto") + assert "reasoning_effort" not in payload + assert "thinking" not in payload + + +def test_zhipu_base_fields_and_other_hosts_unchanged(): + payload = chat_payload(ZHIPU, [{"role": "user", "content": "hi"}], "high") + assert payload["model"] == "glm-5.3" + assert payload["stream"] is True + assert payload["max_tokens"] == 8192 + assert payload["messages"] == [{"role": "user", "content": "hi"}] + other = chat_payload({"modelId": "x", "url": "https://api.example.com/v1"}, [], "medium") + assert other["reasoning_effort"] == "medium" + deepseek = chat_payload({"modelId": "x", "url": "https://api.deepseek.com/v1"}, [], "high") + assert deepseek["thinking"] == {"type": "enabled"} + assert deepseek["reasoning_effort"] == "max"