From a63caebdac8fb02370fcd5cbb63ec659be446094 Mon Sep 17 00:00:00 2001 From: Peng Cheng Date: Wed, 11 Nov 2026 13:35:21 +0800 Subject: [PATCH] [feat] providers: add Beijing Model Studio Qwen chat preset --- docs/guides/user-guide.md | 2 ++ docs/guides/user-guide.zh-CN.md | 2 ++ src/canvas/ModelServiceForm.tsx | 4 +++- src/canvas/providerPresets.ts | 17 ++++++++++++- src/i18n/messages/en-US/models.ts | 2 ++ src/i18n/messages/zh-CN/models.ts | 2 ++ tests/providerPresets.test.ts | 40 +++++++++++++++++++++++++++++++ tests/test_native_runtime.py | 11 +++++++-- 8 files changed, 76 insertions(+), 4 deletions(-) diff --git a/docs/guides/user-guide.md b/docs/guides/user-guide.md index 0faf8aff..7ca3ec6d 100644 --- a/docs/guides/user-guide.md +++ b/docs/guides/user-guide.md @@ -27,6 +27,8 @@ A service can provide multiple models within the same category. After entering i The service URL and Model ID must match your model service. Self-hosted service URLs typically include `/v1`; use the model name actually exposed by the service as the Model ID. For third-party services, follow the information provided by the service. +For Qwen assistant chat, choose **Alibaba Cloud Model Studio (Beijing)** and enter a Beijing API key. The preset selects `qwen3.8-max`. You can keep the shared Beijing URL or enter your Beijing workspace's OpenAI-compatible Base URL ending in `/compatible-mode/v1`. [Alibaba Cloud endpoint guide](https://help.aliyun.com/en/model-studio/compatibility-of-openai-with-dashscope) + For a self-hosted chat service, allow at least 8192 tokens of context: the application sends `max_tokens: 8192`, so a smaller `--max-model-len` (for example vLLM's 4096 default) rejects every request with HTTP 400. diff --git a/docs/guides/user-guide.zh-CN.md b/docs/guides/user-guide.zh-CN.md index 31be205f..e5a6b74c 100644 --- a/docs/guides/user-guide.zh-CN.md +++ b/docs/guides/user-guide.zh-CN.md @@ -27,6 +27,8 @@ 服务地址和 Model ID 应与自己的模型服务一致。自部署服务的地址通常包含 `/v1`,Model ID 使用服务实际公开的模型名称;第三方服务按其提供的信息填写。 +使用千问对话助手时,选择 **阿里云百炼(北京)** 并填写北京地域的 API Key。预设会选中 `qwen3.8-max`。服务地址可保留默认的北京通用地址,也可填写以 `/compatible-mode/v1` 结尾的北京业务空间专属地址。[百炼 OpenAI 兼容接口指南](https://help.aliyun.com/zh/model-studio/compatibility-of-openai-with-dashscope) + 自部署的对话服务需要至少 8192 token 的上下文:应用固定发送 `max_tokens: 8192`, 若服务端的 `--max-model-len` 更小(例如 vLLM 的 4096 默认值),每次请求都会返回 HTTP 400。 diff --git a/src/canvas/ModelServiceForm.tsx b/src/canvas/ModelServiceForm.tsx index c3d775ad..70832187 100644 --- a/src/canvas/ModelServiceForm.tsx +++ b/src/canvas/ModelServiceForm.tsx @@ -437,7 +437,9 @@ export function ModelServiceForm({ />

- {t("models.service.urlNote")} + {t(presetId === "bailian-beijing" + ? "models.service.beijingWorkspaceUrl" + : "models.service.urlNote")}

diff --git a/src/canvas/providerPresets.ts b/src/canvas/providerPresets.ts index 08bea610..f5a48160 100644 --- a/src/canvas/providerPresets.ts +++ b/src/canvas/providerPresets.ts @@ -7,6 +7,7 @@ const EN_NAMES: Record = { "OpenAI 兼容": "OpenAI compatible", "MiniMax 官方视频": "MiniMax official video", "智谱 BigModel": "Zhipu BigModel", + "阿里云百炼(北京)": "Alibaba Cloud Model Studio (Beijing)", "可灵官方视频": "Kling official video", "MiniMax 官方图片": "MiniMax Official Images", "MOSS 开放平台": "MOSS Open Platform", @@ -58,6 +59,7 @@ export interface ProviderPreset { optionLabel?: string; kinds: ModelKind[]; url: string; + workspaceUrlPattern?: RegExp; protocol: ModelConfigInput["protocol"]; connectionType: ModelConfigInput["connectionType"]; authMode: ModelConfigInput["authMode"]; @@ -131,6 +133,18 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ connectionType: "api", authMode: "bearer", }, + { + id: "bailian-beijing", + logoOrganization: "Qwen", + name: "阿里云百炼(北京)", + kinds: ["agent"], + url: "https://dashscope.aliyuncs.com/compatible-mode/v1", + workspaceUrlPattern: /^https:\/\/[a-z0-9-]+\.cn-beijing\.maas\.aliyuncs\.com\/compatible-mode\/v1$/, + protocol: "openai", + connectionType: "api", + authMode: "bearer", + modelsByKind: { agent: ["qwen3.8-max"] }, + }, { id: "zhipu", logoOrganization: "zai-org", @@ -225,12 +239,13 @@ export function providerPresetFor(model: ModelConfig): string { ); if (deployed && model.connectionType === "self_hosted") return deployed.id; if (model.connectionType === "self_hosted") return "custom"; + const url = model.url.replace(/\/+$/, ""); return ( PROVIDER_PRESETS.find( (preset) => preset.kinds.includes(model.kind) && preset.protocol === model.protocol && - preset.url === model.url.replace(/\/+$/, ""), + (preset.url === url || preset.workspaceUrlPattern?.test(url)), )?.id ?? "custom" ); } diff --git a/src/i18n/messages/en-US/models.ts b/src/i18n/messages/en-US/models.ts index 6e2d6c99..8188f0f3 100644 --- a/src/i18n/messages/en-US/models.ts +++ b/src/i18n/messages/en-US/models.ts @@ -72,6 +72,8 @@ export const models = { "models.service.show": "Show", "models.service.urlNote": "Self-hosted services usually use a /v1 URL; when connecting through an SSH tunnel, enter the local forwarded address.", + "models.service.beijingWorkspaceUrl": + "Use a Beijing API key. To use your workspace endpoint, enter https://.cn-beijing.maas.aliyuncs.com/compatible-mode/v1 with your workspace ID.", "models.service.inputCapabilities": "Input capabilities", "models.service.referenceOutput": "References and output", "models.service.textInput": "Text", diff --git a/src/i18n/messages/zh-CN/models.ts b/src/i18n/messages/zh-CN/models.ts index 2e9c132c..cf39e6db 100644 --- a/src/i18n/messages/zh-CN/models.ts +++ b/src/i18n/messages/zh-CN/models.ts @@ -63,6 +63,8 @@ export const models = { "models.service.show": "显示", "models.service.urlNote": "自部署服务通常填写 /v1 地址;通过 SSH 隧道连接时填写本地转发地址。", + "models.service.beijingWorkspaceUrl": + "使用北京地域的 API Key。如需使用业务空间专属地址,请将 https://.cn-beijing.maas.aliyuncs.com/compatible-mode/v1 中的 替换为你的业务空间 ID。", "models.service.inputCapabilities": "输入能力", "models.service.referenceOutput": "参考与输出能力", "models.service.textInput": "文本", diff --git a/tests/providerPresets.test.ts b/tests/providerPresets.test.ts index 6c66bf57..ae72c77c 100644 --- a/tests/providerPresets.test.ts +++ b/tests/providerPresets.test.ts @@ -17,6 +17,46 @@ test("provider presets separate official protocol from self-hosted model names", assert.deepEqual(matchingConnections([{ ...model, providerId: "shared" }, { ...model, id: "two", providerId: "shared" }, { ...model, id: "three", providerId: "other" }], "video", { ...model, protocol: "minimax_video_v2", authMode: "bearer", apiKey: "" }).map((item) => item.id), ["one", "three"]); }); +test("Beijing Model Studio offers Qwen chat in both languages", () => { + const preset = PROVIDER_PRESETS.find((item) => item.id === "bailian-beijing"); + assert.ok(preset); + assert.deepEqual(preset.kinds, ["agent"]); + assert.deepEqual(preset.modelsByKind, { agent: ["qwen3.8-max"] }); + assert.equal(preset.url, "https://dashscope.aliyuncs.com/compatible-mode/v1"); + assert.equal(preset.connectionType, "api"); + assert.equal(preset.protocol, "openai"); + assert.equal(preset.authMode, "bearer"); + const previous = getActiveLocale(); + try { + for (const [locale, label] of [["zh-CN", "阿里云百炼(北京)"], ["en-US", "Alibaba Cloud Model Studio (Beijing)"]] as const) { + setActiveLocale(locale); + assert.equal(providerOptions("agent").find((item) => item.id === preset.id)?.name, label); + } + } finally { + setActiveLocale(previous); + } +}); + +test("saved Beijing workspace connections retain their preset", () => { + const model: ModelConfig = { + id: "saved-qwen", kind: "agent", modelId: "qwen3.8-max", connectionType: "api", + protocol: "openai", authMode: "bearer", url: "https://dashscope.aliyuncs.com/compatible-mode/v1/", + inputRules: { profile: "agent", allowedKinds: [], maxImages: 0, maxVideos: 0, maxAudios: 0, videoModes: [] }, + }; + assert.equal(providerPresetFor(model), "bailian-beijing"); + const workspace = "https://ws-example.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"; + assert.equal(providerPresetFor({ ...model, url: workspace + "/" }), "bailian-beijing"); + for (const url of [ + workspace.replace("cn-beijing", "ap-southeast-1"), + workspace.replace("aliyuncs.com", "aliyuncs.com.example.org"), + workspace.replace("/compatible-mode/v1", "/v1"), + "https://proxy.example.org/v1", + ]) { + assert.equal(providerPresetFor({ ...model, url }), "custom"); + } + assert.equal(providerPresetFor({ ...model, kind: "image", url: workspace }), "custom"); +}); + test("MOSS presets offer the supported image and speech models by kind", () => { const preset = PROVIDER_PRESETS.find((item) => item.id === "moss"); assert.ok(preset); diff --git a/tests/test_native_runtime.py b/tests/test_native_runtime.py index c02640a9..d62cbdb3 100644 --- a/tests/test_native_runtime.py +++ b/tests/test_native_runtime.py @@ -445,12 +445,17 @@ async def no_skills(*args): def test_real_opencode_native_skill_and_gateway_write(tmp_path, runtime, resume, monkeypatch, real_opencode_worker): if not real_opencode_worker: return + # Exercise the Beijing workspace endpoint with an in-process provider double. + base_url = 'https://ws-test.cn-beijing.maas.aliyuncs.com/compatible-mode/v1' + model_id = 'qwen3.8-max' requests = [] write_receipts = [] def model(request): payload = json.loads(request.content) if payload.get('tools'): requests.append(payload) + assert str(request.url) == f'{base_url}/chat/completions' + assert payload['model'] == model_id and payload['stream'] is True assert request.headers['authorization'] == 'Bearer server-only-key' count = len(requests) print(f'stage: model request {count} (tools={bool(payload.get("tools"))})', flush=True) @@ -460,6 +465,8 @@ def model(request): assert any(tool['function']['name'] == 'skill' for tool in payload['tools']) call = {'name':'skill','arguments':json.dumps({'name':'character-replacement'})} elif count == 2: + previous = next(message for message in payload['messages'] if message.get('tool_calls')) + assert previous['reasoning_content'] == 'Check the request.' assert 'Create a 24-FPS constant-frame-rate working copy' in json.dumps(payload['messages']) assert not any('opendirector_' in tool['function']['name'] for tool in payload['tools']) call = {'name': 'bash', 'arguments': json.dumps({ @@ -473,7 +480,7 @@ def model(request): write_receipts.append(receipt) call = None delta = {'tool_calls':[{'index':0,'id':f'call-{count}','type':'function','function':call}]} if call else {'content':'OpenCode ready.'} - chunk = {'id':f'completion-{count}','object':'chat.completion.chunk','created':1,'model':'test-model', + chunk = {'id':f'completion-{count}','object':'chat.completion.chunk','created':1,'model':model_id, 'choices':[{'index':0,'delta':delta,'finish_reason':None}]} finish = {**chunk,'choices':[{'index':0,'delta':{},'finish_reason':'tool_calls' if call else 'stop'}]} reasoning = {**chunk, 'choices': [{'index': 0, @@ -481,7 +488,7 @@ def model(request): return httpx.Response(200,headers={'content-type':'text/event-stream'},content=( 'data: '+json.dumps(reasoning)+'\n\ndata: '+json.dumps(chunk)+'\n\ndata: '+json.dumps(finish)+'\n\ndata: [DONE]\n\n').encode()) local = LocalProjects(tmp_path) - config = ModelConfigs(tmp_path).save('agent','http://test.invalid/v1','test-model','server-only-key') + config = ModelConfigs(tmp_path).save('agent',base_url,model_id,'server-only-key') provider = ChatCompletions(httpx.MockTransport(model)) with TestClient(create_app(local, chat_provider=provider), headers={'X-OpenDirector-Request':'1','X-OpenDirector-Session':str(uuid4())}) as client: path = project(client)