hedgequantx 2.7.23 → 2.7.24
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 +2 -1
- package/src/pages/ai-agents-ui.js +1 -1
- package/src/pages/ai-agents.js +71 -147
- package/src/services/cliproxy/index.js +184 -0
- package/src/services/cliproxy/manager.js +417 -0
- package/src/services/cliproxy.js +0 -255
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hedgequantx",
|
|
3
|
-
"version": "2.7.
|
|
3
|
+
"version": "2.7.24",
|
|
4
4
|
"description": "HedgeQuantX - Prop Futures Trading CLI",
|
|
5
5
|
"main": "src/app.js",
|
|
6
6
|
"bin": {
|
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
"inquirer": "^8.2.6",
|
|
65
65
|
"ora": "^5.4.1",
|
|
66
66
|
"protobufjs": "^8.0.0",
|
|
67
|
+
"tar": "^7.5.2",
|
|
67
68
|
"uuid": "^9.0.1",
|
|
68
69
|
"ws": "^8.18.3"
|
|
69
70
|
},
|
|
@@ -90,7 +90,7 @@ const drawProvidersTable = (providers, config, boxWidth, cliproxyUrl = null) =>
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
console.log(chalk.cyan('╠' + '─'.repeat(W) + '╣'));
|
|
93
|
-
console.log(chalk.cyan('║') + chalk.gray(centerText('[
|
|
93
|
+
console.log(chalk.cyan('║') + chalk.gray(centerText('[S] CLIProxy Status', W)) + chalk.cyan('║'));
|
|
94
94
|
console.log(chalk.cyan('╠' + '─'.repeat(W) + '╣'));
|
|
95
95
|
console.log(chalk.cyan('║') + chalk.red(centerText('[B] Back to Menu', W)) + chalk.cyan('║'));
|
|
96
96
|
console.log(chalk.cyan('╚' + '═'.repeat(W) + '╝'));
|
package/src/pages/ai-agents.js
CHANGED
|
@@ -15,7 +15,7 @@ const { getLogoWidth } = require('../ui');
|
|
|
15
15
|
const { prompts } = require('../utils');
|
|
16
16
|
const { fetchModelsFromApi } = require('./ai-models');
|
|
17
17
|
const { drawProvidersTable, drawModelsTable, drawProviderWindow } = require('./ai-agents-ui');
|
|
18
|
-
const
|
|
18
|
+
const cliproxy = require('../services/cliproxy');
|
|
19
19
|
|
|
20
20
|
// Config file path
|
|
21
21
|
const CONFIG_DIR = path.join(os.homedir(), '.hqx');
|
|
@@ -98,76 +98,53 @@ const activateProvider = (config, providerId, data) => {
|
|
|
98
98
|
Object.assign(config.providers[providerId], data, { active: true, configuredAt: new Date().toISOString() });
|
|
99
99
|
};
|
|
100
100
|
|
|
101
|
-
/** Handle CLIProxy connection */
|
|
101
|
+
/** Handle CLIProxy connection (with auto-install) */
|
|
102
102
|
const handleCliProxyConnection = async (provider, config, boxWidth) => {
|
|
103
103
|
console.log();
|
|
104
|
-
const currentUrl = getCliProxyUrl();
|
|
105
|
-
const spinner = ora({ text: `Checking CLIProxy at ${currentUrl}...`, color: 'yellow' }).start();
|
|
106
|
-
let proxyStatus = await isCliProxyRunning();
|
|
107
104
|
|
|
108
|
-
if
|
|
109
|
-
|
|
110
|
-
console.log();
|
|
111
|
-
|
|
112
|
-
console.log(chalk.gray(' [1] Local - localhost:8317 (default)'));
|
|
113
|
-
console.log(chalk.gray(' [2] Remote - Enter custom URL (e.g., http://your-pc-ip:8317)'));
|
|
114
|
-
console.log(chalk.gray(' [B] Back'));
|
|
115
|
-
console.log();
|
|
105
|
+
// Check if CLIProxyAPI is installed
|
|
106
|
+
if (!cliproxy.isInstalled()) {
|
|
107
|
+
console.log(chalk.yellow(' CLIProxyAPI not installed. Installing...'));
|
|
108
|
+
const spinner = ora({ text: 'Downloading CLIProxyAPI...', color: 'yellow' }).start();
|
|
116
109
|
|
|
117
|
-
const
|
|
110
|
+
const installResult = await cliproxy.install((msg, percent) => {
|
|
111
|
+
spinner.text = `${msg} ${percent}%`;
|
|
112
|
+
});
|
|
118
113
|
|
|
119
|
-
if (!
|
|
114
|
+
if (!installResult.success) {
|
|
115
|
+
spinner.fail(`Installation failed: ${installResult.error}`);
|
|
116
|
+
await prompts.waitForEnter();
|
|
120
117
|
return false;
|
|
121
118
|
}
|
|
119
|
+
spinner.succeed('CLIProxyAPI installed');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Check if running, start if not
|
|
123
|
+
let status = await cliproxy.isRunning();
|
|
124
|
+
if (!status.running) {
|
|
125
|
+
const spinner = ora({ text: 'Starting CLIProxyAPI...', color: 'yellow' }).start();
|
|
126
|
+
const startResult = await cliproxy.start();
|
|
122
127
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
} else if (urlChoice === '2') {
|
|
127
|
-
console.log(chalk.gray('\n Enter CLIProxy URL (e.g., http://192.168.1.100:8317):'));
|
|
128
|
-
const customUrl = await prompts.textInput(chalk.cyan(' URL: '));
|
|
129
|
-
if (!customUrl || customUrl.trim() === '') {
|
|
130
|
-
console.log(chalk.gray(' Cancelled.'));
|
|
131
|
-
await prompts.waitForEnter();
|
|
132
|
-
return false;
|
|
133
|
-
}
|
|
134
|
-
newUrl = customUrl.trim();
|
|
135
|
-
// Add http:// if missing
|
|
136
|
-
if (!newUrl.startsWith('http://') && !newUrl.startsWith('https://')) {
|
|
137
|
-
newUrl = 'http://' + newUrl;
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
if (newUrl) {
|
|
142
|
-
const testSpinner = ora({ text: `Testing connection to ${newUrl}...`, color: 'yellow' }).start();
|
|
143
|
-
proxyStatus = await isCliProxyRunning(newUrl);
|
|
144
|
-
|
|
145
|
-
if (!proxyStatus.running) {
|
|
146
|
-
testSpinner.fail(`Cannot connect to ${newUrl}`);
|
|
147
|
-
console.log(chalk.gray(` Error: ${proxyStatus.error || 'Connection failed'}`));
|
|
148
|
-
console.log(chalk.yellow('\n Make sure CLIProxy is running and accessible.'));
|
|
149
|
-
await prompts.waitForEnter();
|
|
150
|
-
return false;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
testSpinner.succeed(`Connected to CLIProxy at ${newUrl}`);
|
|
154
|
-
setCliProxyUrl(newUrl);
|
|
155
|
-
} else {
|
|
128
|
+
if (!startResult.success) {
|
|
129
|
+
spinner.fail(`Failed to start: ${startResult.error}`);
|
|
130
|
+
await prompts.waitForEnter();
|
|
156
131
|
return false;
|
|
157
132
|
}
|
|
133
|
+
spinner.succeed('CLIProxyAPI started');
|
|
158
134
|
} else {
|
|
159
|
-
|
|
135
|
+
console.log(chalk.green(' ✓ CLIProxyAPI is running'));
|
|
160
136
|
}
|
|
161
|
-
const oauthResult = await getOAuthUrl(provider.id);
|
|
162
137
|
|
|
163
|
-
if
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
138
|
+
// Check if provider supports OAuth
|
|
139
|
+
const oauthProviders = ['anthropic', 'openai', 'google', 'qwen'];
|
|
140
|
+
if (!oauthProviders.includes(provider.id)) {
|
|
141
|
+
// Try to fetch models directly
|
|
142
|
+
console.log(chalk.gray(` Checking available models for ${provider.name}...`));
|
|
143
|
+
const modelsResult = await cliproxy.fetchProviderModels(provider.id);
|
|
167
144
|
|
|
168
145
|
if (!modelsResult.success || modelsResult.models.length === 0) {
|
|
169
|
-
console.log(chalk.red(` No models available
|
|
170
|
-
console.log(chalk.gray(
|
|
146
|
+
console.log(chalk.red(` No models available for ${provider.name}`));
|
|
147
|
+
console.log(chalk.gray(' This provider may require API key connection.'));
|
|
171
148
|
await prompts.waitForEnter();
|
|
172
149
|
return false;
|
|
173
150
|
}
|
|
@@ -189,42 +166,25 @@ const handleCliProxyConnection = async (provider, config, boxWidth) => {
|
|
|
189
166
|
return true;
|
|
190
167
|
}
|
|
191
168
|
|
|
192
|
-
// OAuth flow
|
|
193
|
-
console.log(chalk.cyan(
|
|
194
|
-
|
|
195
|
-
console.log(chalk.gray(' Waiting for authentication... (Press Enter to cancel)'));
|
|
196
|
-
|
|
197
|
-
let authenticated = false;
|
|
198
|
-
const maxWait = 120000, pollInterval = 3000;
|
|
199
|
-
let waited = 0;
|
|
200
|
-
|
|
201
|
-
const pollPromise = (async () => {
|
|
202
|
-
while (waited < maxWait) {
|
|
203
|
-
await new Promise(r => setTimeout(r, pollInterval));
|
|
204
|
-
waited += pollInterval;
|
|
205
|
-
if (oauthResult.state) {
|
|
206
|
-
const statusResult = await checkOAuthStatus(oauthResult.state);
|
|
207
|
-
if (statusResult.success && statusResult.status === 'ok') { authenticated = true; return true; }
|
|
208
|
-
if (statusResult.status === 'error') {
|
|
209
|
-
console.log(chalk.red(`\n Authentication error: ${statusResult.error || 'Unknown'}`));
|
|
210
|
-
return false;
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
return false;
|
|
215
|
-
})();
|
|
216
|
-
|
|
217
|
-
await Promise.race([pollPromise, prompts.waitForEnter()]);
|
|
169
|
+
// OAuth flow - get login URL
|
|
170
|
+
console.log(chalk.cyan(`\n Starting OAuth login for ${provider.name}...`));
|
|
171
|
+
const loginResult = await cliproxy.getLoginUrl(provider.id);
|
|
218
172
|
|
|
219
|
-
if (!
|
|
220
|
-
console.log(chalk.
|
|
173
|
+
if (!loginResult.success) {
|
|
174
|
+
console.log(chalk.red(` OAuth error: ${loginResult.error}`));
|
|
221
175
|
await prompts.waitForEnter();
|
|
222
176
|
return false;
|
|
223
177
|
}
|
|
224
178
|
|
|
225
|
-
console.log(chalk.
|
|
179
|
+
console.log(chalk.cyan('\n Open this URL in your browser to authenticate:\n'));
|
|
180
|
+
console.log(chalk.yellow(` ${loginResult.url}\n`));
|
|
181
|
+
console.log(chalk.gray(' After authenticating, press Enter to continue...'));
|
|
182
|
+
|
|
183
|
+
await prompts.waitForEnter();
|
|
184
|
+
|
|
185
|
+
// Try to fetch models after auth
|
|
186
|
+
const modelsResult = await cliproxy.fetchProviderModels(provider.id);
|
|
226
187
|
|
|
227
|
-
const modelsResult = await fetchModelsFromCliProxy();
|
|
228
188
|
if (modelsResult.success && modelsResult.models.length > 0) {
|
|
229
189
|
const selectedModel = await selectModelFromList(provider, modelsResult.models, boxWidth);
|
|
230
190
|
if (selectedModel) {
|
|
@@ -234,17 +194,20 @@ const handleCliProxyConnection = async (provider, config, boxWidth) => {
|
|
|
234
194
|
modelName: selectedModel.name
|
|
235
195
|
});
|
|
236
196
|
if (saveConfig(config)) {
|
|
237
|
-
console.log(chalk.green(`\n ✓ ${provider.name} connected via
|
|
197
|
+
console.log(chalk.green(`\n ✓ ${provider.name} connected via Paid Plan.`));
|
|
238
198
|
console.log(chalk.cyan(` Model: ${selectedModel.name}`));
|
|
239
199
|
}
|
|
240
200
|
}
|
|
241
201
|
} else {
|
|
202
|
+
// No models but auth might have worked
|
|
242
203
|
activateProvider(config, provider.id, {
|
|
243
204
|
connectionType: 'cliproxy',
|
|
244
205
|
modelId: null,
|
|
245
|
-
modelName: '
|
|
206
|
+
modelName: 'Auto'
|
|
246
207
|
});
|
|
247
|
-
if (saveConfig(config))
|
|
208
|
+
if (saveConfig(config)) {
|
|
209
|
+
console.log(chalk.green(`\n ✓ ${provider.name} connected via Paid Plan.`));
|
|
210
|
+
}
|
|
248
211
|
}
|
|
249
212
|
|
|
250
213
|
await prompts.waitForEnter();
|
|
@@ -348,64 +311,24 @@ const getActiveProvider = () => {
|
|
|
348
311
|
/** Count active AI agents */
|
|
349
312
|
const getActiveAgentCount = () => getActiveProvider() ? 1 : 0;
|
|
350
313
|
|
|
351
|
-
/**
|
|
352
|
-
const
|
|
353
|
-
const currentUrl = getCliProxyUrl();
|
|
314
|
+
/** Show CLIProxy status */
|
|
315
|
+
const showCliProxyStatus = async () => {
|
|
354
316
|
console.clear();
|
|
355
|
-
console.log(chalk.yellow('\n
|
|
356
|
-
console.log(chalk.gray(` Current: ${currentUrl}`));
|
|
357
|
-
console.log();
|
|
358
|
-
console.log(chalk.white(' [1] Local - localhost:8317 (default)'));
|
|
359
|
-
console.log(chalk.white(' [2] Remote - Enter custom URL'));
|
|
360
|
-
console.log(chalk.white(' [B] Back'));
|
|
361
|
-
console.log();
|
|
317
|
+
console.log(chalk.yellow('\n CLIProxyAPI Status\n'));
|
|
362
318
|
|
|
363
|
-
const
|
|
319
|
+
const installed = cliproxy.isInstalled();
|
|
320
|
+
console.log(chalk.gray(' Installed: ') + (installed ? chalk.green('Yes') : chalk.red('No')));
|
|
364
321
|
|
|
365
|
-
if (
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
console.log(chalk.
|
|
370
|
-
|
|
371
|
-
return;
|
|
322
|
+
if (installed) {
|
|
323
|
+
const status = await cliproxy.isRunning();
|
|
324
|
+
console.log(chalk.gray(' Running: ') + (status.running ? chalk.green('Yes') : chalk.red('No')));
|
|
325
|
+
console.log(chalk.gray(' Version: ') + chalk.cyan(cliproxy.CLIPROXY_VERSION));
|
|
326
|
+
console.log(chalk.gray(' Port: ') + chalk.cyan(cliproxy.DEFAULT_PORT));
|
|
327
|
+
console.log(chalk.gray(' Install dir: ') + chalk.cyan(cliproxy.INSTALL_DIR));
|
|
372
328
|
}
|
|
373
329
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
const customUrl = await prompts.textInput(chalk.cyan(' URL: '));
|
|
377
|
-
|
|
378
|
-
if (!customUrl || customUrl.trim() === '') {
|
|
379
|
-
console.log(chalk.gray(' Cancelled.'));
|
|
380
|
-
await prompts.waitForEnter();
|
|
381
|
-
return;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
let newUrl = customUrl.trim();
|
|
385
|
-
if (!newUrl.startsWith('http://') && !newUrl.startsWith('https://')) {
|
|
386
|
-
newUrl = 'http://' + newUrl;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
// Test connection
|
|
390
|
-
const spinner = ora({ text: `Testing connection to ${newUrl}...`, color: 'yellow' }).start();
|
|
391
|
-
const status = await isCliProxyRunning(newUrl);
|
|
392
|
-
|
|
393
|
-
if (status.running) {
|
|
394
|
-
spinner.succeed(`Connected to ${newUrl}`);
|
|
395
|
-
setCliProxyUrl(newUrl);
|
|
396
|
-
console.log(chalk.green(`\n ✓ CLIProxy URL saved.`));
|
|
397
|
-
} else {
|
|
398
|
-
spinner.warn(`Cannot connect to ${newUrl}`);
|
|
399
|
-
console.log(chalk.gray(` Error: ${status.error || 'Connection failed'}`));
|
|
400
|
-
console.log(chalk.yellow('\n Save anyway? (URL will be used when CLIProxy is available)'));
|
|
401
|
-
const save = await prompts.textInput(chalk.cyan(' Save? (y/N): '));
|
|
402
|
-
if (save && save.toLowerCase() === 'y') {
|
|
403
|
-
setCliProxyUrl(newUrl);
|
|
404
|
-
console.log(chalk.green(' ✓ URL saved.'));
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
await prompts.waitForEnter();
|
|
408
|
-
}
|
|
330
|
+
console.log();
|
|
331
|
+
await prompts.waitForEnter();
|
|
409
332
|
};
|
|
410
333
|
|
|
411
334
|
/** Main AI Agents menu */
|
|
@@ -415,16 +338,17 @@ const aiAgentsMenu = async () => {
|
|
|
415
338
|
|
|
416
339
|
while (true) {
|
|
417
340
|
console.clear();
|
|
418
|
-
const
|
|
419
|
-
|
|
341
|
+
const status = await cliproxy.isRunning();
|
|
342
|
+
const statusText = status.running ? `localhost:${cliproxy.DEFAULT_PORT}` : 'Not running';
|
|
343
|
+
drawProvidersTable(AI_PROVIDERS, config, boxWidth, statusText);
|
|
420
344
|
|
|
421
|
-
const input = await prompts.textInput(chalk.cyan('Select (1-8/
|
|
345
|
+
const input = await prompts.textInput(chalk.cyan('Select (1-8/S/B): '));
|
|
422
346
|
const choice = (input || '').toLowerCase().trim();
|
|
423
347
|
|
|
424
348
|
if (choice === 'b' || choice === '') break;
|
|
425
349
|
|
|
426
|
-
if (choice === '
|
|
427
|
-
await
|
|
350
|
+
if (choice === 's') {
|
|
351
|
+
await showCliProxyStatus();
|
|
428
352
|
continue;
|
|
429
353
|
}
|
|
430
354
|
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLIProxy Service
|
|
3
|
+
*
|
|
4
|
+
* Provides OAuth connections to paid AI plans (Claude Pro, ChatGPT Plus, etc.)
|
|
5
|
+
* via the embedded CLIProxyAPI binary.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const http = require('http');
|
|
9
|
+
const manager = require('./manager');
|
|
10
|
+
|
|
11
|
+
// Re-export manager functions
|
|
12
|
+
const {
|
|
13
|
+
CLIPROXY_VERSION,
|
|
14
|
+
INSTALL_DIR,
|
|
15
|
+
AUTH_DIR,
|
|
16
|
+
DEFAULT_PORT,
|
|
17
|
+
isInstalled,
|
|
18
|
+
install,
|
|
19
|
+
isRunning,
|
|
20
|
+
start,
|
|
21
|
+
stop,
|
|
22
|
+
ensureRunning,
|
|
23
|
+
getLoginUrl
|
|
24
|
+
} = manager;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Make HTTP request to local CLIProxyAPI
|
|
28
|
+
* @param {string} path - API path
|
|
29
|
+
* @param {string} method - HTTP method
|
|
30
|
+
* @param {Object} body - Request body (optional)
|
|
31
|
+
* @param {number} timeout - Timeout in ms (default 60000 per RULES.md #15)
|
|
32
|
+
* @returns {Promise<Object>} { success, data, error }
|
|
33
|
+
*/
|
|
34
|
+
const fetchLocal = (path, method = 'GET', body = null, timeout = 60000) => {
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
const options = {
|
|
37
|
+
hostname: 'localhost',
|
|
38
|
+
port: DEFAULT_PORT,
|
|
39
|
+
path,
|
|
40
|
+
method,
|
|
41
|
+
headers: { 'Content-Type': 'application/json' },
|
|
42
|
+
timeout
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const req = http.request(options, (res) => {
|
|
46
|
+
let data = '';
|
|
47
|
+
res.on('data', chunk => data += chunk);
|
|
48
|
+
res.on('end', () => {
|
|
49
|
+
try {
|
|
50
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
51
|
+
const parsed = data ? JSON.parse(data) : {};
|
|
52
|
+
resolve({ success: true, data: parsed, error: null });
|
|
53
|
+
} else {
|
|
54
|
+
resolve({ success: false, error: `HTTP ${res.statusCode}`, data: null });
|
|
55
|
+
}
|
|
56
|
+
} catch (error) {
|
|
57
|
+
resolve({ success: false, error: 'Invalid JSON response', data: null });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
req.on('error', (error) => {
|
|
63
|
+
if (error.code === 'ECONNREFUSED') {
|
|
64
|
+
resolve({ success: false, error: 'CLIProxyAPI not running', data: null });
|
|
65
|
+
} else {
|
|
66
|
+
resolve({ success: false, error: error.message, data: null });
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
req.on('timeout', () => {
|
|
71
|
+
req.destroy();
|
|
72
|
+
resolve({ success: false, error: 'Request timeout', data: null });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (body) {
|
|
76
|
+
req.write(JSON.stringify(body));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
req.end();
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Fetch available models from CLIProxyAPI
|
|
85
|
+
* @returns {Promise<Object>} { success, models, error }
|
|
86
|
+
*/
|
|
87
|
+
const fetchModels = async () => {
|
|
88
|
+
const result = await fetchLocal('/v1/models');
|
|
89
|
+
|
|
90
|
+
if (!result.success) {
|
|
91
|
+
return { success: false, models: [], error: result.error };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const data = result.data;
|
|
95
|
+
if (!data || !data.data || !Array.isArray(data.data)) {
|
|
96
|
+
return { success: false, models: [], error: 'Invalid response format' };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const models = data.data
|
|
100
|
+
.filter(m => m.id)
|
|
101
|
+
.map(m => ({ id: m.id, name: m.id }));
|
|
102
|
+
|
|
103
|
+
if (models.length === 0) {
|
|
104
|
+
return { success: false, models: [], error: 'No models available' };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { success: true, models, error: null };
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get provider-specific models
|
|
112
|
+
* @param {string} providerId - Provider ID
|
|
113
|
+
* @returns {Promise<Object>} { success, models, error }
|
|
114
|
+
*/
|
|
115
|
+
const fetchProviderModels = async (providerId) => {
|
|
116
|
+
const result = await fetchModels();
|
|
117
|
+
if (!result.success) return result;
|
|
118
|
+
|
|
119
|
+
// Filter by provider prefix
|
|
120
|
+
const prefixMap = {
|
|
121
|
+
anthropic: 'claude',
|
|
122
|
+
openai: 'gpt',
|
|
123
|
+
google: 'gemini',
|
|
124
|
+
qwen: 'qwen'
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const prefix = prefixMap[providerId];
|
|
128
|
+
if (!prefix) return result;
|
|
129
|
+
|
|
130
|
+
const filtered = result.models.filter(m =>
|
|
131
|
+
m.id.toLowerCase().includes(prefix)
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
success: true,
|
|
136
|
+
models: filtered.length > 0 ? filtered : result.models,
|
|
137
|
+
error: null
|
|
138
|
+
};
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Chat completion request
|
|
143
|
+
* @param {string} model - Model ID
|
|
144
|
+
* @param {Array} messages - Chat messages
|
|
145
|
+
* @param {Object} options - Additional options
|
|
146
|
+
* @returns {Promise<Object>} { success, response, error }
|
|
147
|
+
*/
|
|
148
|
+
const chatCompletion = async (model, messages, options = {}) => {
|
|
149
|
+
const body = {
|
|
150
|
+
model,
|
|
151
|
+
messages,
|
|
152
|
+
stream: false,
|
|
153
|
+
...options
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const result = await fetchLocal('/v1/chat/completions', 'POST', body);
|
|
157
|
+
|
|
158
|
+
if (!result.success) {
|
|
159
|
+
return { success: false, response: null, error: result.error };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { success: true, response: result.data, error: null };
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
module.exports = {
|
|
166
|
+
// Manager
|
|
167
|
+
CLIPROXY_VERSION,
|
|
168
|
+
INSTALL_DIR,
|
|
169
|
+
AUTH_DIR,
|
|
170
|
+
DEFAULT_PORT,
|
|
171
|
+
isInstalled,
|
|
172
|
+
install,
|
|
173
|
+
isRunning,
|
|
174
|
+
start,
|
|
175
|
+
stop,
|
|
176
|
+
ensureRunning,
|
|
177
|
+
getLoginUrl,
|
|
178
|
+
|
|
179
|
+
// API
|
|
180
|
+
fetchLocal,
|
|
181
|
+
fetchModels,
|
|
182
|
+
fetchProviderModels,
|
|
183
|
+
chatCompletion
|
|
184
|
+
};
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLIProxyAPI Manager
|
|
3
|
+
*
|
|
4
|
+
* Downloads, installs and manages CLIProxyAPI binary for OAuth connections
|
|
5
|
+
* to paid AI plans (Claude Pro, ChatGPT Plus, Gemini, etc.)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const https = require('https');
|
|
12
|
+
const http = require('http');
|
|
13
|
+
const { spawn } = require('child_process');
|
|
14
|
+
const { createGunzip } = require('zlib');
|
|
15
|
+
const tar = require('tar');
|
|
16
|
+
|
|
17
|
+
// CLIProxyAPI version and download URLs
|
|
18
|
+
const CLIPROXY_VERSION = '6.6.88';
|
|
19
|
+
const GITHUB_RELEASE_BASE = 'https://github.com/router-for-me/CLIProxyAPI/releases/download';
|
|
20
|
+
|
|
21
|
+
// Installation directory
|
|
22
|
+
const INSTALL_DIR = path.join(os.homedir(), '.hqx', 'cliproxy');
|
|
23
|
+
const BINARY_NAME = process.platform === 'win32' ? 'cli-proxy-api.exe' : 'cli-proxy-api';
|
|
24
|
+
const BINARY_PATH = path.join(INSTALL_DIR, BINARY_NAME);
|
|
25
|
+
const PID_FILE = path.join(INSTALL_DIR, 'cliproxy.pid');
|
|
26
|
+
const AUTH_DIR = path.join(INSTALL_DIR, 'auths');
|
|
27
|
+
|
|
28
|
+
// Default port
|
|
29
|
+
const DEFAULT_PORT = 8317;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Get download URL for current platform
|
|
33
|
+
* @returns {Object} { url, filename } or null if unsupported
|
|
34
|
+
*/
|
|
35
|
+
const getDownloadUrl = () => {
|
|
36
|
+
const platform = process.platform;
|
|
37
|
+
const arch = process.arch;
|
|
38
|
+
|
|
39
|
+
let osName, archName, ext;
|
|
40
|
+
|
|
41
|
+
if (platform === 'darwin') {
|
|
42
|
+
osName = 'darwin';
|
|
43
|
+
ext = 'tar.gz';
|
|
44
|
+
} else if (platform === 'linux') {
|
|
45
|
+
osName = 'linux';
|
|
46
|
+
ext = 'tar.gz';
|
|
47
|
+
} else if (platform === 'win32') {
|
|
48
|
+
osName = 'windows';
|
|
49
|
+
ext = 'zip';
|
|
50
|
+
} else {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (arch === 'x64' || arch === 'amd64') {
|
|
55
|
+
archName = 'amd64';
|
|
56
|
+
} else if (arch === 'arm64') {
|
|
57
|
+
archName = 'arm64';
|
|
58
|
+
} else {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const filename = `CLIProxyAPI_${CLIPROXY_VERSION}_${osName}_${archName}.${ext}`;
|
|
63
|
+
const url = `${GITHUB_RELEASE_BASE}/v${CLIPROXY_VERSION}/${filename}`;
|
|
64
|
+
|
|
65
|
+
return { url, filename, ext };
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Check if CLIProxyAPI is installed
|
|
70
|
+
* @returns {boolean}
|
|
71
|
+
*/
|
|
72
|
+
const isInstalled = () => {
|
|
73
|
+
return fs.existsSync(BINARY_PATH);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Download file from URL
|
|
78
|
+
* @param {string} url - URL to download
|
|
79
|
+
* @param {string} destPath - Destination path
|
|
80
|
+
* @param {Function} onProgress - Progress callback (percent)
|
|
81
|
+
* @returns {Promise<boolean>}
|
|
82
|
+
*/
|
|
83
|
+
const downloadFile = (url, destPath, onProgress = null) => {
|
|
84
|
+
return new Promise((resolve, reject) => {
|
|
85
|
+
const file = fs.createWriteStream(destPath);
|
|
86
|
+
|
|
87
|
+
const request = (url.startsWith('https') ? https : http).get(url, (response) => {
|
|
88
|
+
// Handle redirects
|
|
89
|
+
if (response.statusCode === 302 || response.statusCode === 301) {
|
|
90
|
+
file.close();
|
|
91
|
+
fs.unlinkSync(destPath);
|
|
92
|
+
return downloadFile(response.headers.location, destPath, onProgress)
|
|
93
|
+
.then(resolve)
|
|
94
|
+
.catch(reject);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (response.statusCode !== 200) {
|
|
98
|
+
file.close();
|
|
99
|
+
fs.unlinkSync(destPath);
|
|
100
|
+
return reject(new Error(`HTTP ${response.statusCode}`));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const totalSize = parseInt(response.headers['content-length'], 10);
|
|
104
|
+
let downloadedSize = 0;
|
|
105
|
+
|
|
106
|
+
response.on('data', (chunk) => {
|
|
107
|
+
downloadedSize += chunk.length;
|
|
108
|
+
if (onProgress && totalSize) {
|
|
109
|
+
onProgress(Math.round((downloadedSize / totalSize) * 100));
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
response.pipe(file);
|
|
114
|
+
|
|
115
|
+
file.on('finish', () => {
|
|
116
|
+
file.close();
|
|
117
|
+
resolve(true);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
request.on('error', (err) => {
|
|
122
|
+
file.close();
|
|
123
|
+
if (fs.existsSync(destPath)) fs.unlinkSync(destPath);
|
|
124
|
+
reject(err);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
request.setTimeout(120000, () => {
|
|
128
|
+
request.destroy();
|
|
129
|
+
reject(new Error('Download timeout'));
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Extract tar.gz file
|
|
136
|
+
* @param {string} archivePath - Path to archive
|
|
137
|
+
* @param {string} destDir - Destination directory
|
|
138
|
+
* @returns {Promise<boolean>}
|
|
139
|
+
*/
|
|
140
|
+
const extractTarGz = (archivePath, destDir) => {
|
|
141
|
+
return new Promise((resolve, reject) => {
|
|
142
|
+
fs.createReadStream(archivePath)
|
|
143
|
+
.pipe(createGunzip())
|
|
144
|
+
.pipe(tar.extract({ cwd: destDir }))
|
|
145
|
+
.on('finish', () => resolve(true))
|
|
146
|
+
.on('error', reject);
|
|
147
|
+
});
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Extract zip file (Windows)
|
|
152
|
+
* @param {string} archivePath - Path to archive
|
|
153
|
+
* @param {string} destDir - Destination directory
|
|
154
|
+
* @returns {Promise<boolean>}
|
|
155
|
+
*/
|
|
156
|
+
const extractZip = async (archivePath, destDir) => {
|
|
157
|
+
const { execSync } = require('child_process');
|
|
158
|
+
|
|
159
|
+
if (process.platform === 'win32') {
|
|
160
|
+
// Use PowerShell on Windows
|
|
161
|
+
execSync(`powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force"`, {
|
|
162
|
+
stdio: 'ignore'
|
|
163
|
+
});
|
|
164
|
+
} else {
|
|
165
|
+
// Use unzip on Unix
|
|
166
|
+
execSync(`unzip -o "${archivePath}" -d "${destDir}"`, { stdio: 'ignore' });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return true;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Install CLIProxyAPI
|
|
174
|
+
* @param {Function} onProgress - Progress callback (message, percent)
|
|
175
|
+
* @returns {Promise<Object>} { success, error }
|
|
176
|
+
*/
|
|
177
|
+
const install = async (onProgress = null) => {
|
|
178
|
+
try {
|
|
179
|
+
const download = getDownloadUrl();
|
|
180
|
+
if (!download) {
|
|
181
|
+
return { success: false, error: 'Unsupported platform' };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Create install directory
|
|
185
|
+
if (!fs.existsSync(INSTALL_DIR)) {
|
|
186
|
+
fs.mkdirSync(INSTALL_DIR, { recursive: true });
|
|
187
|
+
}
|
|
188
|
+
if (!fs.existsSync(AUTH_DIR)) {
|
|
189
|
+
fs.mkdirSync(AUTH_DIR, { recursive: true });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const archivePath = path.join(INSTALL_DIR, download.filename);
|
|
193
|
+
|
|
194
|
+
// Download
|
|
195
|
+
if (onProgress) onProgress('Downloading CLIProxyAPI...', 0);
|
|
196
|
+
await downloadFile(download.url, archivePath, (percent) => {
|
|
197
|
+
if (onProgress) onProgress('Downloading CLIProxyAPI...', percent);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Extract
|
|
201
|
+
if (onProgress) onProgress('Extracting...', 100);
|
|
202
|
+
if (download.ext === 'tar.gz') {
|
|
203
|
+
await extractTarGz(archivePath, INSTALL_DIR);
|
|
204
|
+
} else {
|
|
205
|
+
await extractZip(archivePath, INSTALL_DIR);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Clean up archive
|
|
209
|
+
if (fs.existsSync(archivePath)) {
|
|
210
|
+
fs.unlinkSync(archivePath);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Make executable on Unix
|
|
214
|
+
if (process.platform !== 'win32' && fs.existsSync(BINARY_PATH)) {
|
|
215
|
+
fs.chmodSync(BINARY_PATH, '755');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (!fs.existsSync(BINARY_PATH)) {
|
|
219
|
+
return { success: false, error: 'Binary not found after extraction' };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return { success: true, error: null };
|
|
223
|
+
} catch (error) {
|
|
224
|
+
return { success: false, error: error.message };
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Check if CLIProxyAPI is running
|
|
230
|
+
* @returns {Promise<Object>} { running, pid }
|
|
231
|
+
*/
|
|
232
|
+
const isRunning = async () => {
|
|
233
|
+
// Check PID file
|
|
234
|
+
if (fs.existsSync(PID_FILE)) {
|
|
235
|
+
try {
|
|
236
|
+
const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10);
|
|
237
|
+
// Check if process exists
|
|
238
|
+
process.kill(pid, 0);
|
|
239
|
+
return { running: true, pid };
|
|
240
|
+
} catch (e) {
|
|
241
|
+
// Process doesn't exist, clean up PID file
|
|
242
|
+
fs.unlinkSync(PID_FILE);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Also check by trying to connect
|
|
247
|
+
return new Promise((resolve) => {
|
|
248
|
+
const req = http.get(`http://localhost:${DEFAULT_PORT}/v1/models`, (res) => {
|
|
249
|
+
resolve({ running: res.statusCode === 200, pid: null });
|
|
250
|
+
});
|
|
251
|
+
req.on('error', () => resolve({ running: false, pid: null }));
|
|
252
|
+
req.setTimeout(2000, () => {
|
|
253
|
+
req.destroy();
|
|
254
|
+
resolve({ running: false, pid: null });
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Start CLIProxyAPI
|
|
261
|
+
* @returns {Promise<Object>} { success, error, pid }
|
|
262
|
+
*/
|
|
263
|
+
const start = async () => {
|
|
264
|
+
if (!isInstalled()) {
|
|
265
|
+
return { success: false, error: 'CLIProxyAPI not installed', pid: null };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const status = await isRunning();
|
|
269
|
+
if (status.running) {
|
|
270
|
+
return { success: true, error: null, pid: status.pid };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
const args = [
|
|
275
|
+
'--port', String(DEFAULT_PORT),
|
|
276
|
+
'--auth-dir', AUTH_DIR
|
|
277
|
+
];
|
|
278
|
+
|
|
279
|
+
const child = spawn(BINARY_PATH, args, {
|
|
280
|
+
detached: true,
|
|
281
|
+
stdio: 'ignore',
|
|
282
|
+
cwd: INSTALL_DIR
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
child.unref();
|
|
286
|
+
|
|
287
|
+
// Save PID
|
|
288
|
+
fs.writeFileSync(PID_FILE, String(child.pid));
|
|
289
|
+
|
|
290
|
+
// Wait for startup
|
|
291
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
292
|
+
|
|
293
|
+
const runStatus = await isRunning();
|
|
294
|
+
if (runStatus.running) {
|
|
295
|
+
return { success: true, error: null, pid: child.pid };
|
|
296
|
+
} else {
|
|
297
|
+
return { success: false, error: 'Failed to start CLIProxyAPI', pid: null };
|
|
298
|
+
}
|
|
299
|
+
} catch (error) {
|
|
300
|
+
return { success: false, error: error.message, pid: null };
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Stop CLIProxyAPI
|
|
306
|
+
* @returns {Promise<Object>} { success, error }
|
|
307
|
+
*/
|
|
308
|
+
const stop = async () => {
|
|
309
|
+
const status = await isRunning();
|
|
310
|
+
if (!status.running) {
|
|
311
|
+
return { success: true, error: null };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
if (status.pid) {
|
|
316
|
+
process.kill(status.pid, 'SIGTERM');
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (fs.existsSync(PID_FILE)) {
|
|
320
|
+
fs.unlinkSync(PID_FILE);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return { success: true, error: null };
|
|
324
|
+
} catch (error) {
|
|
325
|
+
return { success: false, error: error.message };
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Ensure CLIProxyAPI is installed and running
|
|
331
|
+
* @param {Function} onProgress - Progress callback
|
|
332
|
+
* @returns {Promise<Object>} { success, error }
|
|
333
|
+
*/
|
|
334
|
+
const ensureRunning = async (onProgress = null) => {
|
|
335
|
+
// Check if installed
|
|
336
|
+
if (!isInstalled()) {
|
|
337
|
+
if (onProgress) onProgress('Installing CLIProxyAPI...', 0);
|
|
338
|
+
const installResult = await install(onProgress);
|
|
339
|
+
if (!installResult.success) {
|
|
340
|
+
return installResult;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Check if running
|
|
345
|
+
const status = await isRunning();
|
|
346
|
+
if (status.running) {
|
|
347
|
+
return { success: true, error: null };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Start
|
|
351
|
+
if (onProgress) onProgress('Starting CLIProxyAPI...', 100);
|
|
352
|
+
return start();
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Get OAuth login URL for a provider
|
|
357
|
+
* @param {string} provider - Provider ID (anthropic, openai, google, etc.)
|
|
358
|
+
* @returns {Promise<Object>} { success, url, error }
|
|
359
|
+
*/
|
|
360
|
+
const getLoginUrl = async (provider) => {
|
|
361
|
+
const providerFlags = {
|
|
362
|
+
anthropic: '--claude-login',
|
|
363
|
+
openai: '--codex-login',
|
|
364
|
+
google: '--gemini-login',
|
|
365
|
+
qwen: '--qwen-login'
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const flag = providerFlags[provider];
|
|
369
|
+
if (!flag) {
|
|
370
|
+
return { success: false, url: null, error: 'Provider not supported for OAuth' };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// For headless/VPS, use --no-browser flag
|
|
374
|
+
return new Promise((resolve) => {
|
|
375
|
+
const args = [flag, '--no-browser'];
|
|
376
|
+
const child = spawn(BINARY_PATH, args, {
|
|
377
|
+
cwd: INSTALL_DIR,
|
|
378
|
+
env: { ...process.env, AUTH_DIR: AUTH_DIR }
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
let output = '';
|
|
382
|
+
|
|
383
|
+
child.stdout.on('data', (data) => {
|
|
384
|
+
output += data.toString();
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
child.stderr.on('data', (data) => {
|
|
388
|
+
output += data.toString();
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// Look for URL in output
|
|
392
|
+
setTimeout(() => {
|
|
393
|
+
const urlMatch = output.match(/https?:\/\/[^\s]+/);
|
|
394
|
+
if (urlMatch) {
|
|
395
|
+
resolve({ success: true, url: urlMatch[0], error: null });
|
|
396
|
+
} else {
|
|
397
|
+
resolve({ success: false, url: null, error: 'Could not get login URL' });
|
|
398
|
+
}
|
|
399
|
+
}, 3000);
|
|
400
|
+
});
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
module.exports = {
|
|
404
|
+
CLIPROXY_VERSION,
|
|
405
|
+
INSTALL_DIR,
|
|
406
|
+
BINARY_PATH,
|
|
407
|
+
AUTH_DIR,
|
|
408
|
+
DEFAULT_PORT,
|
|
409
|
+
getDownloadUrl,
|
|
410
|
+
isInstalled,
|
|
411
|
+
install,
|
|
412
|
+
isRunning,
|
|
413
|
+
start,
|
|
414
|
+
stop,
|
|
415
|
+
ensureRunning,
|
|
416
|
+
getLoginUrl
|
|
417
|
+
};
|
package/src/services/cliproxy.js
DELETED
|
@@ -1,255 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* CLIProxy Service
|
|
3
|
-
*
|
|
4
|
-
* Connects to CLIProxyAPI for AI provider access
|
|
5
|
-
* via paid plans (Claude Pro, ChatGPT Plus, etc.)
|
|
6
|
-
*
|
|
7
|
-
* Supports both local (localhost:8317) and remote connections.
|
|
8
|
-
* Docs: https://help.router-for.me
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
const http = require('http');
|
|
12
|
-
const https = require('https');
|
|
13
|
-
const os = require('os');
|
|
14
|
-
const path = require('path');
|
|
15
|
-
const fs = require('fs');
|
|
16
|
-
|
|
17
|
-
// Config file path (same as ai-agents)
|
|
18
|
-
const CONFIG_DIR = path.join(os.homedir(), '.hqx');
|
|
19
|
-
const CONFIG_FILE = path.join(CONFIG_DIR, 'ai-config.json');
|
|
20
|
-
|
|
21
|
-
// Default CLIProxy endpoint
|
|
22
|
-
const DEFAULT_CLIPROXY_URL = 'http://localhost:8317';
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Get CLIProxy URL from config or default
|
|
26
|
-
* @returns {string} CLIProxy base URL
|
|
27
|
-
*/
|
|
28
|
-
const getCliProxyUrl = () => {
|
|
29
|
-
try {
|
|
30
|
-
if (fs.existsSync(CONFIG_FILE)) {
|
|
31
|
-
const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
32
|
-
if (config.cliproxyUrl && config.cliproxyUrl.trim()) {
|
|
33
|
-
return config.cliproxyUrl.trim();
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
} catch (error) { /* ignore */ }
|
|
37
|
-
return DEFAULT_CLIPROXY_URL;
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Set CLIProxy URL in config
|
|
42
|
-
* @param {string} url - CLIProxy URL
|
|
43
|
-
* @returns {boolean} Success status
|
|
44
|
-
*/
|
|
45
|
-
const setCliProxyUrl = (url) => {
|
|
46
|
-
try {
|
|
47
|
-
let config = { providers: {} };
|
|
48
|
-
if (fs.existsSync(CONFIG_FILE)) {
|
|
49
|
-
config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
50
|
-
}
|
|
51
|
-
if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
52
|
-
config.cliproxyUrl = url;
|
|
53
|
-
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
54
|
-
return true;
|
|
55
|
-
} catch (error) {
|
|
56
|
-
return false;
|
|
57
|
-
}
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Make HTTP request to CLIProxy
|
|
62
|
-
* @param {string} path - API path
|
|
63
|
-
* @param {string} method - HTTP method
|
|
64
|
-
* @param {Object} headers - Request headers
|
|
65
|
-
* @param {number} timeout - Timeout in ms (default 60000 per RULES.md #15)
|
|
66
|
-
* @param {string} baseUrl - Optional base URL override
|
|
67
|
-
* @returns {Promise<Object>} { success, data, error }
|
|
68
|
-
*/
|
|
69
|
-
const fetchCliProxy = (path, method = 'GET', headers = {}, timeout = 60000, baseUrl = null) => {
|
|
70
|
-
return new Promise((resolve) => {
|
|
71
|
-
const base = baseUrl || getCliProxyUrl();
|
|
72
|
-
const url = new URL(path, base);
|
|
73
|
-
const isHttps = url.protocol === 'https:';
|
|
74
|
-
const httpModule = isHttps ? https : http;
|
|
75
|
-
|
|
76
|
-
const options = {
|
|
77
|
-
hostname: url.hostname,
|
|
78
|
-
port: url.port || (isHttps ? 443 : 8317),
|
|
79
|
-
path: url.pathname + url.search,
|
|
80
|
-
method,
|
|
81
|
-
headers: {
|
|
82
|
-
'Content-Type': 'application/json',
|
|
83
|
-
...headers
|
|
84
|
-
},
|
|
85
|
-
timeout,
|
|
86
|
-
rejectUnauthorized: false // Allow self-signed certs for remote
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
const req = httpModule.request(options, (res) => {
|
|
90
|
-
let data = '';
|
|
91
|
-
res.on('data', chunk => data += chunk);
|
|
92
|
-
res.on('end', () => {
|
|
93
|
-
try {
|
|
94
|
-
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
95
|
-
resolve({ success: true, data: JSON.parse(data) });
|
|
96
|
-
} else {
|
|
97
|
-
resolve({ success: false, error: `HTTP ${res.statusCode}`, data: null });
|
|
98
|
-
}
|
|
99
|
-
} catch (error) {
|
|
100
|
-
resolve({ success: false, error: 'Invalid JSON response', data: null });
|
|
101
|
-
}
|
|
102
|
-
});
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
req.on('error', (error) => {
|
|
106
|
-
if (error.code === 'ECONNREFUSED') {
|
|
107
|
-
resolve({ success: false, error: 'CLIProxy not reachable', data: null });
|
|
108
|
-
} else {
|
|
109
|
-
resolve({ success: false, error: error.message, data: null });
|
|
110
|
-
}
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
req.on('timeout', () => {
|
|
114
|
-
req.destroy();
|
|
115
|
-
resolve({ success: false, error: 'Request timeout', data: null });
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
req.end();
|
|
119
|
-
});
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Check if CLIProxy is running/reachable
|
|
124
|
-
* @param {string} url - Optional URL to test (uses config if not provided)
|
|
125
|
-
* @returns {Promise<Object>} { running, error, url }
|
|
126
|
-
*/
|
|
127
|
-
const isCliProxyRunning = async (url = null) => {
|
|
128
|
-
const testUrl = url || getCliProxyUrl();
|
|
129
|
-
const result = await fetchCliProxy('/v1/models', 'GET', {}, 5000, testUrl);
|
|
130
|
-
return {
|
|
131
|
-
running: result.success,
|
|
132
|
-
error: result.success ? null : result.error,
|
|
133
|
-
url: testUrl
|
|
134
|
-
};
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Fetch available models from CLIProxy
|
|
139
|
-
* @returns {Promise<Object>} { success, models, error }
|
|
140
|
-
*/
|
|
141
|
-
const fetchModelsFromCliProxy = async () => {
|
|
142
|
-
const result = await fetchCliProxy('/v1/models');
|
|
143
|
-
|
|
144
|
-
if (!result.success) {
|
|
145
|
-
return { success: false, models: [], error: result.error };
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Parse OpenAI-compatible format: { data: [{ id, ... }] }
|
|
149
|
-
const data = result.data;
|
|
150
|
-
if (!data || !data.data || !Array.isArray(data.data)) {
|
|
151
|
-
return { success: false, models: [], error: 'Invalid response format' };
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const models = data.data
|
|
155
|
-
.filter(m => m.id)
|
|
156
|
-
.map(m => ({
|
|
157
|
-
id: m.id,
|
|
158
|
-
name: m.id
|
|
159
|
-
}));
|
|
160
|
-
|
|
161
|
-
if (models.length === 0) {
|
|
162
|
-
return { success: false, models: [], error: 'No models available' };
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
return { success: true, models, error: null };
|
|
166
|
-
};
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Get OAuth URL for a provider
|
|
170
|
-
* @param {string} providerId - Provider ID (anthropic, openai, google, etc.)
|
|
171
|
-
* @returns {Promise<Object>} { success, url, state, error }
|
|
172
|
-
*/
|
|
173
|
-
const getOAuthUrl = async (providerId) => {
|
|
174
|
-
// Map HQX provider IDs to CLIProxy endpoints
|
|
175
|
-
const oauthEndpoints = {
|
|
176
|
-
anthropic: '/v0/management/anthropic-auth-url',
|
|
177
|
-
openai: '/v0/management/codex-auth-url',
|
|
178
|
-
google: '/v0/management/gemini-cli-auth-url',
|
|
179
|
-
// Others may not have OAuth support in CLIProxy
|
|
180
|
-
};
|
|
181
|
-
|
|
182
|
-
const endpoint = oauthEndpoints[providerId];
|
|
183
|
-
if (!endpoint) {
|
|
184
|
-
return { success: false, url: null, state: null, error: 'OAuth not supported for this provider' };
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
const result = await fetchCliProxy(endpoint);
|
|
188
|
-
|
|
189
|
-
if (!result.success) {
|
|
190
|
-
return { success: false, url: null, state: null, error: result.error };
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
const data = result.data;
|
|
194
|
-
if (!data || !data.url) {
|
|
195
|
-
return { success: false, url: null, state: null, error: 'Invalid OAuth response' };
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
return {
|
|
199
|
-
success: true,
|
|
200
|
-
url: data.url,
|
|
201
|
-
state: data.state || null,
|
|
202
|
-
error: null
|
|
203
|
-
};
|
|
204
|
-
};
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Check OAuth status
|
|
208
|
-
* @param {string} state - OAuth state from getOAuthUrl
|
|
209
|
-
* @returns {Promise<Object>} { success, status, error }
|
|
210
|
-
*/
|
|
211
|
-
const checkOAuthStatus = async (state) => {
|
|
212
|
-
const result = await fetchCliProxy(`/v0/management/get-auth-status?state=${encodeURIComponent(state)}`);
|
|
213
|
-
|
|
214
|
-
if (!result.success) {
|
|
215
|
-
return { success: false, status: null, error: result.error };
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
const data = result.data;
|
|
219
|
-
// status can be: "wait", "ok", "error"
|
|
220
|
-
return {
|
|
221
|
-
success: true,
|
|
222
|
-
status: data.status || 'unknown',
|
|
223
|
-
error: data.error || null
|
|
224
|
-
};
|
|
225
|
-
};
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* Get CLIProxy auth files (connected accounts)
|
|
229
|
-
* @returns {Promise<Object>} { success, files, error }
|
|
230
|
-
*/
|
|
231
|
-
const getAuthFiles = async () => {
|
|
232
|
-
const result = await fetchCliProxy('/v0/management/auth-files');
|
|
233
|
-
|
|
234
|
-
if (!result.success) {
|
|
235
|
-
return { success: false, files: [], error: result.error };
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
return {
|
|
239
|
-
success: true,
|
|
240
|
-
files: result.data?.files || [],
|
|
241
|
-
error: null
|
|
242
|
-
};
|
|
243
|
-
};
|
|
244
|
-
|
|
245
|
-
module.exports = {
|
|
246
|
-
DEFAULT_CLIPROXY_URL,
|
|
247
|
-
getCliProxyUrl,
|
|
248
|
-
setCliProxyUrl,
|
|
249
|
-
isCliProxyRunning,
|
|
250
|
-
fetchModelsFromCliProxy,
|
|
251
|
-
getOAuthUrl,
|
|
252
|
-
checkOAuthStatus,
|
|
253
|
-
getAuthFiles,
|
|
254
|
-
fetchCliProxy
|
|
255
|
-
};
|