diff --git a/CHANGELOG.md b/CHANGELOG.md index bbaf4003790..7d47ff5e976 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/extensions/qwen/index.test.ts b/extensions/qwen/index.test.ts index b18d960b990..4f9b02f0295 100644 --- a/extensions/qwen/index.test.ts +++ b/extensions/qwen/index.test.ts @@ -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(); diff --git a/extensions/qwen/models.ts b/extensions/qwen/models.ts index e67d325448c..3847db02c77 100644 --- a/extensions/qwen/models.ts +++ b/extensions/qwen/models.ts @@ -109,11 +109,12 @@ export const QWEN_MODEL_CATALOG: ReadonlyArray = [ ]; 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" diff --git a/extensions/qwen/provider-catalog.test.ts b/extensions/qwen/provider-catalog.test.ts index f6b94e29ba2..b4ccb2a6f1b 100644 --- a/extensions/qwen/provider-catalog.test.ts +++ b/extensions/qwen/provider-catalog.test.ts @@ -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(); }); diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index d23b0a734d9..b6b7cb5fde8 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -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 } }) + ?.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: { diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index 1487fe92df3..d5a5580b2a7 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -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 | 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; diff --git a/src/plugins/manifest-model-suppression.test.ts b/src/plugins/manifest-model-suppression.test.ts index 7d7ecbb40ea..278ccb0098e 100644 --- a/src/plugins/manifest-model-suppression.test.ts +++ b/src/plugins/manifest-model-suppression.test.ts @@ -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", diff --git a/src/plugins/manifest-model-suppression.ts b/src/plugins/manifest-model-suppression.ts index a4d1a7772c7..ce56195661d 100644 --- a/src/plugins/manifest-model-suppression.ts +++ b/src/plugins/manifest-model-suppression.ts @@ -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; }