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:
12
extensions/qwen/README.md
Normal file
12
extensions/qwen/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# OpenClaw Qwen Cloud Provider
|
||||
|
||||
Official OpenClaw provider plugin for Qwen Cloud.
|
||||
|
||||
Install from OpenClaw:
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/qwen-provider
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
See <https://docs.openclaw.ai/providers/qwen> for setup and configuration.
|
||||
35
extensions/qwen/api.ts
Normal file
35
extensions/qwen/api.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// Qwen API module exposes the plugin public contract.
|
||||
export {
|
||||
applyQwenNativeStreamingUsageCompat,
|
||||
buildQwenDefaultModelDefinition,
|
||||
buildQwenModelDefinition,
|
||||
buildQwenModelCatalogForBaseUrl,
|
||||
isNativeQwenBaseUrl,
|
||||
isQwen36PlusSupportedBaseUrl,
|
||||
isQwenCodingPlanBaseUrl,
|
||||
QWEN_36_PLUS_MODEL_ID,
|
||||
QWEN_BASE_URL,
|
||||
QWEN_CN_BASE_URL,
|
||||
QWEN_DEFAULT_COST,
|
||||
QWEN_DEFAULT_MODEL_ID,
|
||||
QWEN_DEFAULT_MODEL_REF,
|
||||
QWEN_GLOBAL_BASE_URL,
|
||||
QWEN_STANDARD_CN_BASE_URL,
|
||||
QWEN_STANDARD_GLOBAL_BASE_URL,
|
||||
QWEN_MODEL_CATALOG,
|
||||
applyModelStudioNativeStreamingUsageCompat,
|
||||
buildModelStudioDefaultModelDefinition,
|
||||
buildModelStudioModelDefinition,
|
||||
isNativeModelStudioBaseUrl,
|
||||
MODELSTUDIO_BASE_URL,
|
||||
MODELSTUDIO_CN_BASE_URL,
|
||||
MODELSTUDIO_DEFAULT_COST,
|
||||
MODELSTUDIO_DEFAULT_MODEL_ID,
|
||||
MODELSTUDIO_DEFAULT_MODEL_REF,
|
||||
MODELSTUDIO_GLOBAL_BASE_URL,
|
||||
MODELSTUDIO_STANDARD_CN_BASE_URL,
|
||||
MODELSTUDIO_STANDARD_GLOBAL_BASE_URL,
|
||||
MODELSTUDIO_MODEL_CATALOG,
|
||||
} from "./models.js";
|
||||
export { buildModelStudioProvider, buildQwenProvider } from "./provider-catalog.js";
|
||||
export { createQwenThinkingWrapper, wrapQwenProviderStream } from "./stream.js";
|
||||
148
extensions/qwen/index.test.ts
Normal file
148
extensions/qwen/index.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
// Qwen tests cover index plugin behavior.
|
||||
import {
|
||||
registerProviderPlugin,
|
||||
requireRegisteredProvider,
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import type { ProviderCatalogResult } from "openclaw/plugin-sdk/provider-catalog-shared";
|
||||
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { QWEN_36_PLUS_MODEL_ID, QWEN_BASE_URL } from "./api.js";
|
||||
import qwenPlugin from "./index.js";
|
||||
import { wrapQwenProviderStream } from "./stream.js";
|
||||
|
||||
function requireCatalogProvider(result: ProviderCatalogResult): ModelProviderConfig {
|
||||
if (!result || !("provider" in result)) {
|
||||
throw new Error("single provider catalog result missing");
|
||||
}
|
||||
return result.provider;
|
||||
}
|
||||
|
||||
async function registerQwenProvider() {
|
||||
const { providers } = await registerProviderPlugin({
|
||||
plugin: qwenPlugin,
|
||||
id: "qwen",
|
||||
name: "Qwen Provider",
|
||||
});
|
||||
return requireRegisteredProvider(providers, "qwen");
|
||||
}
|
||||
|
||||
describe("qwen provider plugin", () => {
|
||||
it("keeps qwen3.6-plus out of Coding Plan normalized catalogs", async () => {
|
||||
const provider = await registerQwenProvider();
|
||||
|
||||
const normalized = provider.normalizeConfig?.({
|
||||
provider: "qwen",
|
||||
providerConfig: {
|
||||
baseUrl: QWEN_BASE_URL,
|
||||
models: [{ id: "qwen3.5-plus" }, { id: QWEN_36_PLUS_MODEL_ID }],
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(normalized?.models?.map((model) => model.id)).toEqual(["qwen3.5-plus"]);
|
||||
});
|
||||
|
||||
it("does not expose runtime model suppression hooks", async () => {
|
||||
const provider = await registerQwenProvider();
|
||||
|
||||
expect(provider.suppressBuiltInModel).toBeUndefined();
|
||||
});
|
||||
|
||||
it("registers qwen-oauth as a portal provider", async () => {
|
||||
const { providers } = await registerProviderPlugin({
|
||||
plugin: qwenPlugin,
|
||||
id: "qwen",
|
||||
name: "Qwen Provider",
|
||||
});
|
||||
const provider = requireRegisteredProvider(providers, "qwen-oauth");
|
||||
|
||||
expect(provider.aliases).toEqual(["qwen-portal", "qwen-cli"]);
|
||||
expect(provider.envVars).toEqual(["QWEN_API_KEY"]);
|
||||
expect(provider.auth?.map((method) => method.id)).toEqual(["api-key"]);
|
||||
|
||||
const result = await provider.staticCatalog?.run({
|
||||
config: {},
|
||||
env: {},
|
||||
resolveProviderApiKey: () => ({}),
|
||||
} as never);
|
||||
const catalogProvider = requireCatalogProvider(result);
|
||||
expect(catalogProvider.baseUrl).toBe("https://portal.qwen.ai/v1");
|
||||
expect(catalogProvider.models?.map((model) => model.id)).toContain("qwen3.5-plus");
|
||||
});
|
||||
|
||||
it("reuses legacy qwen portal auth profiles for qwen-oauth catalog", async () => {
|
||||
const { providers } = await registerProviderPlugin({
|
||||
plugin: qwenPlugin,
|
||||
id: "qwen",
|
||||
name: "Qwen Provider",
|
||||
});
|
||||
const provider = requireRegisteredProvider(providers, "qwen-oauth");
|
||||
|
||||
const result = await provider.catalog?.run({
|
||||
config: {},
|
||||
env: {},
|
||||
resolveProviderApiKey: (providerId: string) =>
|
||||
providerId === "qwen-portal" ? { apiKey: "portal-token" } : {},
|
||||
} as never);
|
||||
|
||||
const catalogProvider = requireCatalogProvider(result);
|
||||
expect(catalogProvider.apiKey).toBe("portal-token");
|
||||
expect(catalogProvider.baseUrl).toBe("https://portal.qwen.ai/v1");
|
||||
});
|
||||
|
||||
it.each([["qwen-oauth"], ["qwen-portal"], ["qwen-cli"]])(
|
||||
"patches %s message payloads for portal compatibility",
|
||||
async (providerId) => {
|
||||
let patchedPayload: Record<string, unknown> | undefined;
|
||||
const streamFn = wrapQwenProviderStream({
|
||||
provider: providerId,
|
||||
thinkingLevel: "off",
|
||||
streamFn: ((
|
||||
_model: unknown,
|
||||
_context: unknown,
|
||||
options?: {
|
||||
onPayload?: (payload: Record<string, unknown>, model: unknown) => void;
|
||||
},
|
||||
) => {
|
||||
const payload = {
|
||||
messages: [
|
||||
{ role: "system", content: "system text" },
|
||||
{ role: "user", content: ["hello", { type: "text", text: "world" }] },
|
||||
],
|
||||
};
|
||||
options?.onPayload?.(payload, _model);
|
||||
patchedPayload = payload;
|
||||
return (async function* () {})();
|
||||
}) as never,
|
||||
} as never);
|
||||
|
||||
const stream = streamFn!(
|
||||
{
|
||||
provider: providerId,
|
||||
api: "openai-completions",
|
||||
id: "qwen3.5-plus",
|
||||
} as never,
|
||||
{} as never,
|
||||
{},
|
||||
) as AsyncIterable<unknown>;
|
||||
for await (const event of stream) {
|
||||
void event;
|
||||
// Drain stream so the payload hook runs.
|
||||
}
|
||||
|
||||
expect(patchedPayload?.vl_high_resolution_images).toBe(true);
|
||||
expect(patchedPayload?.messages).toEqual([
|
||||
{
|
||||
role: "system",
|
||||
content: [{ type: "text", text: "system text", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "hello" },
|
||||
{ type: "text", text: "world" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
243
extensions/qwen/index.ts
Normal file
243
extensions/qwen/index.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
// Qwen plugin entrypoint registers its OpenClaw integration.
|
||||
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
|
||||
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
|
||||
import { applyQwenNativeStreamingUsageCompat } from "./api.js";
|
||||
import { buildQwenMediaUnderstandingProvider } from "./media-understanding-provider.js";
|
||||
import {
|
||||
isQwenCodingPlanBaseUrl,
|
||||
QWEN_36_PLUS_MODEL_ID,
|
||||
QWEN_BASE_URL,
|
||||
QWEN_DEFAULT_MODEL_REF,
|
||||
QWEN_OAUTH_DEFAULT_MODEL_REF,
|
||||
QWEN_OAUTH_PROVIDER_ID,
|
||||
} from "./models.js";
|
||||
import {
|
||||
applyQwenConfig,
|
||||
applyQwenConfigCn,
|
||||
applyQwenOAuthConfig,
|
||||
applyQwenStandardConfig,
|
||||
applyQwenStandardConfigCn,
|
||||
} from "./onboard.js";
|
||||
import { buildQwenOAuthProvider, buildQwenProvider } from "./provider-catalog.js";
|
||||
import { wrapQwenProviderStream } from "./stream.js";
|
||||
import { buildQwenVideoGenerationProvider } from "./video-generation-provider.js";
|
||||
|
||||
const PROVIDER_ID = "qwen";
|
||||
const LEGACY_PROVIDER_ID = "modelstudio";
|
||||
const QWEN_OAUTH_AUTH_PROVIDER_IDS = [QWEN_OAUTH_PROVIDER_ID, "qwen-portal", "qwen-cli"] as const;
|
||||
|
||||
function normalizeProviderId(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function resolveConfiguredQwenBaseUrl(
|
||||
config: { models?: { providers?: Record<string, { baseUrl?: string } | undefined> } } | undefined,
|
||||
): string | undefined {
|
||||
const providers = config?.models?.providers;
|
||||
if (!providers) {
|
||||
return undefined;
|
||||
}
|
||||
for (const [providerId, provider] of Object.entries(providers)) {
|
||||
const normalized = normalizeProviderId(providerId);
|
||||
if (normalized !== PROVIDER_ID && normalized !== LEGACY_PROVIDER_ID) {
|
||||
continue;
|
||||
}
|
||||
const baseUrl = provider?.baseUrl?.trim();
|
||||
if (baseUrl) {
|
||||
return baseUrl;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export default defineSingleProviderPluginEntry({
|
||||
id: PROVIDER_ID,
|
||||
name: "Qwen Provider",
|
||||
description: "Bundled Qwen Cloud provider plugin",
|
||||
provider: {
|
||||
label: "Qwen Cloud",
|
||||
docsPath: "/providers/qwen",
|
||||
aliases: ["modelstudio", "qwencloud"],
|
||||
auth: [
|
||||
{
|
||||
methodId: "standard-api-key-cn",
|
||||
label: "Standard API Key for China (pay-as-you-go)",
|
||||
hint: "Endpoint: dashscope.aliyuncs.com",
|
||||
optionKey: "modelstudioStandardApiKeyCn",
|
||||
flagName: "--modelstudio-standard-api-key-cn",
|
||||
envVar: "QWEN_API_KEY",
|
||||
promptMessage: "Enter Qwen Cloud API key (China standard endpoint)",
|
||||
defaultModel: QWEN_DEFAULT_MODEL_REF,
|
||||
applyConfig: (cfg) => applyQwenStandardConfigCn(cfg),
|
||||
noteMessage: [
|
||||
"Manage API keys: https://home.qwencloud.com/api-keys",
|
||||
"Docs: https://docs.qwencloud.com/",
|
||||
"Endpoint: dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"Models: qwen3.6-plus, qwen3.5-plus, qwen3-coder-plus, etc.",
|
||||
].join("\n"),
|
||||
noteTitle: "Qwen Cloud Standard (China)",
|
||||
wizard: {
|
||||
choiceHint: "Endpoint: dashscope.aliyuncs.com",
|
||||
groupLabel: "Qwen Cloud",
|
||||
groupHint: "Standard / Coding Plan (CN / Global) + multimodal roadmap",
|
||||
},
|
||||
},
|
||||
{
|
||||
methodId: "standard-api-key",
|
||||
label: "Standard API Key for Global/Intl (pay-as-you-go)",
|
||||
hint: "Endpoint: dashscope-intl.aliyuncs.com",
|
||||
optionKey: "modelstudioStandardApiKey",
|
||||
flagName: "--modelstudio-standard-api-key",
|
||||
envVar: "QWEN_API_KEY",
|
||||
promptMessage: "Enter Qwen Cloud API key (Global/Intl standard endpoint)",
|
||||
defaultModel: QWEN_DEFAULT_MODEL_REF,
|
||||
applyConfig: (cfg) => applyQwenStandardConfig(cfg),
|
||||
noteMessage: [
|
||||
"Manage API keys: https://home.qwencloud.com/api-keys",
|
||||
"Docs: https://docs.qwencloud.com/",
|
||||
"Endpoint: dashscope-intl.aliyuncs.com/compatible-mode/v1",
|
||||
"Models: qwen3.6-plus, qwen3.5-plus, qwen3-coder-plus, etc.",
|
||||
].join("\n"),
|
||||
noteTitle: "Qwen Cloud Standard (Global/Intl)",
|
||||
wizard: {
|
||||
choiceHint: "Endpoint: dashscope-intl.aliyuncs.com",
|
||||
groupLabel: "Qwen Cloud",
|
||||
groupHint: "Standard / Coding Plan (CN / Global) + multimodal roadmap",
|
||||
},
|
||||
},
|
||||
{
|
||||
methodId: "api-key-cn",
|
||||
label: "Coding Plan API Key for China (subscription)",
|
||||
hint: "Endpoint: coding.dashscope.aliyuncs.com",
|
||||
optionKey: "modelstudioApiKeyCn",
|
||||
flagName: "--modelstudio-api-key-cn",
|
||||
envVar: "QWEN_API_KEY",
|
||||
promptMessage: "Enter Qwen Cloud Coding Plan API key (China)",
|
||||
defaultModel: QWEN_DEFAULT_MODEL_REF,
|
||||
applyConfig: (cfg) => applyQwenConfigCn(cfg),
|
||||
noteMessage: [
|
||||
"Manage API keys: https://home.qwencloud.com/api-keys",
|
||||
"Docs: https://docs.qwencloud.com/",
|
||||
"Endpoint: coding.dashscope.aliyuncs.com",
|
||||
"Models: qwen3.5-plus, glm-5, kimi-k2.5, MiniMax-M2.5, etc.",
|
||||
].join("\n"),
|
||||
noteTitle: "Qwen Cloud Coding Plan (China)",
|
||||
wizard: {
|
||||
choiceHint: "Endpoint: coding.dashscope.aliyuncs.com",
|
||||
groupLabel: "Qwen Cloud",
|
||||
groupHint: "Standard / Coding Plan (CN / Global) + multimodal roadmap",
|
||||
},
|
||||
},
|
||||
{
|
||||
methodId: "api-key",
|
||||
label: "Coding Plan API Key for Global/Intl (subscription)",
|
||||
hint: "Endpoint: coding-intl.dashscope.aliyuncs.com",
|
||||
optionKey: "modelstudioApiKey",
|
||||
flagName: "--modelstudio-api-key",
|
||||
envVar: "QWEN_API_KEY",
|
||||
promptMessage: "Enter Qwen Cloud Coding Plan API key (Global/Intl)",
|
||||
defaultModel: QWEN_DEFAULT_MODEL_REF,
|
||||
applyConfig: (cfg) => applyQwenConfig(cfg),
|
||||
noteMessage: [
|
||||
"Manage API keys: https://home.qwencloud.com/api-keys",
|
||||
"Docs: https://docs.qwencloud.com/",
|
||||
"Endpoint: coding-intl.dashscope.aliyuncs.com",
|
||||
"Models: qwen3.5-plus, glm-5, kimi-k2.5, MiniMax-M2.5, etc.",
|
||||
].join("\n"),
|
||||
noteTitle: "Qwen Cloud Coding Plan (Global/Intl)",
|
||||
wizard: {
|
||||
choiceHint: "Endpoint: coding-intl.dashscope.aliyuncs.com",
|
||||
groupLabel: "Qwen Cloud",
|
||||
groupHint: "Standard / Coding Plan (CN / Global) + multimodal roadmap",
|
||||
},
|
||||
},
|
||||
],
|
||||
catalog: {
|
||||
run: async (ctx) => {
|
||||
const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey;
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
const baseUrl = resolveConfiguredQwenBaseUrl(ctx.config) ?? QWEN_BASE_URL;
|
||||
return {
|
||||
provider: {
|
||||
...buildQwenProvider({ baseUrl }),
|
||||
apiKey,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
applyNativeStreamingUsageCompat: ({ providerConfig }) =>
|
||||
applyQwenNativeStreamingUsageCompat(providerConfig),
|
||||
wrapStreamFn: wrapQwenProviderStream,
|
||||
normalizeConfig: ({ providerConfig }) => {
|
||||
if (!isQwenCodingPlanBaseUrl(providerConfig.baseUrl)) {
|
||||
return undefined;
|
||||
}
|
||||
const models = providerConfig.models?.filter((model) => model.id !== QWEN_36_PLUS_MODEL_ID);
|
||||
return models && models.length !== providerConfig.models?.length
|
||||
? { ...providerConfig, models }
|
||||
: undefined;
|
||||
},
|
||||
},
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: QWEN_OAUTH_PROVIDER_ID,
|
||||
label: "Qwen OAuth",
|
||||
docsPath: "/providers/qwen",
|
||||
aliases: ["qwen-portal", "qwen-cli"],
|
||||
envVars: ["QWEN_API_KEY"],
|
||||
auth: [
|
||||
createProviderApiKeyAuthMethod({
|
||||
providerId: QWEN_OAUTH_PROVIDER_ID,
|
||||
methodId: "api-key",
|
||||
label: "Qwen OAuth token",
|
||||
hint: "Portal token for portal.qwen.ai",
|
||||
optionKey: "qwenOauthToken",
|
||||
flagName: "--qwen-oauth-token",
|
||||
envVar: "QWEN_API_KEY",
|
||||
promptMessage: "Enter Qwen OAuth token",
|
||||
defaultModel: QWEN_OAUTH_DEFAULT_MODEL_REF,
|
||||
applyConfig: (cfg) => applyQwenOAuthConfig(cfg),
|
||||
wizard: {
|
||||
choiceId: QWEN_OAUTH_PROVIDER_ID,
|
||||
choiceLabel: "Qwen OAuth",
|
||||
choiceHint: "Portal token for portal.qwen.ai",
|
||||
groupId: "qwen",
|
||||
groupLabel: "Qwen Cloud",
|
||||
groupHint: "Standard / Coding Plan / OAuth",
|
||||
},
|
||||
}),
|
||||
],
|
||||
catalog: {
|
||||
order: "simple",
|
||||
run: async (ctx) => {
|
||||
const apiKey = QWEN_OAUTH_AUTH_PROVIDER_IDS.map(
|
||||
(providerId) => ctx.resolveProviderApiKey(providerId).apiKey,
|
||||
).find(
|
||||
(candidate): candidate is string =>
|
||||
typeof candidate === "string" && candidate.length > 0,
|
||||
);
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
provider: {
|
||||
...buildQwenOAuthProvider(),
|
||||
apiKey,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
staticCatalog: {
|
||||
order: "simple",
|
||||
run: async () => ({
|
||||
provider: buildQwenOAuthProvider(),
|
||||
}),
|
||||
},
|
||||
wrapStreamFn: wrapQwenProviderStream,
|
||||
});
|
||||
api.registerMediaUnderstandingProvider(buildQwenMediaUnderstandingProvider());
|
||||
api.registerVideoGenerationProvider(buildQwenVideoGenerationProvider());
|
||||
},
|
||||
});
|
||||
148
extensions/qwen/media-understanding-provider.test.ts
Normal file
148
extensions/qwen/media-understanding-provider.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
// Qwen tests cover media understanding provider plugin behavior.
|
||||
import {
|
||||
createRequestCaptureJsonFetch,
|
||||
installPinnedHostnameTestHooks,
|
||||
} from "openclaw/plugin-sdk/test-env";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describeQwenVideo } from "./media-understanding-provider.js";
|
||||
|
||||
installPinnedHostnameTestHooks();
|
||||
|
||||
function oversizedJsonResponse(params: { chunkCount: number; chunkSize: number }): {
|
||||
response: Response;
|
||||
getReadCount: () => number;
|
||||
wasCanceled: () => boolean;
|
||||
} {
|
||||
const chunk = new Uint8Array(params.chunkSize);
|
||||
let readCount = 0;
|
||||
let canceled = false;
|
||||
return {
|
||||
response: new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (readCount >= params.chunkCount) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
readCount += 1;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
),
|
||||
getReadCount: () => readCount,
|
||||
wasCanceled: () => canceled,
|
||||
};
|
||||
}
|
||||
|
||||
describe("describeQwenVideo", () => {
|
||||
it("builds the expected OpenAI-compatible video payload", async () => {
|
||||
const { fetchFn, getRequest } = createRequestCaptureJsonFetch({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: [{ text: " first " }, { text: "second" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await describeQwenVideo({
|
||||
buffer: Buffer.from("video-bytes"),
|
||||
fileName: "clip.mp4",
|
||||
mime: "video/mp4",
|
||||
apiKey: "test-key",
|
||||
timeoutMs: 1500,
|
||||
baseUrl: "https://example.com/v1",
|
||||
model: "qwen-vl-max",
|
||||
prompt: "summarize the clip",
|
||||
headers: { "X-Other": "1" },
|
||||
fetchFn,
|
||||
});
|
||||
const { url, init } = getRequest();
|
||||
|
||||
expect(result.model).toBe("qwen-vl-max");
|
||||
expect(result.text).toBe("first\nsecond");
|
||||
expect(url).toBe("https://example.com/v1/chat/completions");
|
||||
if (!init) {
|
||||
throw new Error("expected Qwen request init");
|
||||
}
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.signal).toBeInstanceOf(AbortSignal);
|
||||
|
||||
const headers = new Headers(init.headers);
|
||||
expect(headers.get("authorization")).toBe("Bearer test-key");
|
||||
expect(headers.get("content-type")).toBe("application/json");
|
||||
expect(headers.get("x-other")).toBe("1");
|
||||
|
||||
const bodyText =
|
||||
typeof init.body === "string"
|
||||
? init.body
|
||||
: Buffer.isBuffer(init.body)
|
||||
? init.body.toString("utf8")
|
||||
: "";
|
||||
expect(bodyText).not.toBe("");
|
||||
const body = JSON.parse(bodyText);
|
||||
expect(body.model).toBe("qwen-vl-max");
|
||||
const content = body.messages?.[0]?.content;
|
||||
if (!content) {
|
||||
throw new Error("expected Qwen user content");
|
||||
}
|
||||
expect(content[0]?.text).toBe("summarize the clip");
|
||||
const videoContent = content[1];
|
||||
if (!videoContent) {
|
||||
throw new Error("expected Qwen video content");
|
||||
}
|
||||
expect(videoContent.type).toBe("video_url");
|
||||
if (!videoContent.video_url) {
|
||||
throw new Error("expected Qwen video URL payload");
|
||||
}
|
||||
expect(videoContent.video_url.url).toBe(
|
||||
`data:video/mp4;base64,${Buffer.from("video-bytes").toString("base64")}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("bounds successful Qwen video JSON bodies instead of buffering the whole response", async () => {
|
||||
const streamed = oversizedJsonResponse({ chunkCount: 64, chunkSize: 1024 * 1024 });
|
||||
|
||||
await expect(
|
||||
describeQwenVideo({
|
||||
buffer: Buffer.from("video-bytes"),
|
||||
fileName: "clip.mp4",
|
||||
mime: "video/mp4",
|
||||
apiKey: "test-key",
|
||||
timeoutMs: 1500,
|
||||
baseUrl: "https://example.com/v1",
|
||||
fetchFn: async () => streamed.response,
|
||||
}),
|
||||
).rejects.toThrow("Qwen video description failed: JSON response exceeds 16777216 bytes");
|
||||
|
||||
expect(streamed.getReadCount()).toBeLessThan(64);
|
||||
expect(streamed.wasCanceled()).toBe(true);
|
||||
});
|
||||
|
||||
it("reports malformed Qwen video JSON with a provider-owned error", async () => {
|
||||
const response = new Response("not-json{", {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
describeQwenVideo({
|
||||
buffer: Buffer.from("video-bytes"),
|
||||
fileName: "clip.mp4",
|
||||
mime: "video/mp4",
|
||||
apiKey: "test-key",
|
||||
timeoutMs: 1500,
|
||||
baseUrl: "https://example.com/v1",
|
||||
fetchFn: async () => response,
|
||||
}),
|
||||
).rejects.toThrow("Qwen video description failed: malformed JSON response");
|
||||
});
|
||||
});
|
||||
97
extensions/qwen/media-understanding-provider.ts
Normal file
97
extensions/qwen/media-understanding-provider.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
// Qwen provider module implements model/runtime integration.
|
||||
import {
|
||||
buildOpenAiCompatibleVideoRequestBody,
|
||||
coerceOpenAiCompatibleVideoText,
|
||||
describeImageWithModel,
|
||||
describeImagesWithModel,
|
||||
resolveMediaUnderstandingString,
|
||||
type MediaUnderstandingProvider,
|
||||
type OpenAiCompatibleVideoPayload,
|
||||
type VideoDescriptionRequest,
|
||||
type VideoDescriptionResult,
|
||||
} from "openclaw/plugin-sdk/media-understanding";
|
||||
import {
|
||||
assertOkOrThrowHttpError,
|
||||
postJsonRequest,
|
||||
readProviderJsonResponse,
|
||||
resolveProviderHttpRequestConfig,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { QWEN_STANDARD_GLOBAL_BASE_URL } from "./models.js";
|
||||
|
||||
const DEFAULT_QWEN_VIDEO_MODEL = "qwen-vl-max-latest";
|
||||
const DEFAULT_QWEN_VIDEO_PROMPT = "Describe the video in detail.";
|
||||
|
||||
export async function describeQwenVideo(
|
||||
params: VideoDescriptionRequest,
|
||||
): Promise<VideoDescriptionResult> {
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
const model = resolveMediaUnderstandingString(params.model, DEFAULT_QWEN_VIDEO_MODEL);
|
||||
const mime = resolveMediaUnderstandingString(params.mime, "video/mp4");
|
||||
const prompt = resolveMediaUnderstandingString(params.prompt, DEFAULT_QWEN_VIDEO_PROMPT);
|
||||
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
|
||||
resolveProviderHttpRequestConfig({
|
||||
baseUrl: params.baseUrl,
|
||||
defaultBaseUrl: QWEN_STANDARD_GLOBAL_BASE_URL,
|
||||
headers: params.headers,
|
||||
request: params.request,
|
||||
defaultHeaders: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${params.apiKey}`,
|
||||
},
|
||||
provider: "qwen",
|
||||
api: "openai-completions",
|
||||
capability: "video",
|
||||
transport: "media-understanding",
|
||||
});
|
||||
|
||||
const { response: res, release } = await postJsonRequest({
|
||||
url: `${baseUrl}/chat/completions`,
|
||||
headers,
|
||||
body: buildOpenAiCompatibleVideoRequestBody({
|
||||
model,
|
||||
prompt,
|
||||
mime,
|
||||
buffer: params.buffer,
|
||||
}),
|
||||
timeoutMs: params.timeoutMs,
|
||||
fetchFn,
|
||||
allowPrivateNetwork,
|
||||
dispatcherPolicy,
|
||||
});
|
||||
|
||||
try {
|
||||
await assertOkOrThrowHttpError(res, "Qwen video description failed");
|
||||
// Read the success body through the shared byte-bounded JSON reader (16 MiB cap +
|
||||
// stream cancel on overflow) so a hostile or buggy endpoint cannot force the runtime
|
||||
// to buffer an unbounded body. Malformed JSON keeps the
|
||||
// `Qwen video description failed: malformed JSON response` wrapping.
|
||||
const payload = await readProviderJsonResponse<OpenAiCompatibleVideoPayload>(
|
||||
res,
|
||||
"Qwen video description failed",
|
||||
);
|
||||
const text = coerceOpenAiCompatibleVideoText(payload);
|
||||
if (!text) {
|
||||
throw new Error("Qwen video description response missing content");
|
||||
}
|
||||
return { text, model };
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
export function buildQwenMediaUnderstandingProvider(): MediaUnderstandingProvider {
|
||||
return {
|
||||
id: "qwen",
|
||||
capabilities: ["image", "video"],
|
||||
defaultModels: {
|
||||
image: "qwen-vl-max-latest",
|
||||
video: DEFAULT_QWEN_VIDEO_MODEL,
|
||||
},
|
||||
autoPriority: {
|
||||
video: 15,
|
||||
},
|
||||
describeImage: describeImageWithModel,
|
||||
describeImages: describeImagesWithModel,
|
||||
describeVideo: describeQwenVideo,
|
||||
};
|
||||
}
|
||||
210
extensions/qwen/models.ts
Normal file
210
extensions/qwen/models.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
// Qwen plugin module implements models behavior.
|
||||
import {
|
||||
applyProviderNativeStreamingUsageCompat,
|
||||
supportsNativeStreamingUsageCompat,
|
||||
} from "openclaw/plugin-sdk/provider-catalog-shared";
|
||||
import type {
|
||||
ModelDefinitionConfig,
|
||||
ModelProviderConfig,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
|
||||
export const QWEN_BASE_URL = "https://coding-intl.dashscope.aliyuncs.com/v1";
|
||||
export const QWEN_GLOBAL_BASE_URL = QWEN_BASE_URL;
|
||||
export const QWEN_CN_BASE_URL = "https://coding.dashscope.aliyuncs.com/v1";
|
||||
export const QWEN_STANDARD_CN_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1";
|
||||
export const QWEN_STANDARD_GLOBAL_BASE_URL =
|
||||
"https://dashscope-intl.aliyuncs.com/compatible-mode/v1";
|
||||
export const QWEN_OAUTH_PROVIDER_ID = "qwen-oauth";
|
||||
export const QWEN_OAUTH_BASE_URL = "https://portal.qwen.ai/v1";
|
||||
|
||||
export const QWEN_DEFAULT_MODEL_ID = "qwen3.5-plus";
|
||||
export const QWEN_36_PLUS_MODEL_ID = "qwen3.6-plus";
|
||||
export const QWEN_DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
export const QWEN_DEFAULT_MODEL_REF = `qwen/${QWEN_DEFAULT_MODEL_ID}`;
|
||||
export const QWEN_OAUTH_DEFAULT_MODEL_REF = `qwen-oauth/${QWEN_DEFAULT_MODEL_ID}`;
|
||||
|
||||
export const QWEN_MODEL_CATALOG: ReadonlyArray<ModelDefinitionConfig> = [
|
||||
{
|
||||
id: "qwen3.5-plus",
|
||||
name: "qwen3.5-plus",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: QWEN_DEFAULT_COST,
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 65_536,
|
||||
},
|
||||
{
|
||||
id: QWEN_36_PLUS_MODEL_ID,
|
||||
name: QWEN_36_PLUS_MODEL_ID,
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: QWEN_DEFAULT_COST,
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 65_536,
|
||||
},
|
||||
{
|
||||
id: "qwen3-max-2026-01-23",
|
||||
name: "qwen3-max-2026-01-23",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: QWEN_DEFAULT_COST,
|
||||
contextWindow: 262_144,
|
||||
maxTokens: 65_536,
|
||||
},
|
||||
{
|
||||
id: "qwen3-coder-next",
|
||||
name: "qwen3-coder-next",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: QWEN_DEFAULT_COST,
|
||||
contextWindow: 262_144,
|
||||
maxTokens: 65_536,
|
||||
},
|
||||
{
|
||||
id: "qwen3-coder-plus",
|
||||
name: "qwen3-coder-plus",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: QWEN_DEFAULT_COST,
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 65_536,
|
||||
},
|
||||
{
|
||||
id: "MiniMax-M2.5",
|
||||
name: "MiniMax-M2.5",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: QWEN_DEFAULT_COST,
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 65_536,
|
||||
},
|
||||
{
|
||||
id: "glm-5",
|
||||
name: "glm-5",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: QWEN_DEFAULT_COST,
|
||||
contextWindow: 202_752,
|
||||
maxTokens: 16_384,
|
||||
},
|
||||
{
|
||||
id: "glm-4.7",
|
||||
name: "glm-4.7",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: QWEN_DEFAULT_COST,
|
||||
contextWindow: 202_752,
|
||||
maxTokens: 16_384,
|
||||
},
|
||||
{
|
||||
id: "kimi-k2.5",
|
||||
name: "kimi-k2.5",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: QWEN_DEFAULT_COST,
|
||||
contextWindow: 262_144,
|
||||
maxTokens: 32_768,
|
||||
},
|
||||
];
|
||||
|
||||
export function isQwenCodingPlanBaseUrl(baseUrl: string | undefined): boolean {
|
||||
const trimmed = baseUrl?.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const hostname = new URL(trimmed).hostname.toLowerCase().replace(/\.+$/, "");
|
||||
return (
|
||||
hostname === "coding.dashscope.aliyuncs.com" ||
|
||||
hostname === "coding-intl.dashscope.aliyuncs.com"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isQwen36PlusSupportedBaseUrl(baseUrl: string | undefined): boolean {
|
||||
return !isQwenCodingPlanBaseUrl(baseUrl);
|
||||
}
|
||||
|
||||
export function buildQwenModelCatalogForBaseUrl(
|
||||
baseUrl: string | undefined,
|
||||
): ReadonlyArray<ModelDefinitionConfig> {
|
||||
return isQwen36PlusSupportedBaseUrl(baseUrl)
|
||||
? QWEN_MODEL_CATALOG
|
||||
: QWEN_MODEL_CATALOG.filter((model) => model.id !== QWEN_36_PLUS_MODEL_ID);
|
||||
}
|
||||
|
||||
export function isNativeQwenBaseUrl(baseUrl: string | undefined): boolean {
|
||||
return supportsNativeStreamingUsageCompat({
|
||||
providerId: "qwen",
|
||||
baseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
export function applyQwenNativeStreamingUsageCompat(
|
||||
provider: ModelProviderConfig,
|
||||
): ModelProviderConfig {
|
||||
return applyProviderNativeStreamingUsageCompat({
|
||||
providerId: "qwen",
|
||||
providerConfig: provider,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildQwenModelDefinition(params: {
|
||||
id: string;
|
||||
name?: string;
|
||||
reasoning?: boolean;
|
||||
input?: string[];
|
||||
cost?: ModelDefinitionConfig["cost"];
|
||||
contextWindow?: number;
|
||||
maxTokens?: number;
|
||||
}): ModelDefinitionConfig {
|
||||
const catalog = QWEN_MODEL_CATALOG.find((model) => model.id === params.id);
|
||||
return {
|
||||
id: params.id,
|
||||
name: params.name ?? catalog?.name ?? params.id,
|
||||
reasoning: params.reasoning ?? catalog?.reasoning ?? false,
|
||||
input:
|
||||
(params.input as ("text" | "image")[]) ?? (catalog?.input ? [...catalog.input] : ["text"]),
|
||||
cost: params.cost ?? catalog?.cost ?? QWEN_DEFAULT_COST,
|
||||
contextWindow: params.contextWindow ?? catalog?.contextWindow ?? 262_144,
|
||||
maxTokens: params.maxTokens ?? catalog?.maxTokens ?? 65_536,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildQwenDefaultModelDefinition(): ModelDefinitionConfig {
|
||||
return buildQwenModelDefinition({ id: QWEN_DEFAULT_MODEL_ID });
|
||||
}
|
||||
|
||||
export function buildQwenOAuthModelCatalog(): ReadonlyArray<ModelDefinitionConfig> {
|
||||
return QWEN_MODEL_CATALOG.map((model) => ({ ...model, maxTokens: 65_536 }));
|
||||
}
|
||||
|
||||
/** @deprecated Use QWEN_BASE_URL. */
|
||||
export const MODELSTUDIO_BASE_URL = QWEN_BASE_URL;
|
||||
/** @deprecated Use QWEN_GLOBAL_BASE_URL. */
|
||||
export const MODELSTUDIO_GLOBAL_BASE_URL = QWEN_GLOBAL_BASE_URL;
|
||||
/** @deprecated Use QWEN_CN_BASE_URL. */
|
||||
export const MODELSTUDIO_CN_BASE_URL = QWEN_CN_BASE_URL;
|
||||
/** @deprecated Use QWEN_STANDARD_CN_BASE_URL. */
|
||||
export const MODELSTUDIO_STANDARD_CN_BASE_URL = QWEN_STANDARD_CN_BASE_URL;
|
||||
/** @deprecated Use QWEN_STANDARD_GLOBAL_BASE_URL. */
|
||||
export const MODELSTUDIO_STANDARD_GLOBAL_BASE_URL = QWEN_STANDARD_GLOBAL_BASE_URL;
|
||||
/** @deprecated Use QWEN_DEFAULT_MODEL_ID. */
|
||||
export const MODELSTUDIO_DEFAULT_MODEL_ID = QWEN_DEFAULT_MODEL_ID;
|
||||
/** @deprecated Use QWEN_DEFAULT_COST. */
|
||||
export const MODELSTUDIO_DEFAULT_COST = QWEN_DEFAULT_COST;
|
||||
/** @deprecated Use qwen/${QWEN_DEFAULT_MODEL_ID}. */
|
||||
export const MODELSTUDIO_DEFAULT_MODEL_REF = `modelstudio/${QWEN_DEFAULT_MODEL_ID}`;
|
||||
/** @deprecated Use QWEN_MODEL_CATALOG. */
|
||||
export const MODELSTUDIO_MODEL_CATALOG = QWEN_MODEL_CATALOG;
|
||||
export const isNativeModelStudioBaseUrl = isNativeQwenBaseUrl;
|
||||
export const applyModelStudioNativeStreamingUsageCompat = applyQwenNativeStreamingUsageCompat;
|
||||
export const buildModelStudioModelDefinition = buildQwenModelDefinition;
|
||||
export const buildModelStudioDefaultModelDefinition = buildQwenDefaultModelDefinition;
|
||||
12
extensions/qwen/npm-shrinkwrap.json
generated
Normal file
12
extensions/qwen/npm-shrinkwrap.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/qwen-provider",
|
||||
"version": "2026.6.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/qwen-provider",
|
||||
"version": "2026.6.11"
|
||||
}
|
||||
}
|
||||
}
|
||||
72
extensions/qwen/onboard.ts
Normal file
72
extensions/qwen/onboard.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
// Qwen setup module handles plugin onboarding behavior.
|
||||
import {
|
||||
createModelCatalogPresetAppliers,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/provider-onboard";
|
||||
import {
|
||||
QWEN_CN_BASE_URL,
|
||||
QWEN_DEFAULT_MODEL_REF,
|
||||
QWEN_GLOBAL_BASE_URL,
|
||||
QWEN_OAUTH_DEFAULT_MODEL_REF,
|
||||
QWEN_OAUTH_PROVIDER_ID,
|
||||
QWEN_STANDARD_CN_BASE_URL,
|
||||
QWEN_STANDARD_GLOBAL_BASE_URL,
|
||||
} from "./models.js";
|
||||
import { buildQwenOAuthProvider, buildQwenProvider } from "./provider-catalog.js";
|
||||
|
||||
const qwenPresetAppliers = createModelCatalogPresetAppliers<[string]>({
|
||||
primaryModelRef: QWEN_DEFAULT_MODEL_REF,
|
||||
resolveParams: (_cfg: OpenClawConfig, baseUrl: string) => {
|
||||
const provider = buildQwenProvider({ baseUrl });
|
||||
return {
|
||||
providerId: "qwen",
|
||||
api: provider.api ?? "openai-completions",
|
||||
baseUrl,
|
||||
catalogModels: provider.models ?? [],
|
||||
aliases: [
|
||||
...(provider.models ?? []).flatMap((model) => [
|
||||
`qwen/${model.id}`,
|
||||
`modelstudio/${model.id}`,
|
||||
]),
|
||||
{ modelRef: QWEN_DEFAULT_MODEL_REF, alias: "Qwen" },
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const qwenOAuthPresetAppliers = createModelCatalogPresetAppliers<[]>({
|
||||
primaryModelRef: QWEN_OAUTH_DEFAULT_MODEL_REF,
|
||||
resolveParams: () => {
|
||||
const provider = buildQwenOAuthProvider();
|
||||
return {
|
||||
providerId: QWEN_OAUTH_PROVIDER_ID,
|
||||
api: provider.api ?? "openai-completions",
|
||||
baseUrl: provider.baseUrl,
|
||||
catalogModels: provider.models ?? [],
|
||||
aliases: [
|
||||
...(provider.models ?? []).map((model) => `qwen-oauth/${model.id}`),
|
||||
{ modelRef: QWEN_OAUTH_DEFAULT_MODEL_REF, alias: "Qwen OAuth" },
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export function applyQwenConfig(cfg: OpenClawConfig): OpenClawConfig {
|
||||
return qwenPresetAppliers.applyConfig(cfg, QWEN_GLOBAL_BASE_URL);
|
||||
}
|
||||
|
||||
export function applyQwenConfigCn(cfg: OpenClawConfig): OpenClawConfig {
|
||||
return qwenPresetAppliers.applyConfig(cfg, QWEN_CN_BASE_URL);
|
||||
}
|
||||
|
||||
export function applyQwenStandardConfig(cfg: OpenClawConfig): OpenClawConfig {
|
||||
return qwenPresetAppliers.applyConfig(cfg, QWEN_STANDARD_GLOBAL_BASE_URL);
|
||||
}
|
||||
|
||||
export function applyQwenStandardConfigCn(cfg: OpenClawConfig): OpenClawConfig {
|
||||
return qwenPresetAppliers.applyConfig(cfg, QWEN_STANDARD_CN_BASE_URL);
|
||||
}
|
||||
|
||||
export function applyQwenOAuthConfig(cfg: OpenClawConfig): OpenClawConfig {
|
||||
return qwenOAuthPresetAppliers.applyConfig(cfg);
|
||||
}
|
||||
326
extensions/qwen/openclaw.plugin.json
Normal file
326
extensions/qwen/openclaw.plugin.json
Normal file
@@ -0,0 +1,326 @@
|
||||
{
|
||||
"id": "qwen",
|
||||
"icon": "https://cdn.simpleicons.org/qwen",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
"enabledByDefault": true,
|
||||
"providers": ["qwen", "qwencloud", "modelstudio", "dashscope", "qwen-oauth", "qwen-portal", "qwen-cli"],
|
||||
"providerAuthAliases": {
|
||||
"qwen-portal": "qwen-oauth",
|
||||
"qwen-cli": "qwen-oauth"
|
||||
},
|
||||
"providerEndpoints": [
|
||||
{
|
||||
"endpointClass": "modelstudio-native",
|
||||
"baseUrls": [
|
||||
"https://coding-intl.dashscope.aliyuncs.com/v1",
|
||||
"https://coding.dashscope.aliyuncs.com/v1",
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"endpointClass": "qwen-portal-native",
|
||||
"baseUrls": ["https://portal.qwen.ai/v1"]
|
||||
}
|
||||
],
|
||||
"providerRequest": {
|
||||
"providers": {
|
||||
"qwen": {
|
||||
"family": "modelstudio"
|
||||
},
|
||||
"qwencloud": {
|
||||
"family": "modelstudio"
|
||||
},
|
||||
"modelstudio": {
|
||||
"family": "modelstudio"
|
||||
},
|
||||
"dashscope": {
|
||||
"family": "modelstudio"
|
||||
},
|
||||
"qwen-oauth": {
|
||||
"family": "qwen-portal"
|
||||
},
|
||||
"qwen-portal": {
|
||||
"family": "qwen-portal"
|
||||
},
|
||||
"qwen-cli": {
|
||||
"family": "qwen-portal"
|
||||
}
|
||||
}
|
||||
},
|
||||
"modelCatalog": {
|
||||
"aliases": {
|
||||
"qwen-portal": {
|
||||
"provider": "qwen-oauth"
|
||||
},
|
||||
"qwen-cli": {
|
||||
"provider": "qwen-oauth"
|
||||
}
|
||||
},
|
||||
"suppressions": [
|
||||
{
|
||||
"provider": "qwen",
|
||||
"model": "qwen3.6-plus",
|
||||
"reason": "qwen3.6-plus is not supported on the Qwen Coding Plan endpoint; use a Standard pay-as-you-go Qwen endpoint or choose qwen/qwen3.5-plus.",
|
||||
"when": {
|
||||
"baseUrlHosts": ["coding.dashscope.aliyuncs.com", "coding-intl.dashscope.aliyuncs.com"],
|
||||
"providerConfigApiIn": ["qwen", "modelstudio"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"provider": "modelstudio",
|
||||
"model": "qwen3.6-plus",
|
||||
"reason": "qwen3.6-plus is not supported on the Qwen Coding Plan endpoint; use a Standard pay-as-you-go Qwen endpoint or choose qwen/qwen3.5-plus.",
|
||||
"when": {
|
||||
"baseUrlHosts": ["coding.dashscope.aliyuncs.com", "coding-intl.dashscope.aliyuncs.com"],
|
||||
"providerConfigApiIn": ["qwen", "modelstudio"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"providers": {
|
||||
"qwen-oauth": {
|
||||
"baseUrl": "https://portal.qwen.ai/v1",
|
||||
"api": "openai-completions",
|
||||
"models": [
|
||||
{
|
||||
"id": "qwen3.5-plus",
|
||||
"name": "qwen3.5-plus",
|
||||
"reasoning": false,
|
||||
"input": ["text", "image"],
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"contextWindow": 1000000,
|
||||
"maxTokens": 65536
|
||||
},
|
||||
{
|
||||
"id": "qwen3.6-plus",
|
||||
"name": "qwen3.6-plus",
|
||||
"reasoning": false,
|
||||
"input": ["text", "image"],
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"contextWindow": 1000000,
|
||||
"maxTokens": 65536
|
||||
},
|
||||
{
|
||||
"id": "qwen3-max-2026-01-23",
|
||||
"name": "qwen3-max-2026-01-23",
|
||||
"reasoning": false,
|
||||
"input": ["text"],
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"contextWindow": 262144,
|
||||
"maxTokens": 65536
|
||||
},
|
||||
{
|
||||
"id": "qwen3-coder-next",
|
||||
"name": "qwen3-coder-next",
|
||||
"reasoning": false,
|
||||
"input": ["text"],
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"contextWindow": 262144,
|
||||
"maxTokens": 65536
|
||||
},
|
||||
{
|
||||
"id": "qwen3-coder-plus",
|
||||
"name": "qwen3-coder-plus",
|
||||
"reasoning": false,
|
||||
"input": ["text"],
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"contextWindow": 1000000,
|
||||
"maxTokens": 65536
|
||||
},
|
||||
{
|
||||
"id": "MiniMax-M2.5",
|
||||
"name": "MiniMax-M2.5",
|
||||
"reasoning": true,
|
||||
"input": ["text"],
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"contextWindow": 1000000,
|
||||
"maxTokens": 65536
|
||||
},
|
||||
{
|
||||
"id": "glm-5",
|
||||
"name": "glm-5",
|
||||
"reasoning": false,
|
||||
"input": ["text"],
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"contextWindow": 202752,
|
||||
"maxTokens": 65536
|
||||
},
|
||||
{
|
||||
"id": "glm-4.7",
|
||||
"name": "glm-4.7",
|
||||
"reasoning": false,
|
||||
"input": ["text"],
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"contextWindow": 202752,
|
||||
"maxTokens": 65536
|
||||
},
|
||||
{
|
||||
"id": "kimi-k2.5",
|
||||
"name": "kimi-k2.5",
|
||||
"reasoning": false,
|
||||
"input": ["text", "image"],
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"contextWindow": 262144,
|
||||
"maxTokens": 65536
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"contracts": {
|
||||
"mediaUnderstandingProviders": ["qwen"],
|
||||
"videoGenerationProviders": ["qwen"]
|
||||
},
|
||||
"mediaUnderstandingProviderMetadata": {
|
||||
"qwen": {
|
||||
"capabilities": ["image", "video"],
|
||||
"defaultModels": {
|
||||
"image": "qwen-vl-max-latest",
|
||||
"video": "qwen-vl-max-latest"
|
||||
},
|
||||
"autoPriority": {
|
||||
"video": 15
|
||||
}
|
||||
}
|
||||
},
|
||||
"setup": {
|
||||
"providers": [
|
||||
{
|
||||
"id": "qwen",
|
||||
"envVars": ["QWEN_API_KEY", "MODELSTUDIO_API_KEY", "DASHSCOPE_API_KEY"]
|
||||
},
|
||||
{
|
||||
"id": "qwen-oauth",
|
||||
"envVars": ["QWEN_API_KEY"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"providerAuthChoices": [
|
||||
{
|
||||
"provider": "qwen",
|
||||
"method": "standard-api-key-cn",
|
||||
"choiceId": "qwen-standard-api-key-cn",
|
||||
"deprecatedChoiceIds": ["modelstudio-standard-api-key-cn"],
|
||||
"choiceLabel": "Standard API Key for China (pay-as-you-go)",
|
||||
"choiceHint": "Endpoint: dashscope.aliyuncs.com",
|
||||
"groupId": "qwen",
|
||||
"groupLabel": "Qwen Cloud",
|
||||
"groupHint": "Standard / Coding Plan (CN / Global) + multimodal roadmap",
|
||||
"optionKey": "modelstudioStandardApiKeyCn",
|
||||
"cliFlag": "--modelstudio-standard-api-key-cn",
|
||||
"cliOption": "--modelstudio-standard-api-key-cn <key>",
|
||||
"cliDescription": "Qwen Cloud standard API key (China)"
|
||||
},
|
||||
{
|
||||
"provider": "qwen",
|
||||
"method": "standard-api-key",
|
||||
"choiceId": "qwen-standard-api-key",
|
||||
"deprecatedChoiceIds": ["modelstudio-standard-api-key"],
|
||||
"choiceLabel": "Standard API Key for Global/Intl (pay-as-you-go)",
|
||||
"choiceHint": "Endpoint: dashscope-intl.aliyuncs.com",
|
||||
"groupId": "qwen",
|
||||
"groupLabel": "Qwen Cloud",
|
||||
"groupHint": "Standard / Coding Plan (CN / Global) + multimodal roadmap",
|
||||
"optionKey": "modelstudioStandardApiKey",
|
||||
"cliFlag": "--modelstudio-standard-api-key",
|
||||
"cliOption": "--modelstudio-standard-api-key <key>",
|
||||
"cliDescription": "Qwen Cloud standard API key (Global/Intl)"
|
||||
},
|
||||
{
|
||||
"provider": "qwen",
|
||||
"method": "api-key-cn",
|
||||
"choiceId": "qwen-api-key-cn",
|
||||
"deprecatedChoiceIds": ["modelstudio-api-key-cn"],
|
||||
"choiceLabel": "Coding Plan API Key for China (subscription)",
|
||||
"choiceHint": "Endpoint: coding.dashscope.aliyuncs.com",
|
||||
"groupId": "qwen",
|
||||
"groupLabel": "Qwen Cloud",
|
||||
"groupHint": "Standard / Coding Plan (CN / Global) + multimodal roadmap",
|
||||
"optionKey": "modelstudioApiKeyCn",
|
||||
"cliFlag": "--modelstudio-api-key-cn",
|
||||
"cliOption": "--modelstudio-api-key-cn <key>",
|
||||
"cliDescription": "Qwen Cloud Coding Plan API key (China)"
|
||||
},
|
||||
{
|
||||
"provider": "qwen",
|
||||
"method": "api-key",
|
||||
"choiceId": "qwen-api-key",
|
||||
"deprecatedChoiceIds": ["modelstudio-api-key"],
|
||||
"choiceLabel": "Coding Plan API Key for Global/Intl (subscription)",
|
||||
"choiceHint": "Endpoint: coding-intl.dashscope.aliyuncs.com",
|
||||
"groupId": "qwen",
|
||||
"groupLabel": "Qwen Cloud",
|
||||
"groupHint": "Standard / Coding Plan (CN / Global) + multimodal roadmap",
|
||||
"optionKey": "modelstudioApiKey",
|
||||
"cliFlag": "--modelstudio-api-key",
|
||||
"cliOption": "--modelstudio-api-key <key>",
|
||||
"cliDescription": "Qwen Cloud Coding Plan API key (Global/Intl)"
|
||||
},
|
||||
{
|
||||
"provider": "qwen-oauth",
|
||||
"method": "api-key",
|
||||
"choiceId": "qwen-oauth",
|
||||
"choiceLabel": "Qwen OAuth",
|
||||
"choiceHint": "Portal token for portal.qwen.ai",
|
||||
"groupId": "qwen",
|
||||
"groupLabel": "Qwen Cloud",
|
||||
"groupHint": "Standard / Coding Plan / OAuth",
|
||||
"optionKey": "qwenOauthToken",
|
||||
"cliFlag": "--qwen-oauth-token",
|
||||
"cliOption": "--qwen-oauth-token <token>",
|
||||
"cliDescription": "Qwen OAuth token"
|
||||
}
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
35
extensions/qwen/package.json
Normal file
35
extensions/qwen/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@openclaw/qwen-provider",
|
||||
"version": "2026.6.11",
|
||||
"description": "OpenClaw Qwen Cloud provider plugin.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"install": {
|
||||
"clawhubSpec": "clawhub:@openclaw/qwen-provider",
|
||||
"npmSpec": "@openclaw/qwen-provider",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.6.8"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.11"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.11",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
}
|
||||
}
|
||||
}
|
||||
63
extensions/qwen/provider-catalog.test.ts
Normal file
63
extensions/qwen/provider-catalog.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// Qwen tests cover provider catalog plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyQwenNativeStreamingUsageCompat,
|
||||
buildQwenProvider,
|
||||
QWEN_BASE_URL,
|
||||
QWEN_STANDARD_GLOBAL_BASE_URL,
|
||||
QWEN_DEFAULT_MODEL_ID,
|
||||
} from "./api.js";
|
||||
|
||||
type QwenProvider = ReturnType<typeof buildQwenProvider>;
|
||||
|
||||
function getQwenModelIds(provider: QwenProvider): string[] {
|
||||
return provider.models.map((model) => model.id);
|
||||
}
|
||||
|
||||
describe("qwen provider catalog", () => {
|
||||
it("builds the bundled Qwen provider defaults", () => {
|
||||
const provider = buildQwenProvider();
|
||||
|
||||
expect(provider.baseUrl).toBe(QWEN_BASE_URL);
|
||||
expect(provider.api).toBe("openai-completions");
|
||||
const modelIds = getQwenModelIds(provider);
|
||||
expect(modelIds.length).toBeGreaterThan(0);
|
||||
expect(modelIds).toContain(QWEN_DEFAULT_MODEL_ID);
|
||||
expect(modelIds).not.toContain("qwen3.6-plus");
|
||||
});
|
||||
|
||||
it("only advertises qwen3.6-plus on Standard endpoints", () => {
|
||||
const coding = buildQwenProvider({ baseUrl: QWEN_BASE_URL });
|
||||
const codingTrailingDot = buildQwenProvider({
|
||||
baseUrl: " https://coding-intl.dashscope.aliyuncs.com./v1 ",
|
||||
});
|
||||
const standard = buildQwenProvider({ baseUrl: QWEN_STANDARD_GLOBAL_BASE_URL });
|
||||
|
||||
expect(getQwenModelIds(coding)).not.toContain("qwen3.6-plus");
|
||||
expect(getQwenModelIds(codingTrailingDot)).not.toContain("qwen3.6-plus");
|
||||
expect(getQwenModelIds(standard)).toContain("qwen3.6-plus");
|
||||
});
|
||||
|
||||
it("opts native Qwen baseUrls into streaming usage only inside the extension", () => {
|
||||
const nativeProvider = applyQwenNativeStreamingUsageCompat(buildQwenProvider());
|
||||
expect(nativeProvider.models.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
nativeProvider.models.every((model) => {
|
||||
if (!model.compat) {
|
||||
throw new Error(`expected Qwen model ${model.id} compat`);
|
||||
}
|
||||
return model.compat.supportsUsageInStreaming === true;
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
const customProvider = applyQwenNativeStreamingUsageCompat({
|
||||
...buildQwenProvider(),
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
});
|
||||
expect(
|
||||
customProvider.models.some(
|
||||
(model) => model.compat && model.compat.supportsUsageInStreaming === true,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
27
extensions/qwen/provider-catalog.ts
Normal file
27
extensions/qwen/provider-catalog.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Qwen provider module implements model/runtime integration.
|
||||
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import {
|
||||
buildQwenModelCatalogForBaseUrl,
|
||||
buildQwenOAuthModelCatalog,
|
||||
QWEN_BASE_URL,
|
||||
QWEN_OAUTH_BASE_URL,
|
||||
} from "./models.js";
|
||||
|
||||
export function buildQwenProvider(params?: { baseUrl?: string }): ModelProviderConfig {
|
||||
const baseUrl = params?.baseUrl ?? QWEN_BASE_URL;
|
||||
return {
|
||||
baseUrl,
|
||||
api: "openai-completions",
|
||||
models: buildQwenModelCatalogForBaseUrl(baseUrl).map((model) => Object.assign({}, model)),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildQwenOAuthProvider(): ModelProviderConfig {
|
||||
return {
|
||||
baseUrl: QWEN_OAUTH_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: buildQwenOAuthModelCatalog().map((model) => Object.assign({}, model)),
|
||||
};
|
||||
}
|
||||
|
||||
export const buildModelStudioProvider = buildQwenProvider;
|
||||
4
extensions/qwen/provider-discovery.contract.test.ts
Normal file
4
extensions/qwen/provider-discovery.contract.test.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Qwen tests cover provider discovery.contract plugin behavior.
|
||||
import { describeModelStudioProviderDiscoveryContract } from "openclaw/plugin-sdk/provider-test-contracts";
|
||||
|
||||
describeModelStudioProviderDiscoveryContract(() => import("./index.js"));
|
||||
172
extensions/qwen/stream.test.ts
Normal file
172
extensions/qwen/stream.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
// Qwen tests cover stream plugin behavior.
|
||||
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
|
||||
import type { Context, Model } from "openclaw/plugin-sdk/llm";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createQwenThinkingWrapper, wrapQwenProviderStream } from "./stream.js";
|
||||
|
||||
function capturePayload(params: {
|
||||
thinkingLevel?: "off" | "low" | "medium" | "high" | "xhigh" | "max";
|
||||
thinkingFormat?: string;
|
||||
reasoning?: unknown;
|
||||
initialPayload?: Record<string, unknown>;
|
||||
model?: Partial<Model<"openai-completions">>;
|
||||
}): Record<string, unknown> {
|
||||
let captured: Record<string, unknown> = {};
|
||||
const baseStreamFn: StreamFn = (_model, _context, options) => {
|
||||
const payload = { ...params.initialPayload };
|
||||
options?.onPayload?.(payload, _model);
|
||||
captured = payload;
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
|
||||
const wrapped = createQwenThinkingWrapper(
|
||||
baseStreamFn,
|
||||
params.thinkingLevel ?? "high",
|
||||
params.thinkingFormat,
|
||||
);
|
||||
void wrapped(
|
||||
{
|
||||
api: "openai-completions",
|
||||
provider: "qwen",
|
||||
id: "qwen3.6-plus",
|
||||
reasoning: true,
|
||||
...params.model,
|
||||
} as Model<"openai-completions">,
|
||||
{ messages: [] } as Context,
|
||||
params.reasoning === undefined ? {} : ({ reasoning: params.reasoning } as never),
|
||||
);
|
||||
|
||||
return captured;
|
||||
}
|
||||
|
||||
describe("createQwenThinkingWrapper", () => {
|
||||
it("maps disabled thinking to Qwen top-level enable_thinking", () => {
|
||||
const payload = capturePayload({
|
||||
reasoning: "none",
|
||||
initialPayload: {
|
||||
reasoning_effort: "high",
|
||||
reasoning: { effort: "high" },
|
||||
reasoningEffort: "high",
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload).toEqual({ enable_thinking: false });
|
||||
});
|
||||
|
||||
it("maps enabled thinking to Qwen top-level enable_thinking", () => {
|
||||
expect(capturePayload({ reasoning: "medium" })).toEqual({ enable_thinking: true });
|
||||
});
|
||||
|
||||
it("falls back to the session thinking level", () => {
|
||||
expect(capturePayload({ thinkingLevel: "off" })).toEqual({ enable_thinking: false });
|
||||
expect(capturePayload({ thinkingLevel: "high" })).toEqual({ enable_thinking: true });
|
||||
});
|
||||
|
||||
it("overrides qwen-chat-template thinking with the session level", () => {
|
||||
expect(
|
||||
capturePayload({
|
||||
thinkingFormat: "qwen-chat-template",
|
||||
thinkingLevel: "off",
|
||||
initialPayload: {
|
||||
chat_template_kwargs: { enable_thinking: true, preserve_thinking: true },
|
||||
enable_thinking: true,
|
||||
reasoning_effort: "high",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
chat_template_kwargs: { enable_thinking: false, preserve_thinking: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the runtime model qwen-chat-template format when the wrapper context omits it", () => {
|
||||
expect(
|
||||
capturePayload({
|
||||
thinkingLevel: "off",
|
||||
model: { compat: { thinkingFormat: "qwen-chat-template" } },
|
||||
initialPayload: {
|
||||
chat_template_kwargs: { enable_thinking: true },
|
||||
enable_thinking: true,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
chat_template_kwargs: { enable_thinking: false, preserve_thinking: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("skips non-reasoning and non-completions models", () => {
|
||||
expect(capturePayload({ model: { reasoning: false } })).toStrictEqual({});
|
||||
expect(capturePayload({ model: { api: "openai-responses" as never } })).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapQwenProviderStream", () => {
|
||||
it("only registers for Qwen-family OpenAI-compatible providers", () => {
|
||||
const streamFn = wrapQwenProviderStream({
|
||||
provider: "qwencloud",
|
||||
modelId: "qwen3.6-plus",
|
||||
model: {
|
||||
api: "openai-completions",
|
||||
provider: "qwen",
|
||||
id: "qwen3.6-plus",
|
||||
reasoning: true,
|
||||
} as Model<"openai-completions">,
|
||||
streamFn: undefined,
|
||||
} as never);
|
||||
expect(streamFn).toBeTypeOf("function");
|
||||
|
||||
expect(
|
||||
wrapQwenProviderStream({
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.4",
|
||||
model: {
|
||||
api: "openai-completions",
|
||||
provider: "openai",
|
||||
id: "gpt-5.4",
|
||||
} as Model<"openai-completions">,
|
||||
streamFn: undefined,
|
||||
} as never),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes qwen-chat-template format to the Qwen wrapper", () => {
|
||||
let captured: Record<string, unknown> = {};
|
||||
const baseStreamFn: StreamFn = (_model, _context, options) => {
|
||||
const payload = {
|
||||
chat_template_kwargs: { enable_thinking: true },
|
||||
enable_thinking: true,
|
||||
};
|
||||
options?.onPayload?.(payload, _model);
|
||||
captured = payload;
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
|
||||
const wrapped = wrapQwenProviderStream({
|
||||
provider: "qwen",
|
||||
modelId: "qwen3.6-plus",
|
||||
model: {
|
||||
api: "openai-completions",
|
||||
provider: "qwen",
|
||||
id: "qwen3.6-plus",
|
||||
reasoning: true,
|
||||
compat: { thinkingFormat: "qwen-chat-template" },
|
||||
} as Model<"openai-completions">,
|
||||
streamFn: baseStreamFn,
|
||||
thinkingLevel: "off",
|
||||
} as never);
|
||||
|
||||
void wrapped?.(
|
||||
{
|
||||
api: "openai-completions",
|
||||
provider: "qwen",
|
||||
id: "qwen3.6-plus",
|
||||
reasoning: true,
|
||||
} as Model<"openai-completions">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(captured).toStrictEqual({
|
||||
chat_template_kwargs: { enable_thinking: false, preserve_thinking: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
126
extensions/qwen/stream.ts
Normal file
126
extensions/qwen/stream.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
// Qwen plugin module implements stream behavior.
|
||||
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
|
||||
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import {
|
||||
createPayloadPatchStreamWrapper,
|
||||
isOpenAICompatibleThinkingEnabled,
|
||||
setQwenChatTemplateThinking,
|
||||
} from "openclaw/plugin-sdk/provider-stream-shared";
|
||||
|
||||
type QwenThinkingLevel = ProviderWrapStreamFnContext["thinkingLevel"];
|
||||
type QwenThinkingFormat = string | undefined;
|
||||
|
||||
function isQwenProviderId(providerId: string): boolean {
|
||||
const normalized = normalizeProviderId(providerId);
|
||||
return (
|
||||
normalized === "qwen" ||
|
||||
normalized === "qwen-oauth" ||
|
||||
normalized === "qwen-portal" ||
|
||||
normalized === "qwen-cli" ||
|
||||
normalized === "modelstudio" ||
|
||||
normalized === "qwencloud" ||
|
||||
normalized === "dashscope"
|
||||
);
|
||||
}
|
||||
|
||||
function isQwenOAuthProviderId(providerId: string): boolean {
|
||||
const normalized = normalizeProviderId(providerId);
|
||||
return normalized === "qwen-oauth" || normalized === "qwen-portal" || normalized === "qwen-cli";
|
||||
}
|
||||
|
||||
function normalizeQwenOAuthContent(content: unknown): unknown {
|
||||
if (typeof content === "string") {
|
||||
return [{ type: "text", text: content }];
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return content;
|
||||
}
|
||||
const normalized = content
|
||||
.map((part) => {
|
||||
if (typeof part === "string") {
|
||||
return { type: "text", text: part };
|
||||
}
|
||||
return part && typeof part === "object" ? part : undefined;
|
||||
})
|
||||
.filter((part): part is Record<string, unknown> => Boolean(part));
|
||||
return normalized.length > 0 ? normalized : content;
|
||||
}
|
||||
|
||||
function patchQwenOAuthPayload(payload: Record<string, unknown>): void {
|
||||
const messages = payload.messages;
|
||||
if (!Array.isArray(messages)) {
|
||||
return;
|
||||
}
|
||||
for (const message of messages) {
|
||||
if (!message || typeof message !== "object") {
|
||||
continue;
|
||||
}
|
||||
const record = message as Record<string, unknown>;
|
||||
record.content = normalizeQwenOAuthContent(record.content);
|
||||
if (record.role !== "system" || !Array.isArray(record.content) || record.content.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const last = record.content[record.content.length - 1];
|
||||
if (last && typeof last === "object") {
|
||||
(last as Record<string, unknown>).cache_control = { type: "ephemeral" };
|
||||
}
|
||||
}
|
||||
payload.vl_high_resolution_images = true;
|
||||
}
|
||||
|
||||
function readQwenThinkingFormatFromModel(model: Parameters<StreamFn>[0]): QwenThinkingFormat {
|
||||
if (model.api !== "openai-completions") {
|
||||
return undefined;
|
||||
}
|
||||
const compat =
|
||||
model.compat && typeof model.compat === "object"
|
||||
? (model.compat as { thinkingFormat?: unknown })
|
||||
: undefined;
|
||||
return typeof compat?.thinkingFormat === "string" ? compat.thinkingFormat : undefined;
|
||||
}
|
||||
|
||||
export function createQwenThinkingWrapper(
|
||||
baseStreamFn: StreamFn | undefined,
|
||||
thinkingLevel: QwenThinkingLevel,
|
||||
thinkingFormat?: QwenThinkingFormat,
|
||||
): StreamFn {
|
||||
return createPayloadPatchStreamWrapper(
|
||||
baseStreamFn,
|
||||
({ payload: payloadObj, model, options }) => {
|
||||
const enableThinking = isOpenAICompatibleThinkingEnabled({ thinkingLevel, options });
|
||||
const effectiveThinkingFormat = thinkingFormat ?? readQwenThinkingFormatFromModel(model);
|
||||
if (effectiveThinkingFormat === "qwen-chat-template") {
|
||||
setQwenChatTemplateThinking(payloadObj, enableThinking);
|
||||
delete payloadObj.enable_thinking;
|
||||
} else {
|
||||
payloadObj.enable_thinking = enableThinking;
|
||||
}
|
||||
delete payloadObj.reasoning_effort;
|
||||
delete payloadObj.reasoningEffort;
|
||||
delete payloadObj.reasoning;
|
||||
},
|
||||
{
|
||||
shouldPatch: ({ model }) => model.api === "openai-completions" && model.reasoning,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function wrapQwenProviderStream(ctx: ProviderWrapStreamFnContext): StreamFn | undefined {
|
||||
if (!isQwenProviderId(ctx.provider) || (ctx.model && ctx.model.api !== "openai-completions")) {
|
||||
return undefined;
|
||||
}
|
||||
const streamFn = createQwenThinkingWrapper(
|
||||
ctx.streamFn,
|
||||
ctx.thinkingLevel,
|
||||
ctx.model ? readQwenThinkingFormatFromModel(ctx.model) : undefined,
|
||||
);
|
||||
if (!isQwenOAuthProviderId(ctx.provider)) {
|
||||
return streamFn;
|
||||
}
|
||||
return createPayloadPatchStreamWrapper(streamFn, ({ payload, model }) => {
|
||||
if (model.api === "openai-completions") {
|
||||
patchQwenOAuthPayload(payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
3
extensions/qwen/test-api.ts
Normal file
3
extensions/qwen/test-api.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Qwen API module exposes the plugin public contract.
|
||||
export { buildQwenMediaUnderstandingProvider } from "./media-understanding-provider.js";
|
||||
export { buildQwenVideoGenerationProvider } from "./video-generation-provider.js";
|
||||
16
extensions/qwen/tsconfig.json
Normal file
16
extensions/qwen/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"
|
||||
]
|
||||
}
|
||||
201
extensions/qwen/video-generation-provider.test.ts
Normal file
201
extensions/qwen/video-generation-provider.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
// Qwen tests cover video generation provider plugin behavior.
|
||||
import {
|
||||
getProviderHttpMocks,
|
||||
installProviderHttpMockCleanup,
|
||||
} from "openclaw/plugin-sdk/provider-http-test-mocks";
|
||||
import {
|
||||
expectDashscopeVideoTaskPoll,
|
||||
expectExplicitVideoGenerationCapabilities,
|
||||
expectSuccessfulDashscopeVideoResult,
|
||||
mockSuccessfulDashscopeVideoTask,
|
||||
} from "openclaw/plugin-sdk/provider-test-contracts";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
const { postJsonRequestMock, fetchWithTimeoutMock } = getProviderHttpMocks();
|
||||
|
||||
let buildQwenVideoGenerationProvider: typeof import("./video-generation-provider.js").buildQwenVideoGenerationProvider;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ buildQwenVideoGenerationProvider } = await import("./video-generation-provider.js"));
|
||||
});
|
||||
|
||||
installProviderHttpMockCleanup();
|
||||
|
||||
function expectPostJsonRequest(
|
||||
call: unknown,
|
||||
expected: {
|
||||
url: string;
|
||||
body: Record<string, unknown>;
|
||||
},
|
||||
) {
|
||||
if (!call || typeof call !== "object") {
|
||||
throw new Error("expected postJsonRequest call object");
|
||||
}
|
||||
const request = call as {
|
||||
url?: unknown;
|
||||
headers?: unknown;
|
||||
body?: unknown;
|
||||
timeoutMs?: unknown;
|
||||
fetchFn?: unknown;
|
||||
allowPrivateNetwork?: unknown;
|
||||
dispatcherPolicy?: unknown;
|
||||
};
|
||||
expect(request.url).toBe(expected.url);
|
||||
expect(request.body).toEqual(expected.body);
|
||||
expect(request.timeoutMs).toBe(120_000);
|
||||
expect(request.fetchFn).toBe(globalThis.fetch);
|
||||
expect(request.allowPrivateNetwork).toBe(false);
|
||||
expect(request.dispatcherPolicy).toBeUndefined();
|
||||
expect(request.headers).toBeInstanceOf(Headers);
|
||||
expect(Array.from((request.headers as Headers).entries())).toEqual([
|
||||
["authorization", "Bearer provider-key"],
|
||||
["content-type", "application/json"],
|
||||
["x-dashscope-async", "enable"],
|
||||
]);
|
||||
}
|
||||
|
||||
function streamedVideoResponse(bytes: string): Response {
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(bytes));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "video/mp4" } },
|
||||
);
|
||||
}
|
||||
|
||||
describe("qwen video generation provider", () => {
|
||||
it("declares explicit mode capabilities", () => {
|
||||
expectExplicitVideoGenerationCapabilities(buildQwenVideoGenerationProvider());
|
||||
});
|
||||
|
||||
it("submits async Wan generation, polls task status, and downloads the resulting video", async () => {
|
||||
mockSuccessfulDashscopeVideoTask({ postJsonRequestMock, fetchWithTimeoutMock });
|
||||
|
||||
const provider = buildQwenVideoGenerationProvider();
|
||||
const result = await provider.generateVideo({
|
||||
provider: "qwen",
|
||||
model: "wan2.6-r2v-flash",
|
||||
prompt: "animate this shot",
|
||||
cfg: {},
|
||||
inputImages: [{ url: "https://example.com/ref.png" }],
|
||||
durationSeconds: 6,
|
||||
audio: true,
|
||||
});
|
||||
|
||||
expect(postJsonRequestMock).toHaveBeenCalledTimes(1);
|
||||
expectPostJsonRequest(postJsonRequestMock.mock.calls[0]?.[0], {
|
||||
url: "https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis",
|
||||
body: {
|
||||
model: "wan2.6-r2v-flash",
|
||||
input: {
|
||||
prompt: "animate this shot",
|
||||
img_url: "https://example.com/ref.png",
|
||||
},
|
||||
parameters: {
|
||||
duration: 6,
|
||||
enable_audio: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expectDashscopeVideoTaskPoll(fetchWithTimeoutMock);
|
||||
expectSuccessfulDashscopeVideoResult(result);
|
||||
});
|
||||
|
||||
it("rejects DashScope video downloads that exceed the configured media cap", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: {
|
||||
json: async () => ({
|
||||
request_id: "req-too-large",
|
||||
output: { task_id: "task-too-large" },
|
||||
}),
|
||||
},
|
||||
release: async () => {},
|
||||
});
|
||||
fetchWithTimeoutMock
|
||||
.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
output: {
|
||||
task_status: "SUCCEEDED",
|
||||
results: [{ video_url: "https://example.com/too-large.mp4" }],
|
||||
},
|
||||
}),
|
||||
headers: new Headers(),
|
||||
})
|
||||
.mockResolvedValueOnce(streamedVideoResponse("too-large"));
|
||||
|
||||
const provider = buildQwenVideoGenerationProvider();
|
||||
await expect(
|
||||
provider.generateVideo({
|
||||
provider: "qwen",
|
||||
model: "wan2.6-r2v-flash",
|
||||
prompt: "short video",
|
||||
cfg: { agents: { defaults: { mediaMaxMb: 0.000001 } } },
|
||||
}),
|
||||
).rejects.toThrow("Qwen generated video download exceeds 1 bytes");
|
||||
});
|
||||
|
||||
it("fails fast when reference inputs are local buffers instead of remote URLs", async () => {
|
||||
const provider = buildQwenVideoGenerationProvider();
|
||||
|
||||
await expect(
|
||||
provider.generateVideo({
|
||||
provider: "qwen",
|
||||
model: "wan2.6-i2v",
|
||||
prompt: "animate this local frame",
|
||||
cfg: {},
|
||||
inputImages: [{ buffer: Buffer.from("png-bytes"), mimeType: "image/png" }],
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Qwen video generation currently requires remote http(s) URLs for reference images/videos.",
|
||||
);
|
||||
expect(postJsonRequestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves dedicated coding endpoints for dedicated API keys", async () => {
|
||||
mockSuccessfulDashscopeVideoTask(
|
||||
{
|
||||
postJsonRequestMock,
|
||||
fetchWithTimeoutMock,
|
||||
},
|
||||
{ requestId: "req-2", taskId: "task-2" },
|
||||
);
|
||||
|
||||
const provider = buildQwenVideoGenerationProvider();
|
||||
await provider.generateVideo({
|
||||
provider: "qwen",
|
||||
model: "wan2.6-t2v",
|
||||
prompt: "animate this shot",
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
qwen: {
|
||||
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(postJsonRequestMock).toHaveBeenCalledTimes(1);
|
||||
expectPostJsonRequest(postJsonRequestMock.mock.calls[0]?.[0], {
|
||||
url: "https://coding-intl.dashscope.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis",
|
||||
body: {
|
||||
model: "wan2.6-t2v",
|
||||
input: {
|
||||
prompt: "animate this shot",
|
||||
},
|
||||
parameters: {
|
||||
duration: 5,
|
||||
},
|
||||
},
|
||||
});
|
||||
expectDashscopeVideoTaskPoll(fetchWithTimeoutMock, {
|
||||
baseUrl: "https://coding-intl.dashscope.aliyuncs.com",
|
||||
taskId: "task-2",
|
||||
});
|
||||
});
|
||||
});
|
||||
112
extensions/qwen/video-generation-provider.ts
Normal file
112
extensions/qwen/video-generation-provider.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
// Qwen provider module implements model/runtime integration.
|
||||
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import { resolveProviderHttpRequestConfig } from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
DASHSCOPE_WAN_VIDEO_CAPABILITIES,
|
||||
DASHSCOPE_WAN_VIDEO_MODELS,
|
||||
DEFAULT_DASHSCOPE_WAN_VIDEO_MODEL,
|
||||
DEFAULT_VIDEO_GENERATION_TIMEOUT_MS,
|
||||
runDashscopeVideoGenerationTask,
|
||||
} from "openclaw/plugin-sdk/video-generation";
|
||||
import type {
|
||||
VideoGenerationProvider,
|
||||
VideoGenerationRequest,
|
||||
VideoGenerationResult,
|
||||
} from "openclaw/plugin-sdk/video-generation";
|
||||
import { QWEN_STANDARD_CN_BASE_URL, QWEN_STANDARD_GLOBAL_BASE_URL } from "./models.js";
|
||||
|
||||
const DEFAULT_QWEN_VIDEO_BASE_URL = "https://dashscope-intl.aliyuncs.com";
|
||||
const DEFAULT_QWEN_VIDEO_MODEL = DEFAULT_DASHSCOPE_WAN_VIDEO_MODEL;
|
||||
|
||||
function resolveQwenVideoBaseUrl(req: VideoGenerationRequest): string {
|
||||
const direct = req.cfg?.models?.providers?.qwen?.baseUrl?.trim();
|
||||
if (!direct) {
|
||||
return DEFAULT_QWEN_VIDEO_BASE_URL;
|
||||
}
|
||||
try {
|
||||
return new URL(direct).toString();
|
||||
} catch {
|
||||
return DEFAULT_QWEN_VIDEO_BASE_URL;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDashscopeAigcApiBaseUrl(baseUrl: string): string {
|
||||
try {
|
||||
const url = new URL(baseUrl);
|
||||
if (
|
||||
url.hostname === "coding-intl.dashscope.aliyuncs.com" ||
|
||||
url.hostname === "coding.dashscope.aliyuncs.com" ||
|
||||
url.hostname === "dashscope-intl.aliyuncs.com" ||
|
||||
url.hostname === "dashscope.aliyuncs.com"
|
||||
) {
|
||||
return url.origin;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to legacy prefix handling for non-URL strings.
|
||||
}
|
||||
if (baseUrl.startsWith(QWEN_STANDARD_CN_BASE_URL)) {
|
||||
return "https://dashscope.aliyuncs.com";
|
||||
}
|
||||
if (baseUrl.startsWith(QWEN_STANDARD_GLOBAL_BASE_URL)) {
|
||||
return DEFAULT_QWEN_VIDEO_BASE_URL;
|
||||
}
|
||||
return baseUrl.replace(/\/+$/u, "");
|
||||
}
|
||||
|
||||
export function buildQwenVideoGenerationProvider(): VideoGenerationProvider {
|
||||
return {
|
||||
id: "qwen",
|
||||
label: "Qwen Cloud",
|
||||
defaultModel: DEFAULT_QWEN_VIDEO_MODEL,
|
||||
models: [...DASHSCOPE_WAN_VIDEO_MODELS],
|
||||
isConfigured: ({ agentDir }) =>
|
||||
isProviderApiKeyConfigured({
|
||||
provider: "qwen",
|
||||
agentDir,
|
||||
}),
|
||||
capabilities: DASHSCOPE_WAN_VIDEO_CAPABILITIES,
|
||||
async generateVideo(req): Promise<VideoGenerationResult> {
|
||||
const fetchFn = fetch;
|
||||
const auth = await resolveApiKeyForProvider({
|
||||
provider: "qwen",
|
||||
cfg: req.cfg,
|
||||
agentDir: req.agentDir,
|
||||
store: req.authStore,
|
||||
});
|
||||
if (!auth.apiKey) {
|
||||
throw new Error("Qwen API key missing");
|
||||
}
|
||||
|
||||
const requestBaseUrl = resolveQwenVideoBaseUrl(req);
|
||||
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
|
||||
resolveProviderHttpRequestConfig({
|
||||
baseUrl: requestBaseUrl,
|
||||
defaultBaseUrl: DEFAULT_QWEN_VIDEO_BASE_URL,
|
||||
defaultHeaders: {
|
||||
Authorization: `Bearer ${auth.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"X-DashScope-Async": "enable",
|
||||
},
|
||||
provider: "qwen",
|
||||
capability: "video",
|
||||
transport: "http",
|
||||
});
|
||||
|
||||
const model = req.model?.trim() || DEFAULT_QWEN_VIDEO_MODEL;
|
||||
return await runDashscopeVideoGenerationTask({
|
||||
providerLabel: "Qwen",
|
||||
model,
|
||||
req,
|
||||
url: `${resolveDashscopeAigcApiBaseUrl(baseUrl)}/api/v1/services/aigc/video-generation/video-synthesis`,
|
||||
headers,
|
||||
baseUrl: resolveDashscopeAigcApiBaseUrl(baseUrl),
|
||||
timeoutMs: req.timeoutMs,
|
||||
fetchFn,
|
||||
allowPrivateNetwork,
|
||||
dispatcherPolicy,
|
||||
defaultTimeoutMs: DEFAULT_VIDEO_GENERATION_TIMEOUT_MS,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user