在gemini-core.js中添加gemini-2.5-pro-preview-06-05模型 在qwen-core.js中移除未使用的QWEN_LOCK_FILENAME常量并添加模型未找到时的警告日志
861 lines
34 KiB
JavaScript
861 lines
34 KiB
JavaScript
import axios from 'axios';
|
|
import crypto from 'crypto';
|
|
import path from 'node:path';
|
|
import { promises as fs, unlinkSync } from 'node:fs';
|
|
import * as os from 'os';
|
|
import open from 'open';
|
|
import { EventEmitter } from 'events';
|
|
import { randomUUID } from 'node:crypto';
|
|
|
|
// --- Constants ---
|
|
const QWEN_DIR = '.qwen';
|
|
const QWEN_CREDENTIAL_FILENAME = 'oauth_creds.json';
|
|
const QWEN_MODEL_LIST = [
|
|
{ id: 'qwen3-coder-plus', name: 'Qwen3 Coder Plus' },
|
|
{ id: 'qwen3-coder-flash', name: 'Qwen3 Coder Flash' },
|
|
];
|
|
|
|
const TOKEN_REFRESH_BUFFER_MS = 30 * 1000;
|
|
const LOCK_TIMEOUT_MS = 10000;
|
|
const CACHE_CHECK_INTERVAL_MS = 1000;
|
|
|
|
const DEFAULT_LOCK_CONFIG = {
|
|
maxAttempts: 50,
|
|
attemptInterval: 200,
|
|
};
|
|
|
|
const QWEN_OAUTH_BASE_URL = 'https://chat.qwen.ai';
|
|
const QWEN_OAUTH_DEVICE_CODE_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/device/code`;
|
|
const QWEN_OAUTH_TOKEN_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/token`;
|
|
const QWEN_OAUTH_CLIENT_ID = 'f0304373b74a44d2b584a3fb70ca9e56';
|
|
const QWEN_OAUTH_SCOPE = 'openid profile email model.completion';
|
|
const QWEN_OAUTH_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
|
|
// const DEFAULT_QWEN_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1';
|
|
const DEFAULT_QWEN_BASE_URL = 'https://portal.qwen.ai/v1';
|
|
|
|
export const QwenOAuth2Event = {
|
|
AuthUri: 'auth-uri',
|
|
AuthProgress: 'auth-progress',
|
|
AuthCancel: 'auth-cancel',
|
|
};
|
|
export const qwenOAuth2Events = new EventEmitter();
|
|
|
|
|
|
// --- Helper Functions ---
|
|
|
|
function generateCodeVerifier() {
|
|
return crypto.randomBytes(32).toString('base64url');
|
|
}
|
|
|
|
function generateCodeChallenge(codeVerifier) {
|
|
const hash = crypto.createHash('sha256');
|
|
hash.update(codeVerifier);
|
|
return hash.digest('base64url');
|
|
}
|
|
|
|
function generatePKCEPair() {
|
|
const codeVerifier = generateCodeVerifier();
|
|
const codeChallenge = generateCodeChallenge(codeVerifier);
|
|
return { code_verifier: codeVerifier, code_challenge: codeChallenge };
|
|
}
|
|
|
|
function objectToUrlEncoded(data) {
|
|
return Object.keys(data)
|
|
.map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(data[key])}`)
|
|
.join('&');
|
|
}
|
|
|
|
function isDeviceAuthorizationSuccess(response) {
|
|
return 'device_code' in response;
|
|
}
|
|
|
|
function isDeviceTokenSuccess(response) {
|
|
return (
|
|
'access_token' in response &&
|
|
response.access_token !== null &&
|
|
response.access_token !== undefined &&
|
|
typeof response.access_token === 'string' &&
|
|
response.access_token.length > 0
|
|
);
|
|
}
|
|
|
|
function isDeviceTokenPending(response) {
|
|
return 'status' in response && response.status === 'pending';
|
|
}
|
|
|
|
function isErrorResponse(response) {
|
|
return 'error' in response;
|
|
}
|
|
|
|
|
|
// --- Error Classes ---
|
|
|
|
export const TokenError = {
|
|
REFRESH_FAILED: 'REFRESH_FAILED',
|
|
NO_REFRESH_TOKEN: 'NO_REFRESH_TOKEN',
|
|
LOCK_TIMEOUT: 'LOCK_TIMEOUT',
|
|
FILE_ACCESS_ERROR: 'FILE_ACCESS_ERROR',
|
|
NETWORK_ERROR: 'NETWORK_ERROR',
|
|
};
|
|
|
|
export class TokenManagerError extends Error {
|
|
constructor(type, message, originalError) {
|
|
super(message);
|
|
this.type = type;
|
|
this.originalError = originalError;
|
|
this.name = 'TokenManagerError';
|
|
}
|
|
}
|
|
|
|
|
|
// --- Core Service Class ---
|
|
|
|
export class QwenApiService {
|
|
constructor(config) {
|
|
this.config = config;
|
|
this.isInitialized = false;
|
|
this.qwenClient = new QwenOAuth2Client();
|
|
this.sharedManager = SharedTokenManager.getInstance();
|
|
this.currentAxiosInstance = null;
|
|
this.tokenManagerOptions = { credentialFilePath: this._getQwenCachedCredentialPath() };
|
|
}
|
|
|
|
async initialize() {
|
|
if (this.isInitialized) return;
|
|
console.log('[Qwen] Initializing Qwen API Service...');
|
|
await this._initializeAuth();
|
|
|
|
this.currentAxiosInstance = axios.create({
|
|
baseURL: DEFAULT_QWEN_BASE_URL,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer `,
|
|
},
|
|
});
|
|
|
|
this.isInitialized = true;
|
|
console.log('[Qwen] Initialization complete.');
|
|
}
|
|
|
|
async _initializeAuth(forceRefresh = false) {
|
|
try {
|
|
const credentials = await this.sharedManager.getValidCredentials(
|
|
this.qwenClient,
|
|
forceRefresh,
|
|
this.tokenManagerOptions,
|
|
);
|
|
// console.log('credentials', credentials);
|
|
this.qwenClient.setCredentials(credentials);
|
|
} catch (error) {
|
|
console.debug('Shared token manager failed, attempting device flow:', error);
|
|
|
|
if (error instanceof TokenManagerError) {
|
|
switch (error.type) {
|
|
case TokenError.NO_REFRESH_TOKEN:
|
|
console.debug('No refresh token available, proceeding with device flow');
|
|
break;
|
|
case TokenError.REFRESH_FAILED:
|
|
console.debug('Token refresh failed, proceeding with device flow');
|
|
break;
|
|
case TokenError.NETWORK_ERROR:
|
|
console.warn('Network error during token refresh, trying device flow');
|
|
break;
|
|
default:
|
|
console.warn('Token manager error:', error.message);
|
|
}
|
|
}
|
|
|
|
// If cached credentials are present and still valid, use them directly.
|
|
if (await this._loadCachedQwenCredentials(this.qwenClient)) {
|
|
console.log('[Qwen] Using cached OAuth credentials.');
|
|
return;
|
|
}
|
|
|
|
// Otherwise, run device authorization flow to obtain fresh credentials.
|
|
const result = await this._authWithQwenDeviceFlow(this.qwenClient, this.config);
|
|
if (!result.success) {
|
|
if (result.reason === 'timeout') {
|
|
qwenOAuth2Events.emit(
|
|
QwenOAuth2Event.AuthProgress,
|
|
'timeout',
|
|
'Authentication timed out. Please try again or select a different authentication method.',
|
|
);
|
|
}
|
|
switch (result.reason) {
|
|
case 'timeout':
|
|
throw new Error('Qwen OAuth authentication timed out');
|
|
case 'cancelled':
|
|
throw new Error('Qwen OAuth authentication was cancelled by user');
|
|
case 'rate_limit':
|
|
throw new Error('Too many request for Qwen OAuth authentication, please try again later.');
|
|
case 'error':
|
|
default:
|
|
throw new Error('Qwen OAuth authentication failed');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async _authWithQwenDeviceFlow(client, config) {
|
|
let isCancelled = false;
|
|
const cancelHandler = () => { isCancelled = true; };
|
|
const sigintHandler = () => {
|
|
isCancelled = true;
|
|
qwenOAuth2Events.emit(QwenOAuth2Event.AuthCancel);
|
|
};
|
|
qwenOAuth2Events.once(QwenOAuth2Event.AuthCancel, cancelHandler);
|
|
process.once('SIGINT', sigintHandler);
|
|
|
|
try {
|
|
const { code_verifier, code_challenge } = generatePKCEPair();
|
|
const deviceAuth = await client.requestDeviceAuthorization({
|
|
scope: QWEN_OAUTH_SCOPE,
|
|
code_challenge,
|
|
code_challenge_method: 'S256',
|
|
});
|
|
|
|
if (!isDeviceAuthorizationSuccess(deviceAuth)) {
|
|
throw new Error(`Device authorization failed: ${deviceAuth?.error || 'Unknown error'} - ${deviceAuth?.error_description || 'No details'}`);
|
|
}
|
|
|
|
qwenOAuth2Events.emit(QwenOAuth2Event.AuthUri, deviceAuth);
|
|
|
|
const showFallbackMessage = () => {
|
|
console.log('\n=== Qwen OAuth Device Authorization ===');
|
|
console.log('Please visit the following URL in your browser to authorize:');
|
|
console.log(`\n${deviceAuth.verification_uri_complete}\n`);
|
|
console.log('Waiting for authorization to complete...\n');
|
|
};
|
|
|
|
if (config) {
|
|
try {
|
|
const childProcess = await open(deviceAuth.verification_uri_complete);
|
|
if (childProcess) {
|
|
childProcess.on('error', () => showFallbackMessage());
|
|
}
|
|
} catch (_err) {
|
|
showFallbackMessage();
|
|
}
|
|
} else {
|
|
showFallbackMessage();
|
|
}
|
|
|
|
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'polling', 'Waiting for authorization...');
|
|
console.debug('Waiting for authorization...\n');
|
|
|
|
let pollInterval = 2000;
|
|
const maxAttempts = Math.ceil(deviceAuth.expires_in / (pollInterval / 1000));
|
|
|
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
if (isCancelled) {
|
|
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', 'Authentication cancelled by user.');
|
|
return { success: false, reason: 'cancelled' };
|
|
}
|
|
|
|
try {
|
|
const tokenResponse = await client.pollDeviceToken({ device_code: deviceAuth.device_code, code_verifier });
|
|
if (isDeviceTokenSuccess(tokenResponse)) {
|
|
const credentials = {
|
|
access_token: tokenResponse.access_token,
|
|
refresh_token: tokenResponse.refresh_token || undefined,
|
|
token_type: tokenResponse.token_type,
|
|
resource_url: tokenResponse.resource_url,
|
|
expiry_date: tokenResponse.expires_in ? Date.now() + tokenResponse.expires_in * 1000 : undefined,
|
|
};
|
|
client.setCredentials(credentials);
|
|
await this._cacheQwenCredentials(credentials);
|
|
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'success', 'Authentication successful! Access token obtained.');
|
|
return { success: true };
|
|
}
|
|
|
|
if (isDeviceTokenPending(tokenResponse)) {
|
|
if (tokenResponse.slowDown) {
|
|
pollInterval = Math.min(pollInterval * 1.5, 10000);
|
|
} else {
|
|
pollInterval = 2000;
|
|
}
|
|
// Fall through to wait and continue
|
|
} else if (isErrorResponse(tokenResponse)) {
|
|
console.warn(`Token polling failed with error: ${tokenResponse?.error || 'Unknown error'}`);
|
|
// Fall through to wait and continue
|
|
}
|
|
} catch (error) {
|
|
console.warn(`Token polling threw an exception: ${error.message}`);
|
|
// Fall through to wait for the next attempt
|
|
}
|
|
|
|
// Wait for the polling interval before the next attempt
|
|
await new Promise(resolve => {
|
|
const timeoutId = setTimeout(resolve, pollInterval);
|
|
// If cancelled during wait, clear timeout and resolve immediately
|
|
if (isCancelled) {
|
|
clearTimeout(timeoutId);
|
|
resolve();
|
|
}
|
|
});
|
|
|
|
// Check again after waiting
|
|
if (isCancelled) {
|
|
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', 'Authentication cancelled by user.');
|
|
return { success: false, reason: 'cancelled' };
|
|
}
|
|
}
|
|
return { success: false, reason: 'timeout' };
|
|
} catch (error) {
|
|
console.error('Device authorization flow failed:', error.message);
|
|
return { success: false, reason: 'error' };
|
|
} finally {
|
|
qwenOAuth2Events.off(QwenOAuth2Event.AuthCancel, cancelHandler);
|
|
process.off('SIGINT', sigintHandler);
|
|
}
|
|
}
|
|
|
|
_getQwenCachedCredentialPath() {
|
|
if (this.config && this.config.QWEN_OAUTH_CREDS_FILE_PATH) {
|
|
return path.resolve(this.config.QWEN_OAUTH_CREDS_FILE_PATH);
|
|
}
|
|
return path.join(os.homedir(), QWEN_DIR, QWEN_CREDENTIAL_FILENAME);
|
|
}
|
|
|
|
async _loadCachedQwenCredentials(client) {
|
|
try {
|
|
const keyFile = this._getQwenCachedCredentialPath();
|
|
const creds = await fs.readFile(keyFile, 'utf-8');
|
|
const credentials = JSON.parse(creds);
|
|
client.setCredentials(credentials);
|
|
// Consider credentials usable only if access_token exists and not near expiry
|
|
const hasToken = !!credentials?.access_token;
|
|
const notExpired = !!credentials?.expiry_date && (Date.now() < credentials.expiry_date - TOKEN_REFRESH_BUFFER_MS);
|
|
return hasToken && notExpired;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async _cacheQwenCredentials(credentials) {
|
|
const filePath = this._getQwenCachedCredentialPath();
|
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
const credString = JSON.stringify(credentials, null, 2);
|
|
await fs.writeFile(filePath, credString);
|
|
}
|
|
|
|
getCurrentEndpoint(resourceUrl) {
|
|
const baseEndpoint = resourceUrl || DEFAULT_QWEN_BASE_URL;
|
|
const suffix = '/v1';
|
|
|
|
const normalizedUrl = baseEndpoint.startsWith('http') ?
|
|
baseEndpoint :
|
|
`https://${baseEndpoint}`;
|
|
|
|
return normalizedUrl.endsWith(suffix) ?
|
|
normalizedUrl :
|
|
`${normalizedUrl}${suffix}`;
|
|
}
|
|
|
|
isAuthError(error) {
|
|
if (!error) return false;
|
|
const errorMessage = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
const errorCode = error?.status || error?.code || error.response?.status;
|
|
|
|
const code = String(errorCode);
|
|
return (
|
|
code.startsWith('401') || code.startsWith('403') ||
|
|
errorMessage.includes('unauthorized') ||
|
|
errorMessage.includes('forbidden') ||
|
|
errorMessage.includes('invalid api key') ||
|
|
errorMessage.includes('invalid access token') ||
|
|
errorMessage.includes('token expired') ||
|
|
errorMessage.includes('authentication') ||
|
|
errorMessage.includes('access denied')
|
|
);
|
|
}
|
|
|
|
async getValidToken() {
|
|
try {
|
|
const credentials = await this.sharedManager.getValidCredentials(
|
|
this.qwenClient,
|
|
false,
|
|
this.tokenManagerOptions,
|
|
);
|
|
if (!credentials.access_token) throw new Error('No access token available');
|
|
return {
|
|
token: credentials.access_token,
|
|
endpoint: this.getCurrentEndpoint(credentials.resource_url),
|
|
};
|
|
} catch (error) {
|
|
if (this.isAuthError(error)) throw error;
|
|
console.warn('Failed to get token from shared manager:', error);
|
|
throw new Error('Failed to obtain valid Qwen access token. Please re-authenticate.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Processes message content in the request body.
|
|
* If content is an array, it joins the elements with newlines.
|
|
* @param {Object} requestBody - The request body to process
|
|
* @returns {Object} The processed request body
|
|
*/
|
|
processMessageContent(requestBody) {
|
|
if (!requestBody || !requestBody.messages || !Array.isArray(requestBody.messages)) {
|
|
return requestBody;
|
|
}
|
|
|
|
const processedMessages = requestBody.messages.map(message => {
|
|
if (message.content && Array.isArray(message.content)) {
|
|
// Convert each item to JSON string before joining
|
|
const stringifiedContent = message.content.map(item =>
|
|
typeof item === 'string' ? item : item.text
|
|
);
|
|
return {
|
|
...message,
|
|
content: stringifiedContent.join('\n')
|
|
};
|
|
}
|
|
return message;
|
|
});
|
|
|
|
return {
|
|
...requestBody,
|
|
messages: processedMessages
|
|
};
|
|
}
|
|
|
|
async callApiWithAuthAndRetry(endpoint, body, isStream = false, retryCount = 0) {
|
|
const maxRetries = (this.config && this.config.REQUEST_MAX_RETRIES) || 3;
|
|
const baseDelay = (this.config && this.config.REQUEST_BASE_DELAY) || 1000;
|
|
|
|
const version = "0.0.9";
|
|
const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`;
|
|
console.log(`[QwenApiService] User-Agent: ${userAgent}`);
|
|
|
|
try {
|
|
const { token, endpoint: qwenBaseUrl } = await this.getValidToken();
|
|
|
|
this.currentAxiosInstance = axios.create({
|
|
baseURL: qwenBaseUrl,
|
|
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` ,
|
|
'X-DashScope-CacheControl': 'enable',
|
|
'X-DashScope-UserAgent': userAgent,
|
|
'X-DashScope-AuthType': 'qwen-oauth',
|
|
},
|
|
});
|
|
|
|
// Process message content before sending the request
|
|
const processedBody = body;//this.processMessageContent(body);
|
|
|
|
// Check if model in body is in QWEN_MODEL_LIST, if not, use the first model's id
|
|
if (processedBody.model && !QWEN_MODEL_LIST.some(model => model.id === processedBody.model)) {
|
|
console.warn(`[QwenApiService] Model '${processedBody.model}' not found. Using default model: '${QWEN_MODEL_LIST[0].id}'`);
|
|
processedBody.model = QWEN_MODEL_LIST[0].id;
|
|
}
|
|
|
|
const defaultTools = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "ext"
|
|
}
|
|
}
|
|
];
|
|
|
|
// Merge tools if requestBody already has tools defined
|
|
const mergedTools = processedBody.tools ? [...defaultTools, ...processedBody.tools] : defaultTools;
|
|
|
|
const requestBody = isStream ? { ...processedBody, stream: true, tools: mergedTools } : { ...processedBody, tools: mergedTools };
|
|
const options = isStream ? { responseType: 'stream' } : {};
|
|
const response = await this.currentAxiosInstance.post(endpoint, requestBody, options);
|
|
return response.data;
|
|
|
|
} catch (error) {
|
|
const status = error.response?.status;
|
|
const data = error.response?.data || error.message;
|
|
|
|
if (this.isAuthError(error) && retryCount === 0) {
|
|
console.warn(`[QwenApiService] Auth error (${status}). Refreshing token...`);
|
|
try {
|
|
await this.sharedManager.getValidCredentials(
|
|
this.qwenClient,
|
|
true,
|
|
this.tokenManagerOptions,
|
|
);
|
|
return this.callApiWithAuthAndRetry(endpoint, body, isStream, retryCount + 1);
|
|
} catch (refreshError) {
|
|
console.error(`[QwenApiService] Token refresh failed:`, refreshError);
|
|
throw new Error(`Token refresh failed. Please re-authenticate. ${refreshError.message}`);
|
|
}
|
|
}
|
|
|
|
if ((status === 429 || (status >= 500 && status < 600)) && retryCount < maxRetries) {
|
|
const delay = baseDelay * Math.pow(2, retryCount);
|
|
console.log(`[QwenApiService] Status ${status}. Retrying in ${delay}ms...`);
|
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
return this.callApiWithAuthAndRetry(endpoint, body, isStream, retryCount + 1);
|
|
}
|
|
|
|
console.error(`Error calling Qwen API (Status: ${status}):`, data);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async generateContent(model, requestBody) {
|
|
return this.callApiWithAuthAndRetry('/chat/completions', requestBody, false);
|
|
}
|
|
|
|
async *generateContentStream(model, requestBody) {
|
|
const stream = await this.callApiWithAuthAndRetry('/chat/completions', requestBody, true);
|
|
let buffer = '';
|
|
for await (const chunk of stream) {
|
|
buffer += chunk.toString();
|
|
let newlineIndex;
|
|
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
|
|
const line = buffer.substring(0, newlineIndex).trim();
|
|
buffer = buffer.substring(newlineIndex + 1);
|
|
|
|
if (line.startsWith('data: ')) {
|
|
const jsonData = line.substring(6).trim();
|
|
if (jsonData === '[DONE]') return;
|
|
try {
|
|
yield JSON.parse(jsonData);
|
|
} catch (e) {
|
|
console.warn("[QwenApiService] Failed to parse stream chunk:", jsonData);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async listModels() {
|
|
// Return the predefined models for Qwen
|
|
return {
|
|
data: QWEN_MODEL_LIST
|
|
};
|
|
}
|
|
|
|
isExpiryDateNear() {
|
|
try {
|
|
const credentials = this.qwenClient.getCredentials();
|
|
if (!credentials || !credentials.expiry_date) {
|
|
return false;
|
|
}
|
|
const currentTime = Date.now();
|
|
const cronNearMinutesInMillis = (this.config.CRON_NEAR_MINUTES || 10) * 60 * 1000;
|
|
return credentials.expiry_date <= (currentTime + cronNearMinutesInMillis);
|
|
} catch (error) {
|
|
console.error(`[Qwen] Error checking expiry date: ${error.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// --- SharedTokenManager Class (Singleton) ---
|
|
|
|
class SharedTokenManager {
|
|
static instance = null;
|
|
|
|
constructor() {
|
|
this.contexts = new Map();
|
|
this.lockPaths = new Set();
|
|
this.cleanupHandlersRegistered = false;
|
|
this.cleanupFunction = null;
|
|
this.sigintHandler = null;
|
|
this.registerCleanupHandlers();
|
|
}
|
|
|
|
static getInstance() {
|
|
if (!SharedTokenManager.instance) {
|
|
SharedTokenManager.instance = new SharedTokenManager();
|
|
}
|
|
return SharedTokenManager.instance;
|
|
}
|
|
|
|
getContext(options = {}) {
|
|
const credentialFilePath = this.resolveCredentialFilePath(options.credentialFilePath);
|
|
const lockFilePath = this.resolveLockFilePath(credentialFilePath, options.lockFilePath);
|
|
let context = this.contexts.get(credentialFilePath);
|
|
if (!context) {
|
|
context = {
|
|
credentialFilePath,
|
|
lockFilePath,
|
|
lockConfig: options.lockConfig || DEFAULT_LOCK_CONFIG,
|
|
memoryCache: { credentials: null, fileModTime: 0, lastCheck: 0 },
|
|
refreshPromise: null,
|
|
};
|
|
this.contexts.set(credentialFilePath, context);
|
|
this.lockPaths.add(lockFilePath);
|
|
} else if (options.lockConfig) {
|
|
context.lockConfig = options.lockConfig;
|
|
}
|
|
return context;
|
|
}
|
|
|
|
resolveCredentialFilePath(customPath) {
|
|
if (customPath) {
|
|
return path.resolve(customPath);
|
|
}
|
|
return path.join(os.homedir(), QWEN_DIR, QWEN_CREDENTIAL_FILENAME);
|
|
}
|
|
|
|
resolveLockFilePath(credentialFilePath, customLockPath) {
|
|
if (customLockPath) {
|
|
return path.resolve(customLockPath);
|
|
}
|
|
return `${credentialFilePath}.lock`;
|
|
}
|
|
|
|
registerCleanupHandlers() {
|
|
if (this.cleanupHandlersRegistered) return;
|
|
this.cleanupFunction = () => {
|
|
for (const lockPath of this.lockPaths) {
|
|
try { unlinkSync(lockPath); } catch (_error) { /* ignore */ }
|
|
}
|
|
};
|
|
this.sigintHandler = () => {
|
|
this.cleanupFunction();
|
|
process.exit(0);
|
|
};
|
|
process.on('exit', this.cleanupFunction);
|
|
process.on('SIGINT', this.sigintHandler);
|
|
this.cleanupHandlersRegistered = true;
|
|
}
|
|
|
|
async getValidCredentials(qwenClient, forceRefresh = false, options = {}) {
|
|
const context = this.getContext(options);
|
|
try {
|
|
await this.checkAndReloadIfNeeded(context);
|
|
if (!forceRefresh && context.memoryCache.credentials && this.isTokenValid(context.memoryCache.credentials)) {
|
|
return context.memoryCache.credentials;
|
|
}
|
|
if (context.refreshPromise) {
|
|
return context.refreshPromise;
|
|
}
|
|
|
|
qwenClient.setCredentials(context.memoryCache.credentials);
|
|
context.refreshPromise = this.performTokenRefresh(context, qwenClient, forceRefresh);
|
|
const credentials = await context.refreshPromise;
|
|
context.refreshPromise = null;
|
|
return credentials;
|
|
} catch (error) {
|
|
context.refreshPromise = null;
|
|
if (error instanceof TokenManagerError) throw error;
|
|
throw new TokenManagerError(
|
|
TokenError.REFRESH_FAILED,
|
|
`Failed to get valid credentials: ${error.message}`,
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
async checkAndReloadIfNeeded(context) {
|
|
const now = Date.now();
|
|
if (now - context.memoryCache.lastCheck < CACHE_CHECK_INTERVAL_MS) return;
|
|
context.memoryCache.lastCheck = now;
|
|
|
|
try {
|
|
const stats = await fs.stat(context.credentialFilePath);
|
|
if (stats.mtimeMs > context.memoryCache.fileModTime) {
|
|
await this.reloadCredentialsFromFile(context);
|
|
context.memoryCache.fileModTime = stats.mtimeMs;
|
|
}
|
|
} catch (error) {
|
|
if (error.code !== 'ENOENT') {
|
|
context.memoryCache.credentials = null;
|
|
context.memoryCache.fileModTime = 0;
|
|
throw new TokenManagerError(
|
|
TokenError.FILE_ACCESS_ERROR,
|
|
`Failed to access credentials file: ${error.message}`,
|
|
error,
|
|
);
|
|
}
|
|
context.memoryCache.credentials = null;
|
|
context.memoryCache.fileModTime = 0;
|
|
}
|
|
}
|
|
|
|
async reloadCredentialsFromFile(context) {
|
|
try {
|
|
const content = await fs.readFile(context.credentialFilePath, 'utf-8');
|
|
context.memoryCache.credentials = JSON.parse(content);
|
|
} catch (_error) {
|
|
context.memoryCache.credentials = null;
|
|
}
|
|
}
|
|
|
|
async performTokenRefresh(context, qwenClient, forceRefresh = false) {
|
|
try {
|
|
const currentCredentials = qwenClient.getCredentials() || context.memoryCache.credentials;
|
|
if (!currentCredentials || !currentCredentials.refresh_token) {
|
|
throw new TokenManagerError(TokenError.NO_REFRESH_TOKEN, 'No refresh token available');
|
|
}
|
|
|
|
await this.acquireLock(context);
|
|
await this.checkAndReloadIfNeeded(context);
|
|
|
|
if (!forceRefresh && context.memoryCache.credentials && this.isTokenValid(context.memoryCache.credentials)) {
|
|
qwenClient.setCredentials(context.memoryCache.credentials);
|
|
return context.memoryCache.credentials;
|
|
}
|
|
|
|
const response = await qwenClient.refreshAccessToken();
|
|
if (!response || isErrorResponse(response)) {
|
|
throw new TokenManagerError(TokenError.REFRESH_FAILED, `Token refresh failed: ${response?.error}`);
|
|
}
|
|
if (!response.access_token) {
|
|
throw new TokenManagerError(TokenError.REFRESH_FAILED, 'No access token in refresh response');
|
|
}
|
|
const credentials = {
|
|
access_token: response.access_token,
|
|
token_type: response.token_type,
|
|
refresh_token: response.refresh_token || currentCredentials.refresh_token,
|
|
resource_url: response.resource_url,
|
|
expiry_date: Date.now() + response.expires_in * 1000,
|
|
};
|
|
|
|
context.memoryCache.credentials = credentials;
|
|
qwenClient.setCredentials(credentials);
|
|
await this.saveCredentialsToFile(context, credentials);
|
|
console.log('[Qwen Auth] Token refresh response: ok');
|
|
return credentials;
|
|
|
|
} catch (error) {
|
|
if (error instanceof TokenManagerError) throw error;
|
|
// 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(context);
|
|
}
|
|
}
|
|
|
|
async saveCredentialsToFile(context, credentials) {
|
|
await fs.mkdir(path.dirname(context.credentialFilePath), { recursive: true, mode: 0o700 });
|
|
await fs.writeFile(context.credentialFilePath, JSON.stringify(credentials, null, 2), { mode: 0o600 });
|
|
const stats = await fs.stat(context.credentialFilePath);
|
|
context.memoryCache.fileModTime = stats.mtimeMs;
|
|
}
|
|
|
|
isTokenValid(credentials) {
|
|
return credentials?.expiry_date && Date.now() < credentials.expiry_date - TOKEN_REFRESH_BUFFER_MS;
|
|
}
|
|
|
|
async acquireLock(context) {
|
|
const { maxAttempts, attemptInterval } = context.lockConfig || DEFAULT_LOCK_CONFIG;
|
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
try {
|
|
await fs.writeFile(context.lockFilePath, randomUUID(), { flag: 'wx' });
|
|
return;
|
|
} catch (error) {
|
|
if (error.code === 'EEXIST') {
|
|
try {
|
|
const stats = await fs.stat(context.lockFilePath);
|
|
if (Date.now() - stats.mtimeMs > LOCK_TIMEOUT_MS) {
|
|
await fs.unlink(context.lockFilePath);
|
|
continue;
|
|
}
|
|
} catch (_statError) { /* ignore */ }
|
|
await new Promise(resolve => setTimeout(resolve, attemptInterval));
|
|
} else {
|
|
throw new TokenManagerError(
|
|
TokenError.FILE_ACCESS_ERROR,
|
|
`Failed to create lock file: ${error.message}`,
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
throw new TokenManagerError(TokenError.LOCK_TIMEOUT, 'Lock acquisition timeout');
|
|
}
|
|
|
|
async releaseLock(context) {
|
|
try {
|
|
await fs.unlink(context.lockFilePath);
|
|
} catch (error) {
|
|
if (error.code !== 'ENOENT') {
|
|
console.warn(`Failed to release lock: ${error.message}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// --- QwenOAuth2Client Class ---
|
|
|
|
class QwenOAuth2Client {
|
|
credentials = {};
|
|
|
|
setCredentials(credentials) { this.credentials = credentials; }
|
|
getCredentials() { return this.credentials; }
|
|
|
|
async refreshAccessToken() {
|
|
if (!this.credentials.refresh_token) throw new Error('No refresh token');
|
|
const bodyData = {
|
|
grant_type: 'refresh_token',
|
|
refresh_token: this.credentials.refresh_token,
|
|
client_id: QWEN_OAUTH_CLIENT_ID,
|
|
};
|
|
const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
body: objectToUrlEncoded(bodyData),
|
|
});
|
|
if (!response.ok) {
|
|
if (response.status === 400) {
|
|
const err = new Error("Refresh token expired or invalid.");
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
throw new Error(`Token refresh failed: ${response.status}`);
|
|
}
|
|
return await response.json();
|
|
}
|
|
|
|
async requestDeviceAuthorization(options) {
|
|
const bodyData = {
|
|
client_id: QWEN_OAUTH_CLIENT_ID,
|
|
scope: options.scope,
|
|
code_challenge: options.code_challenge,
|
|
code_challenge_method: options.code_challenge_method,
|
|
};
|
|
const response = await fetch(QWEN_OAUTH_DEVICE_CODE_ENDPOINT, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
body: objectToUrlEncoded(bodyData),
|
|
});
|
|
if (!response.ok) throw new Error(`Device authorization failed: ${response.status}`);
|
|
return await response.json();
|
|
}
|
|
|
|
async pollDeviceToken(options) {
|
|
const bodyData = {
|
|
grant_type: QWEN_OAUTH_GRANT_TYPE,
|
|
client_id: QWEN_OAUTH_CLIENT_ID,
|
|
device_code: options.device_code,
|
|
code_verifier: options.code_verifier,
|
|
};
|
|
const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
body: objectToUrlEncoded(bodyData),
|
|
});
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({}));
|
|
if (response.status === 400 && errorData.error === 'authorization_pending') {
|
|
return { status: 'pending' };
|
|
}
|
|
if (response.status === 429 && errorData.error === 'slow_down') {
|
|
return { status: 'pending', slowDown: true };
|
|
}
|
|
const error = new Error(`Device token poll failed: ${errorData.error || response.status}`);
|
|
error.status = response.status;
|
|
throw error;
|
|
}
|
|
return await response.json();
|
|
}
|
|
}
|