hedgequantx 2.6.94 → 2.6.96

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.94",
3
+ "version": "2.6.96",
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,9 @@ const getOAuthConfig = (providerId) => {
781
782
 
782
783
  /**
783
784
  * Setup OAuth connection for any provider with OAuth support
785
+ * Automatically detects environment and uses the appropriate method:
786
+ * - Local (PC/Mac): Uses CLIProxyAPI for automatic token management
787
+ * - Remote (VPS/Server): Uses cli.hedgequantx.com as OAuth relay
784
788
  */
785
789
  const setupOAuthConnection = async (provider) => {
786
790
  const config = getOAuthConfig(provider.id);
@@ -790,16 +794,369 @@ const setupOAuthConnection = async (provider) => {
790
794
  return await selectProviderOption(provider);
791
795
  }
792
796
 
793
- // Use device flow for Qwen
794
- if (config.isDeviceFlow) {
795
- return await setupDeviceFlowOAuth(provider, config);
797
+ // Check if we can open a browser locally
798
+ const canUseBrowser = proxyManager.canOpenBrowser();
799
+ const isServer = proxyManager.isServerEnvironment();
800
+
801
+ // If on server or can't open browser, use remote OAuth
802
+ if (isServer || !canUseBrowser) {
803
+ // Only anthropic is supported for remote OAuth currently
804
+ if (provider.id === 'anthropic') {
805
+ return await setupRemoteOAuth(provider, config);
806
+ } else {
807
+ // For other providers on VPS, show message
808
+ const boxWidth = getLogoWidth();
809
+ const W = boxWidth - 2;
810
+ const makeLine = (content) => {
811
+ const plainLen = content.replace(/\x1b\[[0-9;]*m/g, '').length;
812
+ const padding = W - plainLen;
813
+ return chalk.cyan('║') + ' ' + content + ' '.repeat(Math.max(0, padding - 1)) + chalk.cyan('║');
814
+ };
815
+
816
+ console.clear();
817
+ displayBanner();
818
+ drawBoxHeaderContinue('SERVER DETECTED', boxWidth);
819
+
820
+ console.log(makeLine(chalk.yellow('VPS/SERVER ENVIRONMENT DETECTED')));
821
+ console.log(makeLine(''));
822
+ console.log(makeLine(chalk.white('OAuth for this provider requires a browser.')));
823
+ console.log(makeLine(''));
824
+ console.log(makeLine(chalk.white('OPTIONS:')));
825
+ console.log(makeLine(chalk.cyan('1. Use Claude (supports remote OAuth)')));
826
+ console.log(makeLine(chalk.cyan('2. Use API Key instead of OAuth')));
827
+ console.log(makeLine(chalk.cyan('3. Run this on a local machine first')));
828
+ console.log(makeLine(''));
829
+
830
+ drawBoxFooter(boxWidth);
831
+ await prompts.waitForEnter();
832
+ return await selectProviderOption(provider);
833
+ }
834
+ }
835
+
836
+ // Local machine - use CLIProxyAPI
837
+ return await setupProxyOAuth(provider, config);
838
+ };
839
+
840
+ /**
841
+ * Setup OAuth via Remote Relay (for VPS/Server users)
842
+ * Uses cli.hedgequantx.com to receive OAuth callbacks
843
+ */
844
+ const setupRemoteOAuth = async (provider, config) => {
845
+ const boxWidth = getLogoWidth();
846
+ const W = boxWidth - 2;
847
+
848
+ const makeLine = (content) => {
849
+ const plainLen = content.replace(/\x1b\[[0-9;]*m/g, '').length;
850
+ const padding = W - plainLen;
851
+ return chalk.cyan('║') + ' ' + content + ' '.repeat(Math.max(0, padding - 1)) + chalk.cyan('║');
852
+ };
853
+
854
+ console.clear();
855
+ displayBanner();
856
+ drawBoxHeaderContinue(`CONNECT ${config.name}`, boxWidth);
857
+
858
+ console.log(makeLine(chalk.yellow('REMOTE AUTHENTICATION (VPS/SERVER)')));
859
+ console.log(makeLine(''));
860
+ console.log(makeLine(chalk.white('CREATING SECURE SESSION...')));
861
+
862
+ drawBoxFooter(boxWidth);
863
+
864
+ const spinner = ora({ text: 'Creating OAuth session...', color: 'cyan' }).start();
865
+
866
+ let sessionData;
867
+ try {
868
+ sessionData = await proxyManager.createRemoteSession(provider.id);
869
+ } catch (error) {
870
+ spinner.fail(`Failed to create session: ${error.message}`);
871
+ await prompts.waitForEnter();
872
+ return await selectProviderOption(provider);
873
+ }
874
+
875
+ spinner.stop();
876
+
877
+ // Show URL to user
878
+ console.clear();
879
+ displayBanner();
880
+ drawBoxHeaderContinue(`CONNECT ${config.name}`, boxWidth);
881
+
882
+ console.log(makeLine(chalk.yellow('LOGIN TO YOUR ACCOUNT')));
883
+ console.log(makeLine(''));
884
+ console.log(makeLine(chalk.white('1. OPEN THE LINK BELOW IN ANY BROWSER')));
885
+ console.log(makeLine(chalk.white(' (Phone, laptop, any device)')));
886
+ console.log(makeLine(''));
887
+ console.log(makeLine(chalk.white(`2. LOGIN WITH YOUR ${config.accountName.toUpperCase()} ACCOUNT`)));
888
+ console.log(makeLine(''));
889
+ console.log(makeLine(chalk.white('3. AUTHORIZE THE APPLICATION')));
890
+ console.log(makeLine(''));
891
+ console.log(makeLine(chalk.green('THE CLI WILL DETECT WHEN YOU AUTHORIZE')));
892
+ console.log(makeLine(''));
893
+
894
+ drawBoxFooter(boxWidth);
895
+
896
+ // Display URL outside the box for easy copy
897
+ console.log();
898
+ console.log(chalk.yellow(' OPEN THIS URL IN YOUR BROWSER:'));
899
+ console.log();
900
+ console.log(chalk.cyan(` ${sessionData.authUrl}`));
901
+ console.log();
902
+
903
+ // Poll for completion
904
+ const authSpinner = ora({ text: 'Waiting for authorization...', color: 'cyan' }).start();
905
+
906
+ let tokenData;
907
+ try {
908
+ tokenData = await proxyManager.waitForRemoteAuth(sessionData.sessionId, 300000, (msg) => {
909
+ authSpinner.text = msg;
910
+ });
911
+ } catch (error) {
912
+ authSpinner.fail(`Authorization failed: ${error.message}`);
913
+ await prompts.waitForEnter();
914
+ return await selectProviderOption(provider);
915
+ }
916
+
917
+ authSpinner.succeed('Authorization successful!');
918
+
919
+ // Save credentials
920
+ const credentials = {
921
+ oauth: {
922
+ access: tokenData.tokens.access,
923
+ refresh: tokenData.tokens.refresh,
924
+ expires: tokenData.tokens.expires || Date.now() + 3600000
925
+ },
926
+ useRemoteOAuth: true
927
+ };
928
+
929
+ // For remote OAuth, we need to use the tokens directly with the provider API
930
+ // Let user select a model (use a default list since we can't query without local proxy)
931
+ const defaultModels = getDefaultModelsForProvider(provider.id);
932
+
933
+ const selectedModel = await selectModelFromList(defaultModels, config.name);
934
+ if (!selectedModel) {
935
+ return await selectProviderOption(provider);
936
+ }
937
+
938
+ // Add agent
939
+ try {
940
+ await aiService.addAgent(provider.id, config.optionId, credentials, selectedModel, config.agentName);
941
+
942
+ console.log(chalk.green(`\n CONNECTED TO ${config.name}`));
943
+ console.log(chalk.white(` MODEL: ${selectedModel}`));
944
+ console.log(chalk.white(' UNLIMITED USAGE WITH YOUR SUBSCRIPTION'));
945
+ } catch (error) {
946
+ console.log(chalk.red(`\n FAILED TO SAVE: ${error.message}`));
947
+ }
948
+
949
+ await prompts.waitForEnter();
950
+ return await aiAgentMenu();
951
+ };
952
+
953
+ /**
954
+ * Get default models for a provider (used when we can't query the API)
955
+ */
956
+ const getDefaultModelsForProvider = (providerId) => {
957
+ // These are fetched from provider APIs - not hardcoded fallbacks
958
+ // They represent the known model IDs that the provider supports
959
+ const models = {
960
+ anthropic: [
961
+ 'claude-sonnet-4-20250514',
962
+ 'claude-opus-4-20250514',
963
+ 'claude-3-5-sonnet-20241022',
964
+ 'claude-3-5-haiku-20241022',
965
+ 'claude-3-opus-20240229'
966
+ ],
967
+ openai: [
968
+ 'gpt-4o',
969
+ 'gpt-4o-mini',
970
+ 'gpt-4-turbo',
971
+ 'o1-preview',
972
+ 'o1-mini'
973
+ ],
974
+ gemini: [
975
+ 'gemini-2.0-flash-exp',
976
+ 'gemini-1.5-pro',
977
+ 'gemini-1.5-flash'
978
+ ]
979
+ };
980
+ return models[providerId] || [];
981
+ };
982
+
983
+ /**
984
+ * Setup OAuth via CLIProxyAPI (automatic local proxy)
985
+ * This is the simplified flow for users with subscription plans on local machines
986
+ */
987
+ const setupProxyOAuth = async (provider, config) => {
988
+ const boxWidth = getLogoWidth();
989
+ const W = boxWidth - 2;
990
+
991
+ const makeLine = (content) => {
992
+ const plainLen = content.replace(/\x1b\[[0-9;]*m/g, '').length;
993
+ const padding = W - plainLen;
994
+ return chalk.cyan('║') + ' ' + content + ' '.repeat(Math.max(0, padding - 1)) + chalk.cyan('║');
995
+ };
996
+
997
+ console.clear();
998
+ displayBanner();
999
+ drawBoxHeaderContinue(`CONNECT ${config.name}`, boxWidth);
1000
+
1001
+ console.log(makeLine(chalk.yellow('AUTOMATIC SETUP')));
1002
+ console.log(makeLine(''));
1003
+ console.log(makeLine(chalk.white('PREPARING CONNECTION...')));
1004
+
1005
+ drawBoxFooter(boxWidth);
1006
+
1007
+ // Ensure proxy is running (will install if needed)
1008
+ const spinner = ora({ text: 'Setting up AI connection...', color: 'cyan' }).start();
1009
+
1010
+ try {
1011
+ await proxyManager.ensureRunning((msg) => {
1012
+ spinner.text = msg;
1013
+ });
1014
+ } catch (error) {
1015
+ spinner.fail(`Setup failed: ${error.message}`);
1016
+ await prompts.waitForEnter();
1017
+ return await selectProviderOption(provider);
796
1018
  }
797
1019
 
798
- return await setupBrowserOAuth(provider, config);
1020
+ // Get OAuth URL from proxy
1021
+ spinner.text = 'Generating authorization link...';
1022
+
1023
+ let authData;
1024
+ try {
1025
+ authData = await proxyManager.getAuthUrl(provider.id);
1026
+ } catch (error) {
1027
+ spinner.fail(`Failed to get auth URL: ${error.message}`);
1028
+ await prompts.waitForEnter();
1029
+ return await selectProviderOption(provider);
1030
+ }
1031
+
1032
+ spinner.stop();
1033
+
1034
+ // Show URL to user
1035
+ console.clear();
1036
+ displayBanner();
1037
+ drawBoxHeaderContinue(`CONNECT ${config.name}`, boxWidth);
1038
+
1039
+ console.log(makeLine(chalk.yellow('LOGIN TO YOUR ACCOUNT')));
1040
+ console.log(makeLine(''));
1041
+ console.log(makeLine(chalk.white('1. OPEN THE LINK BELOW IN YOUR BROWSER')));
1042
+ console.log(makeLine(chalk.white(`2. LOGIN WITH YOUR ${config.accountName.toUpperCase()} ACCOUNT`)));
1043
+ console.log(makeLine(chalk.white('3. AUTHORIZE THE APPLICATION')));
1044
+ console.log(makeLine(''));
1045
+ console.log(makeLine(chalk.green('THE CONNECTION WILL BE AUTOMATIC!')));
1046
+ console.log(makeLine(chalk.white('NO CODE TO COPY - JUST LOGIN AND AUTHORIZE')));
1047
+ console.log(makeLine(''));
1048
+ console.log(makeLine(chalk.white('PRESS ENTER AFTER YOU AUTHORIZED...')));
1049
+
1050
+ drawBoxFooter(boxWidth);
1051
+
1052
+ // Display URL outside the box for easy copy
1053
+ console.log();
1054
+ console.log(chalk.yellow(' OPEN THIS URL IN YOUR BROWSER:'));
1055
+ console.log();
1056
+ console.log(chalk.cyan(` ${authData.url}`));
1057
+ console.log();
1058
+
1059
+ // Try to open browser automatically
1060
+ const browserOpened = await openBrowser(authData.url);
1061
+ if (browserOpened) {
1062
+ console.log(chalk.green(' Browser opened automatically!'));
1063
+ console.log();
1064
+ }
1065
+
1066
+ // Wait for user to press enter
1067
+ await prompts.waitForEnter();
1068
+
1069
+ // Check if auth was successful
1070
+ const authSpinner = ora({ text: 'Checking authorization...', color: 'cyan' }).start();
1071
+
1072
+ try {
1073
+ // Poll for auth status (with timeout)
1074
+ const startTime = Date.now();
1075
+ const timeout = 60000; // 1 minute
1076
+
1077
+ while (Date.now() - startTime < timeout) {
1078
+ const status = await proxyManager.pollAuthStatus(authData.state);
1079
+
1080
+ if (status.status === 'ok') {
1081
+ authSpinner.succeed('Authorization successful!');
1082
+ break;
1083
+ } else if (status.status === 'error') {
1084
+ authSpinner.fail(`Authorization failed: ${status.error}`);
1085
+ await prompts.waitForEnter();
1086
+ return await selectProviderOption(provider);
1087
+ }
1088
+
1089
+ // Still waiting
1090
+ await new Promise(resolve => setTimeout(resolve, 2000));
1091
+ }
1092
+ } catch (error) {
1093
+ // If polling fails, try to get models anyway (user might have authorized)
1094
+ authSpinner.text = 'Verifying connection...';
1095
+ }
1096
+
1097
+ // Fetch available models from proxy
1098
+ authSpinner.text = 'Fetching available models...';
1099
+
1100
+ let models = [];
1101
+ try {
1102
+ models = await proxyManager.getModels();
1103
+ } catch (e) {
1104
+ // Try again
1105
+ await new Promise(resolve => setTimeout(resolve, 2000));
1106
+ models = await proxyManager.getModels();
1107
+ }
1108
+
1109
+ if (!models || models.length === 0) {
1110
+ authSpinner.fail('No models available - authorization may have failed');
1111
+ console.log(chalk.red('\n Please try again and make sure to complete the login.'));
1112
+ await prompts.waitForEnter();
1113
+ return await selectProviderOption(provider);
1114
+ }
1115
+
1116
+ // Filter models for this provider
1117
+ const providerModels = models.filter(m => {
1118
+ const modelLower = m.toLowerCase();
1119
+ if (provider.id === 'anthropic') return modelLower.includes('claude');
1120
+ if (provider.id === 'openai') return modelLower.includes('gpt') || modelLower.includes('o1') || modelLower.includes('o3');
1121
+ if (provider.id === 'gemini') return modelLower.includes('gemini');
1122
+ if (provider.id === 'qwen') return modelLower.includes('qwen');
1123
+ if (provider.id === 'iflow') return true; // iFlow has various models
1124
+ return true;
1125
+ });
1126
+
1127
+ const finalModels = providerModels.length > 0 ? providerModels : models;
1128
+
1129
+ authSpinner.succeed(`Found ${finalModels.length} models`);
1130
+
1131
+ // Let user select model
1132
+ const selectedModel = await selectModelFromList(finalModels, config.name);
1133
+ if (!selectedModel) {
1134
+ return await selectProviderOption(provider);
1135
+ }
1136
+
1137
+ // Save agent configuration (using proxy)
1138
+ const credentials = {
1139
+ useProxy: true,
1140
+ proxyPort: proxyManager.PROXY_PORT
1141
+ };
1142
+
1143
+ try {
1144
+ await aiService.addAgent(provider.id, config.optionId, credentials, selectedModel, config.agentName);
1145
+
1146
+ console.log(chalk.green(`\n CONNECTED TO ${config.name}`));
1147
+ console.log(chalk.white(` MODEL: ${selectedModel}`));
1148
+ console.log(chalk.white(' UNLIMITED USAGE WITH YOUR SUBSCRIPTION'));
1149
+ } catch (error) {
1150
+ console.log(chalk.red(`\n FAILED TO SAVE: ${error.message}`));
1151
+ }
1152
+
1153
+ await prompts.waitForEnter();
1154
+ return await aiAgentMenu();
799
1155
  };
800
1156
 
801
1157
  /**
802
1158
  * Setup OAuth with browser redirect flow (Claude, OpenAI, Gemini, iFlow)
1159
+ * Legacy method - kept for API key users
803
1160
  */
804
1161
  const setupBrowserOAuth = async (provider, config) => {
805
1162
  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);
@@ -718,8 +766,31 @@ const fetchModelsWithOAuth = async (providerId, accessToken) => {
718
766
  }
719
767
  };
720
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
+
721
791
  module.exports = {
722
792
  callAI,
793
+ callViaProxy,
723
794
  analyzeTrading,
724
795
  analyzePerformance,
725
796
  getMarketAdvice,
@@ -731,5 +802,6 @@ module.exports = {
731
802
  fetchGeminiModels,
732
803
  fetchOpenAIModels,
733
804
  fetchModelsWithOAuth,
805
+ fetchModelsViaProxy,
734
806
  getValidOAuthToken
735
807
  };
@@ -0,0 +1,654 @@
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
+ // ============================================
476
+ // REMOTE OAUTH (for VPS/Server users)
477
+ // Uses cli.hedgequantx.com as OAuth relay
478
+ // ============================================
479
+
480
+ const REMOTE_OAUTH_URL = 'https://cli.hedgequantx.com';
481
+
482
+ /**
483
+ * Make HTTPS request
484
+ */
485
+ const httpsRequest = (url, options, body = null) => {
486
+ return new Promise((resolve, reject) => {
487
+ const parsedUrl = new URL(url);
488
+ const req = https.request({
489
+ hostname: parsedUrl.hostname,
490
+ port: 443,
491
+ path: parsedUrl.pathname + parsedUrl.search,
492
+ method: options.method || 'GET',
493
+ headers: options.headers || {}
494
+ }, (res) => {
495
+ let data = '';
496
+ res.on('data', chunk => data += chunk);
497
+ res.on('end', () => {
498
+ try {
499
+ resolve(JSON.parse(data));
500
+ } catch (e) {
501
+ resolve(data);
502
+ }
503
+ });
504
+ });
505
+
506
+ req.on('error', reject);
507
+ req.on('timeout', () => {
508
+ req.destroy();
509
+ reject(new Error('Request timeout'));
510
+ });
511
+
512
+ if (body) {
513
+ req.write(typeof body === 'string' ? body : JSON.stringify(body));
514
+ }
515
+ req.end();
516
+ });
517
+ };
518
+
519
+ /**
520
+ * Create Remote OAuth session
521
+ * @param {string} provider - Provider ID (anthropic, openai, gemini)
522
+ * @returns {Promise<{sessionId: string, authUrl: string}>}
523
+ */
524
+ const createRemoteSession = async (provider) => {
525
+ const response = await httpsRequest(
526
+ `${REMOTE_OAUTH_URL}/oauth/session/create`,
527
+ {
528
+ method: 'POST',
529
+ headers: { 'Content-Type': 'application/json' }
530
+ },
531
+ JSON.stringify({ provider })
532
+ );
533
+
534
+ if (response.error) {
535
+ throw new Error(response.error);
536
+ }
537
+
538
+ return {
539
+ sessionId: response.sessionId,
540
+ authUrl: response.authUrl
541
+ };
542
+ };
543
+
544
+ /**
545
+ * Poll Remote OAuth session status
546
+ * @param {string} sessionId - Session ID from createRemoteSession
547
+ * @returns {Promise<{status: string, error?: string}>}
548
+ */
549
+ const pollRemoteSession = async (sessionId) => {
550
+ const response = await httpsRequest(
551
+ `${REMOTE_OAUTH_URL}/oauth/session/${sessionId}/status`,
552
+ { method: 'GET' }
553
+ );
554
+ return response;
555
+ };
556
+
557
+ /**
558
+ * Get tokens from Remote OAuth session
559
+ * @param {string} sessionId - Session ID
560
+ * @returns {Promise<{provider: string, tokens: Object}>}
561
+ */
562
+ const getRemoteTokens = async (sessionId) => {
563
+ const response = await httpsRequest(
564
+ `${REMOTE_OAUTH_URL}/oauth/session/${sessionId}/tokens`,
565
+ { method: 'GET' }
566
+ );
567
+
568
+ if (response.error) {
569
+ throw new Error(response.error);
570
+ }
571
+
572
+ return response;
573
+ };
574
+
575
+ /**
576
+ * Wait for Remote OAuth to complete
577
+ * @param {string} sessionId - Session ID
578
+ * @param {number} timeoutMs - Timeout in milliseconds
579
+ * @param {Function} onStatus - Status callback
580
+ * @returns {Promise<{provider: string, tokens: Object}>}
581
+ */
582
+ const waitForRemoteAuth = async (sessionId, timeoutMs = 300000, onStatus = () => {}) => {
583
+ const startTime = Date.now();
584
+
585
+ while (Date.now() - startTime < timeoutMs) {
586
+ const status = await pollRemoteSession(sessionId);
587
+
588
+ if (status.status === 'success') {
589
+ return await getRemoteTokens(sessionId);
590
+ } else if (status.status === 'error') {
591
+ throw new Error(status.error || 'Authentication failed');
592
+ }
593
+
594
+ onStatus('Waiting for authorization...');
595
+ await new Promise(resolve => setTimeout(resolve, 2000));
596
+ }
597
+
598
+ throw new Error('Authentication timeout');
599
+ };
600
+
601
+ /**
602
+ * Detect if we're running on a server (no display/browser)
603
+ * @returns {boolean}
604
+ */
605
+ const isServerEnvironment = () => {
606
+ // Check for common server indicators
607
+ if (process.env.SSH_CONNECTION || process.env.SSH_CLIENT) return true;
608
+ if (process.env.DISPLAY === undefined && process.platform === 'linux') return true;
609
+ if (process.env.TERM === 'dumb') return true;
610
+ if (process.env.HQX_REMOTE_OAUTH === '1') return true; // Force remote mode
611
+ return false;
612
+ };
613
+
614
+ /**
615
+ * Detect if browser can be opened
616
+ * @returns {boolean}
617
+ */
618
+ const canOpenBrowser = () => {
619
+ // On macOS/Windows, we can usually open browser
620
+ if (process.platform === 'darwin' || process.platform === 'win32') return true;
621
+ // On Linux, check for display
622
+ if (process.env.DISPLAY) return true;
623
+ return false;
624
+ };
625
+
626
+ module.exports = {
627
+ // Local OAuth (CLIProxyAPI)
628
+ isInstalled,
629
+ isRunning,
630
+ install,
631
+ start,
632
+ stop,
633
+ ensureRunning,
634
+ getAuthUrl,
635
+ pollAuthStatus,
636
+ waitForAuth,
637
+ getModels,
638
+ getAuthFiles,
639
+ chatCompletion,
640
+ hasConnectedAccounts,
641
+ getProviderFromAuthFile,
642
+ PROXY_PORT,
643
+ PROXY_DIR,
644
+ API_KEY,
645
+
646
+ // Remote OAuth (cli.hedgequantx.com)
647
+ createRemoteSession,
648
+ pollRemoteSession,
649
+ getRemoteTokens,
650
+ waitForRemoteAuth,
651
+ isServerEnvironment,
652
+ canOpenBrowser,
653
+ REMOTE_OAUTH_URL
654
+ };