mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 15:20:44 +00:00
fix(qwen): preserve custom modelstudio providers
This commit is contained in:
@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
### Fixes
|
### 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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>
|
<Note>
|
||||||
Legacy `modelstudio-*` auth-choice ids and `modelstudio/...` model refs still
|
Legacy `modelstudio-*` auth-choice ids and `modelstudio/...` model refs still
|
||||||
work as compatibility aliases, but new setup flows should prefer the canonical
|
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>
|
</Note>
|
||||||
|
|
||||||
</Tab>
|
</Tab>
|
||||||
@@ -122,7 +125,10 @@ Choose your plan type and follow the setup steps.
|
|||||||
<Note>
|
<Note>
|
||||||
Legacy `modelstudio-*` auth-choice ids and `modelstudio/...` model refs still
|
Legacy `modelstudio-*` auth-choice ids and `modelstudio/...` model refs still
|
||||||
work as compatibility aliases, but new setup flows should prefer the canonical
|
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>
|
</Note>
|
||||||
|
|
||||||
</Tab>
|
</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));
|
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({
|
export default defineSingleProviderPluginEntry({
|
||||||
id: PROVIDER_ID,
|
id: PROVIDER_ID,
|
||||||
name: "Qwen Provider",
|
name: "Qwen Provider",
|
||||||
@@ -180,6 +196,7 @@ export default defineSingleProviderPluginEntry({
|
|||||||
const provider = normalizeProviderId(ctx.provider);
|
const provider = normalizeProviderId(ctx.provider);
|
||||||
if (
|
if (
|
||||||
(provider !== PROVIDER_ID && provider !== LEGACY_PROVIDER_ID) ||
|
(provider !== PROVIDER_ID && provider !== LEGACY_PROVIDER_ID) ||
|
||||||
|
hasExactForeignApiOwner({ provider: ctx.provider, config: ctx.config }) ||
|
||||||
ctx.modelId !== QWEN_36_PLUS_MODEL_ID ||
|
ctx.modelId !== QWEN_36_PLUS_MODEL_ID ||
|
||||||
!isQwen36PlusUnsupportedForConfig({ config: ctx.config, baseUrl: ctx.baseUrl })
|
!isQwen36PlusUnsupportedForConfig({ config: ctx.config, baseUrl: ctx.baseUrl })
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { resolveConfiguredProviderFallback } from "./configured-provider-fallbac
|
|||||||
import { DEFAULT_PROVIDER } from "./defaults.js";
|
import { DEFAULT_PROVIDER } from "./defaults.js";
|
||||||
import type { ModelCatalogEntry } from "./model-catalog.types.js";
|
import type { ModelCatalogEntry } from "./model-catalog.types.js";
|
||||||
import { splitTrailingAuthProfile } from "./model-ref-profile.js";
|
import { splitTrailingAuthProfile } from "./model-ref-profile.js";
|
||||||
|
import { normalizeStaticProviderModelId } from "./model-ref-shared.js";
|
||||||
import {
|
import {
|
||||||
type ModelRef,
|
type ModelRef,
|
||||||
findNormalizedProviderValue,
|
findNormalizedProviderValue,
|
||||||
@@ -225,12 +226,48 @@ export function parseModelRefWithCompatAlias(params: {
|
|||||||
}): ModelRef | null {
|
}): ModelRef | null {
|
||||||
return (
|
return (
|
||||||
resolveConfiguredOpenRouterCompatAlias(params) ??
|
resolveConfiguredOpenRouterCompatAlias(params) ??
|
||||||
|
resolveExactConfiguredProviderRef(params) ??
|
||||||
parseModelRef(params.raw, params.defaultProvider, {
|
parseModelRef(params.raw, params.defaultProvider, {
|
||||||
allowPluginNormalization: params.allowPluginNormalization,
|
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: {
|
export function resolveAllowlistModelKey(params: {
|
||||||
cfg?: OpenClawConfig;
|
cfg?: OpenClawConfig;
|
||||||
raw: string;
|
raw: string;
|
||||||
|
|||||||
@@ -771,7 +771,7 @@ describe("model-selection", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as OpenClawConfig;
|
} as unknown as OpenClawConfig;
|
||||||
|
|
||||||
const result = resolveAllowedModelRef({
|
const result = resolveAllowedModelRef({
|
||||||
cfg,
|
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", () => {
|
it("should fall back to hardcoded default when no custom providers have models", () => {
|
||||||
const cfg = createProviderWithModelsConfig("empty-provider", []);
|
const cfg = createProviderWithModelsConfig("empty-provider", []);
|
||||||
const result = resolveConfiguredRefForTest(cfg);
|
const result = resolveConfiguredRefForTest(cfg);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { normalizeProviderId } from "../agents/provider-id.js";
|
import { normalizeProviderId } from "../agents/provider-id.js";
|
||||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||||
|
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
|
||||||
import { normalizePluginIdScope, serializePluginIdScope } from "./plugin-scope.js";
|
import { normalizePluginIdScope, serializePluginIdScope } from "./plugin-scope.js";
|
||||||
import { resolveProviderConfigApiOwnerHint } from "./provider-config-owner.js";
|
import { resolveProviderConfigApiOwnerHint } from "./provider-config-owner.js";
|
||||||
import { isPluginProvidersLoadInFlight, resolvePluginProviders } from "./providers.runtime.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<
|
let cachedHookProvidersWithoutConfig = new WeakMap<
|
||||||
NodeJS.ProcessEnv,
|
NodeJS.ProcessEnv,
|
||||||
Map<string, ProviderPlugin[]>
|
Map<string, ProviderPlugin[]>
|
||||||
@@ -178,11 +184,14 @@ export function resolveProviderRuntimePlugin(params: {
|
|||||||
bundledProviderAllowlistCompat: params.bundledProviderAllowlistCompat,
|
bundledProviderAllowlistCompat: params.bundledProviderAllowlistCompat,
|
||||||
bundledProviderVitestCompat: params.bundledProviderVitestCompat,
|
bundledProviderVitestCompat: params.bundledProviderVitestCompat,
|
||||||
installBundledRuntimeDeps: params.installBundledRuntimeDeps,
|
installBundledRuntimeDeps: params.installBundledRuntimeDeps,
|
||||||
}).find(
|
}).find((plugin) => {
|
||||||
(plugin) =>
|
if (apiOwnerHint) {
|
||||||
matchesProviderId(plugin, params.provider) ||
|
return (
|
||||||
(apiOwnerHint ? matchesProviderId(plugin, apiOwnerHint) : false),
|
matchesProviderLiteralId(plugin, params.provider) || matchesProviderId(plugin, apiOwnerHint)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
return matchesProviderId(plugin, params.provider);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveProviderHookPlugin(params: {
|
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", () => {
|
it("merges compat contributions from owner and foreign provider plugins", () => {
|
||||||
resolvePluginProvidersMock.mockImplementation((params) => {
|
resolvePluginProvidersMock.mockImplementation((params) => {
|
||||||
const onlyPluginIds = params.onlyPluginIds ?? [];
|
const onlyPluginIds = params.onlyPluginIds ?? [];
|
||||||
|
|||||||
Reference in New Issue
Block a user