mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:20:43 +00:00
fix(qwen): allow explicit qwen3.6-plus on Coding Plan (#72664)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user