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