fix(qwen): preserve custom modelstudio providers

This commit is contained in:
Peter Steinberger
2026-04-27 12:24:05 +01:00
parent dca9fa471f
commit 5afa24a9fc
8 changed files with 205 additions and 8 deletions

View File

@@ -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.

View File

@@ -76,7 +76,10 @@ Choose your plan type and follow the setup steps.
<Note>
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.
</Note>
</Tab>
@@ -122,7 +125,10 @@ Choose your plan type and follow the setup steps.
<Note>
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.
</Note>
</Tab>

View File

@@ -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);
});
});

View File

@@ -47,6 +47,22 @@ function isQwen36PlusUnsupportedForConfig(params: {
return isQwenCodingPlanBaseUrl(params.baseUrl ?? resolveConfiguredQwenBaseUrl(params.config));
}
function hasExactForeignApiOwner(params: {
provider: string;
config: { models?: { providers?: Record<string, { api?: string } | undefined> } } | 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 })
) {

View File

@@ -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;

View File

@@ -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);

View File

@@ -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<string, ProviderPlugin[]>
@@ -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: {

View File

@@ -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 ?? [];