smartcontext-proxy 0.2.0 → 0.2.1

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.
@@ -0,0 +1,177 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.enablePFRedirect = enablePFRedirect;
7
+ exports.disablePFRedirect = disablePFRedirect;
8
+ exports.isPFRedirectActive = isPFRedirectActive;
9
+ exports.refreshPFRules = refreshPFRules;
10
+ const node_child_process_1 = require("node:child_process");
11
+ const node_fs_1 = __importDefault(require("node:fs"));
12
+ const node_dns_1 = __importDefault(require("node:dns"));
13
+ const node_path_1 = __importDefault(require("node:path"));
14
+ const PF_ANCHOR = 'com.smartcontext';
15
+ const PF_CONF_PATH = node_path_1.default.join(process.env['HOME'] || '.', '.smartcontext', 'pf-smartcontext.conf');
16
+ /** Known LLM provider hostnames to intercept */
17
+ const LLM_HOSTS = [
18
+ 'api.anthropic.com',
19
+ 'api.openai.com',
20
+ 'generativelanguage.googleapis.com',
21
+ 'openrouter.ai',
22
+ 'api.together.xyz',
23
+ 'api.fireworks.ai',
24
+ 'api.mistral.ai',
25
+ 'api.cohere.com',
26
+ 'api.groq.com',
27
+ 'api.deepseek.com',
28
+ ];
29
+ /**
30
+ * Resolve hostnames to IPs for pf rules.
31
+ * pf works at IP level, not DNS.
32
+ */
33
+ async function resolveHosts() {
34
+ const results = new Map();
35
+ for (const host of LLM_HOSTS) {
36
+ try {
37
+ const addrs = await new Promise((resolve, reject) => {
38
+ node_dns_1.default.resolve4(host, (err, addresses) => {
39
+ if (err)
40
+ reject(err);
41
+ else
42
+ resolve(addresses);
43
+ });
44
+ });
45
+ results.set(host, addrs);
46
+ }
47
+ catch {
48
+ // Host might not resolve — skip
49
+ }
50
+ }
51
+ return results;
52
+ }
53
+ /**
54
+ * Generate pf redirect rules.
55
+ * Redirects outgoing HTTPS (port 443) traffic to LLM provider IPs
56
+ * to our local proxy port.
57
+ */
58
+ function generatePFConf(hostIPs, proxyPort) {
59
+ const lines = [
60
+ `# SmartContext Proxy — auto-generated pf rules`,
61
+ `# Redirects LLM API traffic to local proxy`,
62
+ ``,
63
+ ];
64
+ // Collect all IPs into a pf table
65
+ const allIPs = [];
66
+ for (const [host, ips] of hostIPs) {
67
+ lines.push(`# ${host}: ${ips.join(', ')}`);
68
+ allIPs.push(...ips);
69
+ }
70
+ if (allIPs.length === 0) {
71
+ lines.push('# No IPs resolved — no rules');
72
+ return lines.join('\n');
73
+ }
74
+ lines.push('');
75
+ lines.push(`table <llm_providers> { ${allIPs.join(', ')} }`);
76
+ lines.push('');
77
+ // Redirect outgoing HTTPS to LLM providers → local proxy
78
+ // rdr-to changes destination to localhost:proxyPort
79
+ lines.push(`rdr pass on lo0 proto tcp from any to <llm_providers> port 443 -> 127.0.0.1 port ${proxyPort}`);
80
+ lines.push('');
81
+ // Route the traffic through loopback so rdr applies
82
+ lines.push(`pass out on en0 route-to (lo0 127.0.0.1) proto tcp from any to <llm_providers> port 443`);
83
+ lines.push(`pass out on en1 route-to (lo0 127.0.0.1) proto tcp from any to <llm_providers> port 443`);
84
+ lines.push('');
85
+ return lines.join('\n');
86
+ }
87
+ /**
88
+ * Install pf redirect rules.
89
+ * Requires sudo.
90
+ */
91
+ async function enablePFRedirect(proxyPort) {
92
+ try {
93
+ const hostIPs = await resolveHosts();
94
+ let totalIPs = 0;
95
+ for (const ips of hostIPs.values())
96
+ totalIPs += ips.length;
97
+ if (totalIPs === 0) {
98
+ return { success: false, message: 'No LLM provider IPs resolved', ips: 0 };
99
+ }
100
+ const conf = generatePFConf(hostIPs, proxyPort);
101
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(PF_CONF_PATH), { recursive: true });
102
+ node_fs_1.default.writeFileSync(PF_CONF_PATH, conf);
103
+ // Load anchor into pf
104
+ try {
105
+ (0, node_child_process_1.execFileSync)('sudo', ['pfctl', '-a', PF_ANCHOR, '-f', PF_CONF_PATH], { stdio: 'pipe' });
106
+ }
107
+ catch (err) {
108
+ return { success: false, message: `pfctl load failed: ${err}`, ips: totalIPs };
109
+ }
110
+ // Enable pf if not already enabled
111
+ try {
112
+ (0, node_child_process_1.execFileSync)('sudo', ['pfctl', '-e'], { stdio: 'pipe' });
113
+ }
114
+ catch {
115
+ // Already enabled — ok
116
+ }
117
+ return { success: true, message: `pf redirect active: ${totalIPs} IPs from ${hostIPs.size} hosts → :${proxyPort}`, ips: totalIPs };
118
+ }
119
+ catch (err) {
120
+ return { success: false, message: `Failed: ${err}`, ips: 0 };
121
+ }
122
+ }
123
+ /**
124
+ * Remove pf redirect rules.
125
+ */
126
+ function disablePFRedirect() {
127
+ try {
128
+ // Flush anchor rules
129
+ try {
130
+ (0, node_child_process_1.execFileSync)('sudo', ['pfctl', '-a', PF_ANCHOR, '-F', 'all'], { stdio: 'pipe' });
131
+ }
132
+ catch { }
133
+ // Remove conf file
134
+ try {
135
+ node_fs_1.default.unlinkSync(PF_CONF_PATH);
136
+ }
137
+ catch { }
138
+ return { success: true, message: 'pf redirect removed' };
139
+ }
140
+ catch (err) {
141
+ return { success: false, message: `Failed: ${err}` };
142
+ }
143
+ }
144
+ /**
145
+ * Check if pf redirect is active.
146
+ */
147
+ function isPFRedirectActive() {
148
+ try {
149
+ const result = (0, node_child_process_1.execFileSync)('sudo', ['pfctl', '-a', PF_ANCHOR, '-sr'], {
150
+ encoding: 'utf-8',
151
+ stdio: ['pipe', 'pipe', 'pipe'],
152
+ });
153
+ return result.includes('rdr') || result.includes('route-to');
154
+ }
155
+ catch {
156
+ return false;
157
+ }
158
+ }
159
+ /**
160
+ * Refresh IP addresses (IPs can change due to DNS).
161
+ * Call periodically to keep rules current.
162
+ */
163
+ async function refreshPFRules(proxyPort) {
164
+ const hostIPs = await resolveHosts();
165
+ let totalIPs = 0;
166
+ for (const ips of hostIPs.values())
167
+ totalIPs += ips.length;
168
+ if (totalIPs === 0)
169
+ return;
170
+ const conf = generatePFConf(hostIPs, proxyPort);
171
+ node_fs_1.default.writeFileSync(PF_CONF_PATH, conf);
172
+ try {
173
+ (0, node_child_process_1.execFileSync)('sudo', ['pfctl', '-a', PF_ANCHOR, '-f', PF_CONF_PATH], { stdio: 'pipe' });
174
+ }
175
+ catch { }
176
+ }
177
+ //# sourceMappingURL=pf-redirect.js.map
@@ -40,6 +40,7 @@ function httpRequest(url, options, body) {
40
40
  node_assert_1.default.ok(res.headers['content-type']?.includes('text/html'));
41
41
  node_assert_1.default.ok(res.body.includes('SmartContext Proxy'));
42
42
  node_assert_1.default.ok(res.body.includes('Total Requests'));
43
+ node_assert_1.default.ok(res.body.includes('Settings'));
43
44
  });
44
45
  (0, node_test_1.it)('returns status via /_sc/status', async () => {
45
46
  const res = await httpRequest(`http://127.0.0.1:${PORT}/_sc/status`, { method: 'GET' });
@@ -1,2 +1,11 @@
1
1
  import type { MetricsCollector } from '../metrics/collector.js';
2
- export declare function renderDashboard(metrics: MetricsCollector, paused: boolean): string;
2
+ export interface DashboardState {
3
+ paused: boolean;
4
+ mode: 'transparent' | 'optimizing';
5
+ proxyType: 'connect' | 'legacy';
6
+ abTestEnabled: boolean;
7
+ debugHeaders: boolean;
8
+ caInstalled: boolean;
9
+ systemProxyActive: boolean;
10
+ }
11
+ export declare function renderDashboard(metrics: MetricsCollector, state: DashboardState): string;
@@ -1,15 +1,17 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.renderDashboard = renderDashboard;
4
- function renderDashboard(metrics, paused) {
4
+ function renderDashboard(metrics, state) {
5
5
  const stats = metrics.getStats();
6
6
  const recent = metrics.getRecent(20);
7
- const uptime = metrics.getUptime();
8
- const uptimeStr = formatDuration(uptime);
9
- const statusBadge = paused
7
+ const uptimeStr = formatDuration(metrics.getUptime());
8
+ const savingsAmount = estimateCostSaved(stats.totalOriginalTokens - stats.totalOptimizedTokens);
9
+ const stateBadge = state.paused
10
10
  ? '<span class="badge paused">PAUSED</span>'
11
11
  : '<span class="badge running">RUNNING</span>';
12
- const savingsAmount = estimateCostSaved(stats.totalOriginalTokens - stats.totalOptimizedTokens);
12
+ const modeBadge = state.mode === 'optimizing'
13
+ ? '<span class="badge opt">OPTIMIZING</span>'
14
+ : '<span class="badge transparent">TRANSPARENT</span>';
13
15
  return `<!DOCTYPE html>
14
16
  <html lang="en">
15
17
  <head>
@@ -21,17 +23,23 @@ function renderDashboard(metrics, paused) {
21
23
  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; background: #0f1117; color: #e1e4e8; }
22
24
  .container { max-width: 1200px; margin: 0 auto; padding: 20px; }
23
25
  header { display: flex; justify-content: space-between; align-items: center; padding: 16px 0; border-bottom: 1px solid #21262d; margin-bottom: 24px; }
24
- h1 { font-size: 20px; font-weight: 600; }
26
+ h1 { font-size: 20px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
25
27
  h2 { font-size: 16px; font-weight: 600; margin-bottom: 12px; color: #8b949e; }
26
- .badge { padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 600; text-transform: uppercase; }
28
+ .badge { padding: 4px 12px; border-radius: 12px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
27
29
  .badge.running { background: #238636; color: #fff; }
28
30
  .badge.paused { background: #d29922; color: #000; }
29
- .controls { display: flex; gap: 8px; }
30
- .btn { padding: 6px 16px; border-radius: 6px; border: 1px solid #30363d; background: #21262d; color: #e1e4e8; cursor: pointer; font-size: 13px; }
31
- .btn:hover { background: #30363d; }
31
+ .badge.opt { background: #1f6feb; color: #fff; }
32
+ .badge.transparent { background: #30363d; color: #8b949e; }
33
+ .badge.on { background: #238636; color: #fff; }
34
+ .badge.off { background: #30363d; color: #8b949e; }
35
+ .controls { display: flex; gap: 8px; align-items: center; }
36
+ .btn { padding: 6px 16px; border-radius: 6px; border: 1px solid #30363d; background: #21262d; color: #e1e4e8; cursor: pointer; font-size: 13px; transition: all 0.15s; }
37
+ .btn:hover { background: #30363d; border-color: #484f58; }
32
38
  .btn.primary { background: #238636; border-color: #238636; }
39
+ .btn.primary:hover { background: #2ea043; }
33
40
  .btn.warn { background: #d29922; border-color: #d29922; color: #000; }
34
- .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 16px; margin-bottom: 24px; }
41
+ .btn.danger { background: #da3633; border-color: #da3633; }
42
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin-bottom: 24px; }
35
43
  .card { background: #161b22; border: 1px solid #21262d; border-radius: 8px; padding: 20px; }
36
44
  .card .value { font-size: 32px; font-weight: 700; color: #58a6ff; }
37
45
  .card .label { font-size: 13px; color: #8b949e; margin-top: 4px; }
@@ -47,20 +55,46 @@ function renderDashboard(metrics, paused) {
47
55
  .tab.active { color: #e1e4e8; border-bottom-color: #58a6ff; }
48
56
  .tab-content { display: none; }
49
57
  .tab-content.active { display: block; }
58
+ .settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
59
+ .setting { background: #161b22; border: 1px solid #21262d; border-radius: 8px; padding: 16px; display: flex; justify-content: space-between; align-items: center; }
60
+ .setting-info { flex: 1; }
61
+ .setting-name { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
62
+ .setting-desc { font-size: 12px; color: #8b949e; }
63
+ .toggle { position: relative; width: 44px; height: 24px; cursor: pointer; }
64
+ .toggle input { display: none; }
65
+ .toggle .slider { position: absolute; inset: 0; background: #30363d; border-radius: 12px; transition: 0.2s; }
66
+ .toggle .slider:before { content: ''; position: absolute; width: 18px; height: 18px; left: 3px; top: 3px; background: #8b949e; border-radius: 50%; transition: 0.2s; }
67
+ .toggle input:checked + .slider { background: #238636; }
68
+ .toggle input:checked + .slider:before { transform: translateX(20px); background: #fff; }
69
+ .status-row { display: flex; gap: 16px; margin-bottom: 16px; flex-wrap: wrap; }
70
+ .status-pill { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #8b949e; }
71
+ .dot { width: 8px; height: 8px; border-radius: 50%; }
72
+ .dot.green { background: #3fb950; }
73
+ .dot.yellow { background: #d29922; }
74
+ .dot.red { background: #f85149; }
75
+ .dot.gray { background: #484f58; }
50
76
  .refresh-note { font-size: 11px; color: #484f58; text-align: right; margin-top: 8px; }
51
77
  </style>
52
78
  </head>
53
79
  <body>
54
80
  <div class="container">
55
81
  <header>
56
- <h1>SmartContext Proxy ${statusBadge}</h1>
82
+ <h1>SmartContext Proxy ${stateBadge} ${modeBadge}</h1>
57
83
  <div class="controls">
58
- ${paused
84
+ ${state.paused
59
85
  ? '<button class="btn primary" onclick="api(\'/_sc/resume\')">Resume</button>'
60
86
  : '<button class="btn warn" onclick="api(\'/_sc/pause\')">Pause</button>'}
61
87
  </div>
62
88
  </header>
63
89
 
90
+ <div class="status-row">
91
+ <div class="status-pill"><div class="dot ${state.caInstalled ? 'green' : 'red'}"></div>CA Certificate</div>
92
+ <div class="status-pill"><div class="dot ${state.systemProxyActive ? 'green' : 'gray'}"></div>System Proxy</div>
93
+ <div class="status-pill"><div class="dot ${state.mode === 'optimizing' ? 'green' : 'gray'}"></div>Context Optimization</div>
94
+ <div class="status-pill"><div class="dot ${state.abTestEnabled ? 'yellow' : 'gray'}"></div>A/B Test</div>
95
+ <div class="status-pill"><div class="dot ${state.proxyType === 'connect' ? 'green' : 'gray'}"></div>${state.proxyType === 'connect' ? 'Transparent' : 'Legacy'} Mode</div>
96
+ </div>
97
+
64
98
  <div class="grid">
65
99
  <div class="card savings">
66
100
  <div class="value">$${savingsAmount}</div>
@@ -81,9 +115,10 @@ function renderDashboard(metrics, paused) {
81
115
  </div>
82
116
 
83
117
  <div class="tab-bar">
84
- <div class="tab active" onclick="switchTab('feed')">Live Feed</div>
85
- <div class="tab" onclick="switchTab('providers')">By Provider</div>
86
- <div class="tab" onclick="switchTab('models')">By Model</div>
118
+ <div class="tab active" onclick="switchTab('feed',this)">Live Feed</div>
119
+ <div class="tab" onclick="switchTab('providers',this)">By Provider</div>
120
+ <div class="tab" onclick="switchTab('models',this)">By Model</div>
121
+ <div class="tab" onclick="switchTab('settings',this)">Settings</div>
87
122
  </div>
88
123
 
89
124
  <div id="tab-feed" class="tab-content active">
@@ -113,12 +148,7 @@ function renderDashboard(metrics, paused) {
113
148
  <thead><tr><th>Provider</th><th>Requests</th><th>Tokens Saved</th><th>Savings %</th></tr></thead>
114
149
  <tbody>
115
150
  ${Object.entries(stats.byProvider).map(([name, s]) => `
116
- <tr>
117
- <td>${name}</td>
118
- <td class="mono">${s.requests}</td>
119
- <td class="mono">${formatTokens(s.tokensSaved)}</td>
120
- <td class="savings-pct">${s.savingsPercent}%</td>
121
- </tr>
151
+ <tr><td>${name}</td><td class="mono">${s.requests}</td><td class="mono">${formatTokens(s.tokensSaved)}</td><td class="savings-pct">${s.savingsPercent}%</td></tr>
122
152
  `).join('')}
123
153
  </tbody>
124
154
  </table>
@@ -130,31 +160,88 @@ function renderDashboard(metrics, paused) {
130
160
  <thead><tr><th>Model</th><th>Requests</th><th>Tokens Saved</th><th>Savings %</th></tr></thead>
131
161
  <tbody>
132
162
  ${Object.entries(stats.byModel).map(([name, s]) => `
133
- <tr>
134
- <td>${name}</td>
135
- <td class="mono">${s.requests}</td>
136
- <td class="mono">${formatTokens(s.tokensSaved)}</td>
137
- <td class="savings-pct">${s.savingsPercent}%</td>
138
- </tr>
163
+ <tr><td>${name}</td><td class="mono">${s.requests}</td><td class="mono">${formatTokens(s.tokensSaved)}</td><td class="savings-pct">${s.savingsPercent}%</td></tr>
139
164
  `).join('')}
140
165
  </tbody>
141
166
  </table>
142
167
  </div>
143
168
 
144
- <div class="refresh-note">Uptime: ${uptimeStr} | Auto-refresh: 10s</div>
169
+ <div id="tab-settings" class="tab-content">
170
+ <h2>Controls</h2>
171
+ <div class="settings-grid">
172
+ <div class="setting">
173
+ <div class="setting-info">
174
+ <div class="setting-name">Context Optimization</div>
175
+ <div class="setting-desc">Optimize LLM context windows to reduce token usage</div>
176
+ </div>
177
+ <label class="toggle">
178
+ <input type="checkbox" ${!state.paused ? 'checked' : ''} onchange="api(this.checked ? '/_sc/resume' : '/_sc/pause')">
179
+ <span class="slider"></span>
180
+ </label>
181
+ </div>
182
+
183
+ <div class="setting">
184
+ <div class="setting-info">
185
+ <div class="setting-name">A/B Test Mode</div>
186
+ <div class="setting-desc">Send each request twice to compare quality</div>
187
+ </div>
188
+ <label class="toggle">
189
+ <input type="checkbox" ${state.abTestEnabled ? 'checked' : ''} onchange="api(this.checked ? '/_sc/ab-test/enable' : '/_sc/ab-test/disable')">
190
+ <span class="slider"></span>
191
+ </label>
192
+ </div>
193
+
194
+ <div class="setting">
195
+ <div class="setting-info">
196
+ <div class="setting-name">Debug Headers</div>
197
+ <div class="setting-desc">Add X-SmartContext-* headers to responses</div>
198
+ </div>
199
+ <label class="toggle">
200
+ <input type="checkbox" ${state.debugHeaders ? 'checked' : ''} onchange="api(this.checked ? '/_sc/debug-headers/enable' : '/_sc/debug-headers/disable')">
201
+ <span class="slider"></span>
202
+ </label>
203
+ </div>
204
+
205
+ <div class="setting">
206
+ <div class="setting-info">
207
+ <div class="setting-name">System Proxy</div>
208
+ <div class="setting-desc">Auto-intercept all LLM traffic system-wide</div>
209
+ </div>
210
+ <label class="toggle">
211
+ <input type="checkbox" ${state.systemProxyActive ? 'checked' : ''} onchange="api(this.checked ? '/_sc/system-proxy/enable' : '/_sc/system-proxy/disable')">
212
+ <span class="slider"></span>
213
+ </label>
214
+ </div>
215
+ </div>
216
+ </div>
217
+
218
+ <div class="refresh-note">v0.2.0 | Uptime: ${uptimeStr} | Auto-refresh: 5s</div>
145
219
  </div>
146
220
  <script>
147
- function switchTab(name) {
221
+ function switchTab(name, el) {
148
222
  document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
149
223
  document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
150
224
  document.getElementById('tab-' + name).classList.add('active');
151
- event.target.classList.add('active');
225
+ if (el) el.classList.add('active');
226
+ location.hash = name;
152
227
  }
153
228
  async function api(path) {
154
229
  await fetch(path, { method: 'POST' });
155
230
  location.reload();
156
231
  }
157
- setTimeout(() => location.reload(), 10000);
232
+ // Restore tab from URL hash
233
+ (function() {
234
+ var hash = location.hash.replace('#', '');
235
+ if (hash && document.getElementById('tab-' + hash)) {
236
+ document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
237
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
238
+ document.getElementById('tab-' + hash).classList.add('active');
239
+ document.querySelectorAll('.tab').forEach(t => {
240
+ if (t.getAttribute('onclick') && t.getAttribute('onclick').indexOf("'" + hash + "'") !== -1) t.classList.add('active');
241
+ });
242
+ }
243
+ })();
244
+ setTimeout(() => { var h = location.hash; location.reload(); if (h) location.hash = h; }, 5000);
158
245
  </script>
159
246
  </body>
160
247
  </html>`;
@@ -174,9 +261,7 @@ function formatDuration(ms) {
174
261
  return `${Math.floor(s / 60)}m ${s % 60}s`;
175
262
  return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
176
263
  }
177
- /** Rough cost estimate based on Anthropic/OpenAI pricing */
178
264
  function estimateCostSaved(tokensSaved) {
179
- // Assume avg $15/1M input tokens (Opus pricing)
180
265
  const cost = (tokensSaved / 1000000) * 15;
181
266
  return cost.toFixed(2);
182
267
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smartcontext-proxy",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Intelligent context window optimization proxy for LLM APIs",
5
5
  "main": "dist/src/index.js",
6
6
  "types": "dist/src/index.d.ts",
@@ -7,7 +7,10 @@ import type { SmartContextConfig } from '../config/schema.js';
7
7
  import type { ProviderAdapter } from '../providers/types.js';
8
8
  import { ContextOptimizer } from '../context/optimizer.js';
9
9
  import { MetricsCollector } from '../metrics/collector.js';
10
- import { renderDashboard } from '../ui/dashboard.js';
10
+ import { renderDashboard, type DashboardState } from '../ui/dashboard.js';
11
+ import { isABEnabled, enableABTest, disableABTest, getABSummary, getABResults } from '../context/ab-test.js';
12
+ import { isCAInstalled } from '../tls/trust-store.js';
13
+ import { cacheRealIPs, getRealIP, isDNSRedirectActive, enableDNSRedirect, disableDNSRedirect } from '../system/dns-redirect.js';
11
14
 
12
15
  /**
13
16
  * HTTP CONNECT proxy that transparently intercepts LLM traffic.
@@ -22,6 +25,8 @@ export class ConnectProxy {
22
25
  private optimizer: ContextOptimizer | null = null;
23
26
  private adapters = new Map<string, ProviderAdapter>();
24
27
  private paused = false;
28
+ private debugHeaders = false;
29
+ private systemProxyActive = false;
25
30
  private requestCounter = { value: 0 };
26
31
  private config: SmartContextConfig;
27
32
 
@@ -80,7 +85,7 @@ export class ConnectProxy {
80
85
  // Dashboard
81
86
  if (path === '/' && method === 'GET') {
82
87
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
83
- res.end(renderDashboard(this.metrics, this.paused));
88
+ res.end(renderDashboard(this.metrics, this.getDashboardState()));
84
89
  return;
85
90
  }
86
91
 
@@ -115,7 +120,7 @@ export class ConnectProxy {
115
120
  res.end(JSON.stringify({ error: 'Not found' }));
116
121
  }
117
122
 
118
- private handleAPI(path: string, method: string, req: http.IncomingMessage, res: http.ServerResponse): void {
123
+ private async handleAPI(path: string, method: string, req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
119
124
  res.setHeader('Content-Type', 'application/json');
120
125
 
121
126
  switch (path) {
@@ -141,12 +146,71 @@ export class ConnectProxy {
141
146
  this.paused = false;
142
147
  res.end(JSON.stringify({ ok: true, state: 'running' }));
143
148
  break;
149
+ case '/_sc/ab-test/enable':
150
+ enableABTest();
151
+ res.end(JSON.stringify({ ok: true, abTest: true }));
152
+ break;
153
+ case '/_sc/ab-test/disable':
154
+ disableABTest();
155
+ res.end(JSON.stringify({ ok: true, abTest: false }));
156
+ break;
157
+ case '/_sc/ab-test/results':
158
+ res.end(JSON.stringify(getABResults()));
159
+ break;
160
+ case '/_sc/ab-test/summary':
161
+ res.end(JSON.stringify(getABSummary()));
162
+ break;
163
+ case '/_sc/debug-headers/enable':
164
+ this.debugHeaders = true;
165
+ this.config.logging.debug_headers = true;
166
+ res.end(JSON.stringify({ ok: true, debugHeaders: true }));
167
+ break;
168
+ case '/_sc/debug-headers/disable':
169
+ this.debugHeaders = false;
170
+ this.config.logging.debug_headers = false;
171
+ res.end(JSON.stringify({ ok: true, debugHeaders: false }));
172
+ break;
173
+ case '/_sc/system-proxy/enable':
174
+ try {
175
+ // Cache real IPs before overriding DNS
176
+ await cacheRealIPs();
177
+ const dnsResult = enableDNSRedirect();
178
+ this.systemProxyActive = dnsResult.success;
179
+ res.end(JSON.stringify({ ok: dnsResult.success, message: dnsResult.message, method: 'dns-redirect' }));
180
+ } catch (err) {
181
+ res.end(JSON.stringify({ ok: false, error: String(err) }));
182
+ }
183
+ break;
184
+ case '/_sc/system-proxy/disable':
185
+ try {
186
+ const disableResult = disableDNSRedirect();
187
+ this.systemProxyActive = false;
188
+ res.end(JSON.stringify({ ok: disableResult.success, message: disableResult.message }));
189
+ } catch (err) {
190
+ res.end(JSON.stringify({ ok: false, error: String(err) }));
191
+ }
192
+ break;
144
193
  default:
145
194
  res.writeHead(404);
146
195
  res.end(JSON.stringify({ error: `Unknown: ${path}` }));
147
196
  }
148
197
  }
149
198
 
199
+ private getDashboardState(): DashboardState {
200
+ let caInstalled = false;
201
+ try { caInstalled = isCAInstalled(); } catch {}
202
+
203
+ return {
204
+ paused: this.paused,
205
+ mode: this.optimizer ? 'optimizing' : 'transparent',
206
+ proxyType: 'connect',
207
+ abTestEnabled: isABEnabled(),
208
+ debugHeaders: this.debugHeaders,
209
+ caInstalled,
210
+ systemProxyActive: this.systemProxyActive,
211
+ };
212
+ }
213
+
150
214
  private generatePAC(): string {
151
215
  const { getLLMHostnames } = require('./classifier.js');
152
216
  const hosts = getLLMHostnames();
@@ -11,7 +11,7 @@ import type { EmbeddingAdapter } from '../embedding/types.js';
11
11
  import type { StorageAdapter } from '../storage/types.js';
12
12
  import { estimateTokens } from '../context/chunker.js';
13
13
  import { getTextContent } from '../context/canonical.js';
14
- import { renderDashboard } from '../ui/dashboard.js';
14
+ import { renderDashboard, type DashboardState } from '../ui/dashboard.js';
15
15
 
16
16
  export class ProxyServer {
17
17
  private server: http.Server;
@@ -72,7 +72,16 @@ export class ProxyServer {
72
72
  // Dashboard (root path)
73
73
  if (path === '/' && method === 'GET') {
74
74
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
75
- res.end(renderDashboard(this.metrics, this.paused));
75
+ const state: DashboardState = {
76
+ paused: this.paused,
77
+ mode: this.optimizer ? 'optimizing' : 'transparent',
78
+ proxyType: 'legacy',
79
+ abTestEnabled: false,
80
+ debugHeaders: this.config.logging.debug_headers,
81
+ caInstalled: false,
82
+ systemProxyActive: false,
83
+ };
84
+ res.end(renderDashboard(this.metrics, state));
76
85
  return;
77
86
  }
78
87