diff --git a/src/api-server.js b/src/api-server.js index 55096e9..fe33f71 100644 --- a/src/api-server.js +++ b/src/api-server.js @@ -80,7 +80,7 @@ * --host
服务器监听地址 / Server listening address (default: localhost) * --port 服务器监听端口 / Server listening port (default: 3000) * --api-key 身份验证所需的 API 密钥 / Required API key for authentication (default: 123456) - * --model-provider AI 模型提供商 / AI model provider: openai-custom, claude-custom, gemini-cli-oauth, claude-kiro-oauth + * --model-provider AI 模型提供商 / AI model provider: openai-custom, claude-custom, gemini-cli-oauth, claude-kiro-oauth * --openai-api-key OpenAI API 密钥 / OpenAI API key (for openai-custom provider) * --openai-base-url OpenAI API 基础 URL / OpenAI API base URL (for openai-custom provider) * --claude-api-key Claude API 密钥 / Claude API key (for claude-custom provider) @@ -127,6 +127,47 @@ import { let CONFIG = {}; // Make CONFIG exportable let PROMPT_LOG_FILENAME = ''; // Make PROMPT_LOG_FILENAME exportable +const ALL_MODEL_PROVIDERS = Object.values(MODEL_PROVIDER); + +function normalizeConfiguredProviders(config) { + const fallbackProvider = MODEL_PROVIDER.GEMINI_CLI; + const dedupedProviders = []; + + const addProvider = (value) => { + if (typeof value !== 'string') { + return; + } + const trimmed = value.trim(); + if (!trimmed) { + return; + } + const matched = ALL_MODEL_PROVIDERS.find((provider) => provider.toLowerCase() === trimmed.toLowerCase()); + if (!matched) { + console.warn(`[Config Warning] Unknown model provider '${trimmed}'. This entry will be ignored.`); + return; + } + if (!dedupedProviders.includes(matched)) { + dedupedProviders.push(matched); + } + }; + + const rawValue = config.MODEL_PROVIDER; + if (Array.isArray(rawValue)) { + rawValue.forEach((entry) => addProvider(typeof entry === 'string' ? entry : String(entry))); + } else if (typeof rawValue === 'string') { + rawValue.split(',').forEach(addProvider); + } else if (rawValue != null) { + addProvider(String(rawValue)); + } + + if (dedupedProviders.length === 0) { + dedupedProviders.push(fallbackProvider); + } + + config.DEFAULT_MODEL_PROVIDERS = dedupedProviders; + config.MODEL_PROVIDER = dedupedProviders[0]; +} + /** * Initializes the server configuration from config.json and command-line arguments. * @param {string[]} args - Command-line arguments. @@ -335,6 +376,8 @@ async function initializeConfig(args = process.argv.slice(2), configFilePath = ' } } + normalizeConfiguredProviders(currentConfig); + if (!currentConfig.SYSTEM_PROMPT_FILE_PATH) { currentConfig.SYSTEM_PROMPT_FILE_PATH = INPUT_SYSTEM_PROMPT_FILE; } @@ -412,23 +455,75 @@ async function initApiService(config) { console.log('[Initialization] No provider pools configured. Using single provider mode.'); } - // Initialize all known service adapters at startup - // 当存在号池时,这里不再提前初始化所有 provider 的实例,而是按需从号池中选择和初始化 - // 而是通过 providerPoolManager.selectProvider 来动态选择配置并初始化服务 - for (const provider of Object.values(MODEL_PROVIDER)) { - if (!config.providerPools || !config.providerPools[provider] || config.providerPools[provider].length === 0) { - try { - // 对于没有配置号池的提供者,仍然按原来的方式初始化一个单例 - console.log(`[Initialization] Initializing single service adapter for ${provider}...`); - getServiceAdapter({ ...config, MODEL_PROVIDER: provider }); // This call populates serviceInstances - } catch (error) { - console.warn(`[Initialization Warning] Failed to initialize single service adapter for ${provider}: ${error.message}`); - } + // Initialize configured service adapters at startup + // 对于未纳入号池的提供者,提前初始化以避免首个请求的额外延迟 + const providersToInit = new Set(); + if (Array.isArray(config.DEFAULT_MODEL_PROVIDERS)) { + config.DEFAULT_MODEL_PROVIDERS.forEach((provider) => providersToInit.add(provider)); + } + if (config.providerPools) { + Object.keys(config.providerPools).forEach((provider) => providersToInit.add(provider)); + } + if (providersToInit.size === 0) { + ALL_MODEL_PROVIDERS.forEach((provider) => providersToInit.add(provider)); + } + + for (const provider of providersToInit) { + if (!ALL_MODEL_PROVIDERS.includes(provider)) { + console.warn(`[Initialization Warning] Skipping unknown model provider '${provider}' during adapter initialization.`); + continue; + } + if (config.providerPools && config.providerPools[provider] && config.providerPools[provider].length > 0) { + // 由号池管理器负责按需初始化 + continue; + } + try { + console.log(`[Initialization] Initializing single service adapter for ${provider}...`); + getServiceAdapter({ ...config, MODEL_PROVIDER: provider }); + } catch (error) { + console.warn(`[Initialization Warning] Failed to initialize single service adapter for ${provider}: ${error.message}`); } } return serviceInstances; // Return the collection of initialized service instances } +function logProviderSpecificDetails(provider, config) { + switch (provider) { + case MODEL_PROVIDER.OPENAI_CUSTOM: + console.log(` [openai-custom] API Key: ${config.OPENAI_API_KEY ? '******' : 'Not Set'}`); + console.log(` [openai-custom] Base URL: ${config.OPENAI_BASE_URL || 'Default'}`); + break; + case MODEL_PROVIDER.CLAUDE_CUSTOM: + console.log(` [claude-custom] API Key: ${config.CLAUDE_API_KEY ? '******' : 'Not Set'}`); + console.log(` [claude-custom] Base URL: ${config.CLAUDE_BASE_URL || 'Default'}`); + break; + case MODEL_PROVIDER.GEMINI_CLI: + if (config.GEMINI_OAUTH_CREDS_FILE_PATH) { + console.log(` [gemini-cli-oauth] OAuth Creds File Path: ${config.GEMINI_OAUTH_CREDS_FILE_PATH}`); + } else if (config.GEMINI_OAUTH_CREDS_BASE64) { + console.log(` [gemini-cli-oauth] OAuth Creds Source: Provided via Base64 string`); + } else { + console.log(` [gemini-cli-oauth] OAuth Creds: Default discovery`); + } + console.log(` [gemini-cli-oauth] Project ID: ${config.PROJECT_ID || 'Auto-discovered'}`); + break; + case MODEL_PROVIDER.KIRO_API: + if (config.KIRO_OAUTH_CREDS_FILE_PATH) { + console.log(` [claude-kiro-oauth] OAuth Creds File Path: ${config.KIRO_OAUTH_CREDS_FILE_PATH}`); + } else if (config.KIRO_OAUTH_CREDS_BASE64) { + console.log(` [claude-kiro-oauth] OAuth Creds Source: Provided via Base64 string`); + } else { + console.log(` [claude-kiro-oauth] OAuth Creds: Default`); + } + break; + case MODEL_PROVIDER.QWEN_API: + console.log(` [openai-qwen-oauth] OAuth Creds File Path: ${config.QWEN_OAUTH_CREDS_FILE_PATH || 'Default'}`); + break; + default: + console.log(` [${provider}] Provider initialized.`); + } +} + async function getApiService(config) { let serviceConfig = config; if (providerPoolManager && config.providerPools && config.providerPools[config.MODEL_PROVIDER]) { @@ -606,21 +701,15 @@ async function startServer() { const server = http.createServer(requestHandlerInstance); server.listen(CONFIG.SERVER_PORT, CONFIG.HOST, () => { console.log(`--- Unified API Server Configuration ---`); - console.log(` Model Provider: ${CONFIG.MODEL_PROVIDER}`); - if (CONFIG.MODEL_PROVIDER === MODEL_PROVIDER.OPENAI_CUSTOM) { - console.log(` OpenAI API Key: ${CONFIG.OPENAI_API_KEY ? '******' : 'Not Set'}`); - console.log(` OpenAI Base URL: ${CONFIG.OPENAI_BASE_URL}`); - } else if (CONFIG.MODEL_PROVIDER === MODEL_PROVIDER.CLAUDE_CUSTOM) { - console.log(` Claude API Key: ${CONFIG.CLAUDE_API_KEY ? '******' : 'Not Set'}`); - console.log(` Claude Base URL: ${CONFIG.CLAUDE_BASE_URL}`); - } else if (CONFIG.MODEL_PROVIDER === MODEL_PROVIDER.GEMINI_CLI) { - console.log(` Gemini OAuth Creds File Path: ${CONFIG.GEMINI_OAUTH_CREDS_FILE_PATH || 'Default'}`); - console.log(` Project ID: ${CONFIG.PROJECT_ID || 'Auto-discovered'}`); - } else if (CONFIG.MODEL_PROVIDER === MODEL_PROVIDER.KIRO_API) { - console.log(` Kiro OAuth Creds File Path: ${CONFIG.KIRO_OAUTH_CREDS_FILE_PATH || 'Default'}`); - } else if (CONFIG.MODEL_PROVIDER === MODEL_PROVIDER.QWEN_API) { - console.log(` Qwen OAuth Creds File Path: ${CONFIG.QWEN_OAUTH_CREDS_FILE_PATH || 'Default'}`); - } + const configuredProviders = Array.isArray(CONFIG.DEFAULT_MODEL_PROVIDERS) && CONFIG.DEFAULT_MODEL_PROVIDERS.length > 0 + ? CONFIG.DEFAULT_MODEL_PROVIDERS + : [CONFIG.MODEL_PROVIDER]; + const uniqueProviders = [...new Set(configuredProviders)]; + console.log(` Primary Model Provider: ${CONFIG.MODEL_PROVIDER}`); + if (uniqueProviders.length > 1) { + console.log(` Additional Model Providers: ${uniqueProviders.slice(1).join(', ')}`); + } + uniqueProviders.forEach((provider) => logProviderSpecificDetails(provider, CONFIG)); console.log(` System Prompt File: ${CONFIG.SYSTEM_PROMPT_FILE_PATH || 'Default'}`); console.log(` System Prompt Mode: ${CONFIG.SYSTEM_PROMPT_MODE}`); console.log(` Host: ${CONFIG.HOST}`); diff --git a/src/openai/qwen-core.js b/src/openai/qwen-core.js index 00eb823..5347149 100644 --- a/src/openai/qwen-core.js +++ b/src/openai/qwen-core.js @@ -119,6 +119,7 @@ export class QwenApiService { this.qwenClient = new QwenOAuth2Client(); this.sharedManager = SharedTokenManager.getInstance(); this.currentAxiosInstance = null; + this.tokenManagerOptions = { credentialFilePath: this._getQwenCachedCredentialPath() }; } async initialize() { @@ -140,7 +141,11 @@ export class QwenApiService { async _initializeAuth(forceRefresh = false) { try { - const credentials = await this.sharedManager.getValidCredentials(this.qwenClient, forceRefresh); + const credentials = await this.sharedManager.getValidCredentials( + this.qwenClient, + forceRefresh, + this.tokenManagerOptions, + ); // console.log('credentials', credentials); this.qwenClient.setCredentials(credentials); } catch (error) { @@ -162,14 +167,13 @@ export class QwenApiService { } } + // If cached credentials are present and still valid, use them directly. if (await this._loadCachedQwenCredentials(this.qwenClient)) { - const result = await this._authWithQwenDeviceFlow(this.qwenClient, this.config); - if (!result.success) { - throw new Error('Qwen OAuth authentication failed'); - } + 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') { @@ -310,7 +314,7 @@ export class QwenApiService { _getQwenCachedCredentialPath() { if (this.config && this.config.QWEN_OAUTH_CREDS_FILE_PATH) { - return 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); } @@ -321,8 +325,10 @@ export class QwenApiService { const creds = await fs.readFile(keyFile, 'utf-8'); const credentials = JSON.parse(creds); client.setCredentials(credentials); - const { token } = await client.getAccessToken(); - return !!token; + // 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; } @@ -368,7 +374,11 @@ export class QwenApiService { async getValidToken() { try { - const credentials = await this.sharedManager.getValidCredentials(this.qwenClient); + 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, @@ -464,7 +474,11 @@ export class QwenApiService { if (this.isAuthError(error) && retryCount === 0) { console.warn(`[QwenApiService] Auth error (${status}). Refreshing token...`); try { - await this.sharedManager.getValidCredentials(this.qwenClient, true); + 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); @@ -539,13 +553,13 @@ export class QwenApiService { class SharedTokenManager { static instance = null; - memoryCache = { credentials: null, fileModTime: 0, lastCheck: 0 }; - refreshPromise = null; - cleanupHandlersRegistered = false; - cleanupFunction = null; - lockConfig = DEFAULT_LOCK_CONFIG; constructor() { + this.contexts = new Map(); + this.lockPaths = new Set(); + this.cleanupHandlersRegistered = false; + this.cleanupFunction = null; + this.sigintHandler = null; this.registerCleanupHandlers(); } @@ -556,13 +570,49 @@ class 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 = () => { - try { unlinkSync(this.getLockFilePath()); } catch (_error) { /* Ignore */ } + for (const lockPath of this.lockPaths) { + try { unlinkSync(lockPath); } catch (_error) { /* ignore */ } + } }; this.sigintHandler = () => { - try { unlinkSync(this.getLockFilePath()); } catch (_error) { /* Ignore */ } + this.cleanupFunction(); process.exit(0); }; process.on('exit', this.cleanupFunction); @@ -570,70 +620,81 @@ class SharedTokenManager { this.cleanupHandlersRegistered = true; } - async getValidCredentials(qwenClient, forceRefresh = false) { + async getValidCredentials(qwenClient, forceRefresh = false, options = {}) { + const context = this.getContext(options); try { - await this.checkAndReloadIfNeeded(); - if (!forceRefresh && this.memoryCache.credentials && this.isTokenValid(this.memoryCache.credentials)) { - return this.memoryCache.credentials; + await this.checkAndReloadIfNeeded(context); + if (!forceRefresh && context.memoryCache.credentials && this.isTokenValid(context.memoryCache.credentials)) { + return context.memoryCache.credentials; } - if (this.refreshPromise) return this.refreshPromise; - - qwenClient.setCredentials(this.memoryCache.credentials); - this.refreshPromise = this.performTokenRefresh(qwenClient, forceRefresh); - const credentials = await this.refreshPromise; - this.refreshPromise = null; + 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) { - this.refreshPromise = null; + context.refreshPromise = null; if (error instanceof TokenManagerError) throw error; - throw new TokenManagerError(TokenError.REFRESH_FAILED,`Failed to get valid credentials: ${error.message}`, error); + throw new TokenManagerError( + TokenError.REFRESH_FAILED, + `Failed to get valid credentials: ${error.message}`, + error, + ); } } - async checkAndReloadIfNeeded() { + async checkAndReloadIfNeeded(context) { const now = Date.now(); - if (now - this.memoryCache.lastCheck < CACHE_CHECK_INTERVAL_MS) return; - this.memoryCache.lastCheck = now; + if (now - context.memoryCache.lastCheck < CACHE_CHECK_INTERVAL_MS) return; + context.memoryCache.lastCheck = now; try { - const stats = await fs.stat(this.getCredentialFilePath()); - if (stats.mtimeMs > this.memoryCache.fileModTime) { - await this.reloadCredentialsFromFile(); - this.memoryCache.fileModTime = stats.mtimeMs; + 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') { - this.memoryCache.credentials = null; - this.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; + throw new TokenManagerError( + TokenError.FILE_ACCESS_ERROR, + `Failed to access credentials file: ${error.message}`, + error, + ); } - this.memoryCache.fileModTime = 0; + context.memoryCache.credentials = null; + context.memoryCache.fileModTime = 0; } } - async reloadCredentialsFromFile() { + async reloadCredentialsFromFile(context) { try { - const content = await fs.readFile(this.getCredentialFilePath(), 'utf-8'); - this.memoryCache.credentials = JSON.parse(content); - } catch (error) { - this.memoryCache.credentials = null; + const content = await fs.readFile(context.credentialFilePath, 'utf-8'); + context.memoryCache.credentials = JSON.parse(content); + } catch (_error) { + context.memoryCache.credentials = null; } } - async performTokenRefresh(qwenClient, forceRefresh = false) { - const lockPath = this.getLockFilePath(); + async performTokenRefresh(context, qwenClient, forceRefresh = false) { try { - const currentCredentials = qwenClient.getCredentials(); - if (!currentCredentials.refresh_token) { + 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(lockPath); - await this.checkAndReloadIfNeeded(); + await this.acquireLock(context); + await this.checkAndReloadIfNeeded(context); - if (!forceRefresh && this.memoryCache.credentials && this.isTokenValid(this.memoryCache.credentials)) { - qwenClient.setCredentials(this.memoryCache.credentials); - return this.memoryCache.credentials; + if (!forceRefresh && context.memoryCache.credentials && this.isTokenValid(context.memoryCache.credentials)) { + qwenClient.setCredentials(context.memoryCache.credentials); + return context.memoryCache.credentials; } const response = await qwenClient.refreshAccessToken(); @@ -651,67 +712,75 @@ class SharedTokenManager { expiry_date: Date.now() + response.expires_in * 1000, }; - this.memoryCache.credentials = credentials; + context.memoryCache.credentials = credentials; qwenClient.setCredentials(credentials); - await this.saveCredentialsToFile(credentials); + await this.saveCredentialsToFile(context, credentials); console.log('[Qwen Auth] Token refresh response: ok'); return credentials; } catch (error) { if (error instanceof TokenManagerError) throw error; - throw new TokenManagerError(TokenError.REFRESH_FAILED, `Unexpected error during token refresh: ${error.message}`, error); + // If refresh token is invalid/expired, remove the corresponding credential file for this context + 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(lockPath); + await this.releaseLock(context); } } - async saveCredentialsToFile(credentials) { - const filePath = this.getCredentialFilePath(); - await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 }); - await fs.writeFile(filePath, JSON.stringify(credentials, null, 2), { mode: 0o600 }); - const stats = await fs.stat(filePath); - this.memoryCache.fileModTime = stats.mtimeMs; + 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; } - getCredentialFilePath() { - return path.join(os.homedir(), QWEN_DIR, QWEN_CREDENTIAL_FILENAME); - } - - getLockFilePath() { - return path.join(os.homedir(), QWEN_DIR, QWEN_LOCK_FILENAME); - } - - async acquireLock(lockPath) { - const { maxAttempts, attemptInterval } = this.lockConfig; + async acquireLock(context) { + const { maxAttempts, attemptInterval } = context.lockConfig || DEFAULT_LOCK_CONFIG; for (let attempt = 0; attempt < maxAttempts; attempt++) { try { - await fs.writeFile(lockPath, randomUUID(), { flag: 'wx' }); + await fs.writeFile(context.lockFilePath, randomUUID(), { flag: 'wx' }); return; } catch (error) { if (error.code === 'EEXIST') { try { - const stats = await fs.stat(lockPath); + const stats = await fs.stat(context.lockFilePath); if (Date.now() - stats.mtimeMs > LOCK_TIMEOUT_MS) { - await fs.unlink(lockPath); + await fs.unlink(context.lockFilePath); continue; } - } catch (statError) { /* ignore */ } + } 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.FILE_ACCESS_ERROR, + `Failed to create lock file: ${error.message}`, + error, + ); } } } throw new TokenManagerError(TokenError.LOCK_TIMEOUT, 'Lock acquisition timeout'); } - async releaseLock(lockPath) { - try { await fs.unlink(lockPath); } - catch (error) { if (error.code !== 'ENOENT') console.warn(`Failed to release lock: ${error.message}`);} + async releaseLock(context) { + try { + await fs.unlink(context.lockFilePath); + } catch (error) { + if (error.code !== 'ENOENT') { + console.warn(`Failed to release lock: ${error.message}`); + } + } } } @@ -738,8 +807,9 @@ class QwenOAuth2Client { }); if (!response.ok) { if (response.status === 400) { - await fs.unlink(path.join(os.homedir(), QWEN_DIR, QWEN_CREDENTIAL_FILENAME)).catch(() => {}); - throw new Error("Refresh token expired or invalid."); + const err = new Error("Refresh token expired or invalid."); + err.status = 400; + throw err; } throw new Error(`Token refresh failed: ${response.status}`); } @@ -788,4 +858,4 @@ class QwenOAuth2Client { } return await response.json(); } -} \ No newline at end of file +}