diff --git a/CHANGELOG.md b/CHANGELOG.md
index e9a73899bb3..f337a9e942a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Control UI: keep session-specific assistant identity loads authoritative after WebSocket connect, so non-main agent chat sessions do not show the main agent name in the header after bootstrap refreshes. Fixes #72776. Thanks @rockytian-top.
+- Agents/Qwen: preserve exact custom `modelstudio` provider configs with foreign `api` owners so explicit OpenAI-compatible Model Studio endpoints no longer get normalized into the bundled Qwen plugin path. Fixes #64483. Thanks @FiredMosquito831.
- Media-understanding/audio: migrate deprecated `{input}` placeholders in legacy `audio.transcription.command` configs to `{{MediaPath}}`, so custom audio transcribers no longer receive the literal placeholder after doctor repair. Fixes #72760. Thanks @krisfanue3-hash.
- Ollama/onboarding: de-dupe suggested bare local models against installed `:latest` tags and skip redundant pulls, so setup shows the installed model once and no longer says it is downloading an already available model. Fixes #68952. Thanks @tleyden.
- Control UI/Gateway: preserve WebChat client version labels across localhost, 127.0.0.1, and IPv6 loopback aliases on the same port, avoiding misleading `vcontrol-ui` connection logs while investigating duplicate-message reports. Refs #72753 and #72742. Thanks @LumenFromTheFuture and @allesgutefy.
diff --git a/docs/providers/qwen.md b/docs/providers/qwen.md
index bf1e2a4b207..8d1ca6b8ced 100644
--- a/docs/providers/qwen.md
+++ b/docs/providers/qwen.md
@@ -76,7 +76,10 @@ Choose your plan type and follow the setup steps.
Legacy `modelstudio-*` auth-choice ids and `modelstudio/...` model refs still
work as compatibility aliases, but new setup flows should prefer the canonical
- `qwen-*` auth-choice ids and `qwen/...` model refs.
+ `qwen-*` auth-choice ids and `qwen/...` model refs. If you define an exact
+ custom `models.providers.modelstudio` entry with another `api` value, that
+ custom provider owns `modelstudio/...` refs instead of the Qwen compatibility
+ alias.
@@ -122,7 +125,10 @@ Choose your plan type and follow the setup steps.
Legacy `modelstudio-*` auth-choice ids and `modelstudio/...` model refs still
work as compatibility aliases, but new setup flows should prefer the canonical
- `qwen-*` auth-choice ids and `qwen/...` model refs.
+ `qwen-*` auth-choice ids and `qwen/...` model refs. If you define an exact
+ custom `models.providers.modelstudio` entry with another `api` value, that
+ custom provider owns `modelstudio/...` refs instead of the Qwen compatibility
+ alias.
diff --git a/extensions/qwen/index.test.ts b/extensions/qwen/index.test.ts
new file mode 100644
index 00000000000..140b4a65898
--- /dev/null
+++ b/extensions/qwen/index.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from "vitest";
+import type { OpenClawConfig } from "../../src/config/types.js";
+import { registerSingleProviderPlugin } from "../../src/test-utils/plugin-registration.js";
+import qwenPlugin from "./index.js";
+
+async function registerQwenProvider() {
+ return registerSingleProviderPlugin(qwenPlugin);
+}
+
+describe("qwen provider plugin", () => {
+ it("does not suppress exact custom modelstudio providers owned by another api", async () => {
+ const provider = await registerQwenProvider();
+ const config = {
+ models: {
+ providers: {
+ modelstudio: {
+ api: "openai-completions",
+ baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1",
+ models: [{ id: "qwen3.6-plus", name: "Qwen 3.6 Plus" }],
+ },
+ },
+ },
+ } as unknown as OpenClawConfig;
+
+ expect(
+ provider.suppressBuiltInModel?.({
+ config,
+ env: {},
+ provider: "modelstudio",
+ modelId: "qwen3.6-plus",
+ baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1",
+ }),
+ ).toBeUndefined();
+ });
+
+ it("still suppresses legacy modelstudio refs on Qwen Coding Plan endpoints", async () => {
+ const provider = await registerQwenProvider();
+
+ expect(
+ provider.suppressBuiltInModel?.({
+ config: {},
+ env: {},
+ provider: "modelstudio",
+ modelId: "qwen3.6-plus",
+ baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1",
+ })?.suppress,
+ ).toBe(true);
+ });
+});
diff --git a/extensions/qwen/index.ts b/extensions/qwen/index.ts
index 3b9bce7f8cd..b22f3341aeb 100644
--- a/extensions/qwen/index.ts
+++ b/extensions/qwen/index.ts
@@ -47,6 +47,22 @@ function isQwen36PlusUnsupportedForConfig(params: {
return isQwenCodingPlanBaseUrl(params.baseUrl ?? resolveConfiguredQwenBaseUrl(params.config));
}
+function hasExactForeignApiOwner(params: {
+ provider: string;
+ config: { models?: { providers?: Record } } | undefined;
+}): boolean {
+ const providers = params.config?.models?.providers;
+ if (!providers) {
+ return false;
+ }
+ const provider = normalizeProviderId(params.provider);
+ const exact = Object.entries(providers).find(
+ ([providerId]) => normalizeProviderId(providerId) === provider,
+ )?.[1];
+ const api = normalizeProviderId(exact?.api ?? "");
+ return !!api && api !== PROVIDER_ID && api !== LEGACY_PROVIDER_ID;
+}
+
export default defineSingleProviderPluginEntry({
id: PROVIDER_ID,
name: "Qwen Provider",
@@ -180,6 +196,7 @@ export default defineSingleProviderPluginEntry({
const provider = normalizeProviderId(ctx.provider);
if (
(provider !== PROVIDER_ID && provider !== LEGACY_PROVIDER_ID) ||
+ hasExactForeignApiOwner({ provider: ctx.provider, config: ctx.config }) ||
ctx.modelId !== QWEN_36_PLUS_MODEL_ID ||
!isQwen36PlusUnsupportedForConfig({ config: ctx.config, baseUrl: ctx.baseUrl })
) {
diff --git a/src/agents/model-selection-shared.ts b/src/agents/model-selection-shared.ts
index 6a05f223bf8..58cceb3b05b 100644
--- a/src/agents/model-selection-shared.ts
+++ b/src/agents/model-selection-shared.ts
@@ -10,6 +10,7 @@ import { resolveConfiguredProviderFallback } from "./configured-provider-fallbac
import { DEFAULT_PROVIDER } from "./defaults.js";
import type { ModelCatalogEntry } from "./model-catalog.types.js";
import { splitTrailingAuthProfile } from "./model-ref-profile.js";
+import { normalizeStaticProviderModelId } from "./model-ref-shared.js";
import {
type ModelRef,
findNormalizedProviderValue,
@@ -225,12 +226,48 @@ export function parseModelRefWithCompatAlias(params: {
}): ModelRef | null {
return (
resolveConfiguredOpenRouterCompatAlias(params) ??
+ resolveExactConfiguredProviderRef(params) ??
parseModelRef(params.raw, params.defaultProvider, {
allowPluginNormalization: params.allowPluginNormalization,
})
);
}
+function resolveExactConfiguredProviderRef(params: {
+ cfg?: OpenClawConfig;
+ raw: string;
+ allowPluginNormalization?: boolean;
+}): ModelRef | null {
+ const slash = params.raw.indexOf("/");
+ if (slash <= 0 || !params.cfg?.models?.providers) {
+ return null;
+ }
+ const providerRaw = params.raw.slice(0, slash).trim();
+ const modelRaw = params.raw.slice(slash + 1).trim();
+ if (!providerRaw || !modelRaw) {
+ return null;
+ }
+ const providerKey = normalizeLowercaseStringOrEmpty(providerRaw);
+ const exactConfigured = Object.entries(params.cfg.models.providers).find(
+ ([key]) => normalizeLowercaseStringOrEmpty(key) === providerKey,
+ );
+ if (!exactConfigured) {
+ return null;
+ }
+ const [configuredProvider, providerConfig] = exactConfigured;
+ const normalizedConfiguredProvider = normalizeProviderId(configuredProvider);
+ const apiOwner =
+ typeof providerConfig?.api === "string" ? normalizeProviderId(providerConfig.api) : "";
+ if (!apiOwner || apiOwner === normalizedConfiguredProvider) {
+ return null;
+ }
+ const provider = normalizeLowercaseStringOrEmpty(configuredProvider);
+ return {
+ provider,
+ model: normalizeStaticProviderModelId(provider, modelRaw.trim()),
+ };
+}
+
export function resolveAllowlistModelKey(params: {
cfg?: OpenClawConfig;
raw: string;
diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts
index a6c80997599..78fcc88b4bc 100644
--- a/src/agents/model-selection.test.ts
+++ b/src/agents/model-selection.test.ts
@@ -771,7 +771,7 @@ describe("model-selection", () => {
},
},
},
- } as OpenClawConfig;
+ } as unknown as OpenClawConfig;
const result = resolveAllowedModelRef({
cfg,
@@ -1114,6 +1114,51 @@ describe("model-selection", () => {
});
});
+ it("preserves exact configured provider ids before legacy alias normalization", () => {
+ const cfg = {
+ agents: {
+ defaults: {
+ model: { primary: "modelstudio/qwen3.6-plus" },
+ },
+ },
+ models: {
+ providers: {
+ modelstudio: {
+ api: "openai-completions",
+ baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1",
+ models: [{ id: "qwen3.6-plus", name: "Qwen 3.6 Plus" }],
+ },
+ },
+ },
+ } as unknown as OpenClawConfig;
+
+ expect(
+ resolveConfiguredModelRef({
+ cfg,
+ defaultProvider: "anthropic",
+ defaultModel: "claude-opus-4-6",
+ }),
+ ).toEqual({ provider: "modelstudio", model: "qwen3.6-plus" });
+ });
+
+ it("keeps legacy modelstudio aliases when no exact foreign api owner is configured", () => {
+ const cfg = {
+ agents: {
+ defaults: {
+ model: { primary: "modelstudio/qwen3.5-plus" },
+ },
+ },
+ } as OpenClawConfig;
+
+ expect(
+ resolveConfiguredModelRef({
+ cfg,
+ defaultProvider: "anthropic",
+ defaultModel: "claude-opus-4-6",
+ }),
+ ).toEqual({ provider: "qwen", model: "qwen3.5-plus" });
+ });
+
it("should fall back to hardcoded default when no custom providers have models", () => {
const cfg = createProviderWithModelsConfig("empty-provider", []);
const result = resolveConfiguredRefForTest(cfg);
diff --git a/src/plugins/provider-hook-runtime.ts b/src/plugins/provider-hook-runtime.ts
index 4d7c705c7fe..d1940155dc6 100644
--- a/src/plugins/provider-hook-runtime.ts
+++ b/src/plugins/provider-hook-runtime.ts
@@ -1,5 +1,6 @@
import { normalizeProviderId } from "../agents/provider-id.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
+import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { normalizePluginIdScope, serializePluginIdScope } from "./plugin-scope.js";
import { resolveProviderConfigApiOwnerHint } from "./provider-config-owner.js";
import { isPluginProvidersLoadInFlight, resolvePluginProviders } from "./providers.runtime.js";
@@ -28,6 +29,11 @@ function matchesProviderId(provider: ProviderPlugin, providerId: string): boolea
);
}
+function matchesProviderLiteralId(provider: ProviderPlugin, providerId: string): boolean {
+ const normalized = normalizeLowercaseStringOrEmpty(providerId);
+ return !!normalized && normalizeLowercaseStringOrEmpty(provider.id) === normalized;
+}
+
let cachedHookProvidersWithoutConfig = new WeakMap<
NodeJS.ProcessEnv,
Map
@@ -178,11 +184,14 @@ export function resolveProviderRuntimePlugin(params: {
bundledProviderAllowlistCompat: params.bundledProviderAllowlistCompat,
bundledProviderVitestCompat: params.bundledProviderVitestCompat,
installBundledRuntimeDeps: params.installBundledRuntimeDeps,
- }).find(
- (plugin) =>
- matchesProviderId(plugin, params.provider) ||
- (apiOwnerHint ? matchesProviderId(plugin, apiOwnerHint) : false),
- );
+ }).find((plugin) => {
+ if (apiOwnerHint) {
+ return (
+ matchesProviderLiteralId(plugin, params.provider) || matchesProviderId(plugin, apiOwnerHint)
+ );
+ }
+ return matchesProviderId(plugin, params.provider);
+ });
}
export function resolveProviderHookPlugin(params: {
diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts
index 741a7c2404a..bc9160b1586 100644
--- a/src/plugins/provider-runtime.test.ts
+++ b/src/plugins/provider-runtime.test.ts
@@ -1662,6 +1662,39 @@ describe("provider-runtime", () => {
);
});
+ it("does not match alias hooks when an exact custom provider declares a foreign api owner", () => {
+ const qwenPlugin: ProviderPlugin = {
+ id: "qwen",
+ label: "Qwen",
+ aliases: ["modelstudio"],
+ auth: [],
+ createStreamFn: vi.fn(() => vi.fn()),
+ };
+ resolvePluginProvidersMock.mockReturnValue([qwenPlugin]);
+
+ const plugin = resolveProviderRuntimePlugin({
+ provider: "modelstudio",
+ config: {
+ models: {
+ providers: {
+ modelstudio: {
+ api: "openai-completions",
+ baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1",
+ models: [],
+ },
+ },
+ },
+ } as never,
+ });
+
+ expect(plugin).toBeUndefined();
+ expect(resolvePluginProvidersMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ providerRefs: ["modelstudio", "openai-completions"],
+ }),
+ );
+ });
+
it("merges compat contributions from owner and foreign provider plugins", () => {
resolvePluginProvidersMock.mockImplementation((params) => {
const onlyPluginIds = params.onlyPluginIds ?? [];