fix(qwen): allow explicit qwen3.6-plus on Coding Plan (#72664)

This commit is contained in:
Vincent Koc
2026-04-28 02:38:47 -07:00
committed by GitHub
parent b4ffef5c5f
commit 058b57867e
8 changed files with 203 additions and 37 deletions

View File

@@ -69,11 +69,9 @@ Docs: https://docs.openclaw.ai
- Agents/failover: classify CJK provider transport, quota, billing, auth, and overload error text so Chinese-language provider failures trigger fallback and user-facing transport copy instead of surfacing as unclassified raw errors. (#56242) Thanks @tomcatzh.
- Agents/failover: seed non-claude-cli fallback prompts with Claude Code session context when a claude-cli attempt fails, so fallback models do not restart cold after billing or quota failover. (#72069) Thanks @stainlu.
- Agents/CLI runner: transfer bundle-MCP tempDir cleanup from the per-turn runner finally to the Claude live-session lifecycle, so persistent Claude CLI sessions keep their `--mcp-config` directory until the live subprocess closes. Fixes #73244. Thanks @edwin-rivera-dev.
### Fixes
- Gateway/nodes: allow Windows companion nodes to use safe declared commands such as canvas, camera list, location, device info, and screen snapshot by default while keeping dangerous media commands opt-in. (#71884) Thanks @shanselman.
- Agents/cron: clarify agent-tool and CLI cron timezone guidance so supplied `tz` values use local wall-clock cron fields and omitted cron `tz` falls back to the Gateway host local timezone. Fixes #53669; carries forward #46177. (#73372) Thanks @chen-zhang-cs-code and @maranello-o.
- Providers/Qwen: allow explicitly configured `qwen/qwen3.6-plus` to resolve on Qwen Coding Plan endpoints while keeping the built-in catalog from advertising it there. Fixes #63654; carries forward #63987. Thanks @jepson-liu.
## 2026.4.27

View File

@@ -1,12 +1,28 @@
import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-runtime";
import { describe, expect, it } from "vitest";
import { QWEN_36_PLUS_MODEL_ID, QWEN_BASE_URL } from "./api.js";
import qwenPlugin from "./index.js";
async function registerQwenProvider() {
// The test runtime asserts the plugin registers exactly one provider and returns it.
return registerSingleProviderPlugin(qwenPlugin);
}
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();

View File

@@ -109,11 +109,12 @@ export const QWEN_MODEL_CATALOG: ReadonlyArray<ModelDefinitionConfig> = [
];
export function isQwenCodingPlanBaseUrl(baseUrl: string | undefined): boolean {
if (!baseUrl?.trim()) {
const trimmed = baseUrl?.trim();
if (!trimmed) {
return false;
}
try {
const hostname = new URL(baseUrl).hostname.toLowerCase();
const hostname = new URL(trimmed).hostname.toLowerCase().replace(/\.+$/, "");
return (
hostname === "coding.dashscope.aliyuncs.com" ||
hostname === "coding-intl.dashscope.aliyuncs.com"

View File

@@ -20,9 +20,13 @@ describe("qwen provider catalog", () => {
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(coding.models?.find((model) => model.id === "qwen3.6-plus")).toBeFalsy();
expect(codingTrailingDot.models?.find((model) => model.id === "qwen3.6-plus")).toBeFalsy();
expect(standard.models?.find((model) => model.id === "qwen3.6-plus")).toBeTruthy();
});

View File

@@ -2,28 +2,104 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { discoverAuthStorage, discoverModels } from "../pi-model-discovery.js";
import { createProviderRuntimeTestMock } from "./model.provider-runtime.test-support.js";
vi.mock("../model-suppression.js", () => ({
shouldSuppressBuiltInModel: ({ provider, id }: { provider?: string; id?: string }) =>
((provider === "openai" ||
provider === "azure-openai-responses" ||
provider === "openai-codex") &&
id?.trim().toLowerCase() === "gpt-5.3-codex-spark") ||
(provider === "openai-codex" && id?.trim().toLowerCase() === "gpt-5.4-mini"),
buildSuppressedBuiltInModelError: ({ provider, id }: { provider?: string; id?: string }) => {
if (provider === "openai-codex" && id?.trim().toLowerCase() === "gpt-5.4-mini") {
return "Unknown model: openai-codex/gpt-5.4-mini. gpt-5.4-mini is not supported by the OpenAI Codex OAuth route. Use openai/gpt-5.4-mini with an OpenAI API key or openai-codex/gpt-5.5 with Codex OAuth.";
vi.mock("../model-suppression.js", () => {
// Mirrors the canonical manifest-driven suppression in
// extensions/qwen/openclaw.plugin.json and src/plugins/manifest-model-suppression.ts.
function isQwenCodingPlanBaseUrl(value: string | undefined): boolean {
const trimmed = value?.trim();
if (!trimmed) {
return false;
}
if (
(provider !== "openai" &&
provider !== "azure-openai-responses" &&
provider !== "openai-codex") ||
id?.trim().toLowerCase() !== "gpt-5.3-codex-spark"
) {
try {
const hostname = new URL(trimmed).hostname.toLowerCase().replace(/\.+$/, "");
return (
hostname === "coding.dashscope.aliyuncs.com" ||
hostname === "coding-intl.dashscope.aliyuncs.com"
);
} catch {
return false;
}
}
function resolveConfiguredQwenBaseUrl(config: unknown): string | undefined {
const providers = (config as { models?: { providers?: Record<string, { baseUrl?: string }> } })
?.models?.providers;
if (!providers) {
return undefined;
}
return `Unknown model: ${provider}/gpt-5.3-codex-spark. gpt-5.3-codex-spark is no longer exposed by the OpenAI or Codex catalogs. Use openai/gpt-5.5.`;
},
}));
for (const [provider, entry] of Object.entries(providers)) {
const normalizedProvider = provider.trim().toLowerCase();
if (normalizedProvider !== "qwen" && normalizedProvider !== "modelstudio") {
continue;
}
const baseUrl = entry?.baseUrl?.trim();
if (baseUrl) {
return baseUrl;
}
}
return undefined;
}
return {
shouldSuppressBuiltInModel: ({
provider,
id,
baseUrl,
config,
}: {
provider?: string;
id?: string;
baseUrl?: string;
config?: unknown;
}) => {
if (
(provider === "openai" ||
provider === "azure-openai-responses" ||
provider === "openai-codex") &&
id?.trim().toLowerCase() === "gpt-5.3-codex-spark"
) {
return true;
}
if (provider === "openai-codex" && id?.trim().toLowerCase() === "gpt-5.4-mini") {
return true;
}
return (
(provider === "qwen" || provider === "modelstudio") &&
id?.trim().toLowerCase() === "qwen3.6-plus" &&
isQwenCodingPlanBaseUrl(baseUrl ?? resolveConfiguredQwenBaseUrl(config))
);
},
buildSuppressedBuiltInModelError: ({
provider,
id,
config,
}: {
provider?: string;
id?: string;
config?: unknown;
}) => {
if (
(provider === "qwen" || provider === "modelstudio") &&
id?.trim().toLowerCase() === "qwen3.6-plus" &&
isQwenCodingPlanBaseUrl(resolveConfiguredQwenBaseUrl(config))
) {
return "Unknown model: qwen/qwen3.6-plus. 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.";
}
if (provider === "openai-codex" && id?.trim().toLowerCase() === "gpt-5.4-mini") {
return "Unknown model: openai-codex/gpt-5.4-mini. gpt-5.4-mini is not supported by the OpenAI Codex OAuth route. Use openai/gpt-5.4-mini with an OpenAI API key or openai-codex/gpt-5.5 with Codex OAuth.";
}
if (
(provider === "openai" ||
provider === "azure-openai-responses" ||
provider === "openai-codex") &&
id?.trim().toLowerCase() === "gpt-5.3-codex-spark"
) {
return `Unknown model: ${provider}/gpt-5.3-codex-spark. gpt-5.3-codex-spark is no longer exposed by the OpenAI or Codex catalogs. Use openai/gpt-5.5.`;
}
return undefined;
},
};
});
vi.mock("../pi-model-discovery.js", () => ({
discoverAuthStorage: vi.fn(() => ({ mocked: true })),
@@ -222,6 +298,63 @@ describe("resolveModel", () => {
expect(getModelProviderRequestTransport(result.model ?? {})).toBeUndefined();
});
it("resolves explicitly configured qwen3.6-plus before Coding Plan built-in suppression", () => {
const cfg = {
models: {
providers: {
qwen: {
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1",
api: "openai-completions",
models: [
{
id: "qwen3.6-plus",
name: "qwen3.6-plus",
input: ["text", "image"],
reasoning: false,
contextWindow: 1_000_000,
maxTokens: 65_536,
},
],
},
},
},
} as unknown as OpenClawConfig;
const result = resolveModelForTest("qwen", "qwen3.6-plus", "/tmp/agent", cfg);
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
provider: "qwen",
id: "qwen3.6-plus",
api: "openai-completions",
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1",
input: ["text", "image"],
contextWindow: 1_000_000,
maxTokens: 65_536,
});
});
it("keeps unconfigured qwen3.6-plus suppressed on Coding Plan endpoints", () => {
const cfg = {
models: {
providers: {
qwen: {
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1",
api: "openai-completions",
models: [],
},
},
},
} as unknown as OpenClawConfig;
const result = resolveModelForTest("qwen", "qwen3.6-plus", "/tmp/agent", cfg);
expect(result.model).toBeUndefined();
expect(result.error).toBe(
"Unknown model: qwen/qwen3.6-plus. 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.",
);
});
it("normalizes Google fallback baseUrls for custom providers", () => {
const cfg = {
models: {

View File

@@ -591,16 +591,6 @@ function resolveExplicitModelWithRegistry(params: {
const { provider, modelId, modelRegistry, cfg, agentDir, runtimeHooks } = params;
const providerConfig = resolveConfiguredProviderConfig(cfg, provider);
const requestTimeoutMs = resolveProviderRequestTimeoutMs(providerConfig?.timeoutSeconds);
if (
shouldSuppressBuiltInModel({
provider,
id: modelId,
baseUrl: providerConfig?.baseUrl,
config: cfg,
})
) {
return { kind: "suppressed" };
}
const inlineMatch = findInlineModelMatch({
providers: cfg?.models?.providers ?? {},
provider,
@@ -628,6 +618,16 @@ function resolveExplicitModelWithRegistry(params: {
}),
};
}
if (
shouldSuppressBuiltInModel({
provider,
id: modelId,
baseUrl: providerConfig?.baseUrl,
config: cfg,
})
) {
return { kind: "suppressed" };
}
const model = modelRegistry.find(provider, modelId) as Model<Api> | null;
if (model) {
@@ -1099,6 +1099,7 @@ function buildUnknownModelError(params: {
const suppressed = buildSuppressedBuiltInModelError({
provider: params.provider,
id: params.modelId,
config: params.cfg,
});
if (suppressed) {
return suppressed;

View File

@@ -124,6 +124,14 @@ describe("manifest model suppression", () => {
env: process.env,
})?.suppress,
).toBe(true);
expect(
resolveManifestBuiltInModelSuppression({
provider: "qwen",
id: "qwen3.6-plus",
baseUrl: " https://coding-intl.dashscope.aliyuncs.com./v1 ",
env: process.env,
})?.suppress,
).toBe(true);
expect(
resolveManifestBuiltInModelSuppression({
provider: "qwen",

View File

@@ -78,16 +78,21 @@ function buildManifestSuppressionError(params: {
}
function normalizeBaseUrlHost(baseUrl: string | null | undefined): string {
if (!baseUrl?.trim()) {
const trimmed = baseUrl?.trim();
if (!trimmed) {
return "";
}
try {
return new URL(baseUrl).hostname.toLowerCase();
return normalizeSuppressionHost(new URL(trimmed).hostname);
} catch {
return "";
}
}
function normalizeSuppressionHost(host: string): string {
return normalizeLowercaseStringOrEmpty(host).replace(/\.+$/, "");
}
function resolveConfiguredProviderValue(params: {
provider: string;
config?: OpenClawConfig;
@@ -133,7 +138,7 @@ function manifestSuppressionMatchesConditions(params: {
if (!baseUrlHost) {
return false;
}
const allowedHosts = new Set(when.baseUrlHosts.map(normalizeLowercaseStringOrEmpty));
const allowedHosts = new Set(when.baseUrlHosts.map(normalizeSuppressionHost));
if (!allowedHosts.has(baseUrlHost)) {
return false;
}