fix(providers): remove Qwen provider support

Qwen OAuth flow (portal.qwen.ai) stopped working reliably; drop the
executor, registry entry, OAuth provider/service, token refresh
profile, usage handler, and related test coverage and baselines.
This commit is contained in:
decolua
2026-08-05 16:17:26 +07:00
parent 6498b3122f
commit dcdd4628b3
27 changed files with 8 additions and 525 deletions

View File

@@ -79,18 +79,6 @@ const createOpenAIModelsConfig = (url) => ({
parseResponse: parseOpenAIStyleModels
});
const resolveQwenModelsUrl = (connection) => {
const fallback = "https://portal.qwen.ai/v1/models";
const raw = connection?.providerSpecificData?.resourceUrl;
if (!raw || typeof raw !== "string") return fallback;
const value = raw.trim();
if (!value) return fallback;
if (value.startsWith("http://") || value.startsWith("https://")) {
return `${value.replace(/\/$/, "")}/models`;
}
return `https://${value.replace(/\/$/, "")}/v1/models`;
};
const getStaticProviderModels = (providerId) =>
getModelsByProviderId(providerId).map((model) => ({
...model,
@@ -156,14 +144,6 @@ const PROVIDER_MODELS_CONFIG = {
authQuery: "key", // Use query param for API key
parseResponse: (data) => data.models || []
},
qwen: {
url: "https://portal.qwen.ai/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || []
},
codex: {
customResolver: buildOAuthResolver({
refreshFn: (conn) => refreshCodexToken(conn.refreshToken),
@@ -572,9 +552,6 @@ export async function GET(request, { params }) {
// Build request URL
let url = config.url;
if (connection.provider === "qwen") {
url = resolveQwenModelsUrl(connection);
}
if (config.authQuery) {
url += `?${config.authQuery}=${token}`;
}

View File

@@ -12,7 +12,6 @@ import {
GEMINI_CONFIG,
ANTIGRAVITY_CONFIG,
KIRO_CONFIG,
QWEN_CONFIG,
CLAUDE_CONFIG,
CLINE_CONFIG,
KILOCODE_CONFIG,
@@ -62,7 +61,6 @@ const OAUTH_TEST_CONFIG = {
method: "GET",
noAuth: true,
},
qwen: { checkExpiry: true, refreshable: true },
kiro: { checkExpiry: true, refreshable: true },
qoder: {
// Test by hitting Qoder's userinfo endpoint with the device token.
@@ -285,21 +283,6 @@ async function refreshOAuthToken(connection) {
return { accessToken: data.accessToken, expiresIn: data.expiresIn || 3600, refreshToken: data.refreshToken || refreshToken };
}
if (provider === "qwen") {
const response = await fetch(QWEN_CONFIG.tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: QWEN_CONFIG.clientId,
}),
});
if (!response.ok) return null;
const data = await response.json();
return { accessToken: data.access_token, expiresIn: data.expires_in, refreshToken: data.refresh_token || refreshToken };
}
if (provider === "cline") {
const response = await fetch(CLINE_CONFIG.refreshUrl, {
method: "POST",

View File

@@ -28,9 +28,6 @@ export const CODEX_CONFIG = { ...PROVIDER_OAUTH["codex"] };
// clientId/clientSecret from GOOGLE_OAUTH_CLIENT (shared.js) — not stored in registry
export const GEMINI_CONFIG = { ...GOOGLE_OAUTH_CLIENT, ...PROVIDER_OAUTH["gemini-cli"] };
// Qwen OAuth Configuration (Device Code Flow with PKCE)
export const QWEN_CONFIG = { ...PROVIDER_OAUTH["qwen"] };
// Qoder OAuth Configuration (Device Token Flow with PKCE).
// Device tokens are long-lived (~30 days for access, ~360 for refresh).
// The upstream refresh endpoint at center.qoder.sh returns 403 for our
@@ -206,7 +203,6 @@ export const PROVIDERS = {
CLAUDE: "claude",
CODEX: "codex",
GEMINI: "gemini-cli",
QWEN: "qwen",
QODER: "qoder",
IFLOW: "iflow",
ANTIGRAVITY: "antigravity",

View File

@@ -12,7 +12,6 @@ import geminiCli from "./gemini-cli.js";
import antigravity from "./antigravity.js";
import iflow from "./iflow.js";
import qoder from "./qoder.js";
import qwen from "./qwen.js";
import github from "./github.js";
import kiro from "./kiro.js";
import cursor from "./cursor.js";
@@ -38,7 +37,6 @@ const PROVIDERS = {
antigravity,
iflow,
qoder,
qwen,
github,
kiro,
cursor,

View File

@@ -1,56 +0,0 @@
import { QWEN_CONFIG } from "../constants/oauth.js";
const qwen = {
config: QWEN_CONFIG,
flowType: "device_code",
requestDeviceCode: async (config, codeChallenge) => {
const response = await fetch(config.deviceCodeUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
client_id: config.clientId,
scope: config.scope,
code_challenge: codeChallenge,
code_challenge_method: config.codeChallengeMethod,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Device code request failed: ${error}`);
}
return await response.json();
},
pollToken: async (config, deviceCode, codeVerifier) => {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
client_id: config.clientId,
device_code: deviceCode,
code_verifier: codeVerifier,
}),
});
return {
ok: response.ok,
data: await response.json(),
};
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
providerSpecificData: { resourceUrl: tokens.resource_url },
}),
};
export default qwen;

View File

@@ -6,7 +6,6 @@ export { OAuthService } from "./oauth.js";
export { ClaudeService } from "./claude.js";
export { CodexService } from "./codex.js";
export { GeminiCLIService } from "./gemini.js";
export { QwenService } from "./qwen.js";
export { IFlowService } from "./iflow.js";
export { QoderService } from "./qoder.js";
export { AntigravityService } from "./antigravity.js";

View File

@@ -1,170 +0,0 @@
import open from "open";
import { QWEN_CONFIG } from "../constants/oauth.js";
import { getServerCredentials } from "../config/index.js";
import { generatePKCE } from "../utils/pkce.js";
import { spinner as createSpinner } from "../utils/ui.js";
/**
* Qwen OAuth Service
* Uses Device Code Flow with PKCE
*/
export class QwenService {
constructor() {
this.config = QWEN_CONFIG;
}
/**
* Request device code
*/
async requestDeviceCode(codeChallenge) {
const response = await fetch(this.config.deviceCodeUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
client_id: this.config.clientId,
scope: this.config.scope,
code_challenge: codeChallenge,
code_challenge_method: this.config.codeChallengeMethod,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Device code request failed: ${error}`);
}
return await response.json();
}
/**
* Poll for token
*/
async pollForToken(deviceCode, codeVerifier, interval = 5) {
const maxAttempts = 60; // 5 minutes
const pollInterval = interval * 1000;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
await new Promise((r) => setTimeout(r, pollInterval));
const response = await fetch(this.config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
client_id: this.config.clientId,
device_code: deviceCode,
code_verifier: codeVerifier,
}),
});
if (response.ok) {
return await response.json();
}
const error = await response.json();
if (error.error === "authorization_pending") {
continue;
} else if (error.error === "slow_down") {
await new Promise((r) => setTimeout(r, 5000));
continue;
} else if (error.error === "expired_token") {
throw new Error("Device code expired");
} else if (error.error === "access_denied") {
throw new Error("Access denied");
} else {
throw new Error(error.error_description || error.error);
}
}
throw new Error("Authorization timeout");
}
/**
* Save Qwen tokens to server
*/
async saveTokens(tokens) {
const { server, token, userId } = getServerCredentials();
const response = await fetch(`${server}/api/cli/providers/qwen`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
"X-User-Id": userId,
},
body: JSON.stringify({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
resourceUrl: tokens.resource_url,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to save tokens");
}
return await response.json();
}
/**
* Complete Qwen OAuth flow
*/
async connect() {
const spinner = createSpinner("Starting Qwen OAuth...").start();
try {
spinner.text = "Generating PKCE...";
// Generate PKCE
const { codeVerifier, codeChallenge } = generatePKCE();
spinner.text = "Requesting device code...";
// Request device code
const deviceData = await this.requestDeviceCode(codeChallenge);
spinner.stop();
console.log("\n📋 Please visit the following URL and enter the code:\n");
console.log(` ${deviceData.verification_uri}\n`);
console.log(` Code: ${deviceData.user_code}\n`);
// Open browser
if (deviceData.verification_uri_complete) {
await open(deviceData.verification_uri_complete);
} else {
await open(deviceData.verification_uri);
}
spinner.start("Waiting for authorization...");
// Poll for token
const tokens = await this.pollForToken(
deviceData.device_code,
codeVerifier,
deviceData.interval || 5
);
spinner.text = "Saving tokens to server...";
// Save tokens to server
await this.saveTokens(tokens);
spinner.succeed("Qwen connected successfully!");
return true;
} catch (error) {
spinner.fail(`Failed: ${error.message}`);
throw error;
}
}
}

View File

@@ -226,7 +226,6 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
// Device code flow providers (must match oauth providers with flowType: "device_code")
const deviceCodeProviders = [
"github",
"qwen",
"kiro",
"kimi",
"kimi-coding",

View File

@@ -21,7 +21,7 @@ const navItems = [
{ href: "/dashboard/endpoint", label: "Endpoint & Key", icon: "api" },
{ href: "/dashboard/providers", label: "Providers", icon: "dns" },
// { href: "/dashboard/basic-chat", label: "Basic Chat", icon: "chat" }, // Hidden
{ href: "/dashboard/combos", label: "Combos", icon: "layers" },
{ href: "/dashboard/combos", label: "Combo & Vision Adapter", icon: "layers" },
{ href: "/dashboard/usage", label: "Usage", icon: "bar_chart" },
{ href: "/dashboard/quota", label: "Quota Tracker", icon: "data_usage" },
{ href: "/dashboard/token-saver", label: "Token Saver", icon: "savings" },

View File

@@ -11,7 +11,6 @@ import {
refreshAccessToken as _refreshAccessToken,
refreshClaudeOAuthToken as _refreshClaudeOAuthToken,
refreshGoogleToken as _refreshGoogleToken,
refreshQwenToken as _refreshQwenToken,
refreshCodexToken as _refreshCodexToken,
refreshIflowToken as _refreshIflowToken,
refreshGitHubToken as _refreshGitHubToken,
@@ -41,9 +40,6 @@ export const refreshClaudeOAuthToken = (refreshToken) =>
export const refreshGoogleToken = (refreshToken, clientId, clientSecret) =>
_refreshGoogleToken(refreshToken, clientId, clientSecret, log);
export const refreshQwenToken = (refreshToken) =>
_refreshQwenToken(refreshToken, log);
export const refreshCodexToken = (refreshToken) =>
_refreshCodexToken(refreshToken, log);