Remove Qwen OAuth integration (qwen-portal-auth) (#52709)

* Remove Qwen OAuth integration (qwen-portal-auth)

Qwen OAuth via portal.qwen.ai is being deprecated by the Qwen team due
to traffic impact on their primary Qwen Code user base. Users should
migrate to the officially supported Model Studio (Alibaba Cloud Coding
Plan) provider instead.

Ref: https://github.com/openclaw/openclaw/issues/49557

- Delete extensions/qwen-portal-auth/ plugin entirely
- Remove qwen-portal from onboarding auth choices, provider aliases,
  auto-enable list, bundled plugin defaults, and pricing cache
- Remove Qwen CLI credential sync (external-cli-sync, cli-credentials)
- Remove QWEN_OAUTH_MARKER from model auth markers
- Update docs/providers/qwen.md to redirect to Model Studio
- Update model-providers docs (EN + zh-CN) to remove Qwen OAuth section
- Regenerate config and plugin-sdk baselines
- Update all affected tests

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* Clean up residual qwen-portal references after OAuth removal

* Add migration hint for deprecated qwen-portal OAuth provider

* fix: finish qwen oauth removal follow-up

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Frank Yang <frank.ekn@gmail.com>
This commit is contained in:
pomelo
2026-03-26 16:32:34 +08:00
committed by GitHub
parent 83e6c12f15
commit dad68d319b
65 changed files with 135 additions and 1461 deletions

View File

@@ -30,7 +30,6 @@ export const BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES = {
openrouter: ["OPENROUTER_API_KEY"],
perplexity: ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"],
qianfan: ["QIANFAN_API_KEY"],
"qwen-portal": ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"],
sglang: ["SGLANG_API_KEY"],
synthetic: ["SYNTHETIC_API_KEY"],
tavily: ["TAVILY_API_KEY"],

View File

@@ -34,10 +34,6 @@ describe("bundled provider auth env vars", () => {
"PERPLEXITY_API_KEY",
"OPENROUTER_API_KEY",
]);
expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES["qwen-portal"]).toEqual([
"QWEN_OAUTH_TOKEN",
"QWEN_PORTAL_API_KEY",
]);
expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES.tavily).toEqual(["TAVILY_API_KEY"]);
expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES["minimax-portal"]).toEqual([
"MINIMAX_OAUTH_TOKEN",

View File

@@ -52,7 +52,6 @@ export const BUNDLED_ENABLED_BY_DEFAULT = new Set<string>([
"openrouter",
"phone-control",
"qianfan",
"qwen-portal-auth",
"sglang",
"synthetic",
"talk-voice",

View File

@@ -1,18 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
createAuthTestLifecycle,
createExitThrowingRuntime,
createWizardPrompter,
readAuthProfilesForAgent,
requireOpenClawAgentDir,
setupAuthTestEnv,
} from "../../../test/helpers/auth-wizard.js";
import { clearRuntimeAuthProfileStoreSnapshots } from "../../agents/auth-profiles/store.js";
import { resolvePreferredProviderForAuthChoice } from "../../plugins/provider-auth-choice-preference.js";
import { runProviderPluginAuthMethod } from "../../plugins/provider-auth-choice.js";
import { buildProviderPluginMethodChoice } from "../provider-wizard.js";
import { requireProviderContractProvider, uniqueProviderContractProviders } from "./registry.js";
import { registerProviders, requireProvider } from "./testkit.js";
type ResolvePluginProviders =
typeof import("../../plugins/provider-auth-choice.runtime.js").resolvePluginProviders;
@@ -20,53 +10,19 @@ type ResolveProviderPluginChoice =
typeof import("../../plugins/provider-auth-choice.runtime.js").resolveProviderPluginChoice;
type RunProviderModelSelectedHook =
typeof import("../../plugins/provider-auth-choice.runtime.js").runProviderModelSelectedHook;
const loginQwenPortalOAuthMock = vi.hoisted(() => vi.fn());
const githubCopilotLoginCommandMock = vi.hoisted(() => vi.fn());
const resolvePluginProvidersMock = vi.hoisted(() => vi.fn<ResolvePluginProviders>(() => []));
const resolveProviderPluginChoiceMock = vi.hoisted(() => vi.fn<ResolveProviderPluginChoice>());
const runProviderModelSelectedHookMock = vi.hoisted(() =>
vi.fn<RunProviderModelSelectedHook>(async () => {}),
);
import qwenPortalPlugin from "../../../extensions/qwen-portal-auth/index.js";
vi.mock("../../../extensions/qwen-portal-auth/oauth.js", () => ({
loginQwenPortalOAuth: loginQwenPortalOAuthMock,
}));
vi.mock("../../../extensions/github-copilot/login.js", () => ({
githubCopilotLoginCommand: githubCopilotLoginCommandMock,
}));
vi.mock("../../plugins/provider-auth-choice.runtime.js", () => ({
resolvePluginProviders: resolvePluginProvidersMock,
resolveProviderPluginChoice: resolveProviderPluginChoiceMock,
runProviderModelSelectedHook: runProviderModelSelectedHookMock,
}));
type StoredAuthProfile = {
type?: string;
provider?: string;
access?: string;
refresh?: string;
key?: string;
token?: string;
};
describe("provider auth-choice contract", () => {
const lifecycle = createAuthTestLifecycle([
"OPENCLAW_STATE_DIR",
"OPENCLAW_AGENT_DIR",
"PI_CODING_AGENT_DIR",
]);
let activeStateDir: string | null = null;
async function setupTempState() {
if (activeStateDir) {
await lifecycle.cleanup();
}
const env = await setupAuthTestEnv("openclaw-provider-auth-choice-");
activeStateDir = env.stateDir;
lifecycle.setStateDir(env.stateDir);
}
beforeEach(() => {
resolvePluginProvidersMock.mockReset();
resolvePluginProvidersMock.mockReturnValue(uniqueProviderContractProviders);
@@ -90,22 +46,17 @@ describe("provider auth-choice contract", () => {
afterEach(async () => {
vi.restoreAllMocks();
loginQwenPortalOAuthMock.mockReset();
githubCopilotLoginCommandMock.mockReset();
resolvePluginProvidersMock.mockReset();
resolvePluginProvidersMock.mockReturnValue([]);
resolveProviderPluginChoiceMock.mockReset();
resolveProviderPluginChoiceMock.mockReturnValue(null);
runProviderModelSelectedHookMock.mockReset();
clearRuntimeAuthProfileStoreSnapshots();
await lifecycle.cleanup();
activeStateDir = null;
});
it("maps provider-plugin choices through the shared preferred-provider fallback resolver", async () => {
const pluginFallbackScenarios = [
"github-copilot",
"qwen-portal",
"minimax-portal",
"modelstudio",
"ollama",
@@ -131,114 +82,4 @@ describe("provider auth-choice contract", () => {
);
expect(resolvePluginProvidersMock).toHaveBeenCalled();
});
it("runs qwen portal auth through the shared plugin auth-method helper", async () => {
await setupTempState();
const qwenProvider = requireProvider(registerProviders(qwenPortalPlugin), "qwen-portal");
loginQwenPortalOAuthMock.mockResolvedValueOnce({
access: "access-token",
refresh: "refresh-token",
expires: 1_700_000_000_000,
resourceUrl: "portal.qwen.ai",
});
const note = vi.fn(async () => {});
const result = await runProviderPluginAuthMethod({
config: {},
prompter: createWizardPrompter({ note }),
runtime: createExitThrowingRuntime(),
method: qwenProvider.auth[0],
allowSecretRefPrompt: false,
});
expect(result.config.auth?.profiles?.["qwen-portal:default"]).toMatchObject({
provider: "qwen-portal",
mode: "oauth",
});
expect(result.config.models?.providers?.["qwen-portal"]).toMatchObject({
baseUrl: "https://portal.qwen.ai/v1",
models: [],
});
expect(result.config.agents?.defaults?.models).toMatchObject({
"qwen-portal/coder-model": { alias: "qwen" },
"qwen-portal/vision-model": {},
});
expect(result.defaultModel).toBe("qwen-portal/coder-model");
expect(note).toHaveBeenCalledWith(
expect.stringContaining("Qwen OAuth tokens auto-refresh."),
"Provider notes",
);
const stored = await readAuthProfilesForAgent<{ profiles?: Record<string, StoredAuthProfile> }>(
requireOpenClawAgentDir(),
);
expect(stored.profiles?.["qwen-portal:default"]).toMatchObject({
type: "oauth",
provider: "qwen-portal",
access: "access-token",
refresh: "refresh-token",
});
});
it("returns qwen portal default-model overrides for deferred callers", async () => {
await setupTempState();
const qwenProvider = requireProvider(registerProviders(qwenPortalPlugin), "qwen-portal");
loginQwenPortalOAuthMock.mockResolvedValueOnce({
access: "access-token",
refresh: "refresh-token",
expires: 1_700_000_000_000,
resourceUrl: "portal.qwen.ai",
});
const result = await runProviderPluginAuthMethod({
config: {},
prompter: createWizardPrompter({}),
runtime: createExitThrowingRuntime(),
method: qwenProvider.auth[0],
allowSecretRefPrompt: false,
});
expect(githubCopilotLoginCommandMock).not.toHaveBeenCalled();
expect(result).toEqual({
config: {
agents: {
defaults: {
models: {
"qwen-portal/coder-model": {
alias: "qwen",
},
"qwen-portal/vision-model": {},
},
},
},
auth: {
profiles: {
"qwen-portal:default": {
provider: "qwen-portal",
mode: "oauth",
},
},
},
models: {
providers: {
"qwen-portal": {
baseUrl: "https://portal.qwen.ai/v1",
models: [],
},
},
},
},
defaultModel: "qwen-portal/coder-model",
});
const stored = await readAuthProfilesForAgent<{
profiles?: Record<string, StoredAuthProfile>;
}>(requireOpenClawAgentDir());
expect(stored.profiles?.["qwen-portal:default"]).toMatchObject({
type: "oauth",
provider: "qwen-portal",
access: "access-token",
refresh: "refresh-token",
});
});
});

View File

@@ -12,8 +12,6 @@ import { registerProviders, requireProvider } from "./testkit.js";
type LoginOpenAICodexOAuth =
(typeof import("openclaw/plugin-sdk/provider-auth-login"))["loginOpenAICodexOAuth"];
type LoginQwenPortalOAuth =
(typeof import("../../../extensions/qwen-portal-auth/oauth.js"))["loginQwenPortalOAuth"];
type GithubCopilotLoginCommand =
(typeof import("openclaw/plugin-sdk/provider-auth-login"))["githubCopilotLoginCommand"];
type CreateVpsAwareHandlers =
@@ -24,7 +22,6 @@ type ListProfilesForProvider =
typeof import("openclaw/plugin-sdk/agent-runtime").listProfilesForProvider;
const loginOpenAICodexOAuthMock = vi.hoisted(() => vi.fn<LoginOpenAICodexOAuth>());
const loginQwenPortalOAuthMock = vi.hoisted(() => vi.fn<LoginQwenPortalOAuth>());
const githubCopilotLoginCommandMock = vi.hoisted(() => vi.fn<GithubCopilotLoginCommand>());
const ensureAuthProfileStoreMock = vi.hoisted(() => vi.fn<EnsureAuthProfileStore>());
const listProfilesForProviderMock = vi.hoisted(() => vi.fn<ListProfilesForProvider>());
@@ -47,13 +44,8 @@ vi.mock("openclaw/plugin-sdk/agent-runtime", async (importOriginal) => {
};
});
vi.mock("../../../extensions/qwen-portal-auth/oauth.js", () => ({
loginQwenPortalOAuth: loginQwenPortalOAuthMock,
}));
import githubCopilotPlugin from "../../../extensions/github-copilot/index.js";
import openAIPlugin from "../../../extensions/openai/index.js";
import qwenPortalPlugin from "../../../extensions/qwen-portal-auth/index.js";
function buildPrompter(): WizardPrompter {
const progress: WizardProgress = {
@@ -114,7 +106,6 @@ describe("provider auth contract", () => {
afterEach(() => {
loginOpenAICodexOAuthMock.mockReset();
loginQwenPortalOAuthMock.mockReset();
githubCopilotLoginCommandMock.mockReset();
ensureAuthProfileStoreMock.mockReset();
listProfilesForProviderMock.mockReset();
@@ -377,50 +368,6 @@ describe("provider auth contract", () => {
});
});
it("keeps Qwen portal OAuth auth results provider-owned", async () => {
const provider = requireProvider(registerProviders(qwenPortalPlugin), "qwen-portal");
loginQwenPortalOAuthMock.mockResolvedValueOnce({
access: "access-token",
refresh: "refresh-token",
expires: 1_700_000_000_000,
resourceUrl: "portal.qwen.ai",
});
const result = await provider.auth[0]?.run(buildAuthContext() as never);
expect(result).toMatchObject({
profiles: [
{
profileId: "qwen-portal:default",
credential: {
type: "oauth",
provider: "qwen-portal",
access: "access-token",
refresh: "refresh-token",
expires: 1_700_000_000_000,
},
},
],
defaultModel: "qwen-portal/coder-model",
configPatch: {
models: {
providers: {
"qwen-portal": {
baseUrl: "https://portal.qwen.ai/v1",
models: [],
},
},
},
},
});
expect(result?.notes).toEqual(
expect.arrayContaining([
expect.stringContaining("auto-refresh"),
expect.stringContaining("Base URL defaults"),
]),
);
});
it("keeps GitHub Copilot device auth results provider-owned", async () => {
const provider = requireProvider(registerProviders(githubCopilotPlugin), "github-copilot");
authStore.profiles["github-copilot:github"] = {

View File

@@ -1,6 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AuthProfileStore } from "../../agents/auth-profiles/types.js";
import { QWEN_OAUTH_MARKER } from "../../agents/model-auth-markers.js";
import type { ModelDefinitionConfig } from "../../config/types.models.js";
import { registerProviders, requireProvider } from "./testkit.js";
@@ -12,7 +11,6 @@ const ensureAuthProfileStoreMock = vi.hoisted(() => vi.fn());
const listProfilesForProviderMock = vi.hoisted(() => vi.fn());
let runProviderCatalog: typeof import("../provider-discovery.js").runProviderCatalog;
let qwenPortalProvider: Awaited<ReturnType<typeof requireProvider>>;
let githubCopilotProvider: Awaited<ReturnType<typeof requireProvider>>;
let ollamaProvider: Awaited<ReturnType<typeof requireProvider>>;
let vllmProvider: Awaited<ReturnType<typeof requireProvider>>;
@@ -53,21 +51,6 @@ function setRuntimeAuthStore(store?: AuthProfileStore) {
);
}
function setQwenPortalOauthSnapshot() {
setRuntimeAuthStore({
version: 1,
profiles: {
"qwen-portal:default": {
type: "oauth",
provider: "qwen-portal",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
},
},
});
}
function setGithubCopilotProfileSnapshot() {
setRuntimeAuthStore({
version: 1,
@@ -169,7 +152,6 @@ describe("provider discovery contract", () => {
({ runProviderCatalog } = await import("../provider-discovery.js"));
const [
{ default: qwenPortalPlugin },
{ default: githubCopilotPlugin },
{ default: ollamaPlugin },
{ default: vllmPlugin },
@@ -178,7 +160,6 @@ describe("provider discovery contract", () => {
{ default: modelStudioPlugin },
{ default: cloudflareAiGatewayPlugin },
] = await Promise.all([
import("../../../extensions/qwen-portal-auth/index.js"),
import("../../../extensions/github-copilot/index.js"),
import("../../../extensions/ollama/index.js"),
import("../../../extensions/vllm/index.js"),
@@ -187,7 +168,6 @@ describe("provider discovery contract", () => {
import("../../../extensions/modelstudio/index.js"),
import("../../../extensions/cloudflare-ai-gateway/index.js"),
]);
qwenPortalProvider = requireProvider(registerProviders(qwenPortalPlugin), "qwen-portal");
githubCopilotProvider = requireProvider(
registerProviders(githubCopilotPlugin),
"github-copilot",
@@ -215,42 +195,6 @@ describe("provider discovery contract", () => {
listProfilesForProviderMock.mockReset();
});
it("keeps qwen portal oauth marker fallback provider-owned", async () => {
setQwenPortalOauthSnapshot();
await expect(
runCatalog({
provider: qwenPortalProvider,
}),
).resolves.toEqual({
provider: {
baseUrl: "https://portal.qwen.ai/v1",
apiKey: QWEN_OAUTH_MARKER,
api: "openai-completions",
models: [
expect.objectContaining({ id: "coder-model", name: "Qwen Coder" }),
expect.objectContaining({ id: "vision-model", name: "Qwen Vision" }),
],
},
});
});
it("keeps qwen portal env api keys higher priority than oauth markers", async () => {
setQwenPortalOauthSnapshot();
await expect(
runCatalog({
provider: qwenPortalProvider,
env: { QWEN_PORTAL_API_KEY: "env-key" } as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({ apiKey: "env-key" }),
}),
).resolves.toMatchObject({
provider: {
apiKey: "env-key",
},
});
});
it("keeps GitHub Copilot catalog disabled without env tokens or profiles", async () => {
await expect(runCatalog({ provider: githubCopilotProvider })).resolves.toBeNull();
});

View File

@@ -27,7 +27,6 @@ import opencodeGoPlugin from "../../../extensions/opencode-go/index.js";
import opencodePlugin from "../../../extensions/opencode/index.js";
import openrouterPlugin from "../../../extensions/openrouter/index.js";
import qianfanPlugin from "../../../extensions/qianfan/index.js";
import qwenPortalAuthPlugin from "../../../extensions/qwen-portal-auth/index.js";
import sglangPlugin from "../../../extensions/sglang/index.js";
import syntheticPlugin from "../../../extensions/synthetic/index.js";
import togetherPlugin from "../../../extensions/together/index.js";
@@ -378,7 +377,6 @@ const bundledProviderPlugins = dedupePlugins([
opencodeGoPlugin,
openrouterPlugin,
qianfanPlugin,
qwenPortalAuthPlugin,
sglangPlugin,
syntheticPlugin,
togetherPlugin,

View File

@@ -3,7 +3,6 @@ import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import openAIPlugin from "../../../extensions/openai/index.js";
import qwenPortalPlugin from "../../../extensions/qwen-portal-auth/index.js";
import { createProviderUsageFetch, makeResponse } from "../../test-utils/provider-usage-fetch.js";
import type { ProviderPlugin, ProviderRuntimeModel } from "../types.js";
import { requireProviderContractProvider as requireBundledProviderContractProvider } from "./registry.js";
@@ -17,14 +16,8 @@ const getOAuthProvidersMock = vi.hoisted(() =>
{ id: "anthropic", envApiKey: "ANTHROPIC_API_KEY", oauthTokenEnv: "ANTHROPIC_OAUTH_TOKEN" }, // pragma: allowlist secret
{ id: "google", envApiKey: "GOOGLE_API_KEY", oauthTokenEnv: "GOOGLE_OAUTH_TOKEN" }, // pragma: allowlist secret
{ id: "openai-codex", envApiKey: "OPENAI_API_KEY", oauthTokenEnv: "OPENAI_OAUTH_TOKEN" }, // pragma: allowlist secret
{
id: "qwen-portal",
envApiKey: "QWEN_PORTAL_API_KEY",
oauthTokenEnv: "QWEN_PORTAL_OAUTH_TOKEN",
}, // pragma: allowlist secret
]),
);
const refreshQwenPortalCredentialsMock = vi.hoisted(() => vi.fn());
vi.mock("@mariozechner/pi-ai/oauth", async () => {
const actual = await vi.importActual<typeof import("@mariozechner/pi-ai/oauth")>(
@@ -37,14 +30,6 @@ vi.mock("@mariozechner/pi-ai/oauth", async () => {
};
});
vi.mock("../../../extensions/qwen-portal-auth/refresh.js", async () => {
const actual = await vi.importActual<object>("../../../extensions/qwen-portal-auth/refresh.js");
return {
...actual,
refreshQwenPortalCredentials: refreshQwenPortalCredentialsMock,
};
});
function createModel(overrides: Partial<ProviderRuntimeModel> & Pick<ProviderRuntimeModel, "id">) {
return {
id: overrides.id,
@@ -64,9 +49,6 @@ function requireProviderContractProvider(providerId: string): ProviderPlugin {
if (providerId === "openai-codex") {
return requireProvider(registerProviders(openAIPlugin), providerId);
}
if (providerId === "qwen-portal") {
return requireProvider(registerProviders(qwenPortalPlugin), providerId);
}
return requireBundledProviderContractProvider(providerId);
}
@@ -74,7 +56,6 @@ describe("provider runtime contract", () => {
beforeEach(() => {
getOAuthApiKeyMock.mockReset();
getOAuthProvidersMock.mockClear();
refreshQwenPortalCredentialsMock.mockReset();
}, CONTRACT_SETUP_TIMEOUT_MS);
describe("anthropic", () => {
@@ -633,29 +614,6 @@ describe("provider runtime contract", () => {
});
});
describe("qwen-portal", () => {
it("owns OAuth refresh", async () => {
const provider = requireProviderContractProvider("qwen-portal");
const credential = {
type: "oauth" as const,
provider: "qwen-portal",
access: "stale-access-token",
refresh: "refresh-token",
expires: Date.now() - 60_000,
};
const refreshed = {
...credential,
access: "fresh-access-token",
expires: Date.now() + 60_000,
};
refreshQwenPortalCredentialsMock.mockReset();
refreshQwenPortalCredentialsMock.mockResolvedValueOnce(refreshed);
await expect(provider.refreshOAuth?.(credential)).resolves.toEqual(refreshed);
});
});
describe("zai", () => {
it("owns glm-5 forward-compat resolution", () => {
const provider = requireProviderContractProvider("zai");