smartcontext-proxy 0.1.0 → 0.2.0
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/PLAN-v2.md +390 -0
- package/dist/src/context/ab-test.d.ts +32 -0
- package/dist/src/context/ab-test.js +133 -0
- package/dist/src/index.js +99 -78
- package/dist/src/proxy/classifier.d.ts +14 -0
- package/dist/src/proxy/classifier.js +63 -0
- package/dist/src/proxy/connect-proxy.d.ts +34 -0
- package/dist/src/proxy/connect-proxy.js +167 -0
- package/dist/src/proxy/tls-interceptor.d.ts +23 -0
- package/dist/src/proxy/tls-interceptor.js +211 -0
- package/dist/src/proxy/tunnel.d.ts +7 -0
- package/dist/src/proxy/tunnel.js +33 -0
- package/dist/src/system/installer.d.ts +25 -0
- package/dist/src/system/installer.js +180 -0
- package/dist/src/system/linux.d.ts +11 -0
- package/dist/src/system/linux.js +60 -0
- package/dist/src/system/macos.d.ts +24 -0
- package/dist/src/system/macos.js +98 -0
- package/dist/src/system/watchdog.d.ts +7 -0
- package/dist/src/system/watchdog.js +115 -0
- package/dist/src/test/connect-proxy.test.d.ts +1 -0
- package/dist/src/test/connect-proxy.test.js +147 -0
- package/dist/src/tls/ca-manager.d.ts +9 -0
- package/dist/src/tls/ca-manager.js +117 -0
- package/dist/src/tls/trust-store.d.ts +11 -0
- package/dist/src/tls/trust-store.js +121 -0
- package/dist/src/tray/bridge.d.ts +8 -0
- package/dist/src/tray/bridge.js +66 -0
- package/dist/src/ui/ws-feed.d.ts +8 -0
- package/dist/src/ui/ws-feed.js +30 -0
- package/native/macos/SmartContextTray/Package.swift +13 -0
- package/native/macos/SmartContextTray/Sources/main.swift +206 -0
- package/package.json +6 -2
- package/src/context/ab-test.ts +172 -0
- package/src/index.ts +104 -74
- package/src/proxy/classifier.ts +71 -0
- package/src/proxy/connect-proxy.ts +187 -0
- package/src/proxy/tls-interceptor.ts +261 -0
- package/src/proxy/tunnel.ts +32 -0
- package/src/system/installer.ts +148 -0
- package/src/system/linux.ts +57 -0
- package/src/system/macos.ts +89 -0
- package/src/system/watchdog.ts +76 -0
- package/src/test/connect-proxy.test.ts +170 -0
- package/src/tls/ca-manager.ts +140 -0
- package/src/tls/trust-store.ts +123 -0
- package/src/tray/bridge.ts +61 -0
- package/src/ui/ws-feed.ts +32 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** Known LLM provider hostnames and their API patterns */
|
|
2
|
+
|
|
3
|
+
export interface ProviderMatch {
|
|
4
|
+
provider: string;
|
|
5
|
+
hostname: string;
|
|
6
|
+
isLLM: true;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const LLM_HOSTS: Record<string, string> = {
|
|
10
|
+
'api.anthropic.com': 'anthropic',
|
|
11
|
+
'api.openai.com': 'openai',
|
|
12
|
+
'generativelanguage.googleapis.com': 'google',
|
|
13
|
+
'openrouter.ai': 'openrouter',
|
|
14
|
+
'api.together.xyz': 'together',
|
|
15
|
+
'api.fireworks.ai': 'fireworks',
|
|
16
|
+
'api.mistral.ai': 'mistral',
|
|
17
|
+
'api.cohere.com': 'cohere',
|
|
18
|
+
'api.groq.com': 'groq',
|
|
19
|
+
'api.deepseek.com': 'deepseek',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Ollama ports to intercept (HTTP, no TLS) */
|
|
23
|
+
const OLLAMA_PORTS = new Set([11434]);
|
|
24
|
+
|
|
25
|
+
/** Custom hosts added via config */
|
|
26
|
+
let customHosts: Record<string, string> = {};
|
|
27
|
+
|
|
28
|
+
export function addCustomHost(hostname: string, provider: string): void {
|
|
29
|
+
customHosts[hostname] = provider;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function removeCustomHost(hostname: string): void {
|
|
33
|
+
delete customHosts[hostname];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Check if a hostname:port is an LLM provider that should be intercepted */
|
|
37
|
+
export function classifyHost(hostname: string, port: number): ProviderMatch | null {
|
|
38
|
+
// Check known LLM hosts
|
|
39
|
+
const provider = LLM_HOSTS[hostname] || customHosts[hostname];
|
|
40
|
+
if (provider) {
|
|
41
|
+
return { provider, hostname, isLLM: true };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Check Ollama local
|
|
45
|
+
if ((hostname === 'localhost' || hostname === '127.0.0.1') && OLLAMA_PORTS.has(port)) {
|
|
46
|
+
return { provider: 'ollama', hostname, isLLM: true };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Get all known LLM hostnames (for PAC file generation) */
|
|
53
|
+
export function getLLMHostnames(): string[] {
|
|
54
|
+
return [
|
|
55
|
+
...Object.keys(LLM_HOSTS),
|
|
56
|
+
...Object.keys(customHosts),
|
|
57
|
+
];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Check if a request path looks like an LLM API call */
|
|
61
|
+
export function isLLMPath(path: string): boolean {
|
|
62
|
+
const llmPaths = [
|
|
63
|
+
'/v1/messages', // Anthropic
|
|
64
|
+
'/v1/chat/completions', // OpenAI
|
|
65
|
+
'/v1/completions', // OpenAI legacy
|
|
66
|
+
'/api/chat', // Ollama
|
|
67
|
+
'/api/generate', // Ollama
|
|
68
|
+
'/v1beta/models', // Google
|
|
69
|
+
];
|
|
70
|
+
return llmPaths.some((p) => path.startsWith(p));
|
|
71
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import type { Socket } from 'node:net';
|
|
3
|
+
import { classifyHost } from './classifier.js';
|
|
4
|
+
import { createTunnel } from './tunnel.js';
|
|
5
|
+
import { interceptTLS, type InterceptorOptions } from './tls-interceptor.js';
|
|
6
|
+
import type { SmartContextConfig } from '../config/schema.js';
|
|
7
|
+
import type { ProviderAdapter } from '../providers/types.js';
|
|
8
|
+
import { ContextOptimizer } from '../context/optimizer.js';
|
|
9
|
+
import { MetricsCollector } from '../metrics/collector.js';
|
|
10
|
+
import { renderDashboard } from '../ui/dashboard.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* HTTP CONNECT proxy that transparently intercepts LLM traffic.
|
|
14
|
+
*
|
|
15
|
+
* - Non-LLM HTTPS: blind TCP tunnel (zero overhead)
|
|
16
|
+
* - LLM HTTPS: TLS intercept → optimize → forward
|
|
17
|
+
* - HTTP requests: direct handling (dashboard, API, Ollama)
|
|
18
|
+
*/
|
|
19
|
+
export class ConnectProxy {
|
|
20
|
+
private server: http.Server;
|
|
21
|
+
private metrics = new MetricsCollector();
|
|
22
|
+
private optimizer: ContextOptimizer | null = null;
|
|
23
|
+
private adapters = new Map<string, ProviderAdapter>();
|
|
24
|
+
private paused = false;
|
|
25
|
+
private requestCounter = { value: 0 };
|
|
26
|
+
private config: SmartContextConfig;
|
|
27
|
+
|
|
28
|
+
constructor(
|
|
29
|
+
config: SmartContextConfig,
|
|
30
|
+
optimizer?: ContextOptimizer | null,
|
|
31
|
+
adapters?: Map<string, ProviderAdapter>,
|
|
32
|
+
) {
|
|
33
|
+
this.config = config;
|
|
34
|
+
this.optimizer = optimizer || null;
|
|
35
|
+
if (adapters) this.adapters = adapters;
|
|
36
|
+
|
|
37
|
+
this.server = http.createServer((req, res) => this.handleHTTP(req, res));
|
|
38
|
+
this.server.on('connect', (req, clientSocket: Socket, head) => this.handleConnect(req, clientSocket, head));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async start(): Promise<void> {
|
|
42
|
+
const { port, host } = this.config.proxy;
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
this.server.listen(port, host, () => resolve());
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async stop(): Promise<void> {
|
|
49
|
+
return new Promise((resolve) => {
|
|
50
|
+
this.server.close(() => resolve());
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
getMetrics(): MetricsCollector { return this.metrics; }
|
|
55
|
+
isPaused(): boolean { return this.paused; }
|
|
56
|
+
setPaused(v: boolean): void { this.paused = v; }
|
|
57
|
+
|
|
58
|
+
/** Handle HTTP CONNECT requests (HTTPS tunnel establishment) */
|
|
59
|
+
private handleConnect(req: http.IncomingMessage, clientSocket: Socket, head: Buffer): void {
|
|
60
|
+
const [hostname, portStr] = (req.url || '').split(':');
|
|
61
|
+
const port = parseInt(portStr || '443', 10);
|
|
62
|
+
|
|
63
|
+
const match = classifyHost(hostname, port);
|
|
64
|
+
|
|
65
|
+
if (match) {
|
|
66
|
+
// LLM provider → intercept TLS
|
|
67
|
+
this.log('info', `INTERCEPT ${hostname}:${port} (${match.provider})`);
|
|
68
|
+
interceptTLS(clientSocket, hostname, port, match, this.interceptorOptions());
|
|
69
|
+
} else {
|
|
70
|
+
// Non-LLM → blind tunnel
|
|
71
|
+
createTunnel(clientSocket, hostname, port);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Handle plain HTTP requests (dashboard, API, Ollama interception) */
|
|
76
|
+
private async handleHTTP(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
77
|
+
const path = req.url || '/';
|
|
78
|
+
const method = req.method || 'GET';
|
|
79
|
+
|
|
80
|
+
// Dashboard
|
|
81
|
+
if (path === '/' && method === 'GET') {
|
|
82
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
83
|
+
res.end(renderDashboard(this.metrics, this.paused));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Health
|
|
88
|
+
if (path === '/health') {
|
|
89
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
90
|
+
res.end(JSON.stringify({
|
|
91
|
+
ok: true,
|
|
92
|
+
requests: this.requestCounter.value,
|
|
93
|
+
paused: this.paused,
|
|
94
|
+
mode: this.optimizer ? 'optimizing' : 'transparent',
|
|
95
|
+
type: 'connect-proxy',
|
|
96
|
+
}));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// PAC file
|
|
101
|
+
if (path === '/proxy.pac') {
|
|
102
|
+
res.writeHead(200, { 'Content-Type': 'application/x-ns-proxy-autoconfig' });
|
|
103
|
+
res.end(this.generatePAC());
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// API endpoints
|
|
108
|
+
if (path.startsWith('/_sc/')) {
|
|
109
|
+
this.handleAPI(path, method, req, res);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Everything else: 404
|
|
114
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
115
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private handleAPI(path: string, method: string, req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
119
|
+
res.setHeader('Content-Type', 'application/json');
|
|
120
|
+
|
|
121
|
+
switch (path) {
|
|
122
|
+
case '/_sc/status':
|
|
123
|
+
res.end(JSON.stringify({
|
|
124
|
+
state: this.paused ? 'paused' : 'running',
|
|
125
|
+
uptime: this.metrics.getUptime(),
|
|
126
|
+
requests: this.requestCounter.value,
|
|
127
|
+
mode: this.optimizer ? 'optimizing' : 'transparent',
|
|
128
|
+
}));
|
|
129
|
+
break;
|
|
130
|
+
case '/_sc/stats':
|
|
131
|
+
res.end(JSON.stringify(this.metrics.getStats()));
|
|
132
|
+
break;
|
|
133
|
+
case '/_sc/feed':
|
|
134
|
+
res.end(JSON.stringify(this.metrics.getRecent(50)));
|
|
135
|
+
break;
|
|
136
|
+
case '/_sc/pause':
|
|
137
|
+
this.paused = true;
|
|
138
|
+
res.end(JSON.stringify({ ok: true, state: 'paused' }));
|
|
139
|
+
break;
|
|
140
|
+
case '/_sc/resume':
|
|
141
|
+
this.paused = false;
|
|
142
|
+
res.end(JSON.stringify({ ok: true, state: 'running' }));
|
|
143
|
+
break;
|
|
144
|
+
default:
|
|
145
|
+
res.writeHead(404);
|
|
146
|
+
res.end(JSON.stringify({ error: `Unknown: ${path}` }));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private generatePAC(): string {
|
|
151
|
+
const { getLLMHostnames } = require('./classifier.js');
|
|
152
|
+
const hosts = getLLMHostnames();
|
|
153
|
+
const { port, host } = this.config.proxy;
|
|
154
|
+
|
|
155
|
+
const conditions = hosts
|
|
156
|
+
.map((h: string) => ` if (dnsDomainIs(host, "${h}")) return proxy;`)
|
|
157
|
+
.join('\n');
|
|
158
|
+
|
|
159
|
+
return `function FindProxyForURL(url, host) {
|
|
160
|
+
var proxy = "PROXY ${host}:${port}";
|
|
161
|
+
${conditions}
|
|
162
|
+
// Ollama local
|
|
163
|
+
if (host === "localhost" && url.indexOf(":11434") !== -1) return proxy;
|
|
164
|
+
return "DIRECT";
|
|
165
|
+
}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private interceptorOptions(): import('./tls-interceptor.js').InterceptorOptions {
|
|
169
|
+
return {
|
|
170
|
+
config: this.config,
|
|
171
|
+
optimizer: this.optimizer,
|
|
172
|
+
metrics: this.metrics,
|
|
173
|
+
adapters: this.adapters,
|
|
174
|
+
paused: this.paused,
|
|
175
|
+
requestCounter: this.requestCounter,
|
|
176
|
+
log: this.log.bind(this),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private log(level: string, message: string): void {
|
|
181
|
+
const timestamp = new Date().toISOString().slice(11, 23);
|
|
182
|
+
const prefix = level === 'error' ? '✗' : '→';
|
|
183
|
+
if (level === 'error' || this.config.logging.level !== 'error') {
|
|
184
|
+
console.log(`[${timestamp}] ${prefix} ${message}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import tls from 'node:tls';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import https from 'node:https';
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
import { URL } from 'node:url';
|
|
6
|
+
import type { Socket } from 'node:net';
|
|
7
|
+
import { getCertForHost } from '../tls/ca-manager.js';
|
|
8
|
+
import { classifyHost, type ProviderMatch } from './classifier.js';
|
|
9
|
+
import { streamResponse } from './stream.js';
|
|
10
|
+
import type { ProviderAdapter } from '../providers/types.js';
|
|
11
|
+
import type { SmartContextConfig } from '../config/schema.js';
|
|
12
|
+
import { ContextOptimizer } from '../context/optimizer.js';
|
|
13
|
+
import { MetricsCollector, type RequestMetric } from '../metrics/collector.js';
|
|
14
|
+
import { estimateTokens } from '../context/chunker.js';
|
|
15
|
+
import { getTextContent } from '../context/canonical.js';
|
|
16
|
+
|
|
17
|
+
export interface InterceptorOptions {
|
|
18
|
+
config: SmartContextConfig;
|
|
19
|
+
optimizer: ContextOptimizer | null;
|
|
20
|
+
metrics: MetricsCollector;
|
|
21
|
+
adapters: Map<string, ProviderAdapter>;
|
|
22
|
+
paused: boolean;
|
|
23
|
+
requestCounter: { value: number };
|
|
24
|
+
log: (level: string, message: string) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Intercept TLS connection to an LLM provider.
|
|
29
|
+
* Terminates TLS with a generated cert, parses the HTTP request inside,
|
|
30
|
+
* optionally optimizes context, then forwards to the real provider.
|
|
31
|
+
*/
|
|
32
|
+
export function interceptTLS(
|
|
33
|
+
clientSocket: Socket,
|
|
34
|
+
hostname: string,
|
|
35
|
+
port: number,
|
|
36
|
+
match: ProviderMatch,
|
|
37
|
+
options: InterceptorOptions,
|
|
38
|
+
): void {
|
|
39
|
+
const { cert, key } = getCertForHost(hostname);
|
|
40
|
+
|
|
41
|
+
const tlsSocket = new tls.TLSSocket(clientSocket, {
|
|
42
|
+
isServer: true,
|
|
43
|
+
cert,
|
|
44
|
+
key,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Tell client the tunnel is established
|
|
48
|
+
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
49
|
+
|
|
50
|
+
// Read HTTP request from decrypted TLS stream
|
|
51
|
+
let requestData = Buffer.alloc(0);
|
|
52
|
+
let headersParsed = false;
|
|
53
|
+
let contentLength = 0;
|
|
54
|
+
let headerEnd = -1;
|
|
55
|
+
|
|
56
|
+
tlsSocket.on('data', (chunk: Buffer) => {
|
|
57
|
+
requestData = Buffer.concat([requestData, chunk]);
|
|
58
|
+
|
|
59
|
+
if (!headersParsed) {
|
|
60
|
+
headerEnd = requestData.indexOf('\r\n\r\n');
|
|
61
|
+
if (headerEnd === -1) return; // Wait for more data
|
|
62
|
+
|
|
63
|
+
headersParsed = true;
|
|
64
|
+
const headersStr = requestData.subarray(0, headerEnd).toString();
|
|
65
|
+
const clMatch = headersStr.match(/content-length:\s*(\d+)/i);
|
|
66
|
+
contentLength = clMatch ? parseInt(clMatch[1], 10) : 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const bodyStart = headerEnd + 4;
|
|
70
|
+
const bodyReceived = requestData.length - bodyStart;
|
|
71
|
+
|
|
72
|
+
if (bodyReceived >= contentLength) {
|
|
73
|
+
tlsSocket.pause();
|
|
74
|
+
handleInterceptedRequest(
|
|
75
|
+
requestData, hostname, port, match, options, tlsSocket,
|
|
76
|
+
).catch((err) => {
|
|
77
|
+
options.log('error', `Intercept error: ${err}`);
|
|
78
|
+
try {
|
|
79
|
+
tlsSocket.write('HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n');
|
|
80
|
+
tlsSocket.end();
|
|
81
|
+
} catch {}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
tlsSocket.on('error', (err) => {
|
|
87
|
+
options.log('error', `TLS socket error for ${hostname}: ${err.message}`);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Handle an intercepted HTTP request: parse, optimize, forward, stream back.
|
|
93
|
+
*/
|
|
94
|
+
async function handleInterceptedRequest(
|
|
95
|
+
rawRequest: Buffer,
|
|
96
|
+
hostname: string,
|
|
97
|
+
port: number,
|
|
98
|
+
match: ProviderMatch,
|
|
99
|
+
options: InterceptorOptions,
|
|
100
|
+
clientTLS: tls.TLSSocket,
|
|
101
|
+
): Promise<void> {
|
|
102
|
+
const startTime = Date.now();
|
|
103
|
+
options.requestCounter.value++;
|
|
104
|
+
const reqId = options.requestCounter.value;
|
|
105
|
+
|
|
106
|
+
// Parse raw HTTP request
|
|
107
|
+
const headerEnd = rawRequest.indexOf('\r\n\r\n');
|
|
108
|
+
const headersStr = rawRequest.subarray(0, headerEnd).toString();
|
|
109
|
+
const body = rawRequest.subarray(headerEnd + 4);
|
|
110
|
+
|
|
111
|
+
const [requestLine, ...headerLines] = headersStr.split('\r\n');
|
|
112
|
+
const [method, path] = requestLine.split(' ');
|
|
113
|
+
|
|
114
|
+
const headers: Record<string, string> = {};
|
|
115
|
+
for (const line of headerLines) {
|
|
116
|
+
const colonIdx = line.indexOf(':');
|
|
117
|
+
if (colonIdx > 0) {
|
|
118
|
+
headers[line.substring(0, colonIdx).toLowerCase().trim()] = line.substring(colonIdx + 1).trim();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Find adapter for this provider
|
|
123
|
+
const adapter = options.adapters.get(match.provider);
|
|
124
|
+
let forwardBody: Buffer;
|
|
125
|
+
let originalTokens = 0;
|
|
126
|
+
let optimizedTokens = 0;
|
|
127
|
+
let savingsPercent = 0;
|
|
128
|
+
let chunksRetrieved = 0;
|
|
129
|
+
let topScore = 0;
|
|
130
|
+
let passThrough = true;
|
|
131
|
+
let reason: string | undefined;
|
|
132
|
+
let model = 'unknown';
|
|
133
|
+
|
|
134
|
+
if (adapter && body.length > 0) {
|
|
135
|
+
try {
|
|
136
|
+
const parsed = JSON.parse(body.toString());
|
|
137
|
+
model = parsed.model || 'unknown';
|
|
138
|
+
const canonical = adapter.parseRequest(parsed, headers);
|
|
139
|
+
|
|
140
|
+
originalTokens = estimateTokens(canonical.systemPrompt || '') +
|
|
141
|
+
canonical.messages.reduce((sum, m) => sum + estimateTokens(getTextContent(m)), 0);
|
|
142
|
+
|
|
143
|
+
// Optimize if available and not paused
|
|
144
|
+
if (options.optimizer && !options.paused) {
|
|
145
|
+
try {
|
|
146
|
+
const result = await options.optimizer.optimize(canonical);
|
|
147
|
+
passThrough = result.passThrough;
|
|
148
|
+
reason = result.reason;
|
|
149
|
+
|
|
150
|
+
if (!result.passThrough) {
|
|
151
|
+
canonical.messages = result.optimizedMessages;
|
|
152
|
+
if (result.systemPrompt !== undefined) canonical.systemPrompt = result.systemPrompt;
|
|
153
|
+
optimizedTokens = result.packed.optimizedTokens;
|
|
154
|
+
savingsPercent = result.packed.savingsPercent;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (result.retrieval) {
|
|
158
|
+
chunksRetrieved = result.retrieval.chunks.length;
|
|
159
|
+
topScore = result.retrieval.topScore;
|
|
160
|
+
}
|
|
161
|
+
} catch (err) {
|
|
162
|
+
passThrough = true;
|
|
163
|
+
reason = `optimization error: ${err}`;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!passThrough) {
|
|
168
|
+
forwardBody = Buffer.from(JSON.stringify(adapter.serializeRequest(canonical)));
|
|
169
|
+
} else {
|
|
170
|
+
forwardBody = body;
|
|
171
|
+
optimizedTokens = originalTokens;
|
|
172
|
+
}
|
|
173
|
+
} catch {
|
|
174
|
+
forwardBody = body;
|
|
175
|
+
optimizedTokens = originalTokens;
|
|
176
|
+
}
|
|
177
|
+
} else {
|
|
178
|
+
forwardBody = body;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const latencyOverhead = Date.now() - startTime;
|
|
182
|
+
const savingsStr = passThrough ? 'pass' : `-${savingsPercent}%`;
|
|
183
|
+
options.log('info',
|
|
184
|
+
`#${reqId} ${match.provider}/${model} ${originalTokens}→${optimizedTokens} ${savingsStr} ${latencyOverhead}ms [intercepted]`);
|
|
185
|
+
|
|
186
|
+
// Forward to real provider
|
|
187
|
+
const useHTTPS = port === 443 || hostname.includes('.');
|
|
188
|
+
const transport = useHTTPS ? https : http;
|
|
189
|
+
const forwardUrl = `${useHTTPS ? 'https' : 'http'}://${hostname}:${port}${path}`;
|
|
190
|
+
|
|
191
|
+
const forwardHeaders = { ...headers };
|
|
192
|
+
forwardHeaders['host'] = hostname;
|
|
193
|
+
forwardHeaders['content-length'] = String(forwardBody.length);
|
|
194
|
+
|
|
195
|
+
const providerRes = await new Promise<http.IncomingMessage>((resolve, reject) => {
|
|
196
|
+
const proxyReq = transport.request(forwardUrl, {
|
|
197
|
+
method,
|
|
198
|
+
headers: forwardHeaders,
|
|
199
|
+
}, resolve);
|
|
200
|
+
proxyReq.on('error', reject);
|
|
201
|
+
proxyReq.write(forwardBody);
|
|
202
|
+
proxyReq.end();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Build response headers
|
|
206
|
+
const resHeaders: string[] = [
|
|
207
|
+
`HTTP/1.1 ${providerRes.statusCode} ${providerRes.statusMessage}`,
|
|
208
|
+
];
|
|
209
|
+
for (const [key, val] of Object.entries(providerRes.headers)) {
|
|
210
|
+
if (val) {
|
|
211
|
+
const values = Array.isArray(val) ? val : [val];
|
|
212
|
+
for (const v of values) {
|
|
213
|
+
resHeaders.push(`${key}: ${v}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
resHeaders.push('', '');
|
|
218
|
+
|
|
219
|
+
clientTLS.write(resHeaders.join('\r\n'));
|
|
220
|
+
|
|
221
|
+
// Stream response body
|
|
222
|
+
await new Promise<void>((resolve) => {
|
|
223
|
+
providerRes.on('data', (chunk) => {
|
|
224
|
+
clientTLS.write(chunk);
|
|
225
|
+
});
|
|
226
|
+
providerRes.on('end', () => {
|
|
227
|
+
clientTLS.end();
|
|
228
|
+
resolve();
|
|
229
|
+
});
|
|
230
|
+
providerRes.on('error', () => {
|
|
231
|
+
clientTLS.end();
|
|
232
|
+
resolve();
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Record metrics
|
|
237
|
+
options.metrics.record({
|
|
238
|
+
id: reqId,
|
|
239
|
+
timestamp: Date.now(),
|
|
240
|
+
provider: match.provider,
|
|
241
|
+
model,
|
|
242
|
+
streaming: headers['accept']?.includes('text/event-stream') || false,
|
|
243
|
+
originalTokens,
|
|
244
|
+
optimizedTokens,
|
|
245
|
+
savingsPercent,
|
|
246
|
+
latencyOverheadMs: latencyOverhead,
|
|
247
|
+
chunksRetrieved,
|
|
248
|
+
topScore,
|
|
249
|
+
passThrough,
|
|
250
|
+
reason,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// Async post-indexing
|
|
254
|
+
if (options.optimizer && !passThrough && adapter) {
|
|
255
|
+
try {
|
|
256
|
+
const parsed = JSON.parse(body.toString());
|
|
257
|
+
const canonical = adapter.parseRequest(parsed, headers);
|
|
258
|
+
options.optimizer.indexExchange(canonical.messages, `intercepted-${reqId}`).catch(() => {});
|
|
259
|
+
} catch {}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import type { Socket } from 'node:net';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Create a blind TCP tunnel between client and target.
|
|
6
|
+
* Zero overhead — no inspection, no buffering.
|
|
7
|
+
* Used for non-LLM HTTPS traffic.
|
|
8
|
+
*/
|
|
9
|
+
export function createTunnel(clientSocket: Socket, targetHost: string, targetPort: number): void {
|
|
10
|
+
const targetSocket = net.connect(targetPort, targetHost, () => {
|
|
11
|
+
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
12
|
+
targetSocket.pipe(clientSocket);
|
|
13
|
+
clientSocket.pipe(targetSocket);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
targetSocket.on('error', (err) => {
|
|
17
|
+
clientSocket.write(`HTTP/1.1 502 Bad Gateway\r\n\r\n`);
|
|
18
|
+
clientSocket.end();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
clientSocket.on('error', () => {
|
|
22
|
+
targetSocket.destroy();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
clientSocket.on('close', () => {
|
|
26
|
+
targetSocket.destroy();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
targetSocket.on('close', () => {
|
|
30
|
+
clientSocket.destroy();
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { ensureCA, getCACertPath } from '../tls/ca-manager.js';
|
|
2
|
+
import { installCA, uninstallCA, isCAInstalled } from '../tls/trust-store.js';
|
|
3
|
+
import { installService, uninstallService } from '../daemon/service.js';
|
|
4
|
+
import * as macos from './macos.js';
|
|
5
|
+
import * as linux from './linux.js';
|
|
6
|
+
|
|
7
|
+
const platform = process.platform === 'darwin' ? macos : linux;
|
|
8
|
+
|
|
9
|
+
export interface InstallResult {
|
|
10
|
+
success: boolean;
|
|
11
|
+
steps: Array<{ step: string; success: boolean; message: string }>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Full installation:
|
|
16
|
+
* 1. Generate CA cert
|
|
17
|
+
* 2. Install CA in trust store
|
|
18
|
+
* 3. Configure system proxy
|
|
19
|
+
* 4. Install system service
|
|
20
|
+
*/
|
|
21
|
+
export function install(port: number): InstallResult {
|
|
22
|
+
const steps: InstallResult['steps'] = [];
|
|
23
|
+
let allOk = true;
|
|
24
|
+
|
|
25
|
+
// Step 1: Generate CA
|
|
26
|
+
try {
|
|
27
|
+
ensureCA();
|
|
28
|
+
steps.push({ step: 'Generate CA certificate', success: true, message: `CA cert at ${getCACertPath()}` });
|
|
29
|
+
} catch (err) {
|
|
30
|
+
steps.push({ step: 'Generate CA certificate', success: false, message: String(err) });
|
|
31
|
+
allOk = false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Step 2: Install CA in trust store
|
|
35
|
+
if (allOk) {
|
|
36
|
+
const result = installCA();
|
|
37
|
+
steps.push({ step: 'Install CA in trust store', success: result.success, message: result.message });
|
|
38
|
+
if (!result.success) allOk = false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Step 3: Configure system proxy (PAC URL for selective proxying)
|
|
42
|
+
if (allOk) {
|
|
43
|
+
if (process.platform === 'darwin') {
|
|
44
|
+
const result = macos.setAutoproxyURL(`http://127.0.0.1:${port}/proxy.pac`);
|
|
45
|
+
steps.push({ step: 'Configure system proxy (PAC)', success: result.success, message: result.message });
|
|
46
|
+
if (!result.success) allOk = false;
|
|
47
|
+
} else {
|
|
48
|
+
const result = platform.setSystemProxy('127.0.0.1', port);
|
|
49
|
+
steps.push({ step: 'Configure system proxy', success: result.success, message: result.message });
|
|
50
|
+
if (!result.success) allOk = false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Step 4: Install system service
|
|
55
|
+
if (allOk) {
|
|
56
|
+
try {
|
|
57
|
+
const servicePath = installService(port);
|
|
58
|
+
steps.push({ step: 'Install system service', success: true, message: `Service at ${servicePath}` });
|
|
59
|
+
} catch (err) {
|
|
60
|
+
steps.push({ step: 'Install system service', success: false, message: String(err) });
|
|
61
|
+
// Non-fatal: proxy can still run manually
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Rollback on failure
|
|
66
|
+
if (!allOk) {
|
|
67
|
+
rollback(steps);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { success: allOk, steps };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Full uninstallation:
|
|
75
|
+
* 1. Remove system service
|
|
76
|
+
* 2. Clear system proxy
|
|
77
|
+
* 3. Remove CA from trust store
|
|
78
|
+
*/
|
|
79
|
+
export function uninstall(purge: boolean = false): InstallResult {
|
|
80
|
+
const steps: InstallResult['steps'] = [];
|
|
81
|
+
|
|
82
|
+
// Step 1: Remove service
|
|
83
|
+
try {
|
|
84
|
+
const msg = uninstallService();
|
|
85
|
+
steps.push({ step: 'Remove system service', success: true, message: msg });
|
|
86
|
+
} catch (err) {
|
|
87
|
+
steps.push({ step: 'Remove system service', success: false, message: String(err) });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Step 2: Clear proxy
|
|
91
|
+
if (process.platform === 'darwin') {
|
|
92
|
+
const r1 = macos.clearAutoproxyURL();
|
|
93
|
+
steps.push({ step: 'Clear auto-proxy', success: r1.success, message: r1.message });
|
|
94
|
+
const r2 = macos.clearSystemProxy();
|
|
95
|
+
steps.push({ step: 'Clear system proxy', success: r2.success, message: r2.message });
|
|
96
|
+
} else {
|
|
97
|
+
const r = linux.clearSystemProxy();
|
|
98
|
+
steps.push({ step: 'Clear system proxy', success: r.success, message: r.message });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Step 3: Remove CA
|
|
102
|
+
const caResult = uninstallCA();
|
|
103
|
+
steps.push({ step: 'Remove CA from trust store', success: caResult.success, message: caResult.message });
|
|
104
|
+
|
|
105
|
+
// Step 4: Purge data (optional)
|
|
106
|
+
if (purge) {
|
|
107
|
+
try {
|
|
108
|
+
const fs = require('node:fs');
|
|
109
|
+
const path = require('node:path');
|
|
110
|
+
const dataDir = path.join(process.env['HOME'] || '.', '.smartcontext');
|
|
111
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
112
|
+
steps.push({ step: 'Purge data directory', success: true, message: `Removed ${dataDir}` });
|
|
113
|
+
} catch (err) {
|
|
114
|
+
steps.push({ step: 'Purge data directory', success: false, message: String(err) });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const allOk = steps.every((s) => s.success);
|
|
119
|
+
return { success: allOk, steps };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Check installation status */
|
|
123
|
+
export function status(port: number): Record<string, boolean | string> {
|
|
124
|
+
return {
|
|
125
|
+
caExists: require('node:fs').existsSync(getCACertPath()),
|
|
126
|
+
caInstalled: isCAInstalled(),
|
|
127
|
+
proxyConfigured: platform.isProxyConfigured(port),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Rollback completed steps on failure */
|
|
132
|
+
function rollback(completedSteps: InstallResult['steps']): void {
|
|
133
|
+
for (const step of completedSteps.reverse()) {
|
|
134
|
+
if (!step.success) continue;
|
|
135
|
+
try {
|
|
136
|
+
if (step.step.includes('CA in trust store')) uninstallCA();
|
|
137
|
+
if (step.step.includes('system proxy') || step.step.includes('PAC')) {
|
|
138
|
+
if (process.platform === 'darwin') {
|
|
139
|
+
macos.clearAutoproxyURL();
|
|
140
|
+
macos.clearSystemProxy();
|
|
141
|
+
} else {
|
|
142
|
+
linux.clearSystemProxy();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (step.step.includes('system service')) uninstallService();
|
|
146
|
+
} catch {}
|
|
147
|
+
}
|
|
148
|
+
}
|