hedgequantx 2.7.20 → 2.7.22

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.7.20",
3
+ "version": "2.7.22",
4
4
  "description": "HedgeQuantX - Prop Futures Trading CLI",
5
5
  "main": "src/app.js",
6
6
  "bin": {
package/src/app.js CHANGED
@@ -126,7 +126,16 @@ const banner = async () => {
126
126
 
127
127
  const tagline = isMobile ? `HQX v${version}` : `Prop Futures Algo Trading v${version}`;
128
128
  console.log(chalk.cyan('║') + chalk.white(centerText(tagline, innerWidth)) + chalk.cyan('║'));
129
- console.log(chalk.cyan('╚' + '═'.repeat(innerWidth) + '╝'));
129
+ };
130
+
131
+ /**
132
+ * Display banner with closed bottom (standalone)
133
+ */
134
+ const bannerClosed = async () => {
135
+ await banner();
136
+ const termWidth = process.stdout.columns || 100;
137
+ const boxWidth = termWidth < 60 ? Math.max(termWidth - 2, 40) : Math.max(getLogoWidth(), 98);
138
+ console.log(chalk.cyan('╚' + '═'.repeat(boxWidth - 2) + '╝'));
130
139
  };
131
140
 
132
141
  const getFullLogo = () => [
@@ -187,7 +196,8 @@ const run = async () => {
187
196
  const totalContentWidth = numCols * colWidth;
188
197
  const leftMargin = Math.max(2, Math.floor((innerWidth - totalContentWidth) / 2));
189
198
 
190
- console.log(chalk.cyan('╔' + '═'.repeat(innerWidth) + '╗'));
199
+ // Continue from banner (connected rectangle)
200
+ console.log(chalk.cyan('╠' + '═'.repeat(innerWidth) + '╣'));
191
201
  console.log(chalk.cyan('║') + chalk.white.bold(centerText('SELECT PROPFIRM', innerWidth)) + chalk.cyan('║'));
192
202
  console.log(chalk.cyan('╠' + '═'.repeat(innerWidth) + '╣'));
193
203
 
@@ -30,8 +30,8 @@ const dashboardMenu = async (service) => {
30
30
  return chalk.cyan('║') + content + ' '.repeat(Math.max(0, padding)) + chalk.cyan('║');
31
31
  };
32
32
 
33
- // New box for dashboard menu
34
- console.log(chalk.cyan('' + '═'.repeat(W) + ''));
33
+ // Continue from banner (connected rectangle)
34
+ console.log(chalk.cyan('' + '═'.repeat(W) + ''));
35
35
  console.log(makeLine(chalk.yellow.bold('Welcome, HQX Trader!'), 'center'));
36
36
  console.log(chalk.cyan('╠' + '═'.repeat(W) + '╣'));
37
37
 
@@ -0,0 +1,185 @@
1
+ /**
2
+ * AI Agents UI Components
3
+ *
4
+ * UI drawing functions for the AI Agents configuration page.
5
+ */
6
+
7
+ const chalk = require('chalk');
8
+ const { centerText, visibleLength } = require('../ui');
9
+
10
+ /**
11
+ * Draw a 2-column row
12
+ * @param {string} leftText - Left column text
13
+ * @param {string} rightText - Right column text
14
+ * @param {number} W - Inner width
15
+ */
16
+ const draw2ColRow = (leftText, rightText, W) => {
17
+ const col1Width = Math.floor(W / 2);
18
+ const col2Width = W - col1Width;
19
+ const leftLen = visibleLength(leftText);
20
+ const leftPad = col1Width - leftLen;
21
+ const leftPadL = Math.floor(leftPad / 2);
22
+ const rightLen = visibleLength(rightText || '');
23
+ const rightPad = col2Width - rightLen;
24
+ const rightPadL = Math.floor(rightPad / 2);
25
+ console.log(
26
+ chalk.cyan('║') +
27
+ ' '.repeat(leftPadL) + leftText + ' '.repeat(leftPad - leftPadL) +
28
+ ' '.repeat(rightPadL) + (rightText || '') + ' '.repeat(rightPad - rightPadL) +
29
+ chalk.cyan('║')
30
+ );
31
+ };
32
+
33
+ /**
34
+ * Draw 2-column table with title and back option
35
+ * @param {string} title - Table title
36
+ * @param {Function} titleColor - Chalk color function
37
+ * @param {Array} items - Items to display
38
+ * @param {string} backText - Back button text
39
+ * @param {number} W - Inner width
40
+ */
41
+ const draw2ColTable = (title, titleColor, items, backText, W) => {
42
+ console.log(chalk.cyan('╔' + '═'.repeat(W) + '╗'));
43
+ console.log(chalk.cyan('║') + titleColor(centerText(title, W)) + chalk.cyan('║'));
44
+ console.log(chalk.cyan('╠' + '═'.repeat(W) + '╣'));
45
+
46
+ const rows = Math.ceil(items.length / 2);
47
+ for (let row = 0; row < rows; row++) {
48
+ const left = items[row];
49
+ const right = items[row + rows];
50
+ draw2ColRow(left || '', right || '', W);
51
+ }
52
+
53
+ console.log(chalk.cyan('╠' + '─'.repeat(W) + '╣'));
54
+ console.log(chalk.cyan('║') + chalk.red(centerText(backText, W)) + chalk.cyan('║'));
55
+ console.log(chalk.cyan('╚' + '═'.repeat(W) + '╝'));
56
+ };
57
+
58
+ /**
59
+ * Draw providers table
60
+ * @param {Array} providers - List of AI providers
61
+ * @param {Object} config - Current config
62
+ * @param {number} boxWidth - Box width
63
+ */
64
+ const drawProvidersTable = (providers, config, boxWidth) => {
65
+ const W = boxWidth - 2;
66
+ const items = providers.map((p, i) => {
67
+ const status = config.providers[p.id]?.active ? chalk.green(' ●') : '';
68
+ return chalk.cyan(`[${i + 1}]`) + ' ' + chalk[p.color](p.name) + status;
69
+ });
70
+ draw2ColTable('AI AGENTS CONFIGURATION', chalk.yellow.bold, items, '[B] Back to Menu', W);
71
+ };
72
+
73
+ /**
74
+ * Draw models table
75
+ * @param {Object} provider - Provider object
76
+ * @param {Array} models - List of models
77
+ * @param {number} boxWidth - Box width
78
+ */
79
+ const drawModelsTable = (provider, models, boxWidth) => {
80
+ const W = boxWidth - 2;
81
+ const items = models.map((m, i) => chalk.cyan(`[${i + 1}]`) + ' ' + chalk.white(m.name));
82
+ draw2ColTable(`${provider.name.toUpperCase()} - MODELS`, chalk[provider.color].bold, items, '[B] Back', W);
83
+ };
84
+
85
+ /**
86
+ * Draw provider configuration window
87
+ * @param {Object} provider - Provider object
88
+ * @param {Object} config - Current config
89
+ * @param {number} boxWidth - Box width
90
+ */
91
+ const drawProviderWindow = (provider, config, boxWidth) => {
92
+ const W = boxWidth - 2;
93
+ const col1Width = Math.floor(W / 2);
94
+ const col2Width = W - col1Width;
95
+ const providerConfig = config.providers[provider.id] || {};
96
+
97
+ // Header
98
+ console.log(chalk.cyan('╔' + '═'.repeat(W) + '╗'));
99
+ console.log(chalk.cyan('║') + chalk[provider.color].bold(centerText(provider.name.toUpperCase(), W)) + chalk.cyan('║'));
100
+ console.log(chalk.cyan('╠' + '═'.repeat(W) + '╣'));
101
+
102
+ // Empty line
103
+ console.log(chalk.cyan('║') + ' '.repeat(W) + chalk.cyan('║'));
104
+
105
+ // Options in 2 columns
106
+ const opt1Title = '[1] Connect via Paid Plan';
107
+ const opt1Desc = 'Uses CLIProxy - No API key needed';
108
+ const opt2Title = '[2] Connect via API Key';
109
+ const opt2Desc = 'Enter your own API key';
110
+
111
+ // Row 1: Titles
112
+ const left1 = chalk.green(opt1Title);
113
+ const right1 = chalk.yellow(opt2Title);
114
+ const left1Len = visibleLength(left1);
115
+ const right1Len = visibleLength(right1);
116
+ const left1PadTotal = col1Width - left1Len;
117
+ const left1PadL = Math.floor(left1PadTotal / 2);
118
+ const left1PadR = left1PadTotal - left1PadL;
119
+ const right1PadTotal = col2Width - right1Len;
120
+ const right1PadL = Math.floor(right1PadTotal / 2);
121
+ const right1PadR = right1PadTotal - right1PadL;
122
+
123
+ console.log(
124
+ chalk.cyan('║') +
125
+ ' '.repeat(left1PadL) + left1 + ' '.repeat(left1PadR) +
126
+ ' '.repeat(right1PadL) + right1 + ' '.repeat(right1PadR) +
127
+ chalk.cyan('║')
128
+ );
129
+
130
+ // Row 2: Descriptions
131
+ const left2 = chalk.gray(opt1Desc);
132
+ const right2 = chalk.gray(opt2Desc);
133
+ const left2Len = visibleLength(left2);
134
+ const right2Len = visibleLength(right2);
135
+ const left2PadTotal = col1Width - left2Len;
136
+ const left2PadL = Math.floor(left2PadTotal / 2);
137
+ const left2PadR = left2PadTotal - left2PadL;
138
+ const right2PadTotal = col2Width - right2Len;
139
+ const right2PadL = Math.floor(right2PadTotal / 2);
140
+ const right2PadR = right2PadTotal - right2PadL;
141
+
142
+ console.log(
143
+ chalk.cyan('║') +
144
+ ' '.repeat(left2PadL) + left2 + ' '.repeat(left2PadR) +
145
+ ' '.repeat(right2PadL) + right2 + ' '.repeat(right2PadR) +
146
+ chalk.cyan('║')
147
+ );
148
+
149
+ // Empty line
150
+ console.log(chalk.cyan('║') + ' '.repeat(W) + chalk.cyan('║'));
151
+
152
+ // Status bar
153
+ console.log(chalk.cyan('╠' + '─'.repeat(W) + '╣'));
154
+
155
+ let statusText = '';
156
+ if (providerConfig.active) {
157
+ const connType = providerConfig.connectionType === 'cliproxy' ? 'CLIProxy' : 'API Key';
158
+ const modelName = providerConfig.modelName || 'N/A';
159
+ statusText = chalk.green('● ACTIVE') + chalk.gray(' Model: ') + chalk.yellow(modelName) + chalk.gray(' via ') + chalk.cyan(connType);
160
+ } else if (providerConfig.apiKey || providerConfig.connectionType) {
161
+ statusText = chalk.yellow('● CONFIGURED') + chalk.gray(' (not active)');
162
+ } else {
163
+ statusText = chalk.gray('○ NOT CONFIGURED');
164
+ }
165
+ console.log(chalk.cyan('║') + centerText(statusText, W) + chalk.cyan('║'));
166
+
167
+ // Disconnect option if active
168
+ if (providerConfig.active) {
169
+ console.log(chalk.cyan('╠' + '─'.repeat(W) + '╣'));
170
+ console.log(chalk.cyan('║') + chalk.red(centerText('[D] Disconnect', W)) + chalk.cyan('║'));
171
+ }
172
+
173
+ // Back
174
+ console.log(chalk.cyan('╠' + '─'.repeat(W) + '╣'));
175
+ console.log(chalk.cyan('║') + chalk.red(centerText('[B] Back', W)) + chalk.cyan('║'));
176
+ console.log(chalk.cyan('╚' + '═'.repeat(W) + '╝'));
177
+ };
178
+
179
+ module.exports = {
180
+ draw2ColRow,
181
+ draw2ColTable,
182
+ drawProvidersTable,
183
+ drawModelsTable,
184
+ drawProviderWindow
185
+ };
@@ -9,10 +9,13 @@ const chalk = require('chalk');
9
9
  const os = require('os');
10
10
  const path = require('path');
11
11
  const fs = require('fs');
12
+ const ora = require('ora');
12
13
 
13
- const { getLogoWidth, centerText, visibleLength } = require('../ui');
14
+ const { getLogoWidth } = require('../ui');
14
15
  const { prompts } = require('../utils');
15
- const { getModelsForProvider, getModelById } = require('./ai-models');
16
+ const { fetchModelsFromApi } = require('./ai-models');
17
+ const { drawProvidersTable, drawModelsTable, drawProviderWindow } = require('./ai-agents-ui');
18
+ const { isCliProxyRunning, fetchModelsFromCliProxy, getOAuthUrl, checkOAuthStatus } = require('../services/cliproxy');
16
19
 
17
20
  // Config file path
18
21
  const CONFIG_DIR = path.join(os.homedir(), '.hqx');
@@ -30,32 +33,20 @@ const AI_PROVIDERS = [
30
33
  { id: 'openrouter', name: 'OpenRouter', color: 'gray' },
31
34
  ];
32
35
 
33
- /**
34
- * Load AI config from file
35
- * @returns {Object} Config object with provider settings
36
- */
36
+ /** Load AI config from file */
37
37
  const loadConfig = () => {
38
38
  try {
39
39
  if (fs.existsSync(CONFIG_FILE)) {
40
- const data = fs.readFileSync(CONFIG_FILE, 'utf8');
41
- return JSON.parse(data);
40
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
42
41
  }
43
- } catch (error) {
44
- // Config file doesn't exist or is invalid
45
- }
42
+ } catch (error) { /* ignore */ }
46
43
  return { providers: {} };
47
44
  };
48
45
 
49
- /**
50
- * Save AI config to file
51
- * @param {Object} config - Config object to save
52
- * @returns {boolean} Success status
53
- */
46
+ /** Save AI config to file */
54
47
  const saveConfig = (config) => {
55
48
  try {
56
- if (!fs.existsSync(CONFIG_DIR)) {
57
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
58
- }
49
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
59
50
  fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
60
51
  return true;
61
52
  } catch (error) {
@@ -63,211 +54,199 @@ const saveConfig = (config) => {
63
54
  }
64
55
  };
65
56
 
66
- /**
67
- * Mask API key for display
68
- * @param {string} key - API key
69
- * @returns {string} Masked key
70
- */
71
- const maskKey = (key) => {
72
- if (!key || key.length < 16) return '****';
73
- return key.substring(0, 8) + '...' + key.substring(key.length - 4);
74
- };
75
-
76
- /**
77
- * Draw a 2-column row
78
- */
79
- const draw2ColRow = (leftText, rightText, W) => {
80
- const col1Width = Math.floor(W / 2);
81
- const col2Width = W - col1Width;
82
- const leftLen = visibleLength(leftText);
83
- const leftPad = col1Width - leftLen;
84
- const leftPadL = Math.floor(leftPad / 2);
85
- const rightLen = visibleLength(rightText || '');
86
- const rightPad = col2Width - rightLen;
87
- const rightPadL = Math.floor(rightPad / 2);
88
- console.log(
89
- chalk.cyan('║') +
90
- ' '.repeat(leftPadL) + leftText + ' '.repeat(leftPad - leftPadL) +
91
- ' '.repeat(rightPadL) + (rightText || '') + ' '.repeat(rightPad - rightPadL) +
92
- chalk.cyan('║')
93
- );
57
+ /** Select a model from a pre-fetched list */
58
+ const selectModelFromList = async (provider, models, boxWidth) => {
59
+ while (true) {
60
+ console.clear();
61
+ drawModelsTable(provider, models, boxWidth);
62
+
63
+ const input = await prompts.textInput(chalk.cyan('Select model: '));
64
+ const choice = (input || '').toLowerCase().trim();
65
+
66
+ if (choice === 'b' || choice === '') return null;
67
+
68
+ const num = parseInt(choice);
69
+ if (!isNaN(num) && num >= 1 && num <= models.length) return models[num - 1];
70
+
71
+ console.log(chalk.red(' Invalid option.'));
72
+ await new Promise(r => setTimeout(r, 1000));
73
+ }
94
74
  };
95
75
 
96
- /**
97
- * Draw 2-column table
98
- */
99
- const draw2ColTable = (title, titleColor, items, backText, W) => {
100
- console.log(chalk.cyan('╔' + '═'.repeat(W) + '╗'));
101
- console.log(chalk.cyan('║') + titleColor(centerText(title, W)) + chalk.cyan('║'));
102
- console.log(chalk.cyan('╠' + '═'.repeat(W) + '╣'));
76
+ /** Select a model for a provider (fetches from API) */
77
+ const selectModel = async (provider, apiKey) => {
78
+ const boxWidth = getLogoWidth();
79
+ const spinner = ora({ text: 'Fetching models from API...', color: 'yellow' }).start();
80
+ const result = await fetchModelsFromApi(provider.id, apiKey);
103
81
 
104
- const rows = Math.ceil(items.length / 2);
105
- for (let row = 0; row < rows; row++) {
106
- const left = items[row];
107
- const right = items[row + rows];
108
- draw2ColRow(left || '', right || '', W);
82
+ if (!result.success || result.models.length === 0) {
83
+ spinner.fail(result.error || 'No models available');
84
+ await prompts.waitForEnter();
85
+ return null;
109
86
  }
110
87
 
111
- console.log(chalk.cyan('╠' + '─'.repeat(W) + '╣'));
112
- console.log(chalk.cyan('║') + chalk.red(centerText(backText, W)) + chalk.cyan('║'));
113
- console.log(chalk.cyan('╚' + '═'.repeat(W) + '╝'));
88
+ spinner.succeed(`Found ${result.models.length} models`);
89
+ return selectModelFromList(provider, result.models, boxWidth);
114
90
  };
115
91
 
116
- /**
117
- * Draw providers table
118
- */
119
- const drawProvidersTable = (config, boxWidth) => {
120
- const W = boxWidth - 2;
121
- const items = AI_PROVIDERS.map((p, i) => {
122
- const status = config.providers[p.id]?.active ? chalk.green(' ●') : '';
123
- return chalk.cyan(`[${i + 1}]`) + ' ' + chalk[p.color](p.name) + status;
92
+ /** Deactivate all providers and activate one */
93
+ const activateProvider = (config, providerId, data) => {
94
+ Object.keys(config.providers).forEach(id => {
95
+ if (config.providers[id]) config.providers[id].active = false;
124
96
  });
125
- draw2ColTable('AI AGENTS CONFIGURATION', chalk.yellow.bold, items, '[B] Back to Menu', W);
126
- };
127
-
128
- /**
129
- * Draw models table
130
- */
131
- const drawModelsTable = (provider, models, boxWidth) => {
132
- const W = boxWidth - 2;
133
- const items = models.map((m, i) => chalk.cyan(`[${i + 1}]`) + ' ' + chalk.white(m.name));
134
- draw2ColTable(`${provider.name.toUpperCase()} - MODELS`, chalk[provider.color].bold, items, '[B] Back', W);
97
+ if (!config.providers[providerId]) config.providers[providerId] = {};
98
+ Object.assign(config.providers[providerId], data, { active: true, configuredAt: new Date().toISOString() });
135
99
  };
136
100
 
137
- /**
138
- * Select a model for a provider
139
- * @param {Object} provider - Provider object
140
- * @returns {Object|null} Selected model or null if cancelled
141
- */
142
- const selectModel = async (provider) => {
143
- const boxWidth = getLogoWidth();
144
- const models = getModelsForProvider(provider.id);
101
+ /** Handle CLIProxy connection */
102
+ const handleCliProxyConnection = async (provider, config, boxWidth) => {
103
+ console.log();
104
+ const spinner = ora({ text: 'Checking CLIProxy status...', color: 'yellow' }).start();
105
+ const proxyStatus = await isCliProxyRunning();
145
106
 
146
- if (models.length === 0) {
147
- return null;
107
+ if (!proxyStatus.running) {
108
+ spinner.fail('CLIProxy is not running');
109
+ console.log(chalk.yellow('\n CLIProxy must be running on localhost:8317'));
110
+ console.log(chalk.gray(' Install: https://help.router-for.me\n'));
111
+ await prompts.waitForEnter();
112
+ return false;
148
113
  }
149
114
 
150
- while (true) {
151
- console.clear();
152
- drawModelsTable(provider, models, boxWidth);
153
-
154
- const input = await prompts.textInput(chalk.cyan('Select model: '));
155
- const choice = (input || '').toLowerCase().trim();
115
+ spinner.succeed('CLIProxy is running');
116
+ const oauthResult = await getOAuthUrl(provider.id);
117
+
118
+ if (!oauthResult.success) {
119
+ // OAuth not supported - try direct model fetch
120
+ console.log(chalk.gray(` OAuth not available for ${provider.name}, checking models...`));
121
+ const modelsResult = await fetchModelsFromCliProxy();
156
122
 
157
- if (choice === 'b' || choice === '') {
158
- return null;
123
+ if (!modelsResult.success || modelsResult.models.length === 0) {
124
+ console.log(chalk.red(` No models available via CLIProxy for ${provider.name}`));
125
+ console.log(chalk.gray(` Error: ${modelsResult.error || 'Unknown'}`));
126
+ await prompts.waitForEnter();
127
+ return false;
159
128
  }
160
129
 
161
- const num = parseInt(choice);
162
- if (!isNaN(num) && num >= 1 && num <= models.length) {
163
- return models[num - 1];
164
- }
130
+ const selectedModel = await selectModelFromList(provider, modelsResult.models, boxWidth);
131
+ if (!selectedModel) return false;
165
132
 
166
- console.log(chalk.red(' Invalid option.'));
167
- await new Promise(r => setTimeout(r, 1000));
133
+ activateProvider(config, provider.id, {
134
+ connectionType: 'cliproxy',
135
+ modelId: selectedModel.id,
136
+ modelName: selectedModel.name
137
+ });
138
+
139
+ if (saveConfig(config)) {
140
+ console.log(chalk.green(`\n ✓ ${provider.name} connected via CLIProxy.`));
141
+ console.log(chalk.cyan(` Model: ${selectedModel.name}`));
142
+ }
143
+ await prompts.waitForEnter();
144
+ return true;
168
145
  }
169
- };
170
-
171
- /**
172
- * Draw provider configuration window
173
- * @param {Object} provider - Provider object
174
- * @param {Object} config - Current config
175
- * @param {number} boxWidth - Box width
176
- */
177
- const drawProviderWindow = (provider, config, boxWidth) => {
178
- const W = boxWidth - 2;
179
- const col1Width = Math.floor(W / 2);
180
- const col2Width = W - col1Width;
181
- const providerConfig = config.providers[provider.id] || {};
182
146
 
183
- // Header
184
- console.log(chalk.cyan('╔' + '═'.repeat(W) + '╗'));
185
- console.log(chalk.cyan('║') + chalk[provider.color].bold(centerText(provider.name.toUpperCase(), W)) + chalk.cyan('║'));
186
- console.log(chalk.cyan('╠' + '═'.repeat(W) + '╣'));
147
+ // OAuth flow
148
+ console.log(chalk.cyan('\n Open this URL in your browser to authenticate:\n'));
149
+ console.log(chalk.yellow(` ${oauthResult.url}\n`));
150
+ console.log(chalk.gray(' Waiting for authentication... (Press Enter to cancel)'));
187
151
 
188
- // Empty line
189
- console.log(chalk.cyan('║') + ' '.repeat(W) + chalk.cyan('║'));
152
+ let authenticated = false;
153
+ const maxWait = 120000, pollInterval = 3000;
154
+ let waited = 0;
190
155
 
191
- // Options in 2 columns
192
- const opt1Title = '[1] Connect via Paid Plan';
193
- const opt1Desc = 'Uses CLIProxy - No API key needed';
194
- const opt2Title = '[2] Connect via API Key';
195
- const opt2Desc = 'Enter your own API key';
156
+ const pollPromise = (async () => {
157
+ while (waited < maxWait) {
158
+ await new Promise(r => setTimeout(r, pollInterval));
159
+ waited += pollInterval;
160
+ if (oauthResult.state) {
161
+ const statusResult = await checkOAuthStatus(oauthResult.state);
162
+ if (statusResult.success && statusResult.status === 'ok') { authenticated = true; return true; }
163
+ if (statusResult.status === 'error') {
164
+ console.log(chalk.red(`\n Authentication error: ${statusResult.error || 'Unknown'}`));
165
+ return false;
166
+ }
167
+ }
168
+ }
169
+ return false;
170
+ })();
196
171
 
197
- // Row 1: Titles
198
- const left1 = chalk.green(opt1Title);
199
- const right1 = chalk.yellow(opt2Title);
200
- const left1Len = visibleLength(left1);
201
- const right1Len = visibleLength(right1);
202
- const left1PadTotal = col1Width - left1Len;
203
- const left1PadL = Math.floor(left1PadTotal / 2);
204
- const left1PadR = left1PadTotal - left1PadL;
205
- const right1PadTotal = col2Width - right1Len;
206
- const right1PadL = Math.floor(right1PadTotal / 2);
207
- const right1PadR = right1PadTotal - right1PadL;
172
+ await Promise.race([pollPromise, prompts.waitForEnter()]);
208
173
 
209
- console.log(
210
- chalk.cyan('') +
211
- ' '.repeat(left1PadL) + left1 + ' '.repeat(left1PadR) +
212
- ' '.repeat(right1PadL) + right1 + ' '.repeat(right1PadR) +
213
- chalk.cyan('║')
214
- );
174
+ if (!authenticated) {
175
+ console.log(chalk.yellow(' Authentication cancelled or timed out.'));
176
+ await prompts.waitForEnter();
177
+ return false;
178
+ }
215
179
 
216
- // Row 2: Descriptions
217
- const left2 = chalk.gray(opt1Desc);
218
- const right2 = chalk.gray(opt2Desc);
219
- const left2Len = visibleLength(left2);
220
- const right2Len = visibleLength(right2);
221
- const left2PadTotal = col1Width - left2Len;
222
- const left2PadL = Math.floor(left2PadTotal / 2);
223
- const left2PadR = left2PadTotal - left2PadL;
224
- const right2PadTotal = col2Width - right2Len;
225
- const right2PadL = Math.floor(right2PadTotal / 2);
226
- const right2PadR = right2PadTotal - right2PadL;
180
+ console.log(chalk.green(' ✓ Authentication successful!'));
227
181
 
228
- console.log(
229
- chalk.cyan('║') +
230
- ' '.repeat(left2PadL) + left2 + ' '.repeat(left2PadR) +
231
- ' '.repeat(right2PadL) + right2 + ' '.repeat(right2PadR) +
232
- chalk.cyan('║')
233
- );
182
+ const modelsResult = await fetchModelsFromCliProxy();
183
+ if (modelsResult.success && modelsResult.models.length > 0) {
184
+ const selectedModel = await selectModelFromList(provider, modelsResult.models, boxWidth);
185
+ if (selectedModel) {
186
+ activateProvider(config, provider.id, {
187
+ connectionType: 'cliproxy',
188
+ modelId: selectedModel.id,
189
+ modelName: selectedModel.name
190
+ });
191
+ if (saveConfig(config)) {
192
+ console.log(chalk.green(`\n ✓ ${provider.name} connected via CLIProxy.`));
193
+ console.log(chalk.cyan(` Model: ${selectedModel.name}`));
194
+ }
195
+ }
196
+ } else {
197
+ activateProvider(config, provider.id, {
198
+ connectionType: 'cliproxy',
199
+ modelId: null,
200
+ modelName: 'Default'
201
+ });
202
+ if (saveConfig(config)) console.log(chalk.green(`\n ✓ ${provider.name} connected via CLIProxy.`));
203
+ }
234
204
 
235
- // Empty line
236
- console.log(chalk.cyan('║') + ' '.repeat(W) + chalk.cyan('║'));
205
+ await prompts.waitForEnter();
206
+ return true;
207
+ };
208
+
209
+ /** Handle API Key connection */
210
+ const handleApiKeyConnection = async (provider, config) => {
211
+ console.clear();
212
+ console.log(chalk.yellow(`\n Enter your ${provider.name} API key:`));
213
+ console.log(chalk.gray(' (Press Enter to cancel)\n'));
237
214
 
238
- // Status bar
239
- console.log(chalk.cyan('╠' + '─'.repeat(W) + '╣'));
215
+ const apiKey = await prompts.textInput(chalk.cyan(' API Key: '), true);
240
216
 
241
- let statusText = '';
242
- if (providerConfig.active) {
243
- const connType = providerConfig.connectionType === 'cliproxy' ? 'CLIProxy' : 'API Key';
244
- const modelName = providerConfig.modelName || 'N/A';
245
- statusText = chalk.green('● ACTIVE') + chalk.gray(' Model: ') + chalk.yellow(modelName) + chalk.gray(' via ') + chalk.cyan(connType);
246
- } else if (providerConfig.apiKey || providerConfig.connectionType) {
247
- statusText = chalk.yellow('● CONFIGURED') + chalk.gray(' (not active)');
248
- } else {
249
- statusText = chalk.gray('○ NOT CONFIGURED');
217
+ if (!apiKey || apiKey.trim() === '') {
218
+ console.log(chalk.gray(' Cancelled.'));
219
+ await prompts.waitForEnter();
220
+ return false;
250
221
  }
251
- console.log(chalk.cyan('║') + centerText(statusText, W) + chalk.cyan('║'));
252
222
 
253
- // Disconnect option if active
254
- if (providerConfig.active) {
255
- console.log(chalk.cyan('╠' + '─'.repeat(W) + '╣'));
256
- console.log(chalk.cyan('║') + chalk.red(centerText('[D] Disconnect', W)) + chalk.cyan('║'));
223
+ if (apiKey.length < 20) {
224
+ console.log(chalk.red(' Invalid API key format (too short).'));
225
+ await prompts.waitForEnter();
226
+ return false;
257
227
  }
258
228
 
259
- // Back
260
- console.log(chalk.cyan('╠' + '─'.repeat(W) + '╣'));
261
- console.log(chalk.cyan('║') + chalk.red(centerText('[B] Back', W)) + chalk.cyan('║'));
262
- console.log(chalk.cyan('╚' + '═'.repeat(W) + '╝'));
229
+ const selectedModel = await selectModel(provider, apiKey.trim());
230
+ if (!selectedModel) return false;
231
+
232
+ activateProvider(config, provider.id, {
233
+ connectionType: 'apikey',
234
+ apiKey: apiKey.trim(),
235
+ modelId: selectedModel.id,
236
+ modelName: selectedModel.name
237
+ });
238
+
239
+ if (saveConfig(config)) {
240
+ console.log(chalk.green(`\n ✓ ${provider.name} connected via API Key.`));
241
+ console.log(chalk.cyan(` Model: ${selectedModel.name}`));
242
+ } else {
243
+ console.log(chalk.red('\n Failed to save config.'));
244
+ }
245
+ await prompts.waitForEnter();
246
+ return true;
263
247
  };
264
248
 
265
- /**
266
- * Handle provider configuration
267
- * @param {Object} provider - Provider to configure
268
- * @param {Object} config - Current config
269
- * @returns {Object} Updated config
270
- */
249
+ /** Handle provider configuration */
271
250
  const handleProviderConfig = async (provider, config) => {
272
251
  const boxWidth = getLogoWidth();
273
252
 
@@ -278,92 +257,23 @@ const handleProviderConfig = async (provider, config) => {
278
257
  const input = await prompts.textInput(chalk.cyan('Select option: '));
279
258
  const choice = (input || '').toLowerCase().trim();
280
259
 
281
- if (choice === 'b' || choice === '') {
282
- break;
283
- }
260
+ if (choice === 'b' || choice === '') break;
284
261
 
285
- if (choice === 'd') {
286
- // Disconnect
287
- if (config.providers[provider.id]) {
288
- config.providers[provider.id].active = false;
289
- saveConfig(config);
290
- console.log(chalk.yellow(`\n ${provider.name} disconnected.`));
291
- await prompts.waitForEnter();
292
- }
262
+ if (choice === 'd' && config.providers[provider.id]) {
263
+ config.providers[provider.id].active = false;
264
+ saveConfig(config);
265
+ console.log(chalk.yellow(`\n ${provider.name} disconnected.`));
266
+ await prompts.waitForEnter();
293
267
  continue;
294
268
  }
295
269
 
296
270
  if (choice === '1') {
297
- // CLIProxy connection - select model first
298
- const selectedModel = await selectModel(provider);
299
- if (!selectedModel) continue;
300
-
301
- // Deactivate all other providers
302
- Object.keys(config.providers).forEach(id => {
303
- if (config.providers[id]) config.providers[id].active = false;
304
- });
305
-
306
- if (!config.providers[provider.id]) config.providers[provider.id] = {};
307
- config.providers[provider.id].connectionType = 'cliproxy';
308
- config.providers[provider.id].modelId = selectedModel.id;
309
- config.providers[provider.id].modelName = selectedModel.name;
310
- config.providers[provider.id].active = true;
311
- config.providers[provider.id].configuredAt = new Date().toISOString();
312
-
313
- if (saveConfig(config)) {
314
- console.log(chalk.green(`\n ✓ ${provider.name} connected via CLIProxy.`));
315
- console.log(chalk.cyan(` Model: ${selectedModel.name}`));
316
- } else {
317
- console.log(chalk.red('\n Failed to save config.'));
318
- }
319
- await prompts.waitForEnter();
271
+ await handleCliProxyConnection(provider, config, boxWidth);
320
272
  continue;
321
273
  }
322
274
 
323
275
  if (choice === '2') {
324
- // API Key connection - select model first
325
- const selectedModel = await selectModel(provider);
326
- if (!selectedModel) continue;
327
-
328
- console.clear();
329
- console.log(chalk.yellow(`\n Enter your ${provider.name} API key:`));
330
- console.log(chalk.gray(' (Press Enter to cancel)'));
331
- console.log();
332
-
333
- const apiKey = await prompts.textInput(chalk.cyan(' API Key: '), true);
334
-
335
- if (!apiKey || apiKey.trim() === '') {
336
- console.log(chalk.gray(' Cancelled.'));
337
- await prompts.waitForEnter();
338
- continue;
339
- }
340
-
341
- if (apiKey.length < 20) {
342
- console.log(chalk.red(' Invalid API key format (too short).'));
343
- await prompts.waitForEnter();
344
- continue;
345
- }
346
-
347
- // Deactivate all other providers
348
- Object.keys(config.providers).forEach(id => {
349
- if (config.providers[id]) config.providers[id].active = false;
350
- });
351
-
352
- if (!config.providers[provider.id]) config.providers[provider.id] = {};
353
- config.providers[provider.id].connectionType = 'apikey';
354
- config.providers[provider.id].apiKey = apiKey.trim();
355
- config.providers[provider.id].modelId = selectedModel.id;
356
- config.providers[provider.id].modelName = selectedModel.name;
357
- config.providers[provider.id].active = true;
358
- config.providers[provider.id].configuredAt = new Date().toISOString();
359
-
360
- if (saveConfig(config)) {
361
- console.log(chalk.green(`\n ✓ ${provider.name} connected via API Key.`));
362
- console.log(chalk.cyan(` Model: ${selectedModel.name}`));
363
- } else {
364
- console.log(chalk.red('\n Failed to save config.'));
365
- }
366
- await prompts.waitForEnter();
276
+ await handleApiKeyConnection(provider, config);
367
277
  continue;
368
278
  }
369
279
  }
@@ -371,54 +281,41 @@ const handleProviderConfig = async (provider, config) => {
371
281
  return config;
372
282
  };
373
283
 
374
- /**
375
- * Get active AI provider config
376
- * @returns {Object|null} Active provider config or null
377
- */
284
+ /** Get active AI provider config */
378
285
  const getActiveProvider = () => {
379
286
  const config = loadConfig();
380
287
  for (const provider of AI_PROVIDERS) {
381
- const providerConfig = config.providers[provider.id];
382
- if (providerConfig && providerConfig.active) {
288
+ const pc = config.providers[provider.id];
289
+ if (pc && pc.active) {
383
290
  return {
384
291
  id: provider.id,
385
292
  name: provider.name,
386
- connectionType: providerConfig.connectionType,
387
- apiKey: providerConfig.apiKey || null,
388
- modelId: providerConfig.modelId || null,
389
- modelName: providerConfig.modelName || null
293
+ connectionType: pc.connectionType,
294
+ apiKey: pc.apiKey || null,
295
+ modelId: pc.modelId || null,
296
+ modelName: pc.modelName || null
390
297
  };
391
298
  }
392
299
  }
393
300
  return null;
394
301
  };
395
302
 
396
- /**
397
- * Count active AI agents
398
- * @returns {number} Number of active agents (0 or 1)
399
- */
400
- const getActiveAgentCount = () => {
401
- const active = getActiveProvider();
402
- return active ? 1 : 0;
403
- };
303
+ /** Count active AI agents */
304
+ const getActiveAgentCount = () => getActiveProvider() ? 1 : 0;
404
305
 
405
- /**
406
- * Main AI Agents menu
407
- */
306
+ /** Main AI Agents menu */
408
307
  const aiAgentsMenu = async () => {
409
308
  let config = loadConfig();
410
309
  const boxWidth = getLogoWidth();
411
310
 
412
311
  while (true) {
413
312
  console.clear();
414
- drawProvidersTable(config, boxWidth);
313
+ drawProvidersTable(AI_PROVIDERS, config, boxWidth);
415
314
 
416
315
  const input = await prompts.textInput(chalk.cyan('Select provider: '));
417
316
  const choice = (input || '').toLowerCase().trim();
418
317
 
419
- if (choice === 'b' || choice === '') {
420
- break;
421
- }
318
+ if (choice === 'b' || choice === '') break;
422
319
 
423
320
  const num = parseInt(choice);
424
321
  if (!isNaN(num) && num >= 1 && num <= AI_PROVIDERS.length) {
@@ -1,84 +1,224 @@
1
1
  /**
2
- * AI Models Configuration
2
+ * AI Models - Fetch from provider APIs
3
3
  *
4
- * Lists available models for each AI provider.
5
- * These are technical configuration values, not trading data.
4
+ * Models are fetched dynamically from each provider's API.
5
+ * No hardcoded model lists - data comes from real APIs only.
6
6
  */
7
7
 
8
- // Models by provider ID
9
- const PROVIDER_MODELS = {
10
- anthropic: [
11
- { id: 'claude-opus-4-20250514', name: 'Claude Opus 4', tier: 'flagship' },
12
- { id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4', tier: 'balanced' },
13
- { id: 'claude-3-5-sonnet-20241022', name: 'Claude Sonnet 3.5', tier: 'balanced' },
14
- { id: 'claude-3-5-haiku-20241022', name: 'Claude Haiku 3.5', tier: 'fast' },
15
- ],
16
- openai: [
17
- { id: 'gpt-4o', name: 'GPT-4o', tier: 'flagship' },
18
- { id: 'gpt-4o-mini', name: 'GPT-4o Mini', tier: 'fast' },
19
- { id: 'gpt-4-turbo', name: 'GPT-4 Turbo', tier: 'balanced' },
20
- { id: 'o1', name: 'o1', tier: 'reasoning' },
21
- { id: 'o1-mini', name: 'o1-mini', tier: 'reasoning' },
22
- { id: 'o3-mini', name: 'o3-mini', tier: 'reasoning' },
23
- ],
24
- google: [
25
- { id: 'gemini-2.0-flash', name: 'Gemini 2.0 Flash', tier: 'flagship' },
26
- { id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro', tier: 'balanced' },
27
- { id: 'gemini-1.5-flash', name: 'Gemini 1.5 Flash', tier: 'fast' },
28
- { id: 'gemini-1.0-pro', name: 'Gemini 1.0 Pro', tier: 'legacy' },
29
- ],
30
- mistral: [
31
- { id: 'mistral-large-latest', name: 'Mistral Large', tier: 'flagship' },
32
- { id: 'mistral-medium-latest', name: 'Mistral Medium', tier: 'balanced' },
33
- { id: 'mistral-small-latest', name: 'Mistral Small', tier: 'fast' },
34
- { id: 'codestral-latest', name: 'Codestral', tier: 'code' },
35
- ],
36
- groq: [
37
- { id: 'llama-3.3-70b-versatile', name: 'Llama 3.3 70B', tier: 'flagship' },
38
- { id: 'llama-3.1-8b-instant', name: 'Llama 3.1 8B', tier: 'fast' },
39
- { id: 'mixtral-8x7b-32768', name: 'Mixtral 8x7B', tier: 'balanced' },
40
- { id: 'gemma2-9b-it', name: 'Gemma 2 9B', tier: 'fast' },
41
- ],
42
- xai: [
43
- { id: 'grok-2', name: 'Grok 2', tier: 'flagship' },
44
- { id: 'grok-2-mini', name: 'Grok 2 Mini', tier: 'fast' },
45
- { id: 'grok-beta', name: 'Grok Beta', tier: 'beta' },
46
- ],
47
- perplexity: [
48
- { id: 'sonar-pro', name: 'Sonar Pro', tier: 'flagship' },
49
- { id: 'sonar', name: 'Sonar', tier: 'balanced' },
50
- { id: 'sonar-reasoning', name: 'Sonar Reasoning', tier: 'reasoning' },
51
- ],
52
- openrouter: [
53
- { id: 'anthropic/claude-opus-4', name: 'Claude Opus 4', tier: 'flagship' },
54
- { id: 'openai/gpt-4o', name: 'GPT-4o', tier: 'flagship' },
55
- { id: 'google/gemini-2.0-flash', name: 'Gemini 2.0 Flash', tier: 'flagship' },
56
- { id: 'meta-llama/llama-3.3-70b', name: 'Llama 3.3 70B', tier: 'open' },
57
- ],
8
+ const https = require('https');
9
+
10
+ /**
11
+ * API endpoints for fetching models
12
+ */
13
+ const API_ENDPOINTS = {
14
+ anthropic: 'https://api.anthropic.com/v1/models',
15
+ openai: 'https://api.openai.com/v1/models',
16
+ google: 'https://generativelanguage.googleapis.com/v1/models',
17
+ mistral: 'https://api.mistral.ai/v1/models',
18
+ groq: 'https://api.groq.com/openai/v1/models',
19
+ xai: 'https://api.x.ai/v1/models',
20
+ perplexity: 'https://api.perplexity.ai/models',
21
+ openrouter: 'https://openrouter.ai/api/v1/models',
22
+ };
23
+
24
+ /**
25
+ * Make HTTPS request
26
+ * @param {string} url - API URL
27
+ * @param {Object} headers - Request headers
28
+ * @param {number} timeout - Timeout in ms (default 60000 per RULES.md #15)
29
+ * @returns {Promise<Object>} Response data
30
+ */
31
+ const fetchApi = (url, headers = {}, timeout = 60000) => {
32
+ return new Promise((resolve, reject) => {
33
+ const urlObj = new URL(url);
34
+ const options = {
35
+ hostname: urlObj.hostname,
36
+ path: urlObj.pathname + urlObj.search,
37
+ method: 'GET',
38
+ headers: {
39
+ 'Content-Type': 'application/json',
40
+ ...headers
41
+ },
42
+ timeout
43
+ };
44
+
45
+ const req = https.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
+ resolve({ success: true, data: JSON.parse(data) });
52
+ } else {
53
+ resolve({ success: false, error: `HTTP ${res.statusCode}` });
54
+ }
55
+ } catch (error) {
56
+ resolve({ success: false, error: 'Invalid JSON response' });
57
+ }
58
+ });
59
+ });
60
+
61
+ req.on('error', (error) => {
62
+ resolve({ success: false, error: error.message });
63
+ });
64
+
65
+ req.on('timeout', () => {
66
+ req.destroy();
67
+ resolve({ success: false, error: 'Request timeout' });
68
+ });
69
+
70
+ req.end();
71
+ });
58
72
  };
59
73
 
60
74
  /**
61
- * Get models for a provider
75
+ * Get auth headers for provider
62
76
  * @param {string} providerId - Provider ID
63
- * @returns {Array} List of models
77
+ * @param {string} apiKey - API key
78
+ * @returns {Object} Headers object
64
79
  */
65
- const getModelsForProvider = (providerId) => {
66
- return PROVIDER_MODELS[providerId] || [];
80
+ const getAuthHeaders = (providerId, apiKey) => {
81
+ if (!apiKey) return {};
82
+
83
+ switch (providerId) {
84
+ case 'anthropic':
85
+ return { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' };
86
+ case 'openai':
87
+ case 'groq':
88
+ case 'xai':
89
+ case 'perplexity':
90
+ case 'openrouter':
91
+ return { 'Authorization': `Bearer ${apiKey}` };
92
+ case 'google':
93
+ return {}; // Google uses query param
94
+ case 'mistral':
95
+ return { 'Authorization': `Bearer ${apiKey}` };
96
+ default:
97
+ return { 'Authorization': `Bearer ${apiKey}` };
98
+ }
67
99
  };
68
100
 
69
101
  /**
70
- * Get model by ID
102
+ * Parse models response based on provider
71
103
  * @param {string} providerId - Provider ID
104
+ * @param {Object} data - API response data
105
+ * @returns {Array} Parsed models list
106
+ */
107
+ const parseModelsResponse = (providerId, data) => {
108
+ if (!data) return [];
109
+
110
+ try {
111
+ switch (providerId) {
112
+ case 'anthropic':
113
+ // Anthropic returns { data: [{ id, display_name, ... }] }
114
+ return (data.data || []).map(m => ({
115
+ id: m.id,
116
+ name: m.display_name || m.id
117
+ }));
118
+
119
+ case 'openai':
120
+ case 'groq':
121
+ case 'xai':
122
+ // OpenAI format: { data: [{ id, ... }] }
123
+ return (data.data || [])
124
+ .filter(m => m.id && !m.id.includes('whisper') && !m.id.includes('tts') && !m.id.includes('dall-e'))
125
+ .map(m => ({
126
+ id: m.id,
127
+ name: m.id
128
+ }));
129
+
130
+ case 'google':
131
+ // Google format: { models: [{ name, displayName, ... }] }
132
+ return (data.models || []).map(m => ({
133
+ id: m.name?.replace('models/', '') || m.name,
134
+ name: m.displayName || m.name
135
+ }));
136
+
137
+ case 'mistral':
138
+ // Mistral format: { data: [{ id, ... }] }
139
+ return (data.data || []).map(m => ({
140
+ id: m.id,
141
+ name: m.id
142
+ }));
143
+
144
+ case 'perplexity':
145
+ // Perplexity format varies
146
+ return (data.models || data.data || []).map(m => ({
147
+ id: m.id || m.model,
148
+ name: m.id || m.model
149
+ }));
150
+
151
+ case 'openrouter':
152
+ // OpenRouter format: { data: [{ id, name, ... }] }
153
+ return (data.data || []).map(m => ({
154
+ id: m.id,
155
+ name: m.name || m.id
156
+ }));
157
+
158
+ default:
159
+ return [];
160
+ }
161
+ } catch (error) {
162
+ return [];
163
+ }
164
+ };
165
+
166
+ /**
167
+ * Fetch models from provider API
168
+ * @param {string} providerId - Provider ID
169
+ * @param {string} apiKey - API key (required for most providers)
170
+ * @returns {Promise<Object>} { success, models, error }
171
+ */
172
+ const fetchModelsFromApi = async (providerId, apiKey) => {
173
+ const endpoint = API_ENDPOINTS[providerId];
174
+ if (!endpoint) {
175
+ return { success: false, models: [], error: 'Unknown provider' };
176
+ }
177
+
178
+ // Build URL (Google needs API key in query)
179
+ let url = endpoint;
180
+ if (providerId === 'google' && apiKey) {
181
+ url += `?key=${apiKey}`;
182
+ }
183
+
184
+ const headers = getAuthHeaders(providerId, apiKey);
185
+ const result = await fetchApi(url, headers);
186
+
187
+ if (!result.success) {
188
+ return { success: false, models: [], error: result.error };
189
+ }
190
+
191
+ const models = parseModelsResponse(providerId, result.data);
192
+
193
+ if (models.length === 0) {
194
+ return { success: false, models: [], error: 'No models returned' };
195
+ }
196
+
197
+ return { success: true, models, error: null };
198
+ };
199
+
200
+ /**
201
+ * Get models for a provider - returns empty, use fetchModelsFromApi
202
+ * @param {string} providerId - Provider ID
203
+ * @returns {Array} Empty array
204
+ */
205
+ const getModelsForProvider = (providerId) => {
206
+ return [];
207
+ };
208
+
209
+ /**
210
+ * Get model by ID - returns null, use API data
211
+ * @param {string} providerId - Provider ID
72
212
  * @param {string} modelId - Model ID
73
- * @returns {Object|null} Model object or null
213
+ * @returns {null} Always null
74
214
  */
75
215
  const getModelById = (providerId, modelId) => {
76
- const models = PROVIDER_MODELS[providerId] || [];
77
- return models.find(m => m.id === modelId) || null;
216
+ return null;
78
217
  };
79
218
 
80
219
  module.exports = {
81
- PROVIDER_MODELS,
220
+ fetchModelsFromApi,
82
221
  getModelsForProvider,
83
- getModelById
222
+ getModelById,
223
+ API_ENDPOINTS
84
224
  };
@@ -0,0 +1,199 @@
1
+ /**
2
+ * CLIProxy Service
3
+ *
4
+ * Connects to CLIProxyAPI (localhost:8317) for AI provider access
5
+ * via paid plans (Claude Pro, ChatGPT Plus, etc.)
6
+ *
7
+ * Docs: https://help.router-for.me
8
+ */
9
+
10
+ const http = require('http');
11
+
12
+ // CLIProxy default endpoint
13
+ const CLIPROXY_BASE = 'http://localhost:8317';
14
+
15
+ /**
16
+ * Make HTTP request to CLIProxy
17
+ * @param {string} path - API path
18
+ * @param {string} method - HTTP method
19
+ * @param {Object} headers - Request headers
20
+ * @param {number} timeout - Timeout in ms (default 60000 per RULES.md #15)
21
+ * @returns {Promise<Object>} { success, data, error }
22
+ */
23
+ const fetchCliProxy = (path, method = 'GET', headers = {}, timeout = 60000) => {
24
+ return new Promise((resolve) => {
25
+ const url = new URL(path, CLIPROXY_BASE);
26
+ const options = {
27
+ hostname: url.hostname,
28
+ port: url.port || 8317,
29
+ path: url.pathname + url.search,
30
+ method,
31
+ headers: {
32
+ 'Content-Type': 'application/json',
33
+ ...headers
34
+ },
35
+ timeout
36
+ };
37
+
38
+ const req = http.request(options, (res) => {
39
+ let data = '';
40
+ res.on('data', chunk => data += chunk);
41
+ res.on('end', () => {
42
+ try {
43
+ if (res.statusCode >= 200 && res.statusCode < 300) {
44
+ resolve({ success: true, data: JSON.parse(data) });
45
+ } else {
46
+ resolve({ success: false, error: `HTTP ${res.statusCode}`, data: null });
47
+ }
48
+ } catch (error) {
49
+ resolve({ success: false, error: 'Invalid JSON response', data: null });
50
+ }
51
+ });
52
+ });
53
+
54
+ req.on('error', (error) => {
55
+ if (error.code === 'ECONNREFUSED') {
56
+ resolve({ success: false, error: 'CLIProxy not running', data: null });
57
+ } else {
58
+ resolve({ success: false, error: error.message, data: null });
59
+ }
60
+ });
61
+
62
+ req.on('timeout', () => {
63
+ req.destroy();
64
+ resolve({ success: false, error: 'Request timeout', data: null });
65
+ });
66
+
67
+ req.end();
68
+ });
69
+ };
70
+
71
+ /**
72
+ * Check if CLIProxy is running
73
+ * @returns {Promise<Object>} { running, error }
74
+ */
75
+ const isCliProxyRunning = async () => {
76
+ const result = await fetchCliProxy('/v1/models', 'GET', {}, 5000);
77
+ return {
78
+ running: result.success,
79
+ error: result.success ? null : result.error
80
+ };
81
+ };
82
+
83
+ /**
84
+ * Fetch available models from CLIProxy
85
+ * @returns {Promise<Object>} { success, models, error }
86
+ */
87
+ const fetchModelsFromCliProxy = async () => {
88
+ const result = await fetchCliProxy('/v1/models');
89
+
90
+ if (!result.success) {
91
+ return { success: false, models: [], error: result.error };
92
+ }
93
+
94
+ // Parse OpenAI-compatible format: { data: [{ id, ... }] }
95
+ const data = result.data;
96
+ if (!data || !data.data || !Array.isArray(data.data)) {
97
+ return { success: false, models: [], error: 'Invalid response format' };
98
+ }
99
+
100
+ const models = data.data
101
+ .filter(m => m.id)
102
+ .map(m => ({
103
+ id: m.id,
104
+ name: m.id
105
+ }));
106
+
107
+ if (models.length === 0) {
108
+ return { success: false, models: [], error: 'No models available' };
109
+ }
110
+
111
+ return { success: true, models, error: null };
112
+ };
113
+
114
+ /**
115
+ * Get OAuth URL for a provider
116
+ * @param {string} providerId - Provider ID (anthropic, openai, google, etc.)
117
+ * @returns {Promise<Object>} { success, url, state, error }
118
+ */
119
+ const getOAuthUrl = async (providerId) => {
120
+ // Map HQX provider IDs to CLIProxy endpoints
121
+ const oauthEndpoints = {
122
+ anthropic: '/v0/management/anthropic-auth-url',
123
+ openai: '/v0/management/codex-auth-url',
124
+ google: '/v0/management/gemini-cli-auth-url',
125
+ // Others may not have OAuth support in CLIProxy
126
+ };
127
+
128
+ const endpoint = oauthEndpoints[providerId];
129
+ if (!endpoint) {
130
+ return { success: false, url: null, state: null, error: 'OAuth not supported for this provider' };
131
+ }
132
+
133
+ const result = await fetchCliProxy(endpoint);
134
+
135
+ if (!result.success) {
136
+ return { success: false, url: null, state: null, error: result.error };
137
+ }
138
+
139
+ const data = result.data;
140
+ if (!data || !data.url) {
141
+ return { success: false, url: null, state: null, error: 'Invalid OAuth response' };
142
+ }
143
+
144
+ return {
145
+ success: true,
146
+ url: data.url,
147
+ state: data.state || null,
148
+ error: null
149
+ };
150
+ };
151
+
152
+ /**
153
+ * Check OAuth status
154
+ * @param {string} state - OAuth state from getOAuthUrl
155
+ * @returns {Promise<Object>} { success, status, error }
156
+ */
157
+ const checkOAuthStatus = async (state) => {
158
+ const result = await fetchCliProxy(`/v0/management/get-auth-status?state=${encodeURIComponent(state)}`);
159
+
160
+ if (!result.success) {
161
+ return { success: false, status: null, error: result.error };
162
+ }
163
+
164
+ const data = result.data;
165
+ // status can be: "wait", "ok", "error"
166
+ return {
167
+ success: true,
168
+ status: data.status || 'unknown',
169
+ error: data.error || null
170
+ };
171
+ };
172
+
173
+ /**
174
+ * Get CLIProxy auth files (connected accounts)
175
+ * @returns {Promise<Object>} { success, files, error }
176
+ */
177
+ const getAuthFiles = async () => {
178
+ const result = await fetchCliProxy('/v0/management/auth-files');
179
+
180
+ if (!result.success) {
181
+ return { success: false, files: [], error: result.error };
182
+ }
183
+
184
+ return {
185
+ success: true,
186
+ files: result.data?.files || [],
187
+ error: null
188
+ };
189
+ };
190
+
191
+ module.exports = {
192
+ CLIPROXY_BASE,
193
+ isCliProxyRunning,
194
+ fetchModelsFromCliProxy,
195
+ getOAuthUrl,
196
+ checkOAuthStatus,
197
+ getAuthFiles,
198
+ fetchCliProxy
199
+ };