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:
@@ -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 },
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
@@ -47,7 +47,6 @@ export {
|
||||
refreshAccessToken,
|
||||
refreshClaudeOAuthToken,
|
||||
refreshGoogleToken,
|
||||
refreshQwenToken,
|
||||
refreshCodexToken,
|
||||
refreshIflowToken,
|
||||
refreshGitHubToken,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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":
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -12,10 +12,6 @@
|
||||
"token": "https://api.anthropic.com/v1/oauth/token",
|
||||
"auth": "https://api.anthropic.com/v1/oauth/authorize"
|
||||
},
|
||||
"qwen": {
|
||||
"token": "https://chat.qwen.ai/api/v1/oauth2/token",
|
||||
"auth": "https://chat.qwen.ai/api/v1/oauth2/device/code"
|
||||
},
|
||||
"iflow": {
|
||||
"token": "https://iflow.cn/oauth/token",
|
||||
"auth": "https://iflow.cn/oauth"
|
||||
@@ -29,7 +25,6 @@
|
||||
"tokenUrls": {
|
||||
"claude": "https://api.anthropic.com/v1/oauth/token",
|
||||
"codex": "https://auth.openai.com/oauth/token",
|
||||
"qwen": "https://chat.qwen.ai/api/v1/oauth2/token",
|
||||
"iflow": "https://iflow.cn/oauth/token",
|
||||
"kiro": "https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken",
|
||||
"xai": "https://auth.x.ai/oauth2/token",
|
||||
@@ -49,7 +44,6 @@
|
||||
"clientIds": {
|
||||
"claude": "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
|
||||
"codex": "app_EMoamEEZ73f0CkXaXp7hrann",
|
||||
"qwen": "f0304373b74a44d2b584a3fb70ca9e56",
|
||||
"iflow": "10009311001",
|
||||
"kimi": "17e5f671-d194-4dfb-9706-5516cb48c098",
|
||||
"grok-cli": "b1a00492-073a-47ea-816f-4c329264a828"
|
||||
|
||||
@@ -15,7 +15,6 @@ const resolved = {
|
||||
tokenUrls: {
|
||||
claude: PROVIDERS.claude?.tokenUrl,
|
||||
codex: PROVIDERS.codex?.tokenUrl,
|
||||
qwen: PROVIDERS.qwen?.tokenUrl,
|
||||
iflow: PROVIDERS.iflow?.tokenUrl,
|
||||
kiro: PROVIDERS.kiro?.tokenUrl,
|
||||
xai: PROVIDERS.xai?.tokenUrl,
|
||||
@@ -25,7 +24,6 @@ const resolved = {
|
||||
kimi: PROVIDERS.kimi?.tokenUrl,
|
||||
},
|
||||
authUrls: {
|
||||
qwen: PROVIDERS.qwen?.authUrl,
|
||||
iflow: PROVIDERS.iflow?.authUrl,
|
||||
kiro: PROVIDERS.kiro?.authUrl,
|
||||
},
|
||||
@@ -38,7 +36,6 @@ const resolved = {
|
||||
clientIds: {
|
||||
claude: PROVIDERS.claude?.clientId,
|
||||
codex: PROVIDERS.codex?.clientId,
|
||||
qwen: PROVIDERS.qwen?.clientId,
|
||||
iflow: PROVIDERS.iflow?.clientId,
|
||||
kimi: PROVIDERS.kimi?.clientId,
|
||||
"grok-cli": PROVIDERS["grok-cli"]?.clientId,
|
||||
|
||||
@@ -18,7 +18,7 @@ const SPECIAL_CRED = {
|
||||
// Chúng được lock riêng ở 11-provider edge tests / unit test chuyên biệt.
|
||||
const SPECIALIZED = new Set([
|
||||
"antigravity", "azure", "gemini-cli", "github", "iflow", "qoder", "kiro",
|
||||
"codex", "cursor", "vertex", "vertex-partner", "qwen", "opencode",
|
||||
"codex", "cursor", "vertex", "vertex-partner", "opencode",
|
||||
"opencode-go", "grok-web", "perplexity-web", "ollama-local", "commandcode",
|
||||
"xiaomi-tokenplan", "mimo-free",
|
||||
]);
|
||||
|
||||
@@ -151,7 +151,6 @@ describe("Codex Refresh Token", () => {
|
||||
expect(getRefreshLeadMs("codex")).toBe(5 * 24 * 60 * 60 * 1000); // 5 days
|
||||
expect(getRefreshLeadMs("claude")).toBe(4 * 60 * 60 * 1000); // 4 hours
|
||||
expect(getRefreshLeadMs("iflow")).toBe(24 * 60 * 60 * 1000); // 24 hours
|
||||
expect(getRefreshLeadMs("qwen")).toBe(20 * 60 * 1000); // 20 minutes
|
||||
expect(getRefreshLeadMs("kimi")).toBe(5 * 60 * 1000); // 5 minutes
|
||||
expect(getRefreshLeadMs("kimi-coding")).toBe(5 * 60 * 1000); // legacy alias
|
||||
expect(getRefreshLeadMs("antigravity")).toBe(5 * 60 * 1000); // 5 minutes
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Generic OAuth2 token refresh — config-driven profiles.
|
||||
*
|
||||
* Verifies refreshAccessToken() handles the 5 foldable providers
|
||||
* (qwen, iflow, github, kimi, claude) via a REFRESH_PROFILES table,
|
||||
* Verifies refreshAccessToken() handles the 4 foldable providers
|
||||
* (iflow, github, kimi, claude) via a REFRESH_PROFILES table,
|
||||
* while preserving the legacy generic path for unknown providers.
|
||||
*/
|
||||
|
||||
@@ -25,32 +25,6 @@ describe("refreshAccessToken — config-driven profiles", () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); global.fetch = originalFetch; });
|
||||
afterEach(() => { global.fetch = originalFetch; });
|
||||
|
||||
it("qwen: form body + clientId, surfaces resource_url as providerSpecificData", async () => {
|
||||
const fm = mockFetchOnce({
|
||||
access_token: "qw-acc",
|
||||
refresh_token: "qw-refresh-rotated",
|
||||
expires_in: 7200,
|
||||
resource_url: "https://dashscope.aliyuncs.com",
|
||||
});
|
||||
const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js");
|
||||
|
||||
const out = await refreshAccessToken("qwen", "qw-old-refresh", {}, console);
|
||||
|
||||
expect(out).toEqual({
|
||||
accessToken: "qw-acc",
|
||||
refreshToken: "qw-refresh-rotated",
|
||||
expiresIn: 7200,
|
||||
providerSpecificData: { resourceUrl: "https://dashscope.aliyuncs.com" },
|
||||
});
|
||||
const [url, init] = fm.mock.calls[0];
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.headers["Content-Type"]).toBe("application/x-www-form-urlencoded");
|
||||
const body = new URLSearchParams(init.body);
|
||||
expect(body.get("grant_type")).toBe("refresh_token");
|
||||
expect(body.get("refresh_token")).toBe("qw-old-refresh");
|
||||
expect(body.get("client_id")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("iflow: Basic Auth header from clientId:clientSecret, form body keeps client_secret", async () => {
|
||||
const fm = mockFetchOnce({ access_token: "if-acc", refresh_token: "if-rot", expires_in: 3600 });
|
||||
const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js");
|
||||
@@ -107,13 +81,13 @@ describe("refreshAccessToken — config-driven profiles", () => {
|
||||
it("returns null on non-ok response", async () => {
|
||||
mockFetchOnce({ error: "invalid_grant" }, { ok: false, status: 400 });
|
||||
const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js");
|
||||
const out = await refreshAccessToken("qwen", "dead", {}, console);
|
||||
const out = await refreshAccessToken("iflow", "dead", {}, console);
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when refreshToken missing", async () => {
|
||||
const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js");
|
||||
const out = await refreshAccessToken("qwen", "", {}, console);
|
||||
const out = await refreshAccessToken("iflow", "", {}, console);
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
|
||||
const load = () => import("../../open-sse/services/usage.js");
|
||||
const SUPPORTED = [
|
||||
"github", "gemini-cli", "antigravity", "claude", "codex", "kiro",
|
||||
"qoder", "qwen", "iflow", "ollama", "glm", "glm-cn",
|
||||
"qoder", "iflow", "ollama", "glm", "glm-cn",
|
||||
"minimax", "minimax-cn", "vercel-ai-gateway", "grok-cli", "kimi",
|
||||
"deepseek",
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user