hedgequantx 2.6.93 → 2.6.95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hedgequantx",
3
- "version": "2.6.93",
3
+ "version": "2.6.95",
4
4
  "description": "HedgeQuantX - Prop Futures Trading CLI",
5
5
  "main": "src/app.js",
6
6
  "bin": {
@@ -15,6 +15,7 @@ const oauthOpenai = require('../services/ai/oauth-openai');
15
15
  const oauthGemini = require('../services/ai/oauth-gemini');
16
16
  const oauthQwen = require('../services/ai/oauth-qwen');
17
17
  const oauthIflow = require('../services/ai/oauth-iflow');
18
+ const proxyManager = require('../services/ai/proxy-manager');
18
19
 
19
20
  /**
20
21
  * Main AI Agent menu
@@ -781,6 +782,7 @@ const getOAuthConfig = (providerId) => {
781
782
 
782
783
  /**
783
784
  * Setup OAuth connection for any provider with OAuth support
785
+ * Uses CLIProxyAPI for automatic token management
784
786
  */
785
787
  const setupOAuthConnection = async (provider) => {
786
788
  const config = getOAuthConfig(provider.id);
@@ -790,16 +792,188 @@ const setupOAuthConnection = async (provider) => {
790
792
  return await selectProviderOption(provider);
791
793
  }
792
794
 
793
- // Use device flow for Qwen
794
- if (config.isDeviceFlow) {
795
- return await setupDeviceFlowOAuth(provider, config);
795
+ // Use the new proxy-based OAuth flow (automatic, no code copying needed)
796
+ // This works for all providers: Claude, ChatGPT, Gemini, Qwen, iFlow
797
+ return await setupProxyOAuth(provider, config);
798
+ };
799
+
800
+ /**
801
+ * Setup OAuth via CLIProxyAPI (automatic local proxy)
802
+ * This is the simplified flow for users with subscription plans
803
+ */
804
+ const setupProxyOAuth = async (provider, config) => {
805
+ const boxWidth = getLogoWidth();
806
+ const W = boxWidth - 2;
807
+
808
+ const makeLine = (content) => {
809
+ const plainLen = content.replace(/\x1b\[[0-9;]*m/g, '').length;
810
+ const padding = W - plainLen;
811
+ return chalk.cyan('║') + ' ' + content + ' '.repeat(Math.max(0, padding - 1)) + chalk.cyan('║');
812
+ };
813
+
814
+ console.clear();
815
+ displayBanner();
816
+ drawBoxHeaderContinue(`CONNECT ${config.name}`, boxWidth);
817
+
818
+ console.log(makeLine(chalk.yellow('AUTOMATIC SETUP')));
819
+ console.log(makeLine(''));
820
+ console.log(makeLine(chalk.white('PREPARING CONNECTION...')));
821
+
822
+ drawBoxFooter(boxWidth);
823
+
824
+ // Ensure proxy is running (will install if needed)
825
+ const spinner = ora({ text: 'Setting up AI connection...', color: 'cyan' }).start();
826
+
827
+ try {
828
+ await proxyManager.ensureRunning((msg) => {
829
+ spinner.text = msg;
830
+ });
831
+ } catch (error) {
832
+ spinner.fail(`Setup failed: ${error.message}`);
833
+ await prompts.waitForEnter();
834
+ return await selectProviderOption(provider);
835
+ }
836
+
837
+ // Get OAuth URL from proxy
838
+ spinner.text = 'Generating authorization link...';
839
+
840
+ let authData;
841
+ try {
842
+ authData = await proxyManager.getAuthUrl(provider.id);
843
+ } catch (error) {
844
+ spinner.fail(`Failed to get auth URL: ${error.message}`);
845
+ await prompts.waitForEnter();
846
+ return await selectProviderOption(provider);
847
+ }
848
+
849
+ spinner.stop();
850
+
851
+ // Show URL to user
852
+ console.clear();
853
+ displayBanner();
854
+ drawBoxHeaderContinue(`CONNECT ${config.name}`, boxWidth);
855
+
856
+ console.log(makeLine(chalk.yellow('LOGIN TO YOUR ACCOUNT')));
857
+ console.log(makeLine(''));
858
+ console.log(makeLine(chalk.white('1. OPEN THE LINK BELOW IN YOUR BROWSER')));
859
+ console.log(makeLine(chalk.white(`2. LOGIN WITH YOUR ${config.accountName.toUpperCase()} ACCOUNT`)));
860
+ console.log(makeLine(chalk.white('3. AUTHORIZE THE APPLICATION')));
861
+ console.log(makeLine(''));
862
+ console.log(makeLine(chalk.green('THE CONNECTION WILL BE AUTOMATIC!')));
863
+ console.log(makeLine(chalk.white('NO CODE TO COPY - JUST LOGIN AND AUTHORIZE')));
864
+ console.log(makeLine(''));
865
+ console.log(makeLine(chalk.white('PRESS ENTER AFTER YOU AUTHORIZED...')));
866
+
867
+ drawBoxFooter(boxWidth);
868
+
869
+ // Display URL outside the box for easy copy
870
+ console.log();
871
+ console.log(chalk.yellow(' OPEN THIS URL IN YOUR BROWSER:'));
872
+ console.log();
873
+ console.log(chalk.cyan(` ${authData.url}`));
874
+ console.log();
875
+
876
+ // Try to open browser automatically
877
+ const browserOpened = await openBrowser(authData.url);
878
+ if (browserOpened) {
879
+ console.log(chalk.green(' Browser opened automatically!'));
880
+ console.log();
881
+ }
882
+
883
+ // Wait for user to press enter
884
+ await prompts.waitForEnter();
885
+
886
+ // Check if auth was successful
887
+ const authSpinner = ora({ text: 'Checking authorization...', color: 'cyan' }).start();
888
+
889
+ try {
890
+ // Poll for auth status (with timeout)
891
+ const startTime = Date.now();
892
+ const timeout = 60000; // 1 minute
893
+
894
+ while (Date.now() - startTime < timeout) {
895
+ const status = await proxyManager.pollAuthStatus(authData.state);
896
+
897
+ if (status.status === 'ok') {
898
+ authSpinner.succeed('Authorization successful!');
899
+ break;
900
+ } else if (status.status === 'error') {
901
+ authSpinner.fail(`Authorization failed: ${status.error}`);
902
+ await prompts.waitForEnter();
903
+ return await selectProviderOption(provider);
904
+ }
905
+
906
+ // Still waiting
907
+ await new Promise(resolve => setTimeout(resolve, 2000));
908
+ }
909
+ } catch (error) {
910
+ // If polling fails, try to get models anyway (user might have authorized)
911
+ authSpinner.text = 'Verifying connection...';
912
+ }
913
+
914
+ // Fetch available models from proxy
915
+ authSpinner.text = 'Fetching available models...';
916
+
917
+ let models = [];
918
+ try {
919
+ models = await proxyManager.getModels();
920
+ } catch (e) {
921
+ // Try again
922
+ await new Promise(resolve => setTimeout(resolve, 2000));
923
+ models = await proxyManager.getModels();
924
+ }
925
+
926
+ if (!models || models.length === 0) {
927
+ authSpinner.fail('No models available - authorization may have failed');
928
+ console.log(chalk.red('\n Please try again and make sure to complete the login.'));
929
+ await prompts.waitForEnter();
930
+ return await selectProviderOption(provider);
931
+ }
932
+
933
+ // Filter models for this provider
934
+ const providerModels = models.filter(m => {
935
+ const modelLower = m.toLowerCase();
936
+ if (provider.id === 'anthropic') return modelLower.includes('claude');
937
+ if (provider.id === 'openai') return modelLower.includes('gpt') || modelLower.includes('o1') || modelLower.includes('o3');
938
+ if (provider.id === 'gemini') return modelLower.includes('gemini');
939
+ if (provider.id === 'qwen') return modelLower.includes('qwen');
940
+ if (provider.id === 'iflow') return true; // iFlow has various models
941
+ return true;
942
+ });
943
+
944
+ const finalModels = providerModels.length > 0 ? providerModels : models;
945
+
946
+ authSpinner.succeed(`Found ${finalModels.length} models`);
947
+
948
+ // Let user select model
949
+ const selectedModel = await selectModelFromList(finalModels, config.name);
950
+ if (!selectedModel) {
951
+ return await selectProviderOption(provider);
952
+ }
953
+
954
+ // Save agent configuration (using proxy)
955
+ const credentials = {
956
+ useProxy: true,
957
+ proxyPort: proxyManager.PROXY_PORT
958
+ };
959
+
960
+ try {
961
+ await aiService.addAgent(provider.id, config.optionId, credentials, selectedModel, config.agentName);
962
+
963
+ console.log(chalk.green(`\n CONNECTED TO ${config.name}`));
964
+ console.log(chalk.white(` MODEL: ${selectedModel}`));
965
+ console.log(chalk.white(' UNLIMITED USAGE WITH YOUR SUBSCRIPTION'));
966
+ } catch (error) {
967
+ console.log(chalk.red(`\n FAILED TO SAVE: ${error.message}`));
796
968
  }
797
969
 
798
- return await setupBrowserOAuth(provider, config);
970
+ await prompts.waitForEnter();
971
+ return await aiAgentMenu();
799
972
  };
800
973
 
801
974
  /**
802
975
  * Setup OAuth with browser redirect flow (Claude, OpenAI, Gemini, iFlow)
976
+ * Legacy method - kept for API key users
803
977
  */
804
978
  const setupBrowserOAuth = async (provider, config) => {
805
979
  const boxWidth = getLogoWidth();
@@ -3,6 +3,10 @@
3
3
  *
4
4
  * STRICT RULE: No mock responses. Real API calls only.
5
5
  * If API fails → return null, not fake data.
6
+ *
7
+ * Supports two modes:
8
+ * 1. Direct API calls (with API keys)
9
+ * 2. Proxy mode (via CLIProxyAPI for subscription accounts)
6
10
  */
7
11
 
8
12
  const https = require('https');
@@ -222,6 +226,45 @@ const callGemini = async (agent, prompt, systemPrompt) => {
222
226
  }
223
227
  };
224
228
 
229
+ /**
230
+ * Call AI via local CLIProxyAPI proxy
231
+ * Used for subscription accounts (ChatGPT Plus, Claude Pro, etc.)
232
+ * @param {Object} agent - Agent configuration
233
+ * @param {string} prompt - User prompt
234
+ * @param {string} systemPrompt - System prompt
235
+ * @returns {Promise<string|null>} Response text or null on error
236
+ */
237
+ const callViaProxy = async (agent, prompt, systemPrompt) => {
238
+ const proxyPort = agent.credentials?.proxyPort || 8317;
239
+ const model = agent.model;
240
+
241
+ if (!model) return null;
242
+
243
+ const url = `http://127.0.0.1:${proxyPort}/v1/chat/completions`;
244
+
245
+ const headers = {
246
+ 'Content-Type': 'application/json',
247
+ 'Authorization': 'Bearer hqx-local-key'
248
+ };
249
+
250
+ const body = {
251
+ model,
252
+ messages: [
253
+ { role: 'system', content: systemPrompt },
254
+ { role: 'user', content: prompt }
255
+ ],
256
+ temperature: 0.3,
257
+ max_tokens: 500
258
+ };
259
+
260
+ try {
261
+ const response = await makeRequest(url, { headers, body, timeout: 30000 });
262
+ return response.choices?.[0]?.message?.content || null;
263
+ } catch (error) {
264
+ return null;
265
+ }
266
+ };
267
+
225
268
  /**
226
269
  * Call AI provider based on agent configuration
227
270
  * @param {Object} agent - Agent with providerId and credentials
@@ -232,6 +275,11 @@ const callGemini = async (agent, prompt, systemPrompt) => {
232
275
  const callAI = async (agent, prompt, systemPrompt = '') => {
233
276
  if (!agent || !agent.providerId) return null;
234
277
 
278
+ // Check if using proxy mode (subscription accounts)
279
+ if (agent.credentials?.useProxy) {
280
+ return callViaProxy(agent, prompt, systemPrompt);
281
+ }
282
+
235
283
  switch (agent.providerId) {
236
284
  case 'anthropic':
237
285
  return callAnthropic(agent, prompt, systemPrompt);
@@ -592,9 +640,16 @@ const fetchOpenAIModels = async (endpoint, apiKey) => {
592
640
 
593
641
  /**
594
642
  * Fetch available models for OAuth-authenticated providers
643
+ * Uses multiple API endpoints to discover available models
644
+ *
595
645
  * @param {string} providerId - Provider ID (anthropic, openai, gemini, etc.)
596
646
  * @param {string} accessToken - OAuth access token
597
- * @returns {Promise<Array|null>} Array of model IDs or null on error
647
+ * @returns {Promise<Array|null>} Array of model IDs from API, null if unavailable
648
+ *
649
+ * Data sources:
650
+ * - OpenAI: https://api.openai.com/v1/models (GET)
651
+ * - Anthropic: https://api.anthropic.com/v1/models (GET)
652
+ * - Gemini: https://generativelanguage.googleapis.com/v1/models (GET)
598
653
  */
599
654
  const fetchModelsWithOAuth = async (providerId, accessToken) => {
600
655
  if (!accessToken) return null;
@@ -604,17 +659,48 @@ const fetchModelsWithOAuth = async (providerId, accessToken) => {
604
659
  case 'anthropic':
605
660
  return await fetchAnthropicModelsOAuth(accessToken);
606
661
 
607
- case 'openai':
608
- // OpenAI OAuth uses the same endpoint as API key
609
- return await fetchOpenAIModels('https://api.openai.com/v1', accessToken);
662
+ case 'openai': {
663
+ // Try OpenAI /v1/models endpoint with OAuth token
664
+ const openaiModels = await fetchOpenAIModels('https://api.openai.com/v1', accessToken);
665
+ if (openaiModels && openaiModels.length > 0) {
666
+ return openaiModels;
667
+ }
668
+
669
+ // Try alternative: ChatGPT backend API (for Plus/Pro plans)
670
+ try {
671
+ const chatgptUrl = 'https://chatgpt.com/backend-api/models';
672
+ const chatgptHeaders = {
673
+ 'Authorization': `Bearer ${accessToken}`,
674
+ 'Content-Type': 'application/json'
675
+ };
676
+ const chatgptResponse = await makeRequest(chatgptUrl, {
677
+ method: 'GET',
678
+ headers: chatgptHeaders,
679
+ timeout: 10000
680
+ });
681
+ if (chatgptResponse.models && Array.isArray(chatgptResponse.models)) {
682
+ return chatgptResponse.models
683
+ .map(m => m.slug || m.id || m.name)
684
+ .filter(Boolean);
685
+ }
686
+ } catch (e) {
687
+ // ChatGPT backend not available
688
+ }
689
+
690
+ return null;
691
+ }
610
692
 
611
- case 'gemini':
612
- // Gemini OAuth - try to fetch from API
693
+ case 'gemini': {
694
+ // Gemini OAuth - fetch from Generative Language API
613
695
  const geminiUrl = 'https://generativelanguage.googleapis.com/v1/models';
614
696
  const geminiHeaders = {
615
697
  'Authorization': `Bearer ${accessToken}`
616
698
  };
617
- const geminiResponse = await makeRequest(geminiUrl, { method: 'GET', headers: geminiHeaders, timeout: 10000 });
699
+ const geminiResponse = await makeRequest(geminiUrl, {
700
+ method: 'GET',
701
+ headers: geminiHeaders,
702
+ timeout: 10000
703
+ });
618
704
  if (geminiResponse.models && Array.isArray(geminiResponse.models)) {
619
705
  return geminiResponse.models
620
706
  .filter(m => m.supportedGenerationMethods?.includes('generateContent'))
@@ -622,6 +708,55 @@ const fetchModelsWithOAuth = async (providerId, accessToken) => {
622
708
  .filter(Boolean);
623
709
  }
624
710
  return null;
711
+ }
712
+
713
+ case 'qwen': {
714
+ // Qwen - try to fetch models from Alibaba Cloud API
715
+ try {
716
+ const qwenUrl = 'https://dashscope.aliyuncs.com/api/v1/models';
717
+ const qwenHeaders = {
718
+ 'Authorization': `Bearer ${accessToken}`,
719
+ 'Content-Type': 'application/json'
720
+ };
721
+ const qwenResponse = await makeRequest(qwenUrl, {
722
+ method: 'GET',
723
+ headers: qwenHeaders,
724
+ timeout: 10000
725
+ });
726
+ if (qwenResponse.data && Array.isArray(qwenResponse.data)) {
727
+ return qwenResponse.data
728
+ .map(m => m.id || m.model_id)
729
+ .filter(Boolean);
730
+ }
731
+ } catch (e) {
732
+ // Qwen API may not support model listing
733
+ }
734
+ return null;
735
+ }
736
+
737
+ case 'iflow': {
738
+ // iFlow - fetch models from iFlow API
739
+ try {
740
+ const iflowUrl = 'https://apis.iflow.cn/v1/models';
741
+ const iflowHeaders = {
742
+ 'Authorization': `Bearer ${accessToken}`,
743
+ 'Content-Type': 'application/json'
744
+ };
745
+ const iflowResponse = await makeRequest(iflowUrl, {
746
+ method: 'GET',
747
+ headers: iflowHeaders,
748
+ timeout: 10000
749
+ });
750
+ if (iflowResponse.data && Array.isArray(iflowResponse.data)) {
751
+ return iflowResponse.data
752
+ .map(m => m.id)
753
+ .filter(Boolean);
754
+ }
755
+ } catch (e) {
756
+ // iFlow API may not support model listing
757
+ }
758
+ return null;
759
+ }
625
760
 
626
761
  default:
627
762
  return null;
@@ -631,8 +766,31 @@ const fetchModelsWithOAuth = async (providerId, accessToken) => {
631
766
  }
632
767
  };
633
768
 
769
+ /**
770
+ * Fetch models via local CLIProxyAPI proxy
771
+ * @returns {Promise<Array|null>} Array of model IDs or null
772
+ */
773
+ const fetchModelsViaProxy = async (proxyPort = 8317) => {
774
+ const url = `http://127.0.0.1:${proxyPort}/v1/models`;
775
+
776
+ const headers = {
777
+ 'Authorization': 'Bearer hqx-local-key'
778
+ };
779
+
780
+ try {
781
+ const response = await makeRequest(url, { method: 'GET', headers, timeout: 10000 });
782
+ if (response.data && Array.isArray(response.data)) {
783
+ return response.data.map(m => m.id || m).filter(Boolean);
784
+ }
785
+ return null;
786
+ } catch (error) {
787
+ return null;
788
+ }
789
+ };
790
+
634
791
  module.exports = {
635
792
  callAI,
793
+ callViaProxy,
636
794
  analyzeTrading,
637
795
  analyzePerformance,
638
796
  getMarketAdvice,
@@ -644,5 +802,6 @@ module.exports = {
644
802
  fetchGeminiModels,
645
803
  fetchOpenAIModels,
646
804
  fetchModelsWithOAuth,
805
+ fetchModelsViaProxy,
647
806
  getValidOAuthToken
648
807
  };
@@ -0,0 +1,493 @@
1
+ /**
2
+ * CLIProxyAPI Manager
3
+ *
4
+ * Automatically downloads, installs, and manages CLIProxyAPI
5
+ * so users can connect their AI subscription accounts (ChatGPT Plus, Claude Pro, etc.)
6
+ * without needing to understand technical details.
7
+ *
8
+ * Flow:
9
+ * 1. User selects "Connect Account" in CLI
10
+ * 2. We ensure CLIProxyAPI is installed and running
11
+ * 3. Generate OAuth URL → User opens in browser → Logs in
12
+ * 4. Callback to localhost → Token saved → Models available
13
+ */
14
+
15
+ const https = require('https');
16
+ const http = require('http');
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const os = require('os');
20
+ const { spawn, exec } = require('child_process');
21
+
22
+ // Configuration
23
+ const PROXY_VERSION = 'v6.6.84';
24
+ const PROXY_PORT = 8317;
25
+ const PROXY_DIR = path.join(os.homedir(), '.hqx', 'proxy');
26
+ const PROXY_BIN = path.join(PROXY_DIR, process.platform === 'win32' ? 'cli-proxy-api.exe' : 'cli-proxy-api');
27
+ const PROXY_CONFIG = path.join(PROXY_DIR, 'config.yaml');
28
+ const PROXY_AUTH_DIR = path.join(PROXY_DIR, 'auths');
29
+ const API_KEY = 'hqx-local-key';
30
+
31
+ // GitHub release URLs
32
+ const getDownloadUrl = () => {
33
+ const platform = process.platform;
34
+ const arch = process.arch;
35
+
36
+ let osName, archName, ext;
37
+
38
+ if (platform === 'darwin') {
39
+ osName = 'darwin';
40
+ archName = arch === 'arm64' ? 'arm64' : 'amd64';
41
+ ext = 'tar.gz';
42
+ } else if (platform === 'win32') {
43
+ osName = 'windows';
44
+ archName = arch === 'arm64' ? 'arm64' : 'amd64';
45
+ ext = 'zip';
46
+ } else {
47
+ osName = 'linux';
48
+ archName = arch === 'arm64' ? 'arm64' : 'amd64';
49
+ ext = 'tar.gz';
50
+ }
51
+
52
+ const version = PROXY_VERSION.replace('v', '');
53
+ return `https://github.com/router-for-me/CLIProxyAPI/releases/download/${PROXY_VERSION}/CLIProxyAPI_${version}_${osName}_${archName}.${ext}`;
54
+ };
55
+
56
+ /**
57
+ * Check if CLIProxyAPI binary exists
58
+ */
59
+ const isInstalled = () => {
60
+ return fs.existsSync(PROXY_BIN);
61
+ };
62
+
63
+ /**
64
+ * Check if CLIProxyAPI is running
65
+ */
66
+ const isRunning = async () => {
67
+ return new Promise((resolve) => {
68
+ const req = http.request({
69
+ hostname: '127.0.0.1',
70
+ port: PROXY_PORT,
71
+ path: '/v1/models',
72
+ method: 'GET',
73
+ headers: {
74
+ 'Authorization': `Bearer ${API_KEY}`
75
+ },
76
+ timeout: 2000
77
+ }, (res) => {
78
+ resolve(res.statusCode === 200);
79
+ });
80
+
81
+ req.on('error', () => resolve(false));
82
+ req.on('timeout', () => {
83
+ req.destroy();
84
+ resolve(false);
85
+ });
86
+ req.end();
87
+ });
88
+ };
89
+
90
+ /**
91
+ * Download file from URL
92
+ */
93
+ const downloadFile = (url, dest) => {
94
+ return new Promise((resolve, reject) => {
95
+ const file = fs.createWriteStream(dest);
96
+
97
+ const request = (url) => {
98
+ https.get(url, (res) => {
99
+ if (res.statusCode === 302 || res.statusCode === 301) {
100
+ // Follow redirect
101
+ request(res.headers.location);
102
+ return;
103
+ }
104
+
105
+ if (res.statusCode !== 200) {
106
+ reject(new Error(`HTTP ${res.statusCode}`));
107
+ return;
108
+ }
109
+
110
+ res.pipe(file);
111
+ file.on('finish', () => {
112
+ file.close();
113
+ resolve();
114
+ });
115
+ }).on('error', (err) => {
116
+ fs.unlink(dest, () => {});
117
+ reject(err);
118
+ });
119
+ };
120
+
121
+ request(url);
122
+ });
123
+ };
124
+
125
+ /**
126
+ * Extract tar.gz archive
127
+ */
128
+ const extractTarGz = (archive, dest) => {
129
+ return new Promise((resolve, reject) => {
130
+ exec(`tar -xzf "${archive}" -C "${dest}"`, (err) => {
131
+ if (err) reject(err);
132
+ else resolve();
133
+ });
134
+ });
135
+ };
136
+
137
+ /**
138
+ * Extract zip archive (Windows)
139
+ */
140
+ const extractZip = (archive, dest) => {
141
+ return new Promise((resolve, reject) => {
142
+ exec(`powershell -command "Expand-Archive -Path '${archive}' -DestinationPath '${dest}' -Force"`, (err) => {
143
+ if (err) reject(err);
144
+ else resolve();
145
+ });
146
+ });
147
+ };
148
+
149
+ /**
150
+ * Download and install CLIProxyAPI
151
+ * @param {Function} onProgress - Progress callback (message)
152
+ */
153
+ const install = async (onProgress = () => {}) => {
154
+ // Create directories
155
+ if (!fs.existsSync(PROXY_DIR)) {
156
+ fs.mkdirSync(PROXY_DIR, { recursive: true });
157
+ }
158
+ if (!fs.existsSync(PROXY_AUTH_DIR)) {
159
+ fs.mkdirSync(PROXY_AUTH_DIR, { recursive: true });
160
+ }
161
+
162
+ onProgress('Downloading CLIProxyAPI...');
163
+
164
+ const downloadUrl = getDownloadUrl();
165
+ const ext = process.platform === 'win32' ? 'zip' : 'tar.gz';
166
+ const archivePath = path.join(PROXY_DIR, `cliproxyapi.${ext}`);
167
+
168
+ // Download
169
+ await downloadFile(downloadUrl, archivePath);
170
+
171
+ onProgress('Extracting...');
172
+
173
+ // Extract
174
+ if (ext === 'zip') {
175
+ await extractZip(archivePath, PROXY_DIR);
176
+ } else {
177
+ await extractTarGz(archivePath, PROXY_DIR);
178
+ }
179
+
180
+ // Find the binary (it might be in a subdirectory)
181
+ const possibleBins = [
182
+ path.join(PROXY_DIR, 'cli-proxy-api'),
183
+ path.join(PROXY_DIR, 'cli-proxy-api.exe'),
184
+ path.join(PROXY_DIR, 'CLIProxyAPI', 'cli-proxy-api'),
185
+ path.join(PROXY_DIR, 'CLIProxyAPI', 'cli-proxy-api.exe')
186
+ ];
187
+
188
+ for (const bin of possibleBins) {
189
+ if (fs.existsSync(bin) && bin !== PROXY_BIN) {
190
+ fs.renameSync(bin, PROXY_BIN);
191
+ break;
192
+ }
193
+ }
194
+
195
+ // Make executable (Unix)
196
+ if (process.platform !== 'win32') {
197
+ fs.chmodSync(PROXY_BIN, 0o755);
198
+ }
199
+
200
+ // Create config file
201
+ const config = `port: ${PROXY_PORT}
202
+ auth-dir: "${PROXY_AUTH_DIR}"
203
+ api-keys:
204
+ - "${API_KEY}"
205
+ request-retry: 3
206
+ quota-exceeded:
207
+ switch-project: true
208
+ switch-preview-model: true
209
+ `;
210
+
211
+ fs.writeFileSync(PROXY_CONFIG, config);
212
+
213
+ // Cleanup archive
214
+ fs.unlinkSync(archivePath);
215
+
216
+ onProgress('Installation complete');
217
+
218
+ return true;
219
+ };
220
+
221
+ /**
222
+ * Start CLIProxyAPI in background
223
+ */
224
+ const start = async () => {
225
+ if (await isRunning()) {
226
+ return true;
227
+ }
228
+
229
+ if (!isInstalled()) {
230
+ throw new Error('CLIProxyAPI not installed');
231
+ }
232
+
233
+ return new Promise((resolve, reject) => {
234
+ const proc = spawn(PROXY_BIN, ['--config', PROXY_CONFIG], {
235
+ detached: true,
236
+ stdio: 'ignore',
237
+ cwd: PROXY_DIR
238
+ });
239
+
240
+ proc.unref();
241
+
242
+ // Wait for it to start
243
+ let attempts = 0;
244
+ const checkInterval = setInterval(async () => {
245
+ attempts++;
246
+ if (await isRunning()) {
247
+ clearInterval(checkInterval);
248
+ resolve(true);
249
+ } else if (attempts > 30) {
250
+ clearInterval(checkInterval);
251
+ reject(new Error('Failed to start CLIProxyAPI'));
252
+ }
253
+ }, 500);
254
+ });
255
+ };
256
+
257
+ /**
258
+ * Stop CLIProxyAPI
259
+ */
260
+ const stop = async () => {
261
+ return new Promise((resolve) => {
262
+ if (process.platform === 'win32') {
263
+ exec('taskkill /F /IM cli-proxy-api.exe', () => resolve());
264
+ } else {
265
+ exec('pkill -f cli-proxy-api', () => resolve());
266
+ }
267
+ });
268
+ };
269
+
270
+ /**
271
+ * Ensure CLIProxyAPI is installed and running
272
+ * @param {Function} onProgress - Progress callback
273
+ */
274
+ const ensureRunning = async (onProgress = () => {}) => {
275
+ if (await isRunning()) {
276
+ return true;
277
+ }
278
+
279
+ if (!isInstalled()) {
280
+ onProgress('Installing AI proxy (one-time setup)...');
281
+ await install(onProgress);
282
+ }
283
+
284
+ onProgress('Starting AI proxy...');
285
+ await start();
286
+
287
+ return true;
288
+ };
289
+
290
+ /**
291
+ * Make request to local proxy
292
+ */
293
+ const proxyRequest = (method, endpoint, body = null) => {
294
+ return new Promise((resolve, reject) => {
295
+ const options = {
296
+ hostname: '127.0.0.1',
297
+ port: PROXY_PORT,
298
+ path: endpoint,
299
+ method,
300
+ headers: {
301
+ 'Authorization': `Bearer ${API_KEY}`,
302
+ 'Content-Type': 'application/json'
303
+ },
304
+ timeout: 30000
305
+ };
306
+
307
+ const req = http.request(options, (res) => {
308
+ let data = '';
309
+ res.on('data', chunk => data += chunk);
310
+ res.on('end', () => {
311
+ try {
312
+ resolve(JSON.parse(data));
313
+ } catch (e) {
314
+ resolve(data);
315
+ }
316
+ });
317
+ });
318
+
319
+ req.on('error', reject);
320
+ req.on('timeout', () => {
321
+ req.destroy();
322
+ reject(new Error('Request timeout'));
323
+ });
324
+
325
+ if (body) {
326
+ req.write(JSON.stringify(body));
327
+ }
328
+ req.end();
329
+ });
330
+ };
331
+
332
+ /**
333
+ * Get OAuth authorization URL for a provider
334
+ * @param {string} provider - Provider ID (anthropic, openai, gemini, qwen, iflow)
335
+ * @returns {Promise<{url: string, state: string}>}
336
+ */
337
+ const getAuthUrl = async (provider) => {
338
+ await ensureRunning();
339
+
340
+ const endpoints = {
341
+ anthropic: '/v0/management/anthropic-auth-url',
342
+ openai: '/v0/management/codex-auth-url',
343
+ gemini: '/v0/management/gemini-cli-auth-url',
344
+ qwen: '/v0/management/qwen-auth-url',
345
+ iflow: '/v0/management/iflow-auth-url'
346
+ };
347
+
348
+ const endpoint = endpoints[provider];
349
+ if (!endpoint) {
350
+ throw new Error(`Unknown provider: ${provider}`);
351
+ }
352
+
353
+ const response = await proxyRequest('GET', endpoint);
354
+
355
+ if (response.status !== 'ok') {
356
+ throw new Error(response.error || 'Failed to get auth URL');
357
+ }
358
+
359
+ return {
360
+ url: response.url,
361
+ state: response.state
362
+ };
363
+ };
364
+
365
+ /**
366
+ * Poll for OAuth authentication status
367
+ * @param {string} state - OAuth state from getAuthUrl
368
+ * @returns {Promise<{status: string, error?: string}>}
369
+ */
370
+ const pollAuthStatus = async (state) => {
371
+ const response = await proxyRequest('GET', `/v0/management/get-auth-status?state=${state}`);
372
+ return response;
373
+ };
374
+
375
+ /**
376
+ * Wait for OAuth authentication to complete
377
+ * @param {string} state - OAuth state
378
+ * @param {number} timeoutMs - Timeout in milliseconds
379
+ * @param {Function} onStatus - Status callback
380
+ * @returns {Promise<boolean>}
381
+ */
382
+ const waitForAuth = async (state, timeoutMs = 300000, onStatus = () => {}) => {
383
+ const startTime = Date.now();
384
+
385
+ while (Date.now() - startTime < timeoutMs) {
386
+ const status = await pollAuthStatus(state);
387
+
388
+ if (status.status === 'ok') {
389
+ return true;
390
+ } else if (status.status === 'error') {
391
+ throw new Error(status.error || 'Authentication failed');
392
+ }
393
+
394
+ onStatus('Waiting for authorization...');
395
+ await new Promise(resolve => setTimeout(resolve, 2000));
396
+ }
397
+
398
+ throw new Error('Authentication timeout');
399
+ };
400
+
401
+ /**
402
+ * Get available models from the proxy
403
+ * @returns {Promise<Array<string>>}
404
+ */
405
+ const getModels = async () => {
406
+ await ensureRunning();
407
+
408
+ const response = await proxyRequest('GET', '/v1/models');
409
+
410
+ if (response.data && Array.isArray(response.data)) {
411
+ return response.data.map(m => m.id || m).filter(Boolean);
412
+ }
413
+
414
+ return [];
415
+ };
416
+
417
+ /**
418
+ * Get list of authenticated accounts
419
+ * @returns {Promise<Array>}
420
+ */
421
+ const getAuthFiles = async () => {
422
+ await ensureRunning();
423
+
424
+ try {
425
+ const response = await proxyRequest('GET', '/v0/management/auth-files');
426
+ return response.files || [];
427
+ } catch (e) {
428
+ return [];
429
+ }
430
+ };
431
+
432
+ /**
433
+ * Make chat completion request through proxy
434
+ * @param {string} model - Model ID
435
+ * @param {Array} messages - Chat messages
436
+ * @param {Object} options - Additional options
437
+ * @returns {Promise<Object>}
438
+ */
439
+ const chatCompletion = async (model, messages, options = {}) => {
440
+ await ensureRunning();
441
+
442
+ const body = {
443
+ model,
444
+ messages,
445
+ ...options
446
+ };
447
+
448
+ return proxyRequest('POST', '/v1/chat/completions', body);
449
+ };
450
+
451
+ /**
452
+ * Check if user has any connected accounts
453
+ */
454
+ const hasConnectedAccounts = async () => {
455
+ try {
456
+ const files = await getAuthFiles();
457
+ return files.length > 0;
458
+ } catch (e) {
459
+ return false;
460
+ }
461
+ };
462
+
463
+ /**
464
+ * Get provider name from auth file
465
+ */
466
+ const getProviderFromAuthFile = (filename) => {
467
+ if (filename.includes('claude') || filename.includes('anthropic')) return 'anthropic';
468
+ if (filename.includes('openai') || filename.includes('codex')) return 'openai';
469
+ if (filename.includes('gemini') || filename.includes('google')) return 'gemini';
470
+ if (filename.includes('qwen')) return 'qwen';
471
+ if (filename.includes('iflow')) return 'iflow';
472
+ return 'unknown';
473
+ };
474
+
475
+ module.exports = {
476
+ isInstalled,
477
+ isRunning,
478
+ install,
479
+ start,
480
+ stop,
481
+ ensureRunning,
482
+ getAuthUrl,
483
+ pollAuthStatus,
484
+ waitForAuth,
485
+ getModels,
486
+ getAuthFiles,
487
+ chatCompletion,
488
+ hasConnectedAccounts,
489
+ getProviderFromAuthFile,
490
+ PROXY_PORT,
491
+ PROXY_DIR,
492
+ API_KEY
493
+ };