import axios from 'axios'; import crypto from 'crypto'; import path from 'node:path'; import { promises as fs, unlinkSync } from 'node:fs'; import * as os from 'os'; import * as http from 'http'; import * as https from 'https'; import open from 'open'; import { EventEmitter } from 'events'; import { randomUUID } from 'node:crypto'; import { getProviderModels } from '../provider-models.js'; import { handleQwenOAuth } from '../oauth-handlers.js'; import { configureAxiosProxy } from '../proxy-utils.js'; // --- Constants --- const QWEN_DIR = '.qwen'; const QWEN_CREDENTIAL_FILENAME = 'oauth_creds.json'; // 从 provider-models.js 获取支持的模型列表 const QWEN_MODELS = getProviderModels('openai-qwen-oauth'); const QWEN_MODEL_LIST = QWEN_MODELS.map(id => ({ id: id, name: id.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ') })); const TOKEN_REFRESH_BUFFER_MS = 30 * 1000; const LOCK_TIMEOUT_MS = 10000; const CACHE_CHECK_INTERVAL_MS = 1000; const DEFAULT_LOCK_CONFIG = { maxAttempts: 50, attemptInterval: 200, }; const DEFAULT_QWEN_OAUTH_BASE_URL = 'https://chat.qwen.ai'; const DEFAULT_QWEN_BASE_URL = 'https://portal.qwen.ai/v1'; const QWEN_OAUTH_CLIENT_ID = 'f0304373b74a44d2b584a3fb70ca9e56'; const QWEN_OAUTH_SCOPE = 'openid profile email model.completion'; const QWEN_OAUTH_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code'; export const QwenOAuth2Event = { AuthUri: 'auth-uri', AuthProgress: 'auth-progress', AuthCancel: 'auth-cancel', }; export const qwenOAuth2Events = new EventEmitter(); // --- Helper Functions --- // 封装公共的 await fetch 方法 async function commonFetch(url, options = {}, useSystemProxy = false) { const defaultOptions = { method: 'GET', headers: { 'Content-Type': 'application/json', Accept: 'application/json', }, }; // 合并默认选项和传入的选项 const mergedOptions = { ...defaultOptions, ...options, headers: { ...defaultOptions.headers, ...options.headers, }, }; // 如果不使用系统代理,设置空的代理配置 // 注意: Node.js 的 fetch 实现会自动使用环境变量中的代理设置 // 这里通过设置 agent 为 null 来尝试禁用代理 if (!useSystemProxy && typeof mergedOptions.agent === 'undefined') { // 对于 Node.js fetch,我们可以通过设置 dispatcher 来控制代理 // 但这需要 undici 支持,这里我们先记录日志 console.debug('[Qwen] System proxy disabled for fetch request'); } const response = await fetch(url, mergedOptions); // 检查响应是否成功 if (!response.ok) { const errorData = await response.json().catch(() => ({})); const error = new Error(`HTTP ${response.status}: ${response.statusText}`); error.status = response.status; error.data = errorData; throw error; } // 返回 JSON 响应 return await response.json(); } function generateCodeVerifier() { return crypto.randomBytes(32).toString('base64url'); } function generateCodeChallenge(codeVerifier) { const hash = crypto.createHash('sha256'); hash.update(codeVerifier); return hash.digest('base64url'); } function generatePKCEPair() { const codeVerifier = generateCodeVerifier(); const codeChallenge = generateCodeChallenge(codeVerifier); return { code_verifier: codeVerifier, code_challenge: codeChallenge }; } function objectToUrlEncoded(data) { return Object.keys(data) .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(data[key])}`) .join('&'); } function isDeviceAuthorizationSuccess(response) { return 'device_code' in response; } function isDeviceTokenSuccess(response) { return ( 'access_token' in response && response.access_token !== null && response.access_token !== undefined && typeof response.access_token === 'string' && response.access_token.length > 0 ); } function isDeviceTokenPending(response) { return 'status' in response && response.status === 'pending'; } function isErrorResponse(response) { return 'error' in response; } // --- Error Classes --- export const TokenError = { REFRESH_FAILED: 'REFRESH_FAILED', NO_REFRESH_TOKEN: 'NO_REFRESH_TOKEN', LOCK_TIMEOUT: 'LOCK_TIMEOUT', FILE_ACCESS_ERROR: 'FILE_ACCESS_ERROR', NETWORK_ERROR: 'NETWORK_ERROR', }; export class TokenManagerError extends Error { constructor(type, message, originalError) { super(message); this.type = type; this.originalError = originalError; this.name = 'TokenManagerError'; } } /** * 自定义错误类,用于指示需要清除凭证 * 当令牌刷新时发生 400 错误时抛出,表示刷新令牌已过期或无效 */ export class CredentialsClearRequiredError extends Error { constructor(message, originalError) { super(message); this.name = 'CredentialsClearRequiredError'; this.originalError = originalError; } } // --- Core Service Class --- export class QwenApiService { constructor(config) { this.config = config; this.isInitialized = false; this.sharedManager = SharedTokenManager.getInstance(); this.currentAxiosInstance = null; this.tokenManagerOptions = { credentialFilePath: this._getQwenCachedCredentialPath() }; this.useSystemProxy = config?.USE_SYSTEM_PROXY_QWEN ?? false; // Initialize instance-specific endpoints this.baseUrl = config.QWEN_BASE_URL || DEFAULT_QWEN_BASE_URL; const oauthBaseUrl = config.QWEN_OAUTH_BASE_URL || DEFAULT_QWEN_OAUTH_BASE_URL; this.oauthDeviceCodeEndpoint = `${oauthBaseUrl}/api/v1/oauth2/device/code`; this.oauthTokenEndpoint = `${oauthBaseUrl}/api/v1/oauth2/token`; console.log(`[Qwen] System proxy ${this.useSystemProxy ? 'enabled' : 'disabled'}`); this.qwenClient = new QwenOAuth2Client(config, this.useSystemProxy); } async initialize() { if (this.isInitialized) return; console.log('[Qwen] Initializing Qwen API Service...'); await this._initializeAuth(); // 配置 HTTP/HTTPS agent 限制连接池大小,避免资源泄漏 const httpAgent = new http.Agent({ keepAlive: true, maxSockets: 100, maxFreeSockets: 5, timeout: 120000, }); const httpsAgent = new https.Agent({ keepAlive: true, maxSockets: 100, maxFreeSockets: 5, timeout: 120000, }); const axiosConfig = { baseURL: this.baseUrl, httpAgent, httpsAgent, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer `, }, }; // 禁用系统代理 if (!this.useSystemProxy) { axiosConfig.proxy = false; } // 配置自定义代理 configureAxiosProxy(axiosConfig, this.config, 'openai-qwen-oauth'); this.currentAxiosInstance = axios.create(axiosConfig); this.isInitialized = true; console.log('[Qwen] Initialization complete.'); } async _initializeAuth(forceRefresh = false) { try { const credentials = await this.sharedManager.getValidCredentials( this.qwenClient, forceRefresh, this.tokenManagerOptions, ); // console.log('credentials', credentials); this.qwenClient.setCredentials(credentials); } catch (error) { console.debug('Shared token manager failed, attempting device flow:', error); if (error instanceof TokenManagerError) { switch (error.type) { case TokenError.NO_REFRESH_TOKEN: console.debug('No refresh token available, proceeding with device flow'); break; case TokenError.REFRESH_FAILED: console.debug('Token refresh failed, proceeding with device flow'); break; case TokenError.NETWORK_ERROR: console.warn('Network error during token refresh, trying device flow'); break; default: console.warn('Token manager error:', error.message); } } // If cached credentials are present and still valid, use them directly. if (await this._loadCachedQwenCredentials(this.qwenClient)) { console.log('[Qwen] Using cached OAuth credentials.'); return; } // Otherwise, run device authorization flow to obtain fresh credentials. const result = await this._authWithQwenDeviceFlow(this.qwenClient, this.config); if (!result.success) { if (result.reason === 'timeout') { qwenOAuth2Events.emit( QwenOAuth2Event.AuthProgress, 'timeout', 'Authentication timed out. Please try again or select a different authentication method.', ); } switch (result.reason) { case 'timeout': throw new Error('Qwen OAuth authentication timed out'); case 'cancelled': throw new Error('Qwen OAuth authentication was cancelled by user'); case 'rate_limit': throw new Error('Too many request for Qwen OAuth authentication, please try again later.'); case 'error': default: throw new Error('Qwen OAuth authentication failed'); } } } } async _authWithQwenDeviceFlow(client, config) { try { // 使用统一的 OAuth 处理方法 const { authUrl, authInfo } = await handleQwenOAuth(config); // 发送授权 URI 事件 qwenOAuth2Events.emit(QwenOAuth2Event.AuthUri, { verification_uri_complete: authUrl, user_code: authInfo.userCode, verification_uri: authInfo.verificationUri, device_code: authInfo.deviceCode, expires_in: authInfo.expiresIn, interval: authInfo.interval }); const showFallbackMessage = () => { console.log('\n=== Qwen OAuth Device Authorization ==='); console.log('Please visit the following URL in your browser to authorize:'); console.log(`\n${authUrl}\n`); console.log('Waiting for authorization to complete...\n'); }; if (config) { try { const childProcess = await open(authUrl); if (childProcess) { childProcess.on('error', () => showFallbackMessage()); } } catch (_err) { showFallbackMessage(); } } else { showFallbackMessage(); } qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'polling', 'Waiting for authorization...'); console.debug('Waiting for authorization...\n'); // 等待 OAuth 回调完成并读取保存的凭据 const credPath = this._getQwenCachedCredentialPath(); const credentials = await new Promise((resolve, reject) => { const checkInterval = setInterval(async () => { try { const data = await fs.readFile(credPath, 'utf8'); const creds = JSON.parse(data); if (creds.access_token) { clearInterval(checkInterval); console.log('[Qwen Auth] New token obtained successfully.'); resolve(creds); } } catch (error) { // 文件尚未创建或无效,继续等待 } }, 1000); // 设置超时(5分钟) setTimeout(() => { clearInterval(checkInterval); reject(new Error('[Qwen Auth] OAuth 授权超时')); }, 5 * 60 * 1000); }); client.setCredentials(credentials); qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'success', 'Authentication successful! Access token obtained.'); return { success: true }; } catch (error) { console.error('Device authorization flow failed:', error.message); qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', error.message); return { success: false, reason: 'error' }; } } _getQwenCachedCredentialPath() { if (this.config && this.config.QWEN_OAUTH_CREDS_FILE_PATH) { return path.resolve(this.config.QWEN_OAUTH_CREDS_FILE_PATH); } return path.join(os.homedir(), QWEN_DIR, QWEN_CREDENTIAL_FILENAME); } async _loadCachedQwenCredentials(client) { try { const keyFile = this._getQwenCachedCredentialPath(); const creds = await fs.readFile(keyFile, 'utf-8'); const credentials = JSON.parse(creds); client.setCredentials(credentials); // Consider credentials usable only if access_token exists and not near expiry const hasToken = !!credentials?.access_token; const notExpired = !!credentials?.expiry_date && (Date.now() < credentials.expiry_date - TOKEN_REFRESH_BUFFER_MS); return hasToken && notExpired; } catch (_) { return false; } } async _cacheQwenCredentials(credentials) { const filePath = this._getQwenCachedCredentialPath(); await fs.mkdir(path.dirname(filePath), { recursive: true }); const credString = JSON.stringify(credentials, null, 2); await fs.writeFile(filePath, credString); } getCurrentEndpoint(resourceUrl) { const baseEndpoint = resourceUrl || this.baseUrl; const suffix = '/v1'; const normalizedUrl = baseEndpoint.startsWith('http') ? baseEndpoint : `https://${baseEndpoint}`; return normalizedUrl.endsWith(suffix) ? normalizedUrl : `${normalizedUrl}${suffix}`; } isAuthError(error) { if (!error) return false; const errorMessage = (error instanceof Error ? error.message : String(error)).toLowerCase(); const errorCode = error?.status || error?.code || error.response?.status; const code = String(errorCode); return ( code.startsWith('401') || code.startsWith('403') || errorMessage.includes('unauthorized') || errorMessage.includes('forbidden') || errorMessage.includes('invalid api key') || errorMessage.includes('invalid access token') || errorMessage.includes('token expired') || errorMessage.includes('authentication') || errorMessage.includes('access denied') ); } async getValidToken() { try { const credentials = await this.sharedManager.getValidCredentials( this.qwenClient, false, this.tokenManagerOptions, ); if (!credentials.access_token) throw new Error('No access token available'); return { token: credentials.access_token, endpoint: this.getCurrentEndpoint(credentials.resource_url), }; } catch (error) { if (this.isAuthError(error)) throw error; console.warn('Failed to get token from shared manager:', error); throw new Error('Failed to obtain valid Qwen access token. Please re-authenticate.'); } } /** * Processes message content in the request body. * If content is an array, it joins the elements with newlines. * @param {Object} requestBody - The request body to process * @returns {Object} The processed request body */ processMessageContent(requestBody) { if (!requestBody || !requestBody.messages || !Array.isArray(requestBody.messages)) { return requestBody; } const processedMessages = requestBody.messages.map(message => { if (message.content && Array.isArray(message.content)) { // Convert each item to JSON string before joining const stringifiedContent = message.content.map(item => typeof item === 'string' ? item : item.text ); return { ...message, content: stringifiedContent.join('\n') }; } return message; }); return { ...requestBody, messages: processedMessages }; } async callApiWithAuthAndRetry(endpoint, body, isStream = false, retryCount = 0) { const maxRetries = (this.config && this.config.REQUEST_MAX_RETRIES) || 3; const baseDelay = (this.config && this.config.REQUEST_BASE_DELAY) || 1000; const version = "0.2.1"; const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; console.log(`[QwenApiService] User-Agent: ${userAgent}`); try { const { token, endpoint: qwenBaseUrl } = await this.getValidToken(); // 配置 HTTP/HTTPS agent 限制连接池大小,避免资源泄漏 const httpAgent = new http.Agent({ keepAlive: true, maxSockets: 100, maxFreeSockets: 5, timeout: 120000, }); const httpsAgent = new https.Agent({ keepAlive: true, maxSockets: 100, maxFreeSockets: 5, timeout: 120000, }); const axiosConfig = { baseURL: qwenBaseUrl, httpAgent, httpsAgent, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, 'X-DashScope-CacheControl': 'enable', 'X-DashScope-UserAgent': userAgent, 'X-DashScope-AuthType': 'qwen-oauth', }, }; // 禁用系统代理 if (!this.useSystemProxy) { axiosConfig.proxy = false; } // 配置自定义代理 configureAxiosProxy(axiosConfig, this.config, 'openai-qwen-oauth'); this.currentAxiosInstance = axios.create(axiosConfig); // Process message content before sending the request const processedBody = body;//this.processMessageContent(body); // Check if model in body is in QWEN_MODEL_LIST, if not, use the first model's id if (processedBody.model && !QWEN_MODEL_LIST.some(model => model.id === processedBody.model)) { console.warn(`[QwenApiService] Model '${processedBody.model}' not found. Using default model: '${QWEN_MODEL_LIST[0].id}'`); processedBody.model = QWEN_MODEL_LIST[0].id; } const defaultTools = [ { "type": "function", "function": { "name": "ext" } } ]; // Merge tools if requestBody already has tools defined const mergedTools = processedBody.tools ? [...defaultTools, ...processedBody.tools] : defaultTools; const requestBody = isStream ? { ...processedBody, stream: true, tools: mergedTools } : { ...processedBody, tools: mergedTools }; const options = isStream ? { responseType: 'stream' } : {}; const response = await this.currentAxiosInstance.post(endpoint, requestBody, options); return response.data; } catch (error) { const status = error.response?.status; const data = error.response?.data || error.message; if (this.isAuthError(error) && retryCount === 0) { console.warn(`[QwenApiService] Auth error (${status}). Refreshing token...`); try { await this.sharedManager.getValidCredentials( this.qwenClient, true, this.tokenManagerOptions, ); return this.callApiWithAuthAndRetry(endpoint, body, isStream, retryCount + 1); } catch (refreshError) { console.error(`[QwenApiService] Token refresh failed:`, refreshError); throw new Error(`Token refresh failed. Please re-authenticate. ${refreshError.message}`); } } if ((status === 429 || (status >= 500 && status < 600)) && retryCount < maxRetries) { const delay = baseDelay * Math.pow(2, retryCount); console.log(`[QwenApiService] Status ${status}. Retrying in ${delay}ms...`); await new Promise(resolve => setTimeout(resolve, delay)); return this.callApiWithAuthAndRetry(endpoint, body, isStream, retryCount + 1); } console.error(`Error calling Qwen API (Status: ${status}):`, data); throw error; } } async generateContent(model, requestBody) { return this.callApiWithAuthAndRetry('/chat/completions', requestBody, false); } async *generateContentStream(model, requestBody) { const stream = await this.callApiWithAuthAndRetry('/chat/completions', requestBody, true); let buffer = ''; for await (const chunk of stream) { buffer += chunk.toString(); let newlineIndex; while ((newlineIndex = buffer.indexOf('\n')) !== -1) { const line = buffer.substring(0, newlineIndex).trim(); buffer = buffer.substring(newlineIndex + 1); if (line.startsWith('data: ')) { const jsonData = line.substring(6).trim(); if (jsonData === '[DONE]') return; try { yield JSON.parse(jsonData); } catch (e) { console.warn("[QwenApiService] Failed to parse stream chunk:", jsonData); } } } } } async listModels() { // Return the predefined models for Qwen return { data: QWEN_MODEL_LIST }; } isExpiryDateNear() { try { const credentials = this.qwenClient.getCredentials(); if (!credentials || !credentials.expiry_date) { return false; } const currentTime = Date.now(); const cronNearMinutesInMillis = (this.config.CRON_NEAR_MINUTES || 10) * 60 * 1000; console.log(`[Qwen] Expiry date: ${credentials.expiry_date}, Current time: ${currentTime}, ${this.config.CRON_NEAR_MINUTES || 10} minutes from now: ${currentTime + cronNearMinutesInMillis}`); return credentials.expiry_date <= (currentTime + cronNearMinutesInMillis); } catch (error) { console.error(`[Qwen] Error checking expiry date: ${error.message}`); return false; } } } // --- SharedTokenManager Class (Singleton) --- class SharedTokenManager { static instance = null; constructor() { this.contexts = new Map(); this.lockPaths = new Set(); this.cleanupHandlersRegistered = false; this.cleanupFunction = null; this.sigintHandler = null; this.registerCleanupHandlers(); } static getInstance() { if (!SharedTokenManager.instance) { SharedTokenManager.instance = new SharedTokenManager(); } return SharedTokenManager.instance; } getContext(options = {}) { const credentialFilePath = this.resolveCredentialFilePath(options.credentialFilePath); const lockFilePath = this.resolveLockFilePath(credentialFilePath, options.lockFilePath); let context = this.contexts.get(credentialFilePath); if (!context) { context = { credentialFilePath, lockFilePath, lockConfig: options.lockConfig || DEFAULT_LOCK_CONFIG, memoryCache: { credentials: null, fileModTime: 0, lastCheck: 0 }, refreshPromise: null, }; this.contexts.set(credentialFilePath, context); this.lockPaths.add(lockFilePath); } else if (options.lockConfig) { context.lockConfig = options.lockConfig; } return context; } resolveCredentialFilePath(customPath) { if (customPath) { return path.resolve(customPath); } return path.join(os.homedir(), QWEN_DIR, QWEN_CREDENTIAL_FILENAME); } resolveLockFilePath(credentialFilePath, customLockPath) { if (customLockPath) { return path.resolve(customLockPath); } return `${credentialFilePath}.lock`; } registerCleanupHandlers() { if (this.cleanupHandlersRegistered) return; this.cleanupFunction = () => { for (const lockPath of this.lockPaths) { try { unlinkSync(lockPath); } catch (_error) { /* ignore */ } } }; this.sigintHandler = () => { this.cleanupFunction(); process.exit(0); }; process.on('exit', this.cleanupFunction); process.on('SIGINT', this.sigintHandler); this.cleanupHandlersRegistered = true; } async getValidCredentials(qwenClient, forceRefresh = false, options = {}) { const context = this.getContext(options); try { await this.checkAndReloadIfNeeded(context); if (!forceRefresh && context.memoryCache.credentials && this.isTokenValid(context.memoryCache.credentials)) { return context.memoryCache.credentials; } if (context.refreshPromise) { return context.refreshPromise; } qwenClient.setCredentials(context.memoryCache.credentials); context.refreshPromise = this.performTokenRefresh(context, qwenClient, forceRefresh); const credentials = await context.refreshPromise; context.refreshPromise = null; return credentials; } catch (error) { context.refreshPromise = null; if (error instanceof TokenManagerError) throw error; throw new TokenManagerError( TokenError.REFRESH_FAILED, `Failed to get valid credentials: ${error.message}`, error, ); } } async checkAndReloadIfNeeded(context) { const now = Date.now(); if (now - context.memoryCache.lastCheck < CACHE_CHECK_INTERVAL_MS) return; context.memoryCache.lastCheck = now; try { const stats = await fs.stat(context.credentialFilePath); if (stats.mtimeMs > context.memoryCache.fileModTime) { await this.reloadCredentialsFromFile(context); context.memoryCache.fileModTime = stats.mtimeMs; } } catch (error) { if (error.code !== 'ENOENT') { context.memoryCache.credentials = null; context.memoryCache.fileModTime = 0; throw new TokenManagerError( TokenError.FILE_ACCESS_ERROR, `Failed to access credentials file: ${error.message}`, error, ); } context.memoryCache.credentials = null; context.memoryCache.fileModTime = 0; } } async reloadCredentialsFromFile(context) { try { const content = await fs.readFile(context.credentialFilePath, 'utf-8'); context.memoryCache.credentials = JSON.parse(content); } catch (_error) { context.memoryCache.credentials = null; } } async performTokenRefresh(context, qwenClient, forceRefresh = false) { try { const currentCredentials = qwenClient.getCredentials() || context.memoryCache.credentials; if (!currentCredentials || !currentCredentials.refresh_token) { throw new TokenManagerError(TokenError.NO_REFRESH_TOKEN, 'No refresh token available'); } await this.acquireLock(context); await this.checkAndReloadIfNeeded(context); if (!forceRefresh && context.memoryCache.credentials && this.isTokenValid(context.memoryCache.credentials)) { qwenClient.setCredentials(context.memoryCache.credentials); return context.memoryCache.credentials; } const response = await qwenClient.refreshAccessToken(); if (!response || isErrorResponse(response)) { throw new TokenManagerError(TokenError.REFRESH_FAILED, `Token refresh failed: ${response?.error}`); } if (!response.access_token) { throw new TokenManagerError(TokenError.REFRESH_FAILED, 'No access token in refresh response'); } const credentials = { access_token: response.access_token, token_type: response.token_type, refresh_token: response.refresh_token || currentCredentials.refresh_token, resource_url: response.resource_url, expiry_date: Date.now() + response.expires_in * 1000, }; context.memoryCache.credentials = credentials; qwenClient.setCredentials(credentials); await this.saveCredentialsToFile(context, credentials); console.log('[Qwen Auth] Token refresh response: ok'); return credentials; } catch (error) { if (error instanceof TokenManagerError) throw error; // 处理 CredentialsClearRequiredError - 清除凭证文件 if (error instanceof CredentialsClearRequiredError) { try { await fs.unlink(context.credentialFilePath); console.log('[Qwen Auth] Credentials cleared due to refresh token expiry'); } catch (_) { /* ignore */ } throw error; // 重新抛出以便上层处理 } // 如果刷新令牌无效/过期,删除此上下文对应的凭证文件 if (error && (error.status === 400 || /expired|invalid/i.test(error.message || ''))) { try { await fs.unlink(context.credentialFilePath); } catch (_) { /* ignore */ } } throw new TokenManagerError( TokenError.REFRESH_FAILED, `Unexpected error during token refresh: ${error.message}`, error, ); } finally { await this.releaseLock(context); } } async saveCredentialsToFile(context, credentials) { await fs.mkdir(path.dirname(context.credentialFilePath), { recursive: true, mode: 0o700 }); await fs.writeFile(context.credentialFilePath, JSON.stringify(credentials, null, 2), { mode: 0o600 }); const stats = await fs.stat(context.credentialFilePath); context.memoryCache.fileModTime = stats.mtimeMs; } isTokenValid(credentials) { return credentials?.expiry_date && Date.now() < credentials.expiry_date - TOKEN_REFRESH_BUFFER_MS; } async acquireLock(context) { const { maxAttempts, attemptInterval } = context.lockConfig || DEFAULT_LOCK_CONFIG; for (let attempt = 0; attempt < maxAttempts; attempt++) { try { await fs.writeFile(context.lockFilePath, randomUUID(), { flag: 'wx' }); return; } catch (error) { if (error.code === 'EEXIST') { try { const stats = await fs.stat(context.lockFilePath); if (Date.now() - stats.mtimeMs > LOCK_TIMEOUT_MS) { await fs.unlink(context.lockFilePath); continue; } } catch (_statError) { /* ignore */ } await new Promise(resolve => setTimeout(resolve, attemptInterval)); } else { throw new TokenManagerError( TokenError.FILE_ACCESS_ERROR, `Failed to create lock file: ${error.message}`, error, ); } } } throw new TokenManagerError(TokenError.LOCK_TIMEOUT, 'Lock acquisition timeout'); } async releaseLock(context) { try { await fs.unlink(context.lockFilePath); } catch (error) { if (error.code !== 'ENOENT') { console.warn(`Failed to release lock: ${error.message}`); } } } } // --- QwenOAuth2Client Class --- class QwenOAuth2Client { credentials = {}; constructor(config, useSystemProxy = false) { this.config = config; this.useSystemProxy = useSystemProxy; // Initialize OAuth endpoints const oauthBaseUrl = config.QWEN_OAUTH_BASE_URL || DEFAULT_QWEN_OAUTH_BASE_URL; this.oauthDeviceCodeEndpoint = `${oauthBaseUrl}/api/v1/oauth2/device/code`; this.oauthTokenEndpoint = `${oauthBaseUrl}/api/v1/oauth2/token`; } setCredentials(credentials) { this.credentials = credentials; } getCredentials() { return this.credentials; } async refreshAccessToken() { if (!this.credentials.refresh_token) throw new Error('No refresh token'); const bodyData = { grant_type: 'refresh_token', refresh_token: this.credentials.refresh_token, client_id: QWEN_OAUTH_CLIENT_ID, }; try { const endpoint = this.oauthTokenEndpoint; const response = await commonFetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, body: objectToUrlEncoded(bodyData), }, this.useSystemProxy); return response; } catch (error) { const errorData = error.data || {}; // 处理 400 错误,可能表示刷新令牌已过期 if (error.status === 400) { // 清除凭证将由 SharedTokenManager 处理 throw new CredentialsClearRequiredError( "刷新令牌已过期或无效。请使用 '/auth' 重新认证。", { status: error.status, response: errorData } ); } throw new Error( `Token refresh failed: ${error.status || 'Unknown'} - ${errorData.error_description || error.message || 'No details'}` ); } } async requestDeviceAuthorization(options) { const bodyData = { client_id: QWEN_OAUTH_CLIENT_ID, scope: options.scope, code_challenge: options.code_challenge, code_challenge_method: options.code_challenge_method, }; try { const endpoint = this.oauthDeviceCodeEndpoint; const response = await commonFetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, body: objectToUrlEncoded(bodyData), }, this.useSystemProxy); return response; } catch (error) { throw new Error(`Device authorization failed: ${error.status || error.message}`); } } async pollDeviceToken(options) { const bodyData = { grant_type: QWEN_OAUTH_GRANT_TYPE, client_id: QWEN_OAUTH_CLIENT_ID, device_code: options.device_code, code_verifier: options.code_verifier, }; try { const endpoint = this.oauthTokenEndpoint; const response = await commonFetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, body: objectToUrlEncoded(bodyData), }, this.useSystemProxy); return response; } catch (error) { // 根据 OAuth RFC 8628,处理标准轮询响应 // 尝试解析错误响应为 JSON const errorData = error.data || {}; const status = error.status; // 用户尚未批准授权请求,继续轮询 if (status === 400 && errorData.error === 'authorization_pending') { return { status: 'pending' }; } // 客户端轮询过于频繁,返回 pending 并设置 slowDown 标志 if (status === 429 && errorData.error === 'slow_down') { return { status: 'pending', slowDown: true }; } // 处理其他 400 错误(access_denied, expired_token 等)作为真正的错误 // 对于其他错误,抛出适当的错误信息 const err = new Error( `Device token poll failed: ${errorData.error || 'Unknown error'} - ${errorData.error_description || 'No details provided'}` ); err.status = status; throw err; } } }