Skip to content

Commit 058b578

Browse files
authored
fix(qwen): allow explicit qwen3.6-plus on Coding Plan (#72664)
1 parent b4ffef5 commit 058b578

8 files changed

Lines changed: 203 additions & 37 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,9 @@ Docs: https://docs.openclaw.ai
6969
- 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.
7070
- 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.
7171
- 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.
72-
73-
### Fixes
74-
7572
- 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.
7673
- 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.
74+
- 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.
7775

7876
## 2026.4.27
7977

extensions/qwen/index.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,28 @@
11
import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-runtime";
22
import { describe, expect, it } from "vitest";
3+
import { QWEN_36_PLUS_MODEL_ID, QWEN_BASE_URL } from "./api.js";
34
import qwenPlugin from "./index.js";
45

56
async function registerQwenProvider() {
7+
// The test runtime asserts the plugin registers exactly one provider and returns it.
68
return registerSingleProviderPlugin(qwenPlugin);
79
}
810

911
describe("qwen provider plugin", () => {
12+
it("keeps qwen3.6-plus out of Coding Plan normalized catalogs", async () => {
13+
const provider = await registerQwenProvider();
14+
15+
const normalized = provider.normalizeConfig?.({
16+
provider: "qwen",
17+
providerConfig: {
18+
baseUrl: QWEN_BASE_URL,
19+
models: [{ id: "qwen3.5-plus" }, { id: QWEN_36_PLUS_MODEL_ID }],
20+
},
21+
} as never);
22+
23+
expect(normalized?.models?.map((model) => model.id)).toEqual(["qwen3.5-plus"]);
24+
});
25+
1026
it("does not expose runtime model suppression hooks", async () => {
1127
const provider = await registerQwenProvider();
1228

extensions/qwen/models.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,12 @@ export const QWEN_MODEL_CATALOG: ReadonlyArray<ModelDefinitionConfig> = [
109109
];
110110

111111
export function isQwenCodingPlanBaseUrl(baseUrl: string | undefined): boolean {
112-
if (!baseUrl?.trim()) {
112+
const trimmed = baseUrl?.trim();
113+
if (!trimmed) {
113114
return false;
114115
}
115116
try {
116-
const hostname = new URL(baseUrl).hostname.toLowerCase();
117+
const hostname = new URL(trimmed).hostname.toLowerCase().replace(/\.+$/, "");
117118
return (
118119
hostname === "coding.dashscope.aliyuncs.com" ||
119120
hostname === "coding-intl.dashscope.aliyuncs.com"

extensions/qwen/provider-catalog.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,13 @@ describe("qwen provider catalog", () => {
2020

2121
it("only advertises qwen3.6-plus on Standard endpoints", () => {
2222
const coding = buildQwenProvider({ baseUrl: QWEN_BASE_URL });
23+
const codingTrailingDot = buildQwenProvider({
24+
baseUrl: " https://coding-intl.dashscope.aliyuncs.com./v1 ",
25+
});
2326
const standard = buildQwenProvider({ baseUrl: QWEN_STANDARD_GLOBAL_BASE_URL });
2427

2528
expect(coding.models?.find((model) => model.id === "qwen3.6-plus")).toBeFalsy();
29+
expect(codingTrailingDot.models?.find((model) => model.id === "qwen3.6-plus")).toBeFalsy();
2630
expect(standard.models?.find((model) => model.id === "qwen3.6-plus")).toBeTruthy();
2731
});
2832

src/agents/pi-embedded-runner/model.test.ts

Lines changed: 152 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,104 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
22
import { discoverAuthStorage, discoverModels } from "../pi-model-discovery.js";
33
import { createProviderRuntimeTestMock } from "./model.provider-runtime.test-support.js";
44

5-
vi.mock("../model-suppression.js", () => ({
6-
shouldSuppressBuiltInModel: ({ provider, id }: { provider?: string; id?: string }) =>
7-
((provider === "openai" ||
8-
provider === "azure-openai-responses" ||
9-
provider === "openai-codex") &&
10-
id?.trim().toLowerCase() === "gpt-5.3-codex-spark") ||
11-
(provider === "openai-codex" && id?.trim().toLowerCase() === "gpt-5.4-mini"),
12-
buildSuppressedBuiltInModelError: ({ provider, id }: { provider?: string; id?: string }) => {
13-
if (provider === "openai-codex" && id?.trim().toLowerCase() === "gpt-5.4-mini") {
14-
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.";
5+
vi.mock("../model-suppression.js", () => {
6+
// Mirrors the canonical manifest-driven suppression in
7+
// extensions/qwen/openclaw.plugin.json and src/plugins/manifest-model-suppression.ts.
8+
function isQwenCodingPlanBaseUrl(value: string | undefined): boolean {
9+
const trimmed = value?.trim();
10+
if (!trimmed) {
11+
return false;
1512
}
16-
if (
17-
(provider !== "openai" &&
18-
provider !== "azure-openai-responses" &&
19-
provider !== "openai-codex") ||
20-
id?.trim().toLowerCase() !== "gpt-5.3-codex-spark"
21-
) {
13+
try {
14+
const hostname = new URL(trimmed).hostname.toLowerCase().replace(/\.+$/, "");
15+
return (
16+
hostname === "coding.dashscope.aliyuncs.com" ||
17+
hostname === "coding-intl.dashscope.aliyuncs.com"
18+
);
19+
} catch {
20+
return false;
21+
}
22+
}
23+
24+
function resolveConfiguredQwenBaseUrl(config: unknown): string | undefined {
25+
const providers = (config as { models?: { providers?: Record<string, { baseUrl?: string }> } })
26+
?.models?.providers;
27+
if (!providers) {
2228
return undefined;
2329
}
24-
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.`;
25-
},
26-
}));
30+
for (const [provider, entry] of Object.entries(providers)) {
31+
const normalizedProvider = provider.trim().toLowerCase();
32+
if (normalizedProvider !== "qwen" && normalizedProvider !== "modelstudio") {
33+
continue;
34+
}
35+
const baseUrl = entry?.baseUrl?.trim();
36+
if (baseUrl) {
37+
return baseUrl;
38+
}
39+
}
40+
return undefined;
41+
}
42+
43+
return {
44+
shouldSuppressBuiltInModel: ({
45+
provider,
46+
id,
47+
baseUrl,
48+
config,
49+
}: {
50+
provider?: string;
51+
id?: string;
52+
baseUrl?: string;
53+
config?: unknown;
54+
}) => {
55+
if (
56+
(provider === "openai" ||
57+
provider === "azure-openai-responses" ||
58+
provider === "openai-codex") &&
59+
id?.trim().toLowerCase() === "gpt-5.3-codex-spark"
60+
) {
61+
return true;
62+
}
63+
if (provider === "openai-codex" && id?.trim().toLowerCase() === "gpt-5.4-mini") {
64+
return true;
65+
}
66+
return (
67+
(provider === "qwen" || provider === "modelstudio") &&
68+
id?.trim().toLowerCase() === "qwen3.6-plus" &&
69+
isQwenCodingPlanBaseUrl(baseUrl ?? resolveConfiguredQwenBaseUrl(config))
70+
);
71+
},
72+
buildSuppressedBuiltInModelError: ({
73+
provider,
74+
id,
75+
config,
76+
}: {
77+
provider?: string;
78+
id?: string;
79+
config?: unknown;
80+
}) => {
81+
if (
82+
(provider === "qwen" || provider === "modelstudio") &&
83+
id?.trim().toLowerCase() === "qwen3.6-plus" &&
84+
isQwenCodingPlanBaseUrl(resolveConfiguredQwenBaseUrl(config))
85+
) {
86+
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.";
87+
}
88+
if (provider === "openai-codex" && id?.trim().toLowerCase() === "gpt-5.4-mini") {
89+
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.";
90+
}
91+
if (
92+
(provider === "openai" ||
93+
provider === "azure-openai-responses" ||
94+
provider === "openai-codex") &&
95+
id?.trim().toLowerCase() === "gpt-5.3-codex-spark"
96+
) {
97+
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.`;
98+
}
99+
return undefined;
100+
},
101+
};
102+
});
27103

28104
vi.mock("../pi-model-discovery.js", () => ({
29105
discoverAuthStorage: vi.fn(() => ({ mocked: true })),
@@ -222,6 +298,63 @@ describe("resolveModel", () => {
222298
expect(getModelProviderRequestTransport(result.model ?? {})).toBeUndefined();
223299
});
224300

301+
it("resolves explicitly configured qwen3.6-plus before Coding Plan built-in suppression", () => {
302+
const cfg = {
303+
models: {
304+
providers: {
305+
qwen: {
306+
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1",
307+
api: "openai-completions",
308+
models: [
309+
{
310+
id: "qwen3.6-plus",
311+
name: "qwen3.6-plus",
312+
input: ["text", "image"],
313+
reasoning: false,
314+
contextWindow: 1_000_000,
315+
maxTokens: 65_536,
316+
},
317+
],
318+
},
319+
},
320+
},
321+
} as unknown as OpenClawConfig;
322+
323+
const result = resolveModelForTest("qwen", "qwen3.6-plus", "/tmp/agent", cfg);
324+
325+
expect(result.error).toBeUndefined();
326+
expect(result.model).toMatchObject({
327+
provider: "qwen",
328+
id: "qwen3.6-plus",
329+
api: "openai-completions",
330+
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1",
331+
input: ["text", "image"],
332+
contextWindow: 1_000_000,
333+
maxTokens: 65_536,
334+
});
335+
});
336+
337+
it("keeps unconfigured qwen3.6-plus suppressed on Coding Plan endpoints", () => {
338+
const cfg = {
339+
models: {
340+
providers: {
341+
qwen: {
342+
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1",
343+
api: "openai-completions",
344+
models: [],
345+
},
346+
},
347+
},
348+
} as unknown as OpenClawConfig;
349+
350+
const result = resolveModelForTest("qwen", "qwen3.6-plus", "/tmp/agent", cfg);
351+
352+
expect(result.model).toBeUndefined();
353+
expect(result.error).toBe(
354+
"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.",
355+
);
356+
});
357+
225358
it("normalizes Google fallback baseUrls for custom providers", () => {
226359
const cfg = {
227360
models: {

src/agents/pi-embedded-runner/model.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -591,16 +591,6 @@ function resolveExplicitModelWithRegistry(params: {
591591
const { provider, modelId, modelRegistry, cfg, agentDir, runtimeHooks } = params;
592592
const providerConfig = resolveConfiguredProviderConfig(cfg, provider);
593593
const requestTimeoutMs = resolveProviderRequestTimeoutMs(providerConfig?.timeoutSeconds);
594-
if (
595-
shouldSuppressBuiltInModel({
596-
provider,
597-
id: modelId,
598-
baseUrl: providerConfig?.baseUrl,
599-
config: cfg,
600-
})
601-
) {
602-
return { kind: "suppressed" };
603-
}
604594
const inlineMatch = findInlineModelMatch({
605595
providers: cfg?.models?.providers ?? {},
606596
provider,
@@ -628,6 +618,16 @@ function resolveExplicitModelWithRegistry(params: {
628618
}),
629619
};
630620
}
621+
if (
622+
shouldSuppressBuiltInModel({
623+
provider,
624+
id: modelId,
625+
baseUrl: providerConfig?.baseUrl,
626+
config: cfg,
627+
})
628+
) {
629+
return { kind: "suppressed" };
630+
}
631631
const model = modelRegistry.find(provider, modelId) as Model<Api> | null;
632632

633633
if (model) {
@@ -1099,6 +1099,7 @@ function buildUnknownModelError(params: {
10991099
const suppressed = buildSuppressedBuiltInModelError({
11001100
provider: params.provider,
11011101
id: params.modelId,
1102+
config: params.cfg,
11021103
});
11031104
if (suppressed) {
11041105
return suppressed;

src/plugins/manifest-model-suppression.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,14 @@ describe("manifest model suppression", () => {
124124
env: process.env,
125125
})?.suppress,
126126
).toBe(true);
127+
expect(
128+
resolveManifestBuiltInModelSuppression({
129+
provider: "qwen",
130+
id: "qwen3.6-plus",
131+
baseUrl: " https://coding-intl.dashscope.aliyuncs.com./v1 ",
132+
env: process.env,
133+
})?.suppress,
134+
).toBe(true);
127135
expect(
128136
resolveManifestBuiltInModelSuppression({
129137
provider: "qwen",

src/plugins/manifest-model-suppression.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,16 +78,21 @@ function buildManifestSuppressionError(params: {
7878
}
7979

8080
function normalizeBaseUrlHost(baseUrl: string | null | undefined): string {
81-
if (!baseUrl?.trim()) {
81+
const trimmed = baseUrl?.trim();
82+
if (!trimmed) {
8283
return "";
8384
}
8485
try {
85-
return new URL(baseUrl).hostname.toLowerCase();
86+
return normalizeSuppressionHost(new URL(trimmed).hostname);
8687
} catch {
8788
return "";
8889
}
8990
}
9091

92+
function normalizeSuppressionHost(host: string): string {
93+
return normalizeLowercaseStringOrEmpty(host).replace(/\.+$/, "");
94+
}
95+
9196
function resolveConfiguredProviderValue(params: {
9297
provider: string;
9398
config?: OpenClawConfig;
@@ -133,7 +138,7 @@ function manifestSuppressionMatchesConditions(params: {
133138
if (!baseUrlHost) {
134139
return false;
135140
}
136-
const allowedHosts = new Set(when.baseUrlHosts.map(normalizeLowercaseStringOrEmpty));
141+
const allowedHosts = new Set(when.baseUrlHosts.map(normalizeSuppressionHost));
137142
if (!allowedHosts.has(baseUrlHost)) {
138143
return false;
139144
}

0 commit comments

Comments
 (0)