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) * --host <address> 服务器监听地址 / Server listening address (default: localhost)
* --port <number> 服务器监听端口 / Server listening port (default: 3000) * --port <number> 服务器监听端口 / Server listening port (default: 3000)
* --api-key <key> 身份验证所需的 API 密钥 / Required API key for authentication (default: 123456) * --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-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) * --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) * --claude-api-key <key> Claude API 密钥 / Claude API key (for claude-custom provider)
@ -127,6 +127,47 @@ import {
let CONFIG = {}; // Make CONFIG exportable let CONFIG = {}; // Make CONFIG exportable
let PROMPT_LOG_FILENAME = ''; // Make PROMPT_LOG_FILENAME 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. * Initializes the server configuration from config.json and command-line arguments.
* @param {string[]} args - 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) { if (!currentConfig.SYSTEM_PROMPT_FILE_PATH) {
currentConfig.SYSTEM_PROMPT_FILE_PATH = INPUT_SYSTEM_PROMPT_FILE; 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.'); console.log('[Initialization] No provider pools configured. Using single provider mode.');
} }
// Initialize all known service adapters at startup // Initialize configured service adapters at startup
// 当存在号池时,这里不再提前初始化所有 provider 的实例,而是按需从号池中选择和初始化 // 对于未纳入号池的提供者,提前初始化以避免首个请求的额外延迟
// 而是通过 providerPoolManager.selectProvider 来动态选择配置并初始化服务 const providersToInit = new Set();
for (const provider of Object.values(MODEL_PROVIDER)) { if (Array.isArray(config.DEFAULT_MODEL_PROVIDERS)) {
if (!config.providerPools || !config.providerPools[provider] || config.providerPools[provider].length === 0) { config.DEFAULT_MODEL_PROVIDERS.forEach((provider) => providersToInit.add(provider));
try { }
// 对于没有配置号池的提供者,仍然按原来的方式初始化一个单例 if (config.providerPools) {
console.log(`[Initialization] Initializing single service adapter for ${provider}...`); Object.keys(config.providerPools).forEach((provider) => providersToInit.add(provider));
getServiceAdapter({ ...config, MODEL_PROVIDER: provider }); // This call populates serviceInstances }
} catch (error) { if (providersToInit.size === 0) {
console.warn(`[Initialization Warning] Failed to initialize single service adapter for ${provider}: ${error.message}`); 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 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) { async function getApiService(config) {
let serviceConfig = config; let serviceConfig = config;
if (providerPoolManager && config.providerPools && config.providerPools[config.MODEL_PROVIDER]) { if (providerPoolManager && config.providerPools && config.providerPools[config.MODEL_PROVIDER]) {
@ -606,21 +701,15 @@ async function startServer() {
const server = http.createServer(requestHandlerInstance); const server = http.createServer(requestHandlerInstance);
server.listen(CONFIG.SERVER_PORT, CONFIG.HOST, () => { server.listen(CONFIG.SERVER_PORT, CONFIG.HOST, () => {
console.log(`--- Unified API Server Configuration ---`); console.log(`--- Unified API Server Configuration ---`);
console.log(` Model Provider: ${CONFIG.MODEL_PROVIDER}`); const configuredProviders = Array.isArray(CONFIG.DEFAULT_MODEL_PROVIDERS) && CONFIG.DEFAULT_MODEL_PROVIDERS.length > 0
if (CONFIG.MODEL_PROVIDER === MODEL_PROVIDER.OPENAI_CUSTOM) { ? CONFIG.DEFAULT_MODEL_PROVIDERS
console.log(` OpenAI API Key: ${CONFIG.OPENAI_API_KEY ? '******' : 'Not Set'}`); : [CONFIG.MODEL_PROVIDER];
console.log(` OpenAI Base URL: ${CONFIG.OPENAI_BASE_URL}`); const uniqueProviders = [...new Set(configuredProviders)];
} else if (CONFIG.MODEL_PROVIDER === MODEL_PROVIDER.CLAUDE_CUSTOM) { console.log(` Primary Model Provider: ${CONFIG.MODEL_PROVIDER}`);
console.log(` Claude API Key: ${CONFIG.CLAUDE_API_KEY ? '******' : 'Not Set'}`); if (uniqueProviders.length > 1) {
console.log(` Claude Base URL: ${CONFIG.CLAUDE_BASE_URL}`); console.log(` Additional Model Providers: ${uniqueProviders.slice(1).join(', ')}`);
} else if (CONFIG.MODEL_PROVIDER === MODEL_PROVIDER.GEMINI_CLI) { }
console.log(` Gemini OAuth Creds File Path: ${CONFIG.GEMINI_OAUTH_CREDS_FILE_PATH || 'Default'}`); uniqueProviders.forEach((provider) => logProviderSpecificDetails(provider, CONFIG));
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'}`);
}
console.log(` System Prompt File: ${CONFIG.SYSTEM_PROMPT_FILE_PATH || 'Default'}`); console.log(` System Prompt File: ${CONFIG.SYSTEM_PROMPT_FILE_PATH || 'Default'}`);
console.log(` System Prompt Mode: ${CONFIG.SYSTEM_PROMPT_MODE}`); console.log(` System Prompt Mode: ${CONFIG.SYSTEM_PROMPT_MODE}`);
console.log(` Host: ${CONFIG.HOST}`); console.log(` Host: ${CONFIG.HOST}`);

View file

@ -119,6 +119,7 @@ export class QwenApiService {
this.qwenClient = new QwenOAuth2Client(); this.qwenClient = new QwenOAuth2Client();
this.sharedManager = SharedTokenManager.getInstance(); this.sharedManager = SharedTokenManager.getInstance();
this.currentAxiosInstance = null; this.currentAxiosInstance = null;
this.tokenManagerOptions = { credentialFilePath: this._getQwenCachedCredentialPath() };
} }
async initialize() { async initialize() {
@ -140,7 +141,11 @@ export class QwenApiService {
async _initializeAuth(forceRefresh = false) { async _initializeAuth(forceRefresh = false) {
try { 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); // console.log('credentials', credentials);
this.qwenClient.setCredentials(credentials); this.qwenClient.setCredentials(credentials);
} catch (error) { } 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)) { if (await this._loadCachedQwenCredentials(this.qwenClient)) {
const result = await this._authWithQwenDeviceFlow(this.qwenClient, this.config); console.log('[Qwen] Using cached OAuth credentials.');
if (!result.success) {
throw new Error('Qwen OAuth authentication failed');
}
return; return;
} }
// Otherwise, run device authorization flow to obtain fresh credentials.
const result = await this._authWithQwenDeviceFlow(this.qwenClient, this.config); const result = await this._authWithQwenDeviceFlow(this.qwenClient, this.config);
if (!result.success) { if (!result.success) {
if (result.reason === 'timeout') { if (result.reason === 'timeout') {
@ -310,7 +314,7 @@ export class QwenApiService {
_getQwenCachedCredentialPath() { _getQwenCachedCredentialPath() {
if (this.config && this.config.QWEN_OAUTH_CREDS_FILE_PATH) { 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); 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 creds = await fs.readFile(keyFile, 'utf-8');
const credentials = JSON.parse(creds); const credentials = JSON.parse(creds);
client.setCredentials(credentials); client.setCredentials(credentials);
const { token } = await client.getAccessToken(); // Consider credentials usable only if access_token exists and not near expiry
return !!token; const hasToken = !!credentials?.access_token;
const notExpired = !!credentials?.expiry_date && (Date.now() < credentials.expiry_date - TOKEN_REFRESH_BUFFER_MS);
return hasToken && notExpired;
} catch (_) { } catch (_) {
return false; return false;
} }
@ -368,7 +374,11 @@ export class QwenApiService {
async getValidToken() { async getValidToken() {
try { 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'); if (!credentials.access_token) throw new Error('No access token available');
return { return {
token: credentials.access_token, token: credentials.access_token,
@ -464,7 +474,11 @@ export class QwenApiService {
if (this.isAuthError(error) && retryCount === 0) { if (this.isAuthError(error) && retryCount === 0) {
console.warn(`[QwenApiService] Auth error (${status}). Refreshing token...`); console.warn(`[QwenApiService] Auth error (${status}). Refreshing token...`);
try { 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); return this.callApiWithAuthAndRetry(endpoint, body, isStream, retryCount + 1);
} catch (refreshError) { } catch (refreshError) {
console.error(`[QwenApiService] Token refresh failed:`, refreshError); console.error(`[QwenApiService] Token refresh failed:`, refreshError);
@ -539,13 +553,13 @@ export class QwenApiService {
class SharedTokenManager { class SharedTokenManager {
static instance = null; static instance = null;
memoryCache = { credentials: null, fileModTime: 0, lastCheck: 0 };
refreshPromise = null;
cleanupHandlersRegistered = false;
cleanupFunction = null;
lockConfig = DEFAULT_LOCK_CONFIG;
constructor() { constructor() {
this.contexts = new Map();
this.lockPaths = new Set();
this.cleanupHandlersRegistered = false;
this.cleanupFunction = null;
this.sigintHandler = null;
this.registerCleanupHandlers(); this.registerCleanupHandlers();
} }
@ -556,13 +570,49 @@ class SharedTokenManager {
return SharedTokenManager.instance; 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() { registerCleanupHandlers() {
if (this.cleanupHandlersRegistered) return; if (this.cleanupHandlersRegistered) return;
this.cleanupFunction = () => { this.cleanupFunction = () => {
try { unlinkSync(this.getLockFilePath()); } catch (_error) { /* Ignore */ } for (const lockPath of this.lockPaths) {
try { unlinkSync(lockPath); } catch (_error) { /* ignore */ }
}
}; };
this.sigintHandler = () => { this.sigintHandler = () => {
try { unlinkSync(this.getLockFilePath()); } catch (_error) { /* Ignore */ } this.cleanupFunction();
process.exit(0); process.exit(0);
}; };
process.on('exit', this.cleanupFunction); process.on('exit', this.cleanupFunction);
@ -570,70 +620,81 @@ class SharedTokenManager {
this.cleanupHandlersRegistered = true; this.cleanupHandlersRegistered = true;
} }
async getValidCredentials(qwenClient, forceRefresh = false) { async getValidCredentials(qwenClient, forceRefresh = false, options = {}) {
const context = this.getContext(options);
try { try {
await this.checkAndReloadIfNeeded(); await this.checkAndReloadIfNeeded(context);
if (!forceRefresh && this.memoryCache.credentials && this.isTokenValid(this.memoryCache.credentials)) { if (!forceRefresh && context.memoryCache.credentials && this.isTokenValid(context.memoryCache.credentials)) {
return this.memoryCache.credentials; return context.memoryCache.credentials;
}
if (context.refreshPromise) {
return context.refreshPromise;
} }
if (this.refreshPromise) return this.refreshPromise;
qwenClient.setCredentials(this.memoryCache.credentials); qwenClient.setCredentials(context.memoryCache.credentials);
this.refreshPromise = this.performTokenRefresh(qwenClient, forceRefresh); context.refreshPromise = this.performTokenRefresh(context, qwenClient, forceRefresh);
const credentials = await this.refreshPromise; const credentials = await context.refreshPromise;
this.refreshPromise = null; context.refreshPromise = null;
return credentials; return credentials;
} catch (error) { } catch (error) {
this.refreshPromise = null; context.refreshPromise = null;
if (error instanceof TokenManagerError) throw error; 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(); const now = Date.now();
if (now - this.memoryCache.lastCheck < CACHE_CHECK_INTERVAL_MS) return; if (now - context.memoryCache.lastCheck < CACHE_CHECK_INTERVAL_MS) return;
this.memoryCache.lastCheck = now; context.memoryCache.lastCheck = now;
try { try {
const stats = await fs.stat(this.getCredentialFilePath()); const stats = await fs.stat(context.credentialFilePath);
if (stats.mtimeMs > this.memoryCache.fileModTime) { if (stats.mtimeMs > context.memoryCache.fileModTime) {
await this.reloadCredentialsFromFile(); await this.reloadCredentialsFromFile(context);
this.memoryCache.fileModTime = stats.mtimeMs; context.memoryCache.fileModTime = stats.mtimeMs;
} }
} catch (error) { } catch (error) {
if (error.code !== 'ENOENT') { if (error.code !== 'ENOENT') {
this.memoryCache.credentials = null; context.memoryCache.credentials = null;
this.memoryCache.fileModTime = 0; context.memoryCache.fileModTime = 0;
throw new TokenManagerError(TokenError.FILE_ACCESS_ERROR, `Failed to access credentials file: ${error.message}`, error); 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 { try {
const content = await fs.readFile(this.getCredentialFilePath(), 'utf-8'); const content = await fs.readFile(context.credentialFilePath, 'utf-8');
this.memoryCache.credentials = JSON.parse(content); context.memoryCache.credentials = JSON.parse(content);
} catch (error) { } catch (_error) {
this.memoryCache.credentials = null; context.memoryCache.credentials = null;
} }
} }
async performTokenRefresh(qwenClient, forceRefresh = false) { async performTokenRefresh(context, qwenClient, forceRefresh = false) {
const lockPath = this.getLockFilePath();
try { try {
const currentCredentials = qwenClient.getCredentials(); const currentCredentials = qwenClient.getCredentials() || context.memoryCache.credentials;
if (!currentCredentials.refresh_token) { if (!currentCredentials || !currentCredentials.refresh_token) {
throw new TokenManagerError(TokenError.NO_REFRESH_TOKEN, 'No refresh token available'); throw new TokenManagerError(TokenError.NO_REFRESH_TOKEN, 'No refresh token available');
} }
await this.acquireLock(lockPath); await this.acquireLock(context);
await this.checkAndReloadIfNeeded(); await this.checkAndReloadIfNeeded(context);
if (!forceRefresh && this.memoryCache.credentials && this.isTokenValid(this.memoryCache.credentials)) { if (!forceRefresh && context.memoryCache.credentials && this.isTokenValid(context.memoryCache.credentials)) {
qwenClient.setCredentials(this.memoryCache.credentials); qwenClient.setCredentials(context.memoryCache.credentials);
return this.memoryCache.credentials; return context.memoryCache.credentials;
} }
const response = await qwenClient.refreshAccessToken(); const response = await qwenClient.refreshAccessToken();
@ -651,67 +712,75 @@ class SharedTokenManager {
expiry_date: Date.now() + response.expires_in * 1000, expiry_date: Date.now() + response.expires_in * 1000,
}; };
this.memoryCache.credentials = credentials; context.memoryCache.credentials = credentials;
qwenClient.setCredentials(credentials); qwenClient.setCredentials(credentials);
await this.saveCredentialsToFile(credentials); await this.saveCredentialsToFile(context, credentials);
console.log('[Qwen Auth] Token refresh response: ok'); console.log('[Qwen Auth] Token refresh response: ok');
return credentials; return credentials;
} catch (error) { } catch (error) {
if (error instanceof TokenManagerError) throw 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 { } finally {
await this.releaseLock(lockPath); await this.releaseLock(context);
} }
} }
async saveCredentialsToFile(credentials) { async saveCredentialsToFile(context, credentials) {
const filePath = this.getCredentialFilePath(); await fs.mkdir(path.dirname(context.credentialFilePath), { recursive: true, mode: 0o700 });
await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 }); await fs.writeFile(context.credentialFilePath, JSON.stringify(credentials, null, 2), { mode: 0o600 });
await fs.writeFile(filePath, JSON.stringify(credentials, null, 2), { mode: 0o600 }); const stats = await fs.stat(context.credentialFilePath);
const stats = await fs.stat(filePath); context.memoryCache.fileModTime = stats.mtimeMs;
this.memoryCache.fileModTime = stats.mtimeMs;
} }
isTokenValid(credentials) { isTokenValid(credentials) {
return credentials?.expiry_date && Date.now() < credentials.expiry_date - TOKEN_REFRESH_BUFFER_MS; return credentials?.expiry_date && Date.now() < credentials.expiry_date - TOKEN_REFRESH_BUFFER_MS;
} }
getCredentialFilePath() { async acquireLock(context) {
return path.join(os.homedir(), QWEN_DIR, QWEN_CREDENTIAL_FILENAME); const { maxAttempts, attemptInterval } = context.lockConfig || DEFAULT_LOCK_CONFIG;
}
getLockFilePath() {
return path.join(os.homedir(), QWEN_DIR, QWEN_LOCK_FILENAME);
}
async acquireLock(lockPath) {
const { maxAttempts, attemptInterval } = this.lockConfig;
for (let attempt = 0; attempt < maxAttempts; attempt++) { for (let attempt = 0; attempt < maxAttempts; attempt++) {
try { try {
await fs.writeFile(lockPath, randomUUID(), { flag: 'wx' }); await fs.writeFile(context.lockFilePath, randomUUID(), { flag: 'wx' });
return; return;
} catch (error) { } catch (error) {
if (error.code === 'EEXIST') { if (error.code === 'EEXIST') {
try { try {
const stats = await fs.stat(lockPath); const stats = await fs.stat(context.lockFilePath);
if (Date.now() - stats.mtimeMs > LOCK_TIMEOUT_MS) { if (Date.now() - stats.mtimeMs > LOCK_TIMEOUT_MS) {
await fs.unlink(lockPath); await fs.unlink(context.lockFilePath);
continue; continue;
} }
} catch (statError) { /* ignore */ } } catch (_statError) { /* ignore */ }
await new Promise(resolve => setTimeout(resolve, attemptInterval)); await new Promise(resolve => setTimeout(resolve, attemptInterval));
} else { } 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'); throw new TokenManagerError(TokenError.LOCK_TIMEOUT, 'Lock acquisition timeout');
} }
async releaseLock(lockPath) { async releaseLock(context) {
try { await fs.unlink(lockPath); } try {
catch (error) { if (error.code !== 'ENOENT') console.warn(`Failed to release lock: ${error.message}`);} 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.ok) {
if (response.status === 400) { if (response.status === 400) {
await fs.unlink(path.join(os.homedir(), QWEN_DIR, QWEN_CREDENTIAL_FILENAME)).catch(() => {}); const err = new Error("Refresh token expired or invalid.");
throw new Error("Refresh token expired or invalid."); err.status = 400;
throw err;
} }
throw new Error(`Token refresh failed: ${response.status}`); throw new Error(`Token refresh failed: ${response.status}`);
} }