smartcontext-proxy 0.1.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.
- 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 +37 -0
- package/dist/src/proxy/connect-proxy.js +234 -0
- package/dist/src/proxy/server.js +10 -1
- package/dist/src/proxy/tls-interceptor.d.ts +23 -0
- package/dist/src/proxy/tls-interceptor.js +211 -0
- package/dist/src/proxy/transparent-listener.d.ts +31 -0
- package/dist/src/proxy/transparent-listener.js +285 -0
- package/dist/src/proxy/tunnel.d.ts +7 -0
- package/dist/src/proxy/tunnel.js +33 -0
- package/dist/src/system/dns-redirect.d.ts +28 -0
- package/dist/src/system/dns-redirect.js +141 -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/pf-redirect.d.ts +25 -0
- package/dist/src/system/pf-redirect.js +177 -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/test/dashboard.test.js +1 -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/dashboard.d.ts +10 -1
- package/dist/src/ui/dashboard.js +119 -34
- 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 +251 -0
- package/src/proxy/server.ts +11 -2
- package/src/proxy/tls-interceptor.ts +261 -0
- package/src/proxy/transparent-listener.ts +328 -0
- package/src/proxy/tunnel.ts +32 -0
- package/src/system/dns-redirect.ts +144 -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/pf-redirect.ts +175 -0
- package/src/system/watchdog.ts +76 -0
- package/src/test/connect-proxy.test.ts +170 -0
- package/src/test/dashboard.test.ts +1 -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/dashboard.ts +129 -35
- package/src/ui/ws-feed.ts +32 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
// MARK: - Status Polling
|
|
5
|
+
|
|
6
|
+
struct ProxyStatus: Codable {
|
|
7
|
+
let state: String
|
|
8
|
+
let uptime: Int
|
|
9
|
+
let requests: Int
|
|
10
|
+
let mode: String
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
struct ProxyStats: Codable {
|
|
14
|
+
let totalRequests: Int
|
|
15
|
+
let totalOriginalTokens: Int
|
|
16
|
+
let totalOptimizedTokens: Int
|
|
17
|
+
let totalSavingsPercent: Int
|
|
18
|
+
let avgLatencyOverheadMs: Int
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
class StatusPoller {
|
|
22
|
+
var status: ProxyStatus?
|
|
23
|
+
var stats: ProxyStats?
|
|
24
|
+
let port: Int
|
|
25
|
+
|
|
26
|
+
init(port: Int = 4800) {
|
|
27
|
+
self.port = port
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func poll() {
|
|
31
|
+
fetchJSON("http://127.0.0.1:\(port)/_sc/status") { (result: ProxyStatus?) in
|
|
32
|
+
self.status = result
|
|
33
|
+
}
|
|
34
|
+
fetchJSON("http://127.0.0.1:\(port)/_sc/stats") { (result: ProxyStats?) in
|
|
35
|
+
self.stats = result
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private func fetchJSON<T: Codable>(_ urlString: String, completion: @escaping (T?) -> Void) {
|
|
40
|
+
guard let url = URL(string: urlString) else { completion(nil); return }
|
|
41
|
+
let task = URLSession.shared.dataTask(with: url) { data, _, _ in
|
|
42
|
+
guard let data = data else { completion(nil); return }
|
|
43
|
+
completion(try? JSONDecoder().decode(T.self, from: data))
|
|
44
|
+
}
|
|
45
|
+
task.resume()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func apiCall(_ path: String) {
|
|
49
|
+
guard let url = URL(string: "http://127.0.0.1:\(port)\(path)") else { return }
|
|
50
|
+
var request = URLRequest(url: url)
|
|
51
|
+
request.httpMethod = "POST"
|
|
52
|
+
URLSession.shared.dataTask(with: request).resume()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// MARK: - App Delegate
|
|
57
|
+
|
|
58
|
+
class TrayAppDelegate: NSObject, NSApplicationDelegate {
|
|
59
|
+
var statusItem: NSStatusItem!
|
|
60
|
+
var poller = StatusPoller()
|
|
61
|
+
var pollTimer: Timer?
|
|
62
|
+
|
|
63
|
+
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
64
|
+
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
|
65
|
+
|
|
66
|
+
updateIcon(state: "starting")
|
|
67
|
+
buildMenu()
|
|
68
|
+
|
|
69
|
+
// Poll every 3 seconds
|
|
70
|
+
poller.poll()
|
|
71
|
+
pollTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
|
|
72
|
+
self?.poller.poll()
|
|
73
|
+
DispatchQueue.main.async { self?.updateMenu() }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
func updateIcon(state: String) {
|
|
78
|
+
guard let button = statusItem.button else { return }
|
|
79
|
+
|
|
80
|
+
switch state {
|
|
81
|
+
case "running":
|
|
82
|
+
button.title = "◉"
|
|
83
|
+
button.contentTintColor = .systemGreen
|
|
84
|
+
case "paused":
|
|
85
|
+
button.title = "◉"
|
|
86
|
+
button.contentTintColor = .systemYellow
|
|
87
|
+
default:
|
|
88
|
+
button.title = "◎"
|
|
89
|
+
button.contentTintColor = .systemGray
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
func buildMenu() {
|
|
94
|
+
let menu = NSMenu()
|
|
95
|
+
|
|
96
|
+
// Header
|
|
97
|
+
let headerItem = NSMenuItem(title: "SmartContext Proxy", action: nil, keyEquivalent: "")
|
|
98
|
+
headerItem.isEnabled = false
|
|
99
|
+
menu.addItem(headerItem)
|
|
100
|
+
|
|
101
|
+
menu.addItem(NSMenuItem.separator())
|
|
102
|
+
|
|
103
|
+
// Stats (placeholder, updated by polling)
|
|
104
|
+
let statsItem = NSMenuItem(title: "Loading...", action: nil, keyEquivalent: "")
|
|
105
|
+
statsItem.tag = 100
|
|
106
|
+
statsItem.isEnabled = false
|
|
107
|
+
menu.addItem(statsItem)
|
|
108
|
+
|
|
109
|
+
let savingsItem = NSMenuItem(title: "", action: nil, keyEquivalent: "")
|
|
110
|
+
savingsItem.tag = 101
|
|
111
|
+
savingsItem.isEnabled = false
|
|
112
|
+
menu.addItem(savingsItem)
|
|
113
|
+
|
|
114
|
+
menu.addItem(NSMenuItem.separator())
|
|
115
|
+
|
|
116
|
+
// Dashboard
|
|
117
|
+
let dashboardItem = NSMenuItem(title: "Open Dashboard", action: #selector(openDashboard), keyEquivalent: "d")
|
|
118
|
+
dashboardItem.target = self
|
|
119
|
+
menu.addItem(dashboardItem)
|
|
120
|
+
|
|
121
|
+
menu.addItem(NSMenuItem.separator())
|
|
122
|
+
|
|
123
|
+
// Pause/Resume
|
|
124
|
+
let pauseItem = NSMenuItem(title: "Pause Optimization", action: #selector(togglePause), keyEquivalent: "p")
|
|
125
|
+
pauseItem.tag = 200
|
|
126
|
+
pauseItem.target = self
|
|
127
|
+
menu.addItem(pauseItem)
|
|
128
|
+
|
|
129
|
+
menu.addItem(NSMenuItem.separator())
|
|
130
|
+
|
|
131
|
+
// Quit
|
|
132
|
+
let quitItem = NSMenuItem(title: "Quit SmartContext", action: #selector(quit), keyEquivalent: "q")
|
|
133
|
+
quitItem.target = self
|
|
134
|
+
menu.addItem(quitItem)
|
|
135
|
+
|
|
136
|
+
statusItem.menu = menu
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
func updateMenu() {
|
|
140
|
+
guard let menu = statusItem.menu else { return }
|
|
141
|
+
|
|
142
|
+
let state = poller.status?.state ?? "offline"
|
|
143
|
+
updateIcon(state: state)
|
|
144
|
+
|
|
145
|
+
// Update stats
|
|
146
|
+
if let stats = poller.stats {
|
|
147
|
+
let savings = estimateCost(stats.totalOriginalTokens - stats.totalOptimizedTokens)
|
|
148
|
+
if let item = menu.item(withTag: 100) {
|
|
149
|
+
item.title = "Requests: \(stats.totalRequests) | Savings: \(stats.totalSavingsPercent)%"
|
|
150
|
+
}
|
|
151
|
+
if let item = menu.item(withTag: 101) {
|
|
152
|
+
item.title = "Estimated saved: $\(savings)"
|
|
153
|
+
}
|
|
154
|
+
} else {
|
|
155
|
+
if let item = menu.item(withTag: 100) {
|
|
156
|
+
item.title = "Proxy offline"
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Update pause/resume text
|
|
161
|
+
if let item = menu.item(withTag: 200) {
|
|
162
|
+
if state == "paused" {
|
|
163
|
+
item.title = "Resume Optimization"
|
|
164
|
+
} else {
|
|
165
|
+
item.title = "Pause Optimization"
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
func estimateCost(_ tokensSaved: Int) -> String {
|
|
171
|
+
let cost = Double(tokensSaved) / 1_000_000.0 * 15.0
|
|
172
|
+
return String(format: "%.2f", cost)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
@objc func openDashboard() {
|
|
176
|
+
if let url = URL(string: "http://localhost:\(poller.port)/") {
|
|
177
|
+
NSWorkspace.shared.open(url)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
@objc func togglePause() {
|
|
182
|
+
let state = poller.status?.state ?? "running"
|
|
183
|
+
if state == "paused" {
|
|
184
|
+
poller.apiCall("/_sc/resume")
|
|
185
|
+
} else {
|
|
186
|
+
poller.apiCall("/_sc/pause")
|
|
187
|
+
}
|
|
188
|
+
// Update after short delay
|
|
189
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
190
|
+
self?.poller.poll()
|
|
191
|
+
self?.updateMenu()
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
@objc func quit() {
|
|
196
|
+
NSApplication.shared.terminate(self)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// MARK: - Entry Point
|
|
201
|
+
|
|
202
|
+
let app = NSApplication.shared
|
|
203
|
+
app.setActivationPolicy(.accessory) // No dock icon
|
|
204
|
+
let delegate = TrayAppDelegate()
|
|
205
|
+
app.delegate = delegate
|
|
206
|
+
app.run()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "smartcontext-proxy",
|
|
3
|
-
"version": "0.1
|
|
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",
|
|
@@ -30,9 +30,13 @@
|
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@types/node": "^22.0.0",
|
|
33
|
+
"@types/node-forge": "^1.3.14",
|
|
34
|
+
"@types/ws": "^8.18.1",
|
|
33
35
|
"typescript": "^5.7.0"
|
|
34
36
|
},
|
|
35
37
|
"dependencies": {
|
|
36
|
-
"@lancedb/lancedb": "^0.27.1"
|
|
38
|
+
"@lancedb/lancedb": "^0.27.1",
|
|
39
|
+
"node-forge": "^1.4.0",
|
|
40
|
+
"ws": "^8.20.0"
|
|
37
41
|
}
|
|
38
42
|
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type { CanonicalRequest } from './canonical.js';
|
|
2
|
+
import type { ProviderAdapter } from '../providers/types.js';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import https from 'node:https';
|
|
5
|
+
import { URL } from 'node:url';
|
|
6
|
+
|
|
7
|
+
export interface ABComparison {
|
|
8
|
+
requestId: number;
|
|
9
|
+
timestamp: number;
|
|
10
|
+
provider: string;
|
|
11
|
+
model: string;
|
|
12
|
+
optimizedTokens: number;
|
|
13
|
+
originalTokens: number;
|
|
14
|
+
savingsPercent: number;
|
|
15
|
+
semanticSimilarity: number;
|
|
16
|
+
qualityMatch: boolean; // >0.95 similarity
|
|
17
|
+
responseA: string; // optimized (returned to client)
|
|
18
|
+
responseB: string; // original (stored, not returned)
|
|
19
|
+
latencyA: number;
|
|
20
|
+
latencyB: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let abResults: ABComparison[] = [];
|
|
24
|
+
let abEnabled = false;
|
|
25
|
+
let abSampleRate = 100; // percent
|
|
26
|
+
|
|
27
|
+
export function enableABTest(sampleRate: number = 100): void {
|
|
28
|
+
abEnabled = true;
|
|
29
|
+
abSampleRate = Math.min(100, Math.max(1, sampleRate));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function disableABTest(): void {
|
|
33
|
+
abEnabled = false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isABEnabled(): boolean {
|
|
37
|
+
return abEnabled;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function shouldABTest(): boolean {
|
|
41
|
+
if (!abEnabled) return false;
|
|
42
|
+
return Math.random() * 100 < abSampleRate;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function getABResults(limit: number = 50): ABComparison[] {
|
|
46
|
+
return abResults.slice(-limit);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function getABSummary(): {
|
|
50
|
+
total: number;
|
|
51
|
+
qualityMatch: number;
|
|
52
|
+
minorDiff: number;
|
|
53
|
+
significantDiff: number;
|
|
54
|
+
avgSavings: number;
|
|
55
|
+
} {
|
|
56
|
+
const total = abResults.length;
|
|
57
|
+
if (total === 0) return { total: 0, qualityMatch: 0, minorDiff: 0, significantDiff: 0, avgSavings: 0 };
|
|
58
|
+
|
|
59
|
+
let qualityMatch = 0;
|
|
60
|
+
let minorDiff = 0;
|
|
61
|
+
let significantDiff = 0;
|
|
62
|
+
let totalSavings = 0;
|
|
63
|
+
|
|
64
|
+
for (const r of abResults) {
|
|
65
|
+
if (r.semanticSimilarity >= 0.95) qualityMatch++;
|
|
66
|
+
else if (r.semanticSimilarity >= 0.85) minorDiff++;
|
|
67
|
+
else significantDiff++;
|
|
68
|
+
totalSavings += r.savingsPercent;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
total,
|
|
73
|
+
qualityMatch,
|
|
74
|
+
minorDiff,
|
|
75
|
+
significantDiff,
|
|
76
|
+
avgSavings: Math.round(totalSavings / total),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Send the original (unoptimized) request to the provider for comparison.
|
|
82
|
+
* This is Path B — the result is stored but NOT returned to the client.
|
|
83
|
+
*/
|
|
84
|
+
export async function sendOriginalForComparison(
|
|
85
|
+
originalBody: Buffer,
|
|
86
|
+
hostname: string,
|
|
87
|
+
port: number,
|
|
88
|
+
path: string,
|
|
89
|
+
headers: Record<string, string>,
|
|
90
|
+
requestId: number,
|
|
91
|
+
provider: string,
|
|
92
|
+
model: string,
|
|
93
|
+
optimizedResponse: string,
|
|
94
|
+
optimizedTokens: number,
|
|
95
|
+
originalTokens: number,
|
|
96
|
+
savingsPercent: number,
|
|
97
|
+
): Promise<void> {
|
|
98
|
+
const startTime = Date.now();
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const useHTTPS = port === 443 || hostname.includes('.');
|
|
102
|
+
const transport = useHTTPS ? https : http;
|
|
103
|
+
const url = `${useHTTPS ? 'https' : 'http'}://${hostname}:${port}${path}`;
|
|
104
|
+
|
|
105
|
+
const forwardHeaders = { ...headers };
|
|
106
|
+
forwardHeaders['content-length'] = String(originalBody.length);
|
|
107
|
+
|
|
108
|
+
const responseB = await new Promise<string>((resolve, reject) => {
|
|
109
|
+
const req = transport.request(url, { method: 'POST', headers: forwardHeaders }, (res) => {
|
|
110
|
+
let data = '';
|
|
111
|
+
res.on('data', (chunk) => (data += chunk));
|
|
112
|
+
res.on('end', () => resolve(data));
|
|
113
|
+
});
|
|
114
|
+
req.on('error', reject);
|
|
115
|
+
req.write(originalBody);
|
|
116
|
+
req.end();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const latencyB = Date.now() - startTime;
|
|
120
|
+
|
|
121
|
+
// Simple text similarity (cosine of character trigrams)
|
|
122
|
+
const similarity = textSimilarity(optimizedResponse, responseB);
|
|
123
|
+
|
|
124
|
+
abResults.push({
|
|
125
|
+
requestId,
|
|
126
|
+
timestamp: Date.now(),
|
|
127
|
+
provider,
|
|
128
|
+
model,
|
|
129
|
+
optimizedTokens,
|
|
130
|
+
originalTokens,
|
|
131
|
+
savingsPercent,
|
|
132
|
+
semanticSimilarity: similarity,
|
|
133
|
+
qualityMatch: similarity >= 0.95,
|
|
134
|
+
responseA: optimizedResponse.slice(0, 500),
|
|
135
|
+
responseB: responseB.slice(0, 500),
|
|
136
|
+
latencyA: 0, // filled externally
|
|
137
|
+
latencyB,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Keep max 1000 results
|
|
141
|
+
if (abResults.length > 1000) {
|
|
142
|
+
abResults = abResults.slice(-500);
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
// A/B test failure is non-critical
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Simple text similarity using character trigrams */
|
|
150
|
+
function textSimilarity(a: string, b: string): number {
|
|
151
|
+
if (a === b) return 1.0;
|
|
152
|
+
if (!a || !b) return 0;
|
|
153
|
+
|
|
154
|
+
const trigramsA = getTrigrams(a.toLowerCase());
|
|
155
|
+
const trigramsB = getTrigrams(b.toLowerCase());
|
|
156
|
+
|
|
157
|
+
let intersection = 0;
|
|
158
|
+
for (const t of trigramsA) {
|
|
159
|
+
if (trigramsB.has(t)) intersection++;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const union = trigramsA.size + trigramsB.size - intersection;
|
|
163
|
+
return union === 0 ? 0 : intersection / union;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function getTrigrams(s: string): Set<string> {
|
|
167
|
+
const set = new Set<string>();
|
|
168
|
+
for (let i = 0; i < s.length - 2; i++) {
|
|
169
|
+
set.add(s.substring(i, i + 3));
|
|
170
|
+
}
|
|
171
|
+
return set;
|
|
172
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,29 +2,38 @@
|
|
|
2
2
|
|
|
3
3
|
import { buildConfig } from './config/auto-detect.js';
|
|
4
4
|
import { ProxyServer } from './proxy/server.js';
|
|
5
|
+
import { ConnectProxy } from './proxy/connect-proxy.js';
|
|
5
6
|
import { OllamaEmbeddingAdapter } from './embedding/ollama.js';
|
|
6
7
|
import { LanceDBAdapter } from './storage/lancedb.js';
|
|
8
|
+
import { ContextOptimizer } from './context/optimizer.js';
|
|
7
9
|
import type { EmbeddingAdapter } from './embedding/types.js';
|
|
8
10
|
import type { StorageAdapter } from './storage/types.js';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
+
import type { ProviderAdapter } from './providers/types.js';
|
|
12
|
+
import { AnthropicAdapter } from './providers/anthropic.js';
|
|
13
|
+
import { OpenAIAdapter } from './providers/openai.js';
|
|
14
|
+
import { OllamaAdapter } from './providers/ollama.js';
|
|
15
|
+
import { GoogleAdapter } from './providers/google.js';
|
|
16
|
+
import { ensureCA } from './tls/ca-manager.js';
|
|
17
|
+
import { writePid, removePid, getPid, stopDaemon } from './daemon/process.js';
|
|
18
|
+
import { install, uninstall, status as installStatus } from './system/installer.js';
|
|
11
19
|
import http from 'node:http';
|
|
12
20
|
|
|
13
|
-
const VERSION = '0.
|
|
21
|
+
const VERSION = '0.2.0';
|
|
14
22
|
|
|
15
23
|
function parseArgs(args: string[]): Record<string, string | boolean> {
|
|
16
24
|
const result: Record<string, string | boolean> = {};
|
|
17
25
|
for (let i = 0; i < args.length; i++) {
|
|
18
26
|
const arg = args[i];
|
|
19
27
|
if (arg === '--port' || arg === '-p') result.port = args[++i];
|
|
20
|
-
else if (arg === '--config' || arg === '-c') result.config = args[++i];
|
|
21
28
|
else if (arg === '--help' || arg === '-h') result.help = true;
|
|
22
29
|
else if (arg === '--version' || arg === '-v') result.version = true;
|
|
23
30
|
else if (arg === '--no-optimize') result.noOptimize = true;
|
|
31
|
+
else if (arg === '--legacy') result.legacy = true;
|
|
32
|
+
else if (arg === '--purge') result.purge = true;
|
|
24
33
|
else if (arg === '--embedding-url') result.embeddingUrl = args[++i];
|
|
25
34
|
else if (arg === '--embedding-model') result.embeddingModel = args[++i];
|
|
26
35
|
else if (arg === '--data-dir') result.dataDir = args[++i];
|
|
27
|
-
else if (!arg.startsWith('-')) result.command = arg;
|
|
36
|
+
else if (!arg.startsWith('-')) result.command = result.command ? result.command : arg;
|
|
28
37
|
}
|
|
29
38
|
return result;
|
|
30
39
|
}
|
|
@@ -32,56 +41,52 @@ function parseArgs(args: string[]): Record<string, string | boolean> {
|
|
|
32
41
|
function printHelp(): void {
|
|
33
42
|
console.log(`
|
|
34
43
|
SmartContext Proxy v${VERSION}
|
|
35
|
-
|
|
44
|
+
Transparent LLM context optimization proxy
|
|
36
45
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
46
|
+
Commands:
|
|
47
|
+
(default) Start proxy in foreground
|
|
48
|
+
install Install: CA cert + system proxy + auto-start service
|
|
49
|
+
uninstall Remove all: CA, proxy config, service (--purge for data)
|
|
50
|
+
status Show installation and proxy status
|
|
51
|
+
start Start as background daemon
|
|
52
|
+
stop Stop daemon
|
|
53
|
+
restart Restart daemon
|
|
40
54
|
|
|
41
55
|
Options:
|
|
42
|
-
--port, -p <port>
|
|
43
|
-
--
|
|
44
|
-
--
|
|
45
|
-
--
|
|
46
|
-
--
|
|
47
|
-
--
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
Client Integration:
|
|
52
|
-
ANTHROPIC_API_URL=http://localhost:4800/v1/anthropic
|
|
53
|
-
OPENAI_BASE_URL=http://localhost:4800/v1/openai
|
|
54
|
-
OLLAMA_HOST=http://localhost:4800/v1/ollama
|
|
55
|
-
|
|
56
|
-
API:
|
|
57
|
-
GET /health Health check
|
|
58
|
-
GET /_sc/status Proxy status
|
|
59
|
-
GET /_sc/stats Aggregate metrics
|
|
60
|
-
GET /_sc/feed Recent requests
|
|
61
|
-
POST /_sc/pause Pause optimization
|
|
62
|
-
POST /_sc/resume Resume optimization
|
|
56
|
+
--port, -p <port> Proxy port (default: 4800)
|
|
57
|
+
--no-optimize Disable context optimization (transparent proxy only)
|
|
58
|
+
--legacy Use legacy explicit-route proxy instead of CONNECT proxy
|
|
59
|
+
--purge With uninstall: also delete all data
|
|
60
|
+
--help, -h Show help
|
|
61
|
+
--version, -v Show version
|
|
62
|
+
|
|
63
|
+
After install, all LLM API traffic is automatically intercepted.
|
|
64
|
+
Dashboard: http://localhost:4800
|
|
63
65
|
`);
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
async function showStatus(port: number): Promise<void> {
|
|
69
|
+
const inst = installStatus(port);
|
|
70
|
+
console.log('Installation:');
|
|
71
|
+
for (const [key, val] of Object.entries(inst)) {
|
|
72
|
+
console.log(` ${key}: ${val}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
67
75
|
return new Promise((resolve) => {
|
|
68
76
|
http.get(`http://127.0.0.1:${port}/_sc/status`, (res) => {
|
|
69
77
|
let data = '';
|
|
70
78
|
res.on('data', (chunk) => (data += chunk));
|
|
71
79
|
res.on('end', () => {
|
|
72
80
|
try {
|
|
73
|
-
const
|
|
74
|
-
console.log(
|
|
75
|
-
console.log(` Uptime: ${Math.round(
|
|
76
|
-
console.log(` Requests: ${
|
|
77
|
-
|
|
78
|
-
} catch {
|
|
79
|
-
console.log('Could not parse status response');
|
|
80
|
-
}
|
|
81
|
+
const s = JSON.parse(data);
|
|
82
|
+
console.log(`\nProxy: ${s.state} (${s.mode})`);
|
|
83
|
+
console.log(` Uptime: ${Math.round(s.uptime / 1000)}s`);
|
|
84
|
+
console.log(` Requests: ${s.requests}`);
|
|
85
|
+
} catch { console.log('\nProxy: response parse error'); }
|
|
81
86
|
resolve();
|
|
82
87
|
});
|
|
83
88
|
}).on('error', () => {
|
|
84
|
-
console.log(
|
|
89
|
+
console.log(`\nProxy: not running on port ${port}`);
|
|
85
90
|
resolve();
|
|
86
91
|
});
|
|
87
92
|
});
|
|
@@ -95,73 +100,98 @@ async function main(): Promise<void> {
|
|
|
95
100
|
|
|
96
101
|
const port = args.port ? parseInt(args.port as string, 10) : 4800;
|
|
97
102
|
|
|
103
|
+
// Commands
|
|
98
104
|
if (args.command === 'status') { await showStatus(port); return; }
|
|
99
105
|
if (args.command === 'stop') { stopDaemon(); return; }
|
|
100
|
-
|
|
101
|
-
if (args.command === '
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
106
|
+
|
|
107
|
+
if (args.command === 'install') {
|
|
108
|
+
console.log('Installing SmartContext Proxy...\n');
|
|
109
|
+
ensureCA();
|
|
110
|
+
const result = install(port);
|
|
111
|
+
for (const step of result.steps) {
|
|
112
|
+
console.log(` ${step.success ? '✓' : '✗'} ${step.step}: ${step.message}`);
|
|
113
|
+
}
|
|
114
|
+
console.log(result.success ? '\nInstalled successfully.' : '\nInstallation failed (rolled back).');
|
|
105
115
|
return;
|
|
106
116
|
}
|
|
107
|
-
|
|
108
|
-
|
|
117
|
+
|
|
118
|
+
if (args.command === 'uninstall') {
|
|
119
|
+
console.log('Uninstalling SmartContext Proxy...\n');
|
|
120
|
+
const result = uninstall(!!args.purge);
|
|
121
|
+
for (const step of result.steps) {
|
|
122
|
+
console.log(` ${step.success ? '✓' : '✗'} ${step.step}: ${step.message}`);
|
|
123
|
+
}
|
|
124
|
+
console.log(result.success ? '\nUninstalled.' : '\nSome steps failed.');
|
|
109
125
|
return;
|
|
110
126
|
}
|
|
111
127
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
});
|
|
128
|
+
// Build config
|
|
129
|
+
const config = buildConfig({ proxy: { port, host: '127.0.0.1' } });
|
|
115
130
|
|
|
116
|
-
// Initialize embedding and storage
|
|
131
|
+
// Initialize embedding and storage
|
|
117
132
|
let embedding: EmbeddingAdapter | undefined;
|
|
118
133
|
let storage: StorageAdapter | undefined;
|
|
134
|
+
let optimizer: ContextOptimizer | undefined;
|
|
119
135
|
|
|
120
136
|
if (!args.noOptimize) {
|
|
121
137
|
try {
|
|
122
|
-
const
|
|
123
|
-
const
|
|
124
|
-
const dataDir = args.dataDir as string | undefined;
|
|
138
|
+
const embUrl = (args.embeddingUrl as string) || process.env['OLLAMA_HOST'] || 'http://localhost:11434';
|
|
139
|
+
const embModel = (args.embeddingModel as string) || 'nomic-embed-text';
|
|
125
140
|
|
|
126
|
-
embedding = new OllamaEmbeddingAdapter(
|
|
141
|
+
embedding = new OllamaEmbeddingAdapter(embUrl, embModel);
|
|
127
142
|
await embedding.initialize();
|
|
128
143
|
|
|
129
|
-
storage = new LanceDBAdapter(dataDir);
|
|
144
|
+
storage = new LanceDBAdapter(args.dataDir as string | undefined);
|
|
130
145
|
await storage.initialize();
|
|
131
146
|
|
|
132
|
-
|
|
133
|
-
console.log(` Storage: LanceDB`);
|
|
147
|
+
optimizer = new ContextOptimizer(embedding, storage, config.context);
|
|
134
148
|
} catch (err) {
|
|
135
149
|
console.log(` Optimization unavailable: ${err}`);
|
|
136
150
|
console.log(` Running in transparent proxy mode`);
|
|
137
|
-
embedding = undefined;
|
|
138
|
-
storage = undefined;
|
|
139
151
|
}
|
|
140
152
|
}
|
|
141
153
|
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
154
|
+
// Build provider adapters map
|
|
155
|
+
const adapters = new Map<string, ProviderAdapter>();
|
|
156
|
+
adapters.set('anthropic', new AnthropicAdapter());
|
|
157
|
+
adapters.set('openai', new OpenAIAdapter());
|
|
158
|
+
adapters.set('ollama', new OllamaAdapter());
|
|
159
|
+
adapters.set('google', new GoogleAdapter());
|
|
160
|
+
adapters.set('openrouter', new OpenAIAdapter('https://openrouter.ai/api'));
|
|
161
|
+
adapters.set('groq', new OpenAIAdapter('https://api.groq.com'));
|
|
162
|
+
adapters.set('together', new OpenAIAdapter('https://api.together.xyz'));
|
|
163
|
+
adapters.set('deepseek', new OpenAIAdapter('https://api.deepseek.com'));
|
|
164
|
+
|
|
165
|
+
const mode = optimizer ? 'optimizing' : 'transparent';
|
|
166
|
+
|
|
167
|
+
if (args.legacy) {
|
|
168
|
+
// Legacy mode: explicit /v1/{provider}/* routing
|
|
169
|
+
const server = new ProxyServer(config, embedding, storage);
|
|
170
|
+
await server.start();
|
|
171
|
+
console.log(` Mode: legacy (explicit routing)`);
|
|
172
|
+
} else {
|
|
173
|
+
// Default: CONNECT proxy with transparent interception
|
|
174
|
+
ensureCA();
|
|
175
|
+
const proxy = new ConnectProxy(config, optimizer, adapters);
|
|
176
|
+
await proxy.start();
|
|
177
|
+
}
|
|
145
178
|
|
|
146
|
-
|
|
179
|
+
writePid();
|
|
147
180
|
|
|
148
181
|
console.log(`
|
|
149
|
-
|
|
150
|
-
│ SmartContext Proxy v${VERSION}
|
|
151
|
-
│ http
|
|
152
|
-
│
|
|
153
|
-
│
|
|
154
|
-
│
|
|
155
|
-
|
|
182
|
+
┌──────────────────────────────────────────────────┐
|
|
183
|
+
│ SmartContext Proxy v${VERSION} │
|
|
184
|
+
│ http://127.0.0.1:${port} │
|
|
185
|
+
│ │
|
|
186
|
+
│ Mode: ${(args.legacy ? 'legacy (explicit)' : 'transparent (CONNECT)').padEnd(40)}│
|
|
187
|
+
│ Optimization: ${mode.padEnd(33)}│
|
|
188
|
+
│ Dashboard: http://localhost:${port}/ │
|
|
189
|
+
└──────────────────────────────────────────────────┘
|
|
156
190
|
`);
|
|
157
191
|
|
|
158
|
-
// Write PID file
|
|
159
|
-
writePid();
|
|
160
|
-
|
|
161
192
|
const shutdown = async () => {
|
|
162
193
|
console.log('\nShutting down...');
|
|
163
194
|
removePid();
|
|
164
|
-
await server.stop();
|
|
165
195
|
if (storage) await storage.close();
|
|
166
196
|
process.exit(0);
|
|
167
197
|
};
|