squeezr-ai 1.11.4 → 1.13.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/README.md CHANGED
@@ -177,7 +177,7 @@ Squeezr auto-detects which provider each request targets from the auth headers.
177
177
  | CLI | Set this env var | Compresses with | Extra keys needed |
178
178
  |---|---|---|---|
179
179
  | **Claude Code** | `ANTHROPIC_BASE_URL=http://localhost:8080` | Claude Haiku | None |
180
- | **Codex CLI** | `openai_base_url=http://localhost:8080` | GPT-4o-mini | None |
180
+ | **Codex CLI** | `squeezr setup` (see below) | gpt-5.4-mini (via your Codex sub) | None |
181
181
  | **Aider** (OpenAI backend) | `openai_base_url=http://localhost:8080` | GPT-4o-mini | None |
182
182
  | **Aider** (Anthropic backend) | `ANTHROPIC_BASE_URL=http://localhost:8080` | Claude Haiku | None |
183
183
  | **OpenCode** | `openai_base_url=http://localhost:8080` | GPT-4o-mini | None |
@@ -202,7 +202,11 @@ Then point your CLI at the proxy:
202
202
  export ANTHROPIC_BASE_URL=http://localhost:8080 # macOS / Linux
203
203
  $env:ANTHROPIC_BASE_URL="http://localhost:8080" # Windows PowerShell
204
204
 
205
- # Codex / Aider / OpenCode
205
+ # Codex (uses MITM proxy — see "Codex deep compression" below)
206
+ export HTTPS_PROXY=http://localhost:8081
207
+ export SSL_CERT_FILE=~/.squeezr/mitm-ca/bundle.crt
208
+
209
+ # Aider / OpenCode
206
210
  export openai_base_url=http://localhost:8080
207
211
 
208
212
  # Gemini CLI
@@ -416,6 +420,44 @@ Shows which deterministic patterns fired, how many outputs hit the AI fallback,
416
420
 
417
421
  ---
418
422
 
423
+ ## Codex deep compression
424
+
425
+ Codex CLI talks to `chatgpt.com` over WebSocket, not the standard OpenAI API. This means a regular HTTP proxy can't inspect or modify the traffic. Squeezr solves this with a TLS-terminating MITM proxy on port 8081.
426
+
427
+ ### How it works
428
+
429
+ 1. `squeezr setup` generates a local CA and configures `HTTPS_PROXY` + `SSL_CERT_FILE` in your shell
430
+ 2. When Codex connects to `chatgpt.com`, Squeezr intercepts the TLS tunnel and generates a per-host certificate signed by the local CA
431
+ 3. Squeezr strips `permessage-deflate` from the WebSocket handshake so frames arrive as plain JSON
432
+ 4. On every client-to-server WebSocket frame, Squeezr looks for `function_call_output` messages (tool results) exceeding the compression threshold
433
+ 5. For each large tool result, Squeezr opens a **separate** WebSocket to `chatgpt.com/backend-api/codex/responses` using the same OAuth token, and asks `gpt-5.4-mini` to summarize it
434
+ 6. The compressed output replaces the original in the frame before forwarding to the server
435
+
436
+ ### Setup
437
+
438
+ ```bash
439
+ squeezr setup # auto-configures everything (HTTPS_PROXY, SSL_CERT_FILE, CA)
440
+ ```
441
+
442
+ Or manually:
443
+
444
+ ```bash
445
+ export HTTPS_PROXY=http://localhost:8081
446
+ export SSL_CERT_FILE=~/.squeezr/mitm-ca/bundle.crt
447
+ ```
448
+
449
+ ### What it costs
450
+
451
+ Nothing extra. The compression calls use `gpt-5.4-mini` through the same ChatGPT WebSocket endpoint that your Codex subscription already covers. No API key required.
452
+
453
+ ### Results
454
+
455
+ In testing, Codex tool results (file reads, command output) are compressed by **80-90%** per turn. A typical file read of 5,000 chars compresses to ~700 chars, saving thousands of tokens across a session.
456
+
457
+ For a detailed technical explanation, see [CODEX.md](CODEX.md).
458
+
459
+ ---
460
+
419
461
  ## How session-level optimisations work
420
462
 
421
463
  ### Session cache + differential compression
package/bin/squeezr.js CHANGED
@@ -113,12 +113,23 @@ function stopProxy() {
113
113
  const match = out.match(/LISTENING\s+(\d+)/)
114
114
  pid = match?.[1]
115
115
  } else {
116
- pid = execSync(`lsof -ti :${port}`, { encoding: 'utf-8', stdio: 'pipe' }).trim()
116
+ // Use -sTCP:LISTEN to get only the listening process, not connected clients.
117
+ // lsof may return multiple PIDs without this flag.
118
+ try {
119
+ pid = execSync(`lsof -ti :${port} -sTCP:LISTEN`, { encoding: 'utf-8', stdio: 'pipe' }).trim()
120
+ } catch {
121
+ // fallback: fuser (available on most Linux/WSL)
122
+ try {
123
+ pid = execSync(`fuser ${port}/tcp 2>/dev/null`, { encoding: 'utf-8', stdio: 'pipe' }).trim()
124
+ } catch {}
125
+ }
117
126
  }
118
127
  if (!pid) {
119
128
  console.log(`Squeezr is not running on port ${port}`)
120
129
  return
121
130
  }
131
+ // Take only the first PID in case multiple are returned
132
+ pid = pid.split(/\s+/)[0]
122
133
  if (process.platform === 'win32') {
123
134
  execSync(`taskkill /F /PID ${pid}`, { stdio: 'pipe' })
124
135
  } else {
@@ -254,11 +265,16 @@ function setupUnix() {
254
265
  // 1. Set env vars + auto-heal guard in shell profile
255
266
  const distIndex = path.join(ROOT, 'dist', 'index.js')
256
267
  const port = process.env.SQUEEZR_PORT || 8080
268
+ const mitmPort = Number(port) + 1
269
+ const bundlePath = path.join(os.homedir(), '.squeezr', 'mitm-ca', 'bundle.crt')
257
270
  const shellBlock = [
258
271
  `# squeezr env vars`,
259
272
  `export ANTHROPIC_BASE_URL=http://localhost:${port}`,
260
273
  `export openai_base_url=http://localhost:${port}`,
261
274
  `export GEMINI_API_BASE_URL=http://localhost:${port}`,
275
+ `# squeezr MITM proxy for Codex (TLS interception)`,
276
+ `export HTTPS_PROXY=http://localhost:${mitmPort}`,
277
+ `export SSL_CERT_FILE=${bundlePath}`,
262
278
  `# squeezr auto-heal: start proxy if not running`,
263
279
  `if ! curl -sf http://localhost:${port}/squeezr/health >/dev/null 2>&1; then`,
264
280
  ` nohup ${nodeExe} ${distIndex} >> "${os.homedir()}/.squeezr/squeezr.log" 2>&1 &`,
@@ -277,13 +293,14 @@ function setupUnix() {
277
293
  fs.appendFileSync(profile, `\n${shellBlock}\n`)
278
294
  console.log(` [ok] Env vars + auto-heal added to ${profile}`)
279
295
  } else {
280
- if (!existing.includes('squeezr auto-heal')) {
296
+ if (!existing.includes('SSL_CERT_FILE') || !existing.includes('squeezr MITM')) {
297
+ // Re-write block to include MITM vars
281
298
  const updatedContent = existing.replace(
282
- /# squeezr env vars\n(?:export [A-Z_]+=http:\/\/localhost:\d+\n?)*/,
299
+ /# squeezr env vars[\s\S]*?fi\n/,
283
300
  shellBlock + '\n'
284
301
  )
285
302
  fs.writeFileSync(profile, updatedContent)
286
- console.log(` [ok] Auto-heal guard added to ${profile}`)
303
+ console.log(` [ok] Shell profile updated with MITM proxy vars`)
287
304
  } else {
288
305
  console.log(` [skip] Env vars + auto-heal already in ${profile}`)
289
306
  }
@@ -381,11 +398,15 @@ function setupWSL() {
381
398
  // it in the background. This is the safety net for WSL2 where systemd and
382
399
  // Task Scheduler may both fail.
383
400
  const port = process.env.SQUEEZR_PORT || 8080
401
+ const mitmPort = Number(port) + 1
402
+ const bundlePath = path.join(os.homedir(), '.squeezr', 'mitm-ca', 'bundle.crt')
384
403
  const shellBlock = [
385
404
  `# squeezr env vars`,
386
405
  `export ANTHROPIC_BASE_URL=http://localhost:${port}`,
387
406
  `export openai_base_url=http://localhost:${port}`,
388
407
  `export GEMINI_API_BASE_URL=http://localhost:${port}`,
408
+ `export HTTPS_PROXY=http://localhost:${mitmPort}`,
409
+ `export SSL_CERT_FILE=${bundlePath}`,
389
410
  `# squeezr auto-heal: start proxy if not running`,
390
411
  `if ! curl -sf http://localhost:${port}/squeezr/health >/dev/null 2>&1; then`,
391
412
  ` nohup ${nodeExe} ${distIndex} >> "${os.homedir()}/.squeezr/squeezr.log" 2>&1 &`,
@@ -404,16 +425,16 @@ function setupWSL() {
404
425
  fs.appendFileSync(profile, `\n${shellBlock}\n`)
405
426
  console.log(` [ok] Env vars + auto-heal added to ${profile}`)
406
427
  } else {
407
- // Update existing block to include auto-heal if missing
408
- if (!existing.includes('squeezr auto-heal')) {
428
+ // Update existing block if missing MITM proxy vars
429
+ if (!existing.includes('SSL_CERT_FILE') || !existing.includes('HTTPS_PROXY')) {
409
430
  const updatedContent = existing.replace(
410
- /# squeezr env vars\n(?:export [A-Z_]+=http:\/\/localhost:\d+\n?)*/,
431
+ /# squeezr env vars[\s\S]*?fi\n/,
411
432
  shellBlock + '\n'
412
433
  )
413
434
  fs.writeFileSync(profile, updatedContent)
414
- console.log(` [ok] Auto-heal guard added to ${profile}`)
435
+ console.log(` [ok] Shell profile updated with MITM proxy vars`)
415
436
  } else {
416
- console.log(` [skip] Env vars + auto-heal already in ${profile}`)
437
+ console.log(` [skip] Env vars already in ${profile}`)
417
438
  }
418
439
  }
419
440
 
@@ -1,10 +1,14 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest';
2
2
  import { CompressionCache } from '../cache.js';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ // Use a unique temp path per test run so no disk state bleeds between tests
6
+ const tmpPath = () => join(tmpdir(), `squeezr-cache-test-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
3
7
  describe('CompressionCache', () => {
4
8
  let cache;
5
9
  beforeEach(() => {
6
- // maxEntries=5 for fast LRU testing; file I/O fails silently in test env
7
- cache = new CompressionCache(5);
10
+ // maxEntries=5, isolated temp file no disk bleed between runs
11
+ cache = new CompressionCache(5, tmpPath());
8
12
  });
9
13
  it('returns undefined for a cache miss', () => {
10
14
  expect(cache.get('never stored this')).toBeUndefined();
@@ -42,7 +46,6 @@ describe('CompressionCache', () => {
42
46
  cache.set('c', '3');
43
47
  cache.set('d', '4');
44
48
  cache.set('e', '5');
45
- // All 5 entries stored
46
49
  expect(cache.stats().size).toBe(5);
47
50
  // Add one more — oldest ('a') should be evicted
48
51
  cache.set('f', '6');
@@ -50,19 +53,19 @@ describe('CompressionCache', () => {
50
53
  expect(cache.get('a')).toBeUndefined();
51
54
  expect(cache.get('f')).toBe('6');
52
55
  });
53
- it('reports correct size (relative to initial)', () => {
54
- // Use a large maxEntries so LRU eviction doesn't interfere
55
- const bigCache = new CompressionCache(1000);
56
- const initialSize = bigCache.stats().size;
57
- bigCache.set('unique-key-x-' + Date.now(), 'y');
58
- expect(bigCache.stats().size).toBe(initialSize + 1);
59
- bigCache.set('unique-key-z-' + Date.now(), 'w');
60
- expect(bigCache.stats().size).toBe(initialSize + 2);
56
+ it('reports correct size after additions', () => {
57
+ expect(cache.stats().size).toBe(0); // fresh isolated cache
58
+ cache.set('key1', 'val1');
59
+ expect(cache.stats().size).toBe(1);
60
+ cache.set('key2', 'val2');
61
+ expect(cache.stats().size).toBe(2);
61
62
  });
62
- it('overwrites existing entry', () => {
63
+ it('overwrites existing entry without growing size', () => {
63
64
  cache.set('key', 'first');
65
+ expect(cache.stats().size).toBe(1);
64
66
  cache.set('key', 'second');
65
67
  expect(cache.get('key')).toBe('second');
68
+ expect(cache.stats().size).toBe(1);
66
69
  });
67
70
  it('different texts produce different cache entries', () => {
68
71
  cache.set('text1', 'compressed1');
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,288 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ // ── WS frame helpers (inline reimplementation for testing) ────────────────────
3
+ // These mirror the logic in codexMitm.ts without importing it directly
4
+ // (importing would require node-forge CA files to exist)
5
+ function xorMask(data, key) {
6
+ const out = Buffer.from(data);
7
+ for (let i = 0; i < out.length; i++)
8
+ out[i] ^= key[i % 4];
9
+ return out;
10
+ }
11
+ function buildWsFrame(opcode, payload, masked) {
12
+ const key = masked ? Buffer.from([0x37, 0xfa, 0x21, 0x3d]) : Buffer.alloc(0);
13
+ const plen = payload.length;
14
+ let hlen = 2 + (masked ? 4 : 0);
15
+ if (plen >= 65536)
16
+ hlen += 8;
17
+ else if (plen >= 126)
18
+ hlen += 2;
19
+ const frame = Buffer.alloc(hlen + plen);
20
+ frame[0] = 0x80 | opcode;
21
+ if (plen >= 126) {
22
+ frame[1] = (masked ? 0x80 : 0) | 126;
23
+ frame.writeUInt16BE(plen, 2);
24
+ if (masked)
25
+ key.copy(frame, 4);
26
+ }
27
+ else {
28
+ frame[1] = (masked ? 0x80 : 0) | plen;
29
+ if (masked)
30
+ key.copy(frame, 2);
31
+ }
32
+ const body = masked ? xorMask(payload, key) : payload;
33
+ body.copy(frame, hlen);
34
+ return frame;
35
+ }
36
+ function parseWsFrame(buf) {
37
+ if (buf.length < 2)
38
+ return null;
39
+ const opcode = buf[0] & 0x0F;
40
+ const masked = !!(buf[1] & 0x80);
41
+ let plen = buf[1] & 0x7F;
42
+ let hlen = 2;
43
+ if (plen === 126) {
44
+ if (buf.length < 4)
45
+ return null;
46
+ plen = buf.readUInt16BE(2);
47
+ hlen = 4;
48
+ }
49
+ else if (plen === 127) {
50
+ if (buf.length < 10)
51
+ return null;
52
+ plen = Number(buf.readBigUInt64BE(2));
53
+ hlen = 10;
54
+ }
55
+ const mask = Buffer.alloc(4);
56
+ if (masked) {
57
+ if (buf.length < hlen + 4)
58
+ return null;
59
+ buf.copy(mask, 0, hlen, hlen + 4);
60
+ hlen += 4;
61
+ }
62
+ if (buf.length < hlen + plen)
63
+ return null;
64
+ return { opcode, masked, mask, payload: buf.slice(hlen, hlen + plen), total: hlen + plen };
65
+ }
66
+ // ── WS frame tests ────────────────────────────────────────────────────────────
67
+ describe('WS frame helpers', () => {
68
+ it('xorMask is its own inverse', () => {
69
+ const data = Buffer.from('hello world');
70
+ const key = Buffer.from([0xAB, 0xCD, 0xEF, 0x12]);
71
+ expect(xorMask(xorMask(data, key), key).toString()).toBe('hello world');
72
+ });
73
+ it('builds and parses an unmasked text frame', () => {
74
+ const payload = Buffer.from('{"type":"ping"}');
75
+ const frame = buildWsFrame(1, payload, false);
76
+ const parsed = parseWsFrame(frame);
77
+ expect(parsed).not.toBeNull();
78
+ expect(parsed.opcode).toBe(1);
79
+ expect(parsed.masked).toBe(false);
80
+ expect(parsed.payload.toString()).toBe('{"type":"ping"}');
81
+ expect(parsed.total).toBe(frame.length);
82
+ });
83
+ it('builds and parses a masked text frame', () => {
84
+ const payload = Buffer.from('{"type":"response.create"}');
85
+ const frame = buildWsFrame(1, payload, true);
86
+ const parsed = parseWsFrame(frame);
87
+ expect(parsed).not.toBeNull();
88
+ expect(parsed.opcode).toBe(1);
89
+ expect(parsed.masked).toBe(true);
90
+ const plain = xorMask(parsed.payload, parsed.mask);
91
+ expect(plain.toString()).toBe('{"type":"response.create"}');
92
+ });
93
+ it('builds a 126-byte extended length frame', () => {
94
+ const payload = Buffer.alloc(130, 0x41); // 130 'A' chars
95
+ const frame = buildWsFrame(2, payload, false);
96
+ const parsed = parseWsFrame(frame);
97
+ expect(parsed.opcode).toBe(2);
98
+ expect(parsed.payload.length).toBe(130);
99
+ });
100
+ it('returns null for incomplete frame', () => {
101
+ const partial = Buffer.from([0x81, 0x05, 0x48]); // says 5-byte payload, only 1 byte
102
+ expect(parseWsFrame(partial)).toBeNull();
103
+ });
104
+ it('handles empty payload', () => {
105
+ const frame = buildWsFrame(1, Buffer.alloc(0), false);
106
+ const parsed = parseWsFrame(frame);
107
+ expect(parsed.payload.length).toBe(0);
108
+ expect(parsed.total).toBe(2);
109
+ });
110
+ it('FIN bit is always set', () => {
111
+ const frame = buildWsFrame(1, Buffer.from('x'), false);
112
+ expect(frame[0] & 0x80).toBe(0x80);
113
+ });
114
+ it('roundtrip: masked frame → parse → unmask → same payload', () => {
115
+ const original = Buffer.from(JSON.stringify({ type: 'response.create', model: 'gpt-5.4-mini' }));
116
+ const frame = buildWsFrame(1, original, true);
117
+ const parsed = parseWsFrame(frame);
118
+ const plain = xorMask(parsed.payload, parsed.mask);
119
+ expect(plain.toString()).toBe(original.toString());
120
+ });
121
+ });
122
+ // ── Compression threshold logic (unit test, no network) ──────────────────────
123
+ describe('processCodexRequest logic', () => {
124
+ // Replicate the field detection logic from codexMitm.ts
125
+ function findToolMessages(input) {
126
+ return input.flatMap(msg => {
127
+ const isToolMsg = msg.type === 'function_call_output' || msg.role === 'tool' || msg.role === 'function';
128
+ if (!isToolMsg)
129
+ return [];
130
+ const text = msg.output ?? (typeof msg.content === 'string' ? msg.content : null);
131
+ if (!text)
132
+ return [];
133
+ return [{ text, field: msg.output !== undefined ? 'output' : 'content' }];
134
+ });
135
+ }
136
+ it('detects function_call_output (Responses API format)', () => {
137
+ const msgs = [
138
+ { type: 'function_call_output', call_id: 'c1', output: 'file contents here' },
139
+ { role: 'user', content: 'read the file' },
140
+ ];
141
+ const found = findToolMessages(msgs);
142
+ expect(found).toHaveLength(1);
143
+ expect(found[0].text).toBe('file contents here');
144
+ expect(found[0].field).toBe('output');
145
+ });
146
+ it('detects role=tool (Chat Completions format)', () => {
147
+ const msgs = [
148
+ { role: 'tool', tool_call_id: 't1', content: 'shell output' },
149
+ { role: 'user', content: 'run ls' },
150
+ ];
151
+ const found = findToolMessages(msgs);
152
+ expect(found).toHaveLength(1);
153
+ expect(found[0].text).toBe('shell output');
154
+ expect(found[0].field).toBe('content');
155
+ });
156
+ it('detects role=function', () => {
157
+ const msgs = [{ role: 'function', name: 'bash', content: 'stdout here' }];
158
+ const found = findToolMessages(msgs);
159
+ expect(found).toHaveLength(1);
160
+ });
161
+ it('ignores non-tool messages', () => {
162
+ const msgs = [
163
+ { role: 'user', content: 'hello' },
164
+ { role: 'assistant', content: 'world' },
165
+ { type: 'response.create', model: 'gpt-5.4' },
166
+ ];
167
+ expect(findToolMessages(msgs)).toHaveLength(0);
168
+ });
169
+ it('ignores function_call_output with no output field', () => {
170
+ const msgs = [{ type: 'function_call_output', call_id: 'c1' }];
171
+ expect(findToolMessages(msgs)).toHaveLength(0);
172
+ });
173
+ it('handles multiple tool messages in one request', () => {
174
+ const msgs = [
175
+ { type: 'function_call_output', call_id: 'c1', output: 'first tool' },
176
+ { type: 'function_call_output', call_id: 'c2', output: 'second tool' },
177
+ { role: 'user', content: 'question' },
178
+ ];
179
+ expect(findToolMessages(msgs)).toHaveLength(2);
180
+ });
181
+ });
182
+ // ── MITM request format ───────────────────────────────────────────────────────
183
+ describe('Codex compression request format', () => {
184
+ const COMPRESS_PROMPT = 'Extract ONLY essential info: errors, file paths, function names, test failures, key values, warnings. Very concise, under 150 tokens. No preamble.';
185
+ function buildCompressMsg(text, model = 'gpt-5.4-mini') {
186
+ return {
187
+ type: 'response.create',
188
+ model,
189
+ instructions: COMPRESS_PROMPT,
190
+ input: [{ role: 'user', content: text.slice(0, 4000) }],
191
+ };
192
+ }
193
+ it('has required top-level fields', () => {
194
+ const msg = buildCompressMsg('some tool output');
195
+ expect(msg.type).toBe('response.create');
196
+ expect(msg.model).toBe('gpt-5.4-mini');
197
+ expect(msg.instructions).toBeTruthy();
198
+ expect(Array.isArray(msg.input)).toBe(true);
199
+ });
200
+ it('instructions are at top level, not nested', () => {
201
+ const msg = buildCompressMsg('x');
202
+ expect(msg.instructions).toBeTruthy();
203
+ expect(msg.response).toBeUndefined();
204
+ });
205
+ it('truncates input to 4000 chars', () => {
206
+ const longText = 'a'.repeat(10_000);
207
+ const msg = buildCompressMsg(longText);
208
+ expect(msg.input[0].content.length).toBe(4000);
209
+ });
210
+ it('uses gpt-5.4-mini model', () => {
211
+ expect(buildCompressMsg('x').model).toBe('gpt-5.4-mini');
212
+ });
213
+ it('serializes to valid JSON', () => {
214
+ const msg = buildCompressMsg('tool output content');
215
+ expect(() => JSON.parse(JSON.stringify(msg))).not.toThrow();
216
+ });
217
+ it('wraps properly in a WS frame', () => {
218
+ const msg = buildCompressMsg('tool output');
219
+ const payload = Buffer.from(JSON.stringify(msg));
220
+ const frame = buildWsFrame(1, payload, true); // masked, client→server
221
+ const parsed = parseWsFrame(frame);
222
+ const plain = xorMask(parsed.payload, parsed.mask);
223
+ const decoded = JSON.parse(plain.toString());
224
+ expect(decoded.type).toBe('response.create');
225
+ expect(decoded.model).toBe('gpt-5.4-mini');
226
+ });
227
+ });
228
+ // ── Compression threshold ─────────────────────────────────────────────────────
229
+ describe('compression threshold', () => {
230
+ const THRESHOLD = 800;
231
+ it('skips short tool outputs', () => {
232
+ const text = 'short output';
233
+ expect(text.length < THRESHOLD).toBe(true);
234
+ });
235
+ it('compresses long tool outputs', () => {
236
+ const text = 'a'.repeat(1000);
237
+ expect(text.length >= THRESHOLD).toBe(true);
238
+ });
239
+ it('only saves if compressed is shorter', () => {
240
+ const original = 'a'.repeat(1000);
241
+ const compressed = 'summary';
242
+ const saved = original.length - compressed.length;
243
+ expect(saved).toBeGreaterThan(0);
244
+ });
245
+ it('falls back to original if compression made it longer', () => {
246
+ const original = 'short';
247
+ const compressed = 'this is actually longer than the original text';
248
+ const shouldApply = compressed.length < original.length;
249
+ expect(shouldApply).toBe(false);
250
+ });
251
+ });
252
+ // ── WebSocket upgrade request manipulation ────────────────────────────────────
253
+ describe('upgrade request header stripping', () => {
254
+ it('strips Sec-WebSocket-Extensions header', () => {
255
+ const upgrade = [
256
+ 'GET /backend-api/codex/responses HTTP/1.1',
257
+ 'Host: chatgpt.com',
258
+ 'Authorization: Bearer eyJ...',
259
+ 'Upgrade: websocket',
260
+ 'Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits',
261
+ 'Sec-WebSocket-Key: abc123==',
262
+ '',
263
+ ].join('\r\n');
264
+ const stripped = upgrade.replace(/Sec-WebSocket-Extensions:[^\r\n]*\r\n/gi, '');
265
+ expect(stripped).not.toContain('Sec-WebSocket-Extensions');
266
+ expect(stripped).toContain('Upgrade: websocket');
267
+ expect(stripped).toContain('Authorization: Bearer');
268
+ });
269
+ it('extracts Authorization header', () => {
270
+ const raw = 'GET /backend-api/codex/responses HTTP/1.1\r\nAuthorization: Bearer eyJmoo\r\n\r\n';
271
+ const match = raw.match(/[Aa]uthorization:\s*(Bearer [^\r\n]+)/);
272
+ expect(match?.[1]).toBe('Bearer eyJmoo');
273
+ });
274
+ it('extracts chatgpt-account-id header', () => {
275
+ const raw = 'GET / HTTP/1.1\r\nchatgpt-account-id: acc-abc123\r\n\r\n';
276
+ const match = raw.match(/chatgpt-account-id:\s*([^\r\n]+)/i);
277
+ expect(match?.[1]).toBe('acc-abc123');
278
+ });
279
+ it('detects Codex WS path', () => {
280
+ const peek = 'get /backend-api/codex/responses http/1.1\r\nupgrade: websocket\r\n';
281
+ expect(peek.includes('upgrade: websocket')).toBe(true);
282
+ expect(peek.includes('/backend-api/codex/responses')).toBe(true);
283
+ });
284
+ it('does not detect non-Codex WS as Codex', () => {
285
+ const peek = 'get /chat/stream http/1.1\r\nupgrade: websocket\r\n';
286
+ expect(peek.includes('/backend-api/codex/responses')).toBe(false);
287
+ });
288
+ });
package/dist/cache.d.ts CHANGED
@@ -3,7 +3,8 @@ export declare class CompressionCache {
3
3
  private store;
4
4
  private hits;
5
5
  private misses;
6
- constructor(maxEntries: number);
6
+ private readonly cachePath;
7
+ constructor(maxEntries: number, cachePath?: string);
7
8
  private key;
8
9
  get(text: string): string | undefined;
9
10
  set(text: string, compressed: string): void;
package/dist/cache.js CHANGED
@@ -2,14 +2,16 @@ import { createHash } from 'crypto';
2
2
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
3
3
  import { join } from 'path';
4
4
  import { homedir } from 'os';
5
- const CACHE_FILE = join(homedir(), '.squeezr', 'cache.json');
5
+ const DEFAULT_CACHE_PATH = join(homedir(), '.squeezr', 'cache.json');
6
6
  export class CompressionCache {
7
7
  maxEntries;
8
8
  store = new Map();
9
9
  hits = 0;
10
10
  misses = 0;
11
- constructor(maxEntries) {
11
+ cachePath;
12
+ constructor(maxEntries, cachePath = DEFAULT_CACHE_PATH) {
12
13
  this.maxEntries = maxEntries;
14
+ this.cachePath = cachePath;
13
15
  this.load();
14
16
  }
15
17
  key(text) {
@@ -44,8 +46,8 @@ export class CompressionCache {
44
46
  }
45
47
  load() {
46
48
  try {
47
- if (existsSync(CACHE_FILE)) {
48
- const raw = JSON.parse(readFileSync(CACHE_FILE, 'utf-8'));
49
+ if (existsSync(this.cachePath)) {
50
+ const raw = JSON.parse(readFileSync(this.cachePath, 'utf-8'));
49
51
  for (const [k, v] of Object.entries(raw)) {
50
52
  this.store.set(k, v);
51
53
  }
@@ -58,7 +60,7 @@ export class CompressionCache {
58
60
  const dir = join(homedir(), '.squeezr');
59
61
  if (!existsSync(dir))
60
62
  mkdirSync(dir, { recursive: true });
61
- writeFileSync(CACHE_FILE, JSON.stringify(Object.fromEntries(this.store)));
63
+ writeFileSync(this.cachePath, JSON.stringify(Object.fromEntries(this.store)));
62
64
  }
63
65
  catch { /* ignore */ }
64
66
  }
@@ -0,0 +1,4 @@
1
+ export declare const BUNDLE_PATH: string;
2
+ export declare const MITM_PORT: number;
3
+ export declare function startMitmProxy(): void;
4
+ export declare function stopMitmProxy(): void;
@@ -0,0 +1,428 @@
1
+ import tls from 'node:tls';
2
+ import http from 'node:http';
3
+ import https from 'node:https';
4
+ import fs from 'node:fs';
5
+ import crypto from 'node:crypto';
6
+ import { homedir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import forge from 'node-forge';
9
+ import { config } from './config.js';
10
+ // ── CA / cert paths ───────────────────────────────────────────────────────────
11
+ const CA_DIR = join(homedir(), '.squeezr', 'mitm-ca');
12
+ const CA_KEY_PATH = join(CA_DIR, 'ca.key');
13
+ const CA_CERT_PATH = join(CA_DIR, 'ca.crt');
14
+ export const BUNDLE_PATH = join(CA_DIR, 'bundle.crt');
15
+ export const MITM_PORT = (config.port ?? 8080) + 1;
16
+ // ── CA generation ─────────────────────────────────────────────────────────────
17
+ function ensureCA() {
18
+ if (fs.existsSync(CA_KEY_PATH) && fs.existsSync(CA_CERT_PATH))
19
+ return;
20
+ fs.mkdirSync(CA_DIR, { recursive: true, mode: 0o700 });
21
+ const keys = forge.pki.rsa.generateKeyPair(2048);
22
+ const cert = forge.pki.createCertificate();
23
+ cert.publicKey = keys.publicKey;
24
+ cert.serialNumber = '01';
25
+ cert.validity.notBefore = new Date();
26
+ cert.validity.notAfter = new Date();
27
+ cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 10);
28
+ const attrs = [{ name: 'commonName', value: 'Squeezr-MITM-CA' }];
29
+ cert.setSubject(attrs);
30
+ cert.setIssuer(attrs);
31
+ cert.setExtensions([
32
+ { name: 'basicConstraints', cA: true },
33
+ { name: 'keyUsage', keyCertSign: true, cRLSign: true },
34
+ ]);
35
+ cert.sign(keys.privateKey, forge.md.sha256.create());
36
+ fs.writeFileSync(CA_KEY_PATH, forge.pki.privateKeyToPem(keys.privateKey), { mode: 0o600 });
37
+ fs.writeFileSync(CA_CERT_PATH, forge.pki.certificateToPem(cert), { mode: 0o644 });
38
+ const systemCAs = [
39
+ '/etc/ssl/certs/ca-certificates.crt',
40
+ '/etc/ssl/cert.pem',
41
+ ].find(p => fs.existsSync(p));
42
+ const bundle = forge.pki.certificateToPem(cert) + (systemCAs ? fs.readFileSync(systemCAs, 'utf-8') : '');
43
+ fs.writeFileSync(BUNDLE_PATH, bundle, { mode: 0o644 });
44
+ console.log(`[squeezr/mitm] CA generated → ${CA_CERT_PATH}`);
45
+ }
46
+ // ── Per-host cert (cached) ────────────────────────────────────────────────────
47
+ const certCache = new Map();
48
+ function getCert(hostname) {
49
+ if (certCache.has(hostname))
50
+ return certCache.get(hostname);
51
+ const caKey = forge.pki.privateKeyFromPem(fs.readFileSync(CA_KEY_PATH, 'utf-8'));
52
+ const caCert = forge.pki.certificateFromPem(fs.readFileSync(CA_CERT_PATH, 'utf-8'));
53
+ const keys = forge.pki.rsa.generateKeyPair(2048);
54
+ const cert = forge.pki.createCertificate();
55
+ cert.publicKey = keys.publicKey;
56
+ cert.serialNumber = crypto.randomBytes(8).toString('hex');
57
+ cert.validity.notBefore = new Date();
58
+ cert.validity.notAfter = new Date();
59
+ cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1);
60
+ cert.setSubject([{ name: 'commonName', value: hostname }]);
61
+ cert.setIssuer(caCert.subject.attributes);
62
+ cert.setExtensions([{ name: 'subjectAltName', altNames: [{ type: 2, value: hostname }] }]);
63
+ cert.sign(caKey, forge.md.sha256.create());
64
+ const result = {
65
+ key: forge.pki.privateKeyToPem(keys.privateKey),
66
+ cert: forge.pki.certificateToPem(cert),
67
+ };
68
+ certCache.set(hostname, result);
69
+ return result;
70
+ }
71
+ // ── WebSocket frame helpers ───────────────────────────────────────────────────
72
+ function xorMask(data, key) {
73
+ const out = Buffer.from(data);
74
+ for (let i = 0; i < out.length; i++)
75
+ out[i] ^= key[i % 4];
76
+ return out;
77
+ }
78
+ function parseWsFrame(buf) {
79
+ if (buf.length < 2)
80
+ return null;
81
+ const opcode = buf[0] & 0x0F;
82
+ const masked = !!(buf[1] & 0x80);
83
+ let plen = buf[1] & 0x7F;
84
+ let hlen = 2;
85
+ if (plen === 126) {
86
+ if (buf.length < 4)
87
+ return null;
88
+ plen = buf.readUInt16BE(2);
89
+ hlen = 4;
90
+ }
91
+ else if (plen === 127) {
92
+ if (buf.length < 10)
93
+ return null;
94
+ plen = Number(buf.readBigUInt64BE(2));
95
+ hlen = 10;
96
+ }
97
+ const mask = Buffer.alloc(4);
98
+ if (masked) {
99
+ if (buf.length < hlen + 4)
100
+ return null;
101
+ buf.copy(mask, 0, hlen, hlen + 4);
102
+ hlen += 4;
103
+ }
104
+ if (buf.length < hlen + plen)
105
+ return null;
106
+ return { opcode, masked, mask, payload: buf.slice(hlen, hlen + plen), total: hlen + plen };
107
+ }
108
+ function buildWsFrame(opcode, payload, masked) {
109
+ const key = masked ? crypto.randomBytes(4) : Buffer.alloc(0);
110
+ const plen = payload.length;
111
+ let hlen = 2 + (masked ? 4 : 0);
112
+ if (plen >= 65536)
113
+ hlen += 8;
114
+ else if (plen >= 126)
115
+ hlen += 2;
116
+ const frame = Buffer.alloc(hlen + plen);
117
+ frame[0] = 0x80 | opcode;
118
+ if (plen >= 65536) {
119
+ frame[1] = (masked ? 0x80 : 0) | 127;
120
+ frame.writeBigUInt64BE(BigInt(plen), 2);
121
+ if (masked)
122
+ key.copy(frame, 10);
123
+ }
124
+ else if (plen >= 126) {
125
+ frame[1] = (masked ? 0x80 : 0) | 126;
126
+ frame.writeUInt16BE(plen, 2);
127
+ if (masked)
128
+ key.copy(frame, 4);
129
+ }
130
+ else {
131
+ frame[1] = (masked ? 0x80 : 0) | plen;
132
+ if (masked)
133
+ key.copy(frame, 2);
134
+ }
135
+ const body = masked ? xorMask(payload, key) : payload;
136
+ body.copy(frame, hlen);
137
+ return frame;
138
+ }
139
+ // ── Compress via separate WS to chatgpt.com ──────────────────────────────────
140
+ const COMPRESS_THRESHOLD = config.threshold ?? 800;
141
+ const COMPRESS_MODEL = 'gpt-5.4-mini';
142
+ const COMPRESS_PROMPT = 'Extract ONLY essential info: errors, file paths, function names, test failures, key values, warnings. Very concise, under 150 tokens. No preamble.';
143
+ function compressViaWs(text, authToken, accountId) {
144
+ return new Promise((resolve) => {
145
+ const timeout = setTimeout(() => { resolve(text); socket.destroy(); }, 15_000);
146
+ const wsKey = crypto.randomBytes(16).toString('base64');
147
+ const upgradeReq = [
148
+ 'GET /backend-api/codex/responses HTTP/1.1',
149
+ 'Host: chatgpt.com',
150
+ `Authorization: ${authToken}`,
151
+ 'Upgrade: websocket',
152
+ 'Connection: Upgrade',
153
+ `Sec-WebSocket-Key: ${wsKey}`,
154
+ 'Sec-WebSocket-Version: 13',
155
+ 'Originator: codex_exec',
156
+ ...(accountId ? [`chatgpt-account-id: ${accountId}`] : []),
157
+ '', '',
158
+ ].join('\r\n');
159
+ const socket = tls.connect(443, 'chatgpt.com', { servername: 'chatgpt.com' }, () => {
160
+ socket.write(upgradeReq);
161
+ });
162
+ socket.on('error', () => { clearTimeout(timeout); resolve(text); });
163
+ let gotUpgrade = false;
164
+ let buf = Buffer.alloc(0);
165
+ socket.on('data', (chunk) => {
166
+ buf = Buffer.concat([buf, chunk]);
167
+ if (!gotUpgrade) {
168
+ const str = buf.toString('latin1');
169
+ if (!str.includes('\r\n\r\n'))
170
+ return;
171
+ const headerEnd = str.indexOf('\r\n\r\n');
172
+ const headers = str.slice(0, headerEnd);
173
+ if (!headers.startsWith('HTTP/1.1 101')) {
174
+ clearTimeout(timeout);
175
+ resolve(text);
176
+ socket.destroy();
177
+ return;
178
+ }
179
+ gotUpgrade = true;
180
+ buf = buf.slice(headerEnd + 4);
181
+ // Send compression request
182
+ const msg = JSON.stringify({
183
+ type: 'response.create',
184
+ model: COMPRESS_MODEL,
185
+ instructions: COMPRESS_PROMPT,
186
+ input: [{ role: 'user', content: text.slice(0, 4000) }],
187
+ });
188
+ socket.write(buildWsFrame(1, Buffer.from(msg), true));
189
+ }
190
+ // Parse response frames
191
+ while (buf.length >= 2) {
192
+ const f = parseWsFrame(buf);
193
+ if (!f)
194
+ break;
195
+ buf = buf.slice(f.total);
196
+ if (f.opcode === 1) {
197
+ const payload = f.masked ? xorMask(f.payload, f.mask) : f.payload;
198
+ try {
199
+ const evt = JSON.parse(payload.toString('utf-8'));
200
+ if (evt.type === 'response.output_text.done') {
201
+ clearTimeout(timeout);
202
+ resolve(evt.text || text);
203
+ socket.destroy();
204
+ return;
205
+ }
206
+ if (evt.type === 'response.completed' || evt.type === 'response.done') {
207
+ const output = evt.response?.output?.[0]?.content?.[0]?.text ?? '';
208
+ clearTimeout(timeout);
209
+ resolve(output || text);
210
+ socket.destroy();
211
+ return;
212
+ }
213
+ }
214
+ catch { }
215
+ }
216
+ else if (f.opcode === 8) {
217
+ clearTimeout(timeout);
218
+ resolve(text);
219
+ socket.destroy();
220
+ return;
221
+ }
222
+ }
223
+ });
224
+ });
225
+ }
226
+ // ── Process Codex request: find tool outputs and compress ─────────────────────
227
+ async function processCodexRequest(json, authToken, accountId) {
228
+ const messages = json.input ?? json.messages ?? [];
229
+ let saved = 0;
230
+ for (const msg of messages) {
231
+ // Responses API: type=function_call_output, output field
232
+ // Chat Completions API: role=tool/function, content field
233
+ const isToolMsg = msg.type === 'function_call_output' || msg.role === 'tool' || msg.role === 'function';
234
+ if (!isToolMsg)
235
+ continue;
236
+ const text = msg.output ?? (typeof msg.content === 'string' ? msg.content : null);
237
+ if (!text || text.length < COMPRESS_THRESHOLD)
238
+ continue;
239
+ const compressed = await compressViaWs(text, authToken, accountId);
240
+ if (compressed.length < text.length) {
241
+ if (msg.output !== undefined)
242
+ msg.output = compressed;
243
+ else
244
+ msg.content = compressed;
245
+ saved += text.length - compressed.length;
246
+ }
247
+ }
248
+ return saved;
249
+ }
250
+ // ── CONNECT handler (HTTPS MITM) ─────────────────────────────────────────────
251
+ function handleConnect(req, clientSocket, _head) {
252
+ const [hostname, portStr] = (req.url ?? '').split(':');
253
+ const port = parseInt(portStr) || 443;
254
+ clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
255
+ const { key, cert } = getCert(hostname);
256
+ const clientTls = new tls.TLSSocket(clientSocket, { isServer: true, key, cert });
257
+ clientTls.on('error', () => { });
258
+ // Capture chatgpt-account-id from any HTTP request to chatgpt.com
259
+ let accountId = '';
260
+ clientTls.once('data', (firstChunk) => {
261
+ const raw = firstChunk.toString('latin1');
262
+ const peek = raw.toLowerCase();
263
+ // Capture account-id header
264
+ const acctMatch = raw.match(/chatgpt-account-id:\s*([^\r\n]+)/i);
265
+ if (acctMatch)
266
+ accountId = acctMatch[1].trim();
267
+ // ── WebSocket upgrade ─────────────────────────────────────────────────────
268
+ if (peek.includes('upgrade: websocket')) {
269
+ const isCodexWs = peek.includes('/backend-api/codex/responses');
270
+ // Extract auth token
271
+ const authMatch = raw.match(/[Aa]uthorization:\s*(Bearer [^\r\n]+)/);
272
+ const authToken = authMatch ? authMatch[1].trim() : '';
273
+ // Strip permessage-deflate so frames are plain text (avoids context desync)
274
+ const modified = raw.replace(/Sec-WebSocket-Extensions:[^\r\n]*\r\n/gi, '');
275
+ const upChunk = Buffer.from(modified, 'latin1');
276
+ const upSocket = tls.connect(port, hostname, { servername: hostname }, () => {
277
+ upSocket.write(upChunk);
278
+ });
279
+ upSocket.on('error', () => { try {
280
+ clientTls.destroy();
281
+ }
282
+ catch { } });
283
+ upSocket.once('data', (upgradeResp) => {
284
+ clientTls.write(upgradeResp);
285
+ if (!isCodexWs) {
286
+ // Non-Codex WS: bidirectional passthrough
287
+ upSocket.on('data', (c) => { try {
288
+ clientTls.write(c);
289
+ }
290
+ catch { } });
291
+ clientTls.on('data', (c) => { try {
292
+ upSocket.write(c);
293
+ }
294
+ catch { } });
295
+ return;
296
+ }
297
+ // ── Codex WS: intercept client→server, compress tool results ──────────
298
+ let clientBuf = Buffer.alloc(0);
299
+ clientTls.on('data', (chunk) => {
300
+ clientBuf = Buffer.concat([clientBuf, chunk]);
301
+ const processNext = async () => {
302
+ while (clientBuf.length >= 2) {
303
+ const frame = parseWsFrame(clientBuf);
304
+ if (!frame)
305
+ break;
306
+ const originalFrame = clientBuf.slice(0, frame.total);
307
+ clientBuf = clientBuf.slice(frame.total);
308
+ if (frame.opcode === 1) {
309
+ const plain = frame.masked ? xorMask(frame.payload, frame.mask) : frame.payload;
310
+ try {
311
+ const json = JSON.parse(plain.toString('utf-8'));
312
+ const saved = await processCodexRequest(json, authToken, accountId);
313
+ if (saved > 0) {
314
+ console.log(`[squeezr/mitm] Codex compressed: -${saved} chars via ${COMPRESS_MODEL}`);
315
+ const newFrame = buildWsFrame(frame.opcode, Buffer.from(JSON.stringify(json)), frame.masked);
316
+ try {
317
+ upSocket.write(newFrame);
318
+ }
319
+ catch { }
320
+ continue;
321
+ }
322
+ }
323
+ catch { }
324
+ }
325
+ try {
326
+ upSocket.write(originalFrame);
327
+ }
328
+ catch { }
329
+ }
330
+ };
331
+ processNext().catch(() => { });
332
+ });
333
+ // Server→client: pass through unmodified
334
+ upSocket.on('data', (c) => { try {
335
+ clientTls.write(c);
336
+ }
337
+ catch { } });
338
+ });
339
+ clientTls.on('error', () => { try {
340
+ upSocket.destroy();
341
+ }
342
+ catch { } });
343
+ clientTls.on('close', () => { try {
344
+ upSocket.destroy();
345
+ }
346
+ catch { } });
347
+ upSocket.on('close', () => { try {
348
+ clientTls.destroy();
349
+ }
350
+ catch { } });
351
+ return;
352
+ }
353
+ // ── Regular HTTP/1.1 (non-WebSocket) ─────────────────────────────────────
354
+ const fakeServer = new http.Server();
355
+ fakeServer.emit('connection', clientTls);
356
+ setImmediate(() => { if (!clientTls.destroyed)
357
+ clientTls.emit('data', firstChunk); });
358
+ fakeServer.on('request', (clientReq, clientRes) => {
359
+ const headers = {};
360
+ for (const [k, v] of Object.entries(clientReq.headers)) {
361
+ if (/^[\w\-]+$/.test(k))
362
+ headers[k] = Array.isArray(v) ? v.join(', ') : (v ?? '');
363
+ }
364
+ headers['host'] = hostname;
365
+ // Capture account-id from HTTP requests too
366
+ if (clientReq.headers['chatgpt-account-id'] && !accountId) {
367
+ accountId = String(clientReq.headers['chatgpt-account-id']);
368
+ }
369
+ const upReq = https.request({
370
+ hostname, port,
371
+ path: clientReq.url ?? '/',
372
+ method: clientReq.method ?? 'GET',
373
+ headers,
374
+ }, (upRes) => {
375
+ clientRes.writeHead(upRes.statusCode ?? 200, upRes.headers);
376
+ upRes.pipe(clientRes);
377
+ });
378
+ upReq.on('error', () => { try {
379
+ clientRes.destroy();
380
+ }
381
+ catch { } });
382
+ clientReq.pipe(upReq);
383
+ });
384
+ fakeServer.on('error', () => { try {
385
+ clientTls.destroy();
386
+ }
387
+ catch { } });
388
+ });
389
+ }
390
+ // ── Plain HTTP handler ────────────────────────────────────────────────────────
391
+ function handleHttp(req, res) {
392
+ const upReq = http.request({
393
+ hostname: req.headers.host?.split(':')[0] ?? 'localhost',
394
+ port: 80,
395
+ path: req.url,
396
+ method: req.method,
397
+ headers: req.headers,
398
+ }, (upRes) => {
399
+ res.writeHead(upRes.statusCode ?? 200, upRes.headers);
400
+ upRes.pipe(res);
401
+ });
402
+ upReq.on('error', () => res.writeHead(502).end());
403
+ req.pipe(upReq);
404
+ }
405
+ // ── Server lifecycle ──────────────────────────────────────────────────────────
406
+ let mitmServer = null;
407
+ export function startMitmProxy() {
408
+ try {
409
+ ensureCA();
410
+ }
411
+ catch (err) {
412
+ console.error('[squeezr/mitm] CA generation failed:', err);
413
+ return;
414
+ }
415
+ mitmServer = http.createServer(handleHttp);
416
+ mitmServer.on('connect', handleConnect);
417
+ mitmServer.on('error', (err) => {
418
+ if (err.code !== 'EADDRINUSE')
419
+ console.error('[squeezr/mitm] error:', err.message);
420
+ });
421
+ mitmServer.listen(MITM_PORT, () => {
422
+ console.log(`[squeezr/mitm] HTTPS proxy on http://localhost:${MITM_PORT}`);
423
+ });
424
+ }
425
+ export function stopMitmProxy() {
426
+ mitmServer?.close();
427
+ mitmServer = null;
428
+ }
package/dist/index.js CHANGED
@@ -1,10 +1,11 @@
1
- import tls from 'node:tls';
2
- import { serve } from '@hono/node-server';
1
+ import { createAdaptorServer } from '@hono/node-server';
3
2
  import { app, stats } from './server.js';
4
3
  import { config } from './config.js';
5
4
  import { VERSION } from './version.js';
5
+ import { startMitmProxy } from './codexMitm.js';
6
6
  const PORT = config.port;
7
- const server = serve({ fetch: app.fetch, port: PORT }, () => {
7
+ const httpServer = createAdaptorServer({ fetch: app.fetch });
8
+ httpServer.listen(PORT, () => {
8
9
  console.log(`Squeezr v${VERSION} listening on http://localhost:${PORT}`);
9
10
  console.log(`Mode: ${config.dryRun ? 'dry-run' : 'active'}`);
10
11
  if (config.disabled)
@@ -12,38 +13,14 @@ const server = serve({ fetch: app.fetch, port: PORT }, () => {
12
13
  console.log(`Backends: Anthropic → Haiku | OpenAI → GPT-4o-mini | Gemini → Flash-8B | Local → ${config.localCompressionModel}`);
13
14
  console.log(`Stats: http://localhost:${PORT}/squeezr/stats`);
14
15
  });
15
- server.on('upgrade', (req, socket, head) => {
16
- if (req.url !== '/responses') {
17
- socket.destroy();
18
- return;
19
- }
20
- const targetHost = 'api.openai.com';
21
- const targetPath = '/v1/responses';
22
- const upstream = tls.connect({ host: targetHost, port: 443, servername: targetHost }, () => {
23
- // Rebuild the HTTP upgrade request with the correct host and path
24
- const fwdHeaders = Object.entries(req.headers)
25
- .filter(([k]) => k.toLowerCase() !== 'host')
26
- .map(([k, v]) => `${k}: ${v}`)
27
- .join('\r\n');
28
- const upgradeReq = `GET ${targetPath} HTTP/1.1\r\nHost: ${targetHost}\r\n${fwdHeaders}\r\n\r\n`;
29
- upstream.write(upgradeReq);
30
- if (head.length > 0)
31
- upstream.write(head);
32
- upstream.pipe(socket);
33
- socket.pipe(upstream);
34
- });
35
- upstream.on('error', () => socket.destroy());
36
- socket.on('error', () => upstream.destroy());
37
- });
16
+ // Start MITM proxy for Codex OAuth (chatgpt.com/backend-api)
17
+ startMitmProxy();
38
18
  const isDaemon = !!process.env.SQUEEZR_DAEMON;
39
19
  if (isDaemon) {
40
- // Daemon mode: ignore SIGINT (Ctrl+C) and SIGHUP (terminal close)
41
- // Only stop via `squeezr stop` which sends SIGTERM
42
20
  process.on('SIGINT', () => { });
43
21
  process.on('SIGHUP', () => { });
44
22
  }
45
23
  else {
46
- // Dev mode (npm run dev): allow Ctrl+C to stop
47
24
  process.on('SIGINT', () => {
48
25
  const s = stats.summary();
49
26
  console.log(`\n[squeezr] Session summary: ${s.requests} requests | -${s.total_saved_chars.toLocaleString()} chars (~${s.total_saved_tokens.toLocaleString()} tokens, ${s.savings_pct}% saved)`);
package/dist/server.js CHANGED
@@ -232,12 +232,24 @@ app.get('/squeezr/expand/:id', (c) => {
232
232
  return c.json({ error: 'Not found or expired' }, 404);
233
233
  return c.json({ id, content: original });
234
234
  });
235
+ // ── OAuth token refresh proxy (Codex: set CODEX_REFRESH_TOKEN_URL_OVERRIDE=http://localhost:PORT/oauth/token) ──
236
+ app.post('/oauth/token', async (c) => {
237
+ const body = await c.req.arrayBuffer();
238
+ const resp = await fetch('https://auth.openai.com/oauth/token', {
239
+ method: 'POST',
240
+ headers: { 'content-type': c.req.header('content-type') ?? 'application/json' },
241
+ body,
242
+ });
243
+ const data = await resp.arrayBuffer();
244
+ return c.body(data, resp.status, { 'content-type': 'application/json' });
245
+ });
235
246
  // ── Catch-all ─────────────────────────────────────────────────────────────────
236
247
  app.all('*', async (c) => {
237
248
  const upstream = detectUpstream(c.req.raw.headers);
238
249
  const url = new URL(c.req.url);
239
- const targetPath = url.pathname === '/responses' ? '/v1/responses' : url.pathname;
240
- const targetUrl = `${upstream}${targetPath}${url.search}`;
250
+ const NEEDS_V1 = new Set(['/models', '/engines', '/files', '/embeddings', '/moderations', '/completions', '/edits']);
251
+ const pathname = NEEDS_V1.has(url.pathname) ? `/v1${url.pathname}` : url.pathname;
252
+ const targetUrl = `${upstream}${pathname}${url.search}`;
241
253
  const body = await c.req.arrayBuffer();
242
254
  const fwdHeaders = forwardHeaders(c.req.raw.headers);
243
255
  const resp = await fetch(targetUrl, {
@@ -250,5 +262,17 @@ app.all('*', async (c) => {
250
262
  if (!SKIP_RESP_HEADERS.has(k.toLowerCase()))
251
263
  respHeaders[k] = v;
252
264
  }
265
+ const contentType = resp.headers.get('content-type') ?? '';
266
+ if (contentType.includes('text/event-stream')) {
267
+ return stream(c, async (s) => {
268
+ const reader = resp.body.getReader();
269
+ while (true) {
270
+ const { done, value } = await reader.read();
271
+ if (done)
272
+ break;
273
+ await s.write(value);
274
+ }
275
+ });
276
+ }
253
277
  return c.body(await resp.arrayBuffer(), resp.status, respHeaders);
254
278
  });
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "1.11.4";
1
+ export declare const VERSION = "1.13.1";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '1.11.4';
1
+ export const VERSION = '1.13.1';
package/package.json CHANGED
@@ -1,58 +1,60 @@
1
- {
2
- "name": "squeezr-ai",
3
- "version": "1.11.4",
4
- "description": "AI proxy that compresses Claude Code, Codex, Aider, Gemini CLI and Ollama context windows to save thousands of tokens per session",
5
- "keywords": [
6
- "claude",
7
- "claude-code",
8
- "codex",
9
- "ollama",
10
- "aider",
11
- "gemini",
12
- "token",
13
- "compression",
14
- "proxy",
15
- "llm",
16
- "ai"
17
- ],
18
- "license": "MIT",
19
- "repository": {
20
- "type": "git",
21
- "url": "git+https://github.com/sergioramosv/Squeezr.git"
22
- },
23
- "homepage": "https://github.com/sergioramosv/Squeezr#readme",
24
- "type": "module",
25
- "bin": {
26
- "squeezr": "bin/squeezr.js"
27
- },
28
- "scripts": {
29
- "build": "tsc",
30
- "dev": "tsx src/index.ts",
31
- "start": "node dist/index.js",
32
- "gain": "node dist/gain.js",
33
- "discover": "node dist/discover.js",
34
- "test": "vitest run",
35
- "test:watch": "vitest"
36
- },
37
- "files": [
38
- "bin/",
39
- "dist/",
40
- "squeezr.toml"
41
- ],
42
- "dependencies": {
43
- "@anthropic-ai/sdk": "^0.39.0",
44
- "@hono/node-server": "^1.13.7",
45
- "hono": "^4.7.5",
46
- "openai": "^4.93.0",
47
- "smol-toml": "^1.3.1"
48
- },
49
- "devDependencies": {
50
- "@types/node": "^22.14.0",
51
- "tsx": "^4.19.3",
52
- "typescript": "^5.8.3",
53
- "vitest": "^3.1.1"
54
- },
55
- "engines": {
56
- "node": ">=18"
57
- }
58
- }
1
+ {
2
+ "name": "squeezr-ai",
3
+ "version": "1.13.1",
4
+ "description": "AI proxy that compresses Claude Code, Codex, Aider, Gemini CLI and Ollama context windows to save thousands of tokens per session",
5
+ "keywords": [
6
+ "claude",
7
+ "claude-code",
8
+ "codex",
9
+ "ollama",
10
+ "aider",
11
+ "gemini",
12
+ "token",
13
+ "compression",
14
+ "proxy",
15
+ "llm",
16
+ "ai"
17
+ ],
18
+ "license": "MIT",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/sergioramosv/Squeezr.git"
22
+ },
23
+ "homepage": "https://github.com/sergioramosv/Squeezr#readme",
24
+ "type": "module",
25
+ "bin": {
26
+ "squeezr": "bin/squeezr.js"
27
+ },
28
+ "scripts": {
29
+ "build": "tsc",
30
+ "dev": "tsx src/index.ts",
31
+ "start": "node dist/index.js",
32
+ "gain": "node dist/gain.js",
33
+ "discover": "node dist/discover.js",
34
+ "test": "vitest run",
35
+ "test:watch": "vitest"
36
+ },
37
+ "files": [
38
+ "bin/",
39
+ "dist/",
40
+ "squeezr.toml"
41
+ ],
42
+ "dependencies": {
43
+ "@anthropic-ai/sdk": "^0.39.0",
44
+ "@hono/node-server": "^1.13.7",
45
+ "hono": "^4.7.5",
46
+ "node-forge": "^1.4.0",
47
+ "openai": "^4.93.0",
48
+ "smol-toml": "^1.3.1"
49
+ },
50
+ "devDependencies": {
51
+ "@types/node": "^22.14.0",
52
+ "@types/node-forge": "^1.3.14",
53
+ "tsx": "^4.19.3",
54
+ "typescript": "^5.8.3",
55
+ "vitest": "^3.1.1"
56
+ },
57
+ "engines": {
58
+ "node": ">=18"
59
+ }
60
+ }