Merge pull request #52 from d7185540/feature/qwen-auth-and-providers

feat(server): 多提供商数组配置与启动初始化; fix(qwen-auth): 复用缓存凭据与账号级刷新清理 (src-only)
This commit is contained in:
何夕2077 2025-09-18 15:59:46 +08:00 committed by GitHub
commit 917d89bffa
2 changed files with 272 additions and 113 deletions

View file

@ -80,7 +80,7 @@
* --host <address> 服务器监听地址 / Server listening address (default: localhost)
* --port <number> 服务器监听端口 / Server listening port (default: 3000)
* --api-key <key> 身份验证所需的 API 密钥 / Required API key for authentication (default: 123456)
* --model-provider <provider> AI 模型提供商 / AI model provider: openai-custom, claude-custom, gemini-cli-oauth, claude-kiro-oauth
* --model-provider <provider[,provider...]> AI 模型提供商 / AI model provider: openai-custom, claude-custom, gemini-cli-oauth, claude-kiro-oauth
* --openai-api-key <key> OpenAI API 密钥 / OpenAI API key (for openai-custom provider)
* --openai-base-url <url> OpenAI API 基础 URL / OpenAI API base URL (for openai-custom provider)
* --claude-api-key <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}`);

View file

@ -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();
}
}
}