/** * Qwen API Client for Multi-AI Brainstorm * Integrates with PromptArch's Qwen OAuth service */ const DEFAULT_ENDPOINT = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"; const PROMPTARCH_PROXY = "https://www.rommark.dev/tools/promptarch/api/qwen/chat"; const CREDENTIALS_PATH = `${process.env.HOME}/.claude/qwen-credentials.json`; // Qwen OAuth Configuration (from Qwen Code source) 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'; /** * Qwen API Client Class */ class QwenClient { constructor() { this.apiKey = null; this.accessToken = null; this.refreshToken = null; this.tokenExpiresAt = null; this.endpoint = DEFAULT_ENDPOINT; this.model = "coder-model"; } /** * Initialize client with credentials */ async initialize() { try { const fs = require('fs'); if (fs.existsSync(CREDENTIALS_PATH)) { const credentials = JSON.parse(fs.readFileSync(CREDENTIALS_PATH, 'utf8')); // Handle both API key and OAuth token credentials if (credentials.accessToken) { this.accessToken = credentials.accessToken; this.refreshToken = credentials.refreshToken; this.tokenExpiresAt = credentials.tokenExpiresAt; this.endpoint = credentials.endpoint || DEFAULT_ENDPOINT; // Check if token needs refresh if (this.isTokenExpired()) { await this.refreshAccessToken(); } return true; } else if (credentials.apiKey) { this.apiKey = credentials.apiKey; this.endpoint = credentials.endpoint || DEFAULT_ENDPOINT; return true; } } } catch (error) { // No credentials stored yet } return false; } /** * Check if access token is expired */ isTokenExpired() { if (!this.tokenExpiresAt) return false; // Add 5 minute buffer before expiration return Date.now() >= (this.tokenExpiresAt - 5 * 60 * 1000); } /** * Prompt user for authentication method */ async promptForCredentials() { const readline = require('readline'); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); return new Promise((resolve, reject) => { rl.question( '\nšŸ” Choose authentication method:\n' + ' 1. OAuth (Recommended) - Free 2000 requests/day with qwen.ai account\n' + ' 2. API Key - Get it at https://help.aliyun.com/zh/dashscope/\n\n' + 'Enter choice (1 or 2): ', async (choice) => { rl.close(); if (choice === '1') { try { await this.performOAuthFlow(); resolve(true); } catch (error) { reject(error); } } else if (choice === '2') { await this.promptForAPIKey(); resolve(true); } else { reject(new Error('Invalid choice. Please enter 1 or 2.')); } } ); }); } /** * Prompt user for API key only */ async promptForAPIKey() { const readline = require('readline'); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); return new Promise((resolve, reject) => { rl.question('Enter your Qwen API key (get it at https://help.aliyun.com/zh/dashscope/): ', (key) => { if (!key || key.trim().length === 0) { rl.close(); reject(new Error('API key is required')); return; } this.apiKey = key.trim(); this.saveCredentials(); rl.close(); resolve(true); }); }); } /** * Save credentials to file */ saveCredentials() { try { const fs = require('fs'); const path = require('path'); const dir = path.dirname(CREDENTIALS_PATH); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } const credentials = { endpoint: this.endpoint }; // Save OAuth tokens if (this.accessToken) { credentials.accessToken = this.accessToken; credentials.refreshToken = this.refreshToken; credentials.tokenExpiresAt = this.tokenExpiresAt; } // Save API key else if (this.apiKey) { credentials.apiKey = this.apiKey; } fs.writeFileSync( CREDENTIALS_PATH, JSON.stringify(credentials, null, 2) ); console.log(`āœ“ Credentials saved to ${CREDENTIALS_PATH}`); } catch (error) { console.warn('Could not save credentials:', error.message); } } /** * Generate PKCE code verifier and challenge pair */ generatePKCEPair() { const crypto = require('crypto'); const codeVerifier = crypto.randomBytes(32).toString('base64url'); const codeChallenge = crypto.createHash('sha256') .update(codeVerifier) .digest('base64url'); return { code_verifier: codeVerifier, code_challenge: codeChallenge }; } /** * Convert object to URL-encoded form data */ objectToUrlEncoded(data) { return Object.keys(data) .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(data[key])}`) .join('&'); } /** * Perform OAuth 2.0 Device Code Flow (from Qwen Code implementation) */ async performOAuthFlow() { const { exec } = require('child_process'); console.log('\nšŸ” Starting Qwen OAuth Device Code Flow...\n'); // Generate PKCE parameters const { code_verifier, code_challenge } = this.generatePKCEPair(); // Step 1: Request device authorization console.log('Requesting device authorization...'); const deviceAuthResponse = await fetch(QWEN_OAUTH_DEVICE_CODE_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json', }, body: this.objectToUrlEncoded({ client_id: QWEN_OAUTH_CLIENT_ID, scope: QWEN_OAUTH_SCOPE, code_challenge: code_challenge, code_challenge_method: 'S256', }), }); if (!deviceAuthResponse.ok) { const error = await deviceAuthResponse.text(); throw new Error(`Device authorization failed: ${deviceAuthResponse.status} - ${error}`); } const deviceAuth = await deviceAuthResponse.json(); if (!deviceAuth.device_code) { throw new Error('Invalid device authorization response'); } // Step 2: Display authorization instructions console.log('\n=== Qwen OAuth Device Authorization ===\n'); console.log('1. Visit this URL in your browser:\n'); console.log(` ${deviceAuth.verification_uri_complete}\n`); console.log('2. Sign in to your qwen.ai account and authorize\n'); console.log('Waiting for authorization to complete...\n'); // Try to open browser automatically try { const openCommand = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'; exec(`${openCommand} "${deviceAuth.verification_uri_complete}"`, (err) => { if (err) { console.debug('Could not open browser automatically'); } }); } catch (err) { console.debug('Failed to open browser:', err.message); } // Step 3: Poll for token let pollInterval = 2000; // Start with 2 seconds const maxAttempts = Math.ceil(deviceAuth.expires_in / (pollInterval / 1000)); let attempt = 0; while (attempt < maxAttempts) { attempt++; try { console.debug(`Polling for token (attempt ${attempt}/${maxAttempts})...`); const tokenResponse = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json', }, body: this.objectToUrlEncoded({ grant_type: QWEN_OAUTH_GRANT_TYPE, client_id: QWEN_OAUTH_CLIENT_ID, device_code: deviceAuth.device_code, code_verifier: code_verifier, }), }); // Check for pending authorization (standard OAuth RFC 8628 response) if (tokenResponse.status === 400) { const errorData = await tokenResponse.json(); if (errorData.error === 'authorization_pending') { // User hasn't authorized yet, continue polling await new Promise(resolve => setTimeout(resolve, pollInterval)); continue; } if (errorData.error === 'slow_down') { // Polling too frequently, increase interval pollInterval = Math.min(pollInterval * 1.5, 10000); await new Promise(resolve => setTimeout(resolve, pollInterval)); continue; } // Other 400 errors (authorization_declined, expired_token, etc.) throw new Error(`Authorization failed: ${errorData.error} - ${errorData.error_description || 'No description'}`); } if (!tokenResponse.ok) { const error = await tokenResponse.text(); throw new Error(`Token request failed: ${tokenResponse.status} - ${error}`); } // Success! We have the token const tokenData = await tokenResponse.json(); if (!tokenData.access_token) { throw new Error('Token response missing access_token'); } // Save credentials this.accessToken = tokenData.access_token; this.refreshToken = tokenData.refresh_token; this.tokenExpiresAt = tokenData.expires_in ? Date.now() + (tokenData.expires_in * 1000) : null; this.saveCredentials(); console.log('\nāœ“ OAuth authentication successful!'); console.log('āœ“ Access token obtained and saved.\n'); return; } catch (error) { // Check if this is a fatal error (not pending/slow_down) if (error.message.includes('Authorization failed') || error.message.includes('Token request failed')) { throw error; } // For other errors, wait and retry await new Promise(resolve => setTimeout(resolve, pollInterval)); } } throw new Error('OAuth authentication timeout'); } /** * Refresh access token using refresh token */ async refreshAccessToken() { if (!this.refreshToken) { throw new Error('No refresh token available. Please re-authenticate.'); } console.log('šŸ”„ Refreshing access token...'); const tokenResponse = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: this.objectToUrlEncoded({ grant_type: 'refresh_token', refresh_token: this.refreshToken, client_id: QWEN_OAUTH_CLIENT_ID, }), }); if (!tokenResponse.ok) { const error = await tokenResponse.text(); throw new Error(`Token refresh failed: ${tokenResponse.status} - ${error}`); } const tokens = await tokenResponse.json(); this.accessToken = tokens.access_token; if (tokens.refresh_token) { this.refreshToken = tokens.refresh_token; } this.tokenExpiresAt = tokens.expires_in ? Date.now() + (tokens.expires_in * 1000) : null; this.saveCredentials(); console.log('āœ“ Token refreshed successfully'); } /** * Get the authentication key (prefer OAuth access token, fallback to API key) */ getAuthKey() { return this.accessToken || this.apiKey; } /** * Make a chat completion request */ async chatCompletion(messages, options = {}) { const authKey = this.getAuthKey(); if (!authKey) { throw new Error('Qwen API key not configured. Run /multi-ai-brainstorm first to set up.'); } // Check if OAuth token needs refresh if (this.accessToken && this.isTokenExpired()) { await this.refreshAccessToken(); } const { model = this.model, stream = false, temperature = 0.7, maxTokens = 2000 } = options; const payload = { model, messages, stream, temperature, max_tokens: maxTokens }; try { const response = await fetch(PROMPTARCH_PROXY, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authKey}` }, body: JSON.stringify({ endpoint: this.endpoint, ...payload }) }); if (!response.ok) { const error = await response.text(); throw new Error(`Qwen API error (${response.status}): ${error}`); } const data = await response.json(); return data.choices?.[0]?.message?.content || ''; } catch (error) { if (error.message.includes('fetch')) { throw new Error('Network error. Please check your internet connection.'); } throw error; } } /** * Check if client is authenticated */ isAuthenticated() { return !!(this.accessToken || this.apiKey); } } // Singleton instance const client = new QwenClient(); module.exports = client;