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:
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user