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

@@ -176,7 +176,6 @@ export const OAUTH_ENDPOINTS = {
google: { token: "https://oauth2.googleapis.com/token", auth: "https://accounts.google.com/o/oauth2/auth" },
openai: { token: PROVIDER_OAUTH["codex"]?.tokenUrl, auth: PROVIDER_OAUTH["codex"]?.authorizeUrl },
anthropic: { token: PROVIDER_OAUTH["claude"]?.tokenUrl, auth: "https://api.anthropic.com/v1/oauth/authorize" }, // ≠ claude.authorizeUrl (claude.ai login) — keep
qwen: { token: PROVIDER_OAUTH["qwen"]?.tokenUrl, auth: PROVIDER_OAUTH["qwen"]?.deviceCodeUrl },
iflow: { token: PROVIDER_OAUTH["iflow"]?.tokenUrl, auth: PROVIDER_OAUTH["iflow"]?.authorizeUrl },
github: { token: PROVIDER_OAUTH["github"]?.tokenUrl, auth: PROVIDER_OAUTH["github"]?.authorizeUrl, deviceCode: PROVIDER_OAUTH["github"]?.deviceCodeUrl },
};

View File

@@ -211,7 +211,6 @@ export class DefaultExecutor extends BaseExecutor {
const refreshers = {
claude: () => this.refreshFromGrant(credentials, proxyOptions),
codex: () => this.refreshFromGrant(credentials, proxyOptions),
qwen: () => this.refreshWithForm(OAUTH_ENDPOINTS.qwen.token, { grant_type: "refresh_token", refresh_token: credentials.refreshToken, client_id: PROVIDERS.qwen.clientId }, proxyOptions),
iflow: () => this.refreshIflow(credentials.refreshToken, proxyOptions),
gemini: () => this.refreshFromGrant(credentials, proxyOptions),
kiro: () => this.refreshKiro(credentials.refreshToken, proxyOptions),

View File

@@ -9,7 +9,6 @@ import { KimchiExecutor } from "./kimchi.js";
import { CodexExecutor } from "./codex.js";
import { CursorExecutor } from "./cursor.js";
import { VertexExecutor } from "./vertex.js";
import { QwenExecutor } from "./qwen.js";
import { OpenCodeExecutor } from "./opencode.js";
import { OpenCodeGoExecutor } from "./opencode-go.js";
import { GrokWebExecutor } from "./grok-web.js";
@@ -41,7 +40,6 @@ const executors = {
cu: new CursorExecutor(), // Alias for cursor
vertex: new VertexExecutor("vertex"),
"vertex-partner": new VertexExecutor("vertex-partner"),
qwen: new QwenExecutor(),
opencode: new OpenCodeExecutor(),
"opencode-go": new OpenCodeGoExecutor(),
"grok-web": new GrokWebExecutor(),
@@ -87,7 +85,6 @@ export { CodexExecutor } from "./codex.js";
export { CursorExecutor } from "./cursor.js";
export { VertexExecutor } from "./vertex.js";
export { DefaultExecutor } from "./default.js";
export { QwenExecutor } from "./qwen.js";
export { OpenCodeExecutor } from "./opencode.js";
export { OpenCodeGoExecutor } from "./opencode-go.js";
export { GrokWebExecutor } from "./grok-web.js";

View File

@@ -1,129 +0,0 @@
import { DefaultExecutor } from "./default.js";
import { PROVIDERS } from "../config/providers.js";
import { OAUTH_ENDPOINTS } from "../config/appConstants.js";
/** portal.qwen.ai — static fingerprint matching stable Qwen Code release */
const QWEN_USER_AGENT = "QwenCode/0.12.3 (linux; x64)";
const QWEN_STAINLESS = {
os: "Linux",
arch: "x64",
lang: "js",
runtime: "node",
runtimeVersion: "v18.19.1",
packageVersion: "5.11.0",
retryCount: "1"
};
const QWEN_DEFAULT_SYSTEM_MESSAGE = {
role: "system",
content: [{ type: "text", text: "", cache_control: { type: "ephemeral" } }]
};
function ensureQwenSystemMessage(body) {
if (!body || typeof body !== "object") return body;
const next = { ...body };
if (Array.isArray(next.messages)) {
next.messages = [QWEN_DEFAULT_SYSTEM_MESSAGE, ...next.messages];
} else {
next.messages = [QWEN_DEFAULT_SYSTEM_MESSAGE];
}
return next;
}
function isQwenThinkingActive(body) {
const thinking = body?.thinking;
if (thinking === true || body?.enable_thinking === true) return true;
return typeof thinking === "object" && thinking !== null && !Array.isArray(thinking) && thinking.type === "enabled";
}
// Qwen rejects tool_choice="required" or object forms when thinking is active; neutralize to "auto".
function sanitizeQwenThinkingToolChoice(body) {
if (!isQwenThinkingActive(body)) return body;
const tc = body.tool_choice;
const incompatible = tc === "required" || (typeof tc === "object" && tc !== null);
if (!incompatible) return body;
return { ...body, tool_choice: "auto" };
}
function buildQwenUpstreamHeaders(credentials, stream = true) {
const token = credentials?.apiKey || credentials?.accessToken || "";
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
"User-Agent": QWEN_USER_AGENT,
"X-DashScope-AuthType": "qwen-oauth",
"X-DashScope-CacheControl": "enable",
"X-DashScope-UserAgent": QWEN_USER_AGENT,
"X-Stainless-Arch": QWEN_STAINLESS.arch,
"X-Stainless-Lang": QWEN_STAINLESS.lang,
"X-Stainless-Os": QWEN_STAINLESS.os,
"X-Stainless-Package-Version": QWEN_STAINLESS.packageVersion,
"X-Stainless-Retry-Count": QWEN_STAINLESS.retryCount,
"X-Stainless-Runtime": QWEN_STAINLESS.runtime,
"X-Stainless-Runtime-Version": QWEN_STAINLESS.runtimeVersion,
Connection: "keep-alive",
"Accept-Language": "*",
"Sec-Fetch-Mode": "cors"
};
headers.Accept = stream ? "text/event-stream" : "application/json";
return headers;
}
export class QwenExecutor extends DefaultExecutor {
constructor() {
super("qwen");
}
// Qwen tokens are bound to a resource_url returned at OAuth time.
// Using portal.qwen.ai when the token is issued for another shard returns 401/403.
buildUrl(model, stream, urlIndex = 0, credentials = null) {
const resourceUrl = credentials?.providerSpecificData?.resourceUrl;
const host = resourceUrl ? resourceUrl.replace(/^https?:\/\//, "").replace(/\/$/, "") : "portal.qwen.ai";
return `https://${host}/v1/chat/completions`;
}
buildHeaders(credentials, stream = true) {
return buildQwenUpstreamHeaders(credentials, stream);
}
transformRequest(model, body, stream, credentials) {
let next = body && typeof body === "object" ? { ...body } : body;
if (stream && next?.messages && !next.stream_options && !next.thinking && !next.enable_thinking && next.stream !== false) {
next.stream_options = { include_usage: true };
}
next = sanitizeQwenThinkingToolChoice(next);
return ensureQwenSystemMessage(next);
}
// Override to capture resource_url from refresh response (required for buildUrl).
async refreshCredentials(credentials, log) {
if (!credentials?.refreshToken) return null;
try {
const response = await fetch(OAUTH_ENDPOINTS.qwen.token, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: credentials.refreshToken,
client_id: PROVIDERS.qwen.clientId
})
});
if (!response.ok) return null;
const tokens = await response.json();
log?.info?.("TOKEN", "qwen refreshed");
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || credentials.refreshToken,
expiresIn: tokens.expires_in,
providerSpecificData: {
...(credentials.providerSpecificData || {}),
...(tokens.resource_url ? { resourceUrl: tokens.resource_url } : {})
}
};
} catch (error) {
log?.error?.("TOKEN", `qwen refresh error: ${error.message}`);
return null;
}
}
}
export default QwenExecutor;

View File

@@ -47,7 +47,6 @@ export {
refreshAccessToken,
refreshClaudeOAuthToken,
refreshGoogleToken,
refreshQwenToken,
refreshCodexToken,
refreshIflowToken,
refreshGitHubToken,

View File

@@ -75,7 +75,6 @@ import p72 from "./perplexity.js";
import p73 from "./perplexity-agent.js";
import p74 from "./playht.js";
import p75 from "./qoder.js";
import p76 from "./qwen.js";
import p77 from "./recraft.js";
import p78 from "./runwayml.js";
import p79 from "./sdwebui.js";
@@ -198,7 +197,6 @@ export default [
p73,
p74,
p75,
p76,
p77,
p78,
p79,

View File

@@ -1,33 +0,0 @@
export default {
id: "qwen",
hidden: true,
priority: 130,
alias: "qw",
display: {
name: "Qwen Code",
icon: "psychology",
color: "#10B981",
website: "https://chat.qwen.ai",
notice: {
signupUrl: "https://chat.qwen.ai",
},
},
category: "oauth",
transport: {
baseUrl: "https://portal.qwen.ai/v1/chat/completions",
},
models: [
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
{ id: "qwen3-coder-flash", name: "Qwen3 Coder Flash" },
{ id: "vision-model", name: "Qwen3 Vision Model" },
{ id: "coder-model", name: "Qwen3.6 Coder Model" },
],
oauth: {
clientId: "f0304373b74a44d2b584a3fb70ca9e56",
deviceCodeUrl: "https://chat.qwen.ai/api/v1/oauth2/device/code",
tokenUrl: "https://chat.qwen.ai/api/v1/oauth2/token",
scope: "openid profile email model.completion",
codeChallengeMethod: "S256",
refreshLeadMs: 1200000,
},
};

View File

@@ -6,7 +6,6 @@ import {
refreshKimiToken,
refreshClaudeOAuthToken,
refreshGoogleToken,
refreshQwenToken,
refreshCodexToken,
refreshKiroToken,
refreshIflowToken,
@@ -26,7 +25,6 @@ export {
refreshKimiToken,
refreshClaudeOAuthToken,
refreshGoogleToken,
refreshQwenToken,
refreshCodexToken,
refreshKiroToken,
refreshIflowToken,
@@ -137,7 +135,6 @@ const REFRESH_HANDLERS = {
antigravity: (c, log) => refreshGoogleToken(c.refreshToken, PROVIDERS.antigravity.clientId, PROVIDERS.antigravity.clientSecret, log),
claude: (c, log) => refreshClaudeOAuthToken(c.refreshToken, log),
codex: (c, log) => refreshCodexToken(c.refreshToken, log),
qwen: (c, log) => refreshQwenToken(c.refreshToken, log),
iflow: (c, log) => refreshIflowToken(c.refreshToken, log),
github: (c, log) => refreshGitHubToken(c.refreshToken, log),
kiro: (c, log) => refreshKiroToken(c.refreshToken, c.providerSpecificData, log),
@@ -205,7 +202,6 @@ export function formatProviderCredentials(provider, credentials, log) {
};
case "codex":
case "qwen":
case "iflow":
case "openai":
case "openrouter":

View File

@@ -40,11 +40,6 @@ const REFRESH_PROFILES = {
url: () => OAUTH_ENDPOINTS.anthropic.token,
dedupKey: "claude",
},
qwen: {
url: () => OAUTH_ENDPOINTS.qwen.token,
dedupKey: "qwen",
parse: (tokens) => tokens.resource_url ? { providerSpecificData: { resourceUrl: tokens.resource_url } } : {},
},
iflow: {
url: () => OAUTH_ENDPOINTS.iflow.token,
dedupKey: "iflow",
@@ -191,11 +186,6 @@ export async function refreshGoogleToken(refreshToken, clientId, clientSecret, l
}, log);
}
// Qwen: form body + clientId, surfaces resource_url. Delegate to refreshAccessToken("qwen", ...).
export async function refreshQwenToken(refreshToken, log) {
return refreshAccessToken("qwen", refreshToken, {}, log);
}
export function classifyOAuthRefreshError(errorText = "", status = 0) {
let parsed = null;
try {

View File

@@ -16,7 +16,6 @@ import { getKimiUsage } from "./usage/kimi.js";
import { getDeepseekUsage } from "./usage/deepseek.js";
import { resolveQoderCredentials } from "./qoderModels.js";
import {
getQwenUsage,
getIflowUsage,
getOllamaUsage,
getGlmUsage,
@@ -43,7 +42,6 @@ const USAGE_HANDLERS = {
const resolved = await resolveQoderCredentials(c, c.proxyOptions).catch(() => null);
return getQoderUsage(resolved?.accessToken || c.accessToken, c.proxyOptions);
},
qwen: (c) => getQwenUsage(c.accessToken, c.providerSpecificData),
iflow: (c) => getIflowUsage(c.accessToken),
ollama: (c) => getOllamaUsage(c.apiKey, c.providerSpecificData, c.proxyOptions),
glm: (c) => getGlmUsage(c.apiKey, c.provider, c.proxyOptions),

View File

@@ -1,5 +1,5 @@
/**
* Misc usage handlers (Qwen, iFlow, Ollama, GLM, Vercel AI Gateway, Qoder)
* Misc usage handlers (iFlow, Ollama, GLM, Vercel AI Gateway, Qoder)
*/
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
@@ -15,23 +15,6 @@ const GLM_QUOTA_URLS = {
// Returns { balance: "95.50", total_used: "4.50" } (USD as decimal strings).
const VERCEL_AI_GATEWAY_CREDITS_URL = U("vercel-ai-gateway").url;
/**
* Qwen Usage
*/
export async function getQwenUsage(accessToken, providerSpecificData) {
try {
const resourceUrl = providerSpecificData?.resourceUrl;
if (!resourceUrl) {
return { message: "Qwen connected. No resource URL available." };
}
// Qwen may have usage endpoint at resource URL
return { message: "Qwen connected. Usage tracked per request." };
} catch (error) {
return { message: "Unable to fetch Qwen usage." };
}
}
/**
* iFlow Usage
*/