mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:00:43 +00:00
fix(qwen): preserve custom modelstudio providers
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
49
extensions/qwen/index.test.ts
Normal file
49
extensions/qwen/index.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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 })
|
||||
) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
Reference in New Issue
Block a user