Merge pull request #52 from d7185540/feature/qwen-auth-and-providers
feat(server): 多提供商数组配置与启动初始化; fix(qwen-auth): 复用缓存凭据与账号级刷新清理 (src-only)
This commit is contained in:
commit
917d89bffa
2 changed files with 272 additions and 113 deletions
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue