diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..70f04ac --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Conrad Lelubre + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ec4bbd7..6a9dc77 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ export default function App() { const [editingConfig, setEditingConfig] = useState(false); const [editHost, setEditHost] = useState(''); const [editPort, setEditPort] = useState(''); + const [editScheme, setEditScheme] = useState<'http' | 'https'>('http'); const [status, setStatus] = useState({ running: false, capturing: false, activeModel: null }); const [sessions, setSessions] = useState([]); const [selectedSessionId, setSelectedSessionId] = useState(null); @@ -169,7 +170,7 @@ export default function App() { }; const handleSaveConfig = () => { - api.updateConfig({ targetHost: editHost, targetPort: parseInt(editPort, 10) }).then(c => { + api.updateConfig({ targetHost: editHost, targetPort: parseInt(editPort, 10), targetScheme: editScheme }).then(c => { setConfig(c); setEditingConfig(false); }).catch(() => showError('Failed to save config')); @@ -179,6 +180,7 @@ export default function App() { if (config) { setEditHost(config.targetHost); setEditPort(String(config.targetPort)); + setEditScheme(config.targetScheme); } setEditingConfig(true); }; @@ -205,12 +207,22 @@ export default function App() {
{config && !editingConfig && ( - {config.targetHost}:{config.targetPort} + {config.targetScheme}://{config.targetHost}:{config.targetPort} )} {config && editingConfig && ( + + :// request('/config'), - updateConfig: (cfg: { targetHost?: string; targetPort?: number }) => + updateConfig: (cfg: { targetHost?: string; targetPort?: number; targetScheme?: 'http' | 'https' }) => request('/config', { method: 'PUT', body: JSON.stringify(cfg) }), proxyStart: () => request<{ running: boolean }>('/proxy/start', { method: 'POST' }), diff --git a/package.json b/package.json index d70acd8..6b24373 100644 --- a/package.json +++ b/package.json @@ -2,13 +2,14 @@ "name": "cache-hunter", "version": "1.0.0", "description": "Transparent proxy for vLLM with SQLite logging for cache debugging", + "license": "MIT", "type": "module", "scripts": { "start": "concurrently -n backend,frontend -c blue,green \"npm run start:backend\" \"npm run start:frontend\"", - "start:backend": "tsx src/index.ts", + "start:backend": "NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt tsx src/index.ts", "start:frontend": "cd frontend && npm run dev", "dev": "concurrently -n backend,frontend -c blue,green \"npm run dev:backend\" \"npm run dev:frontend\"", - "dev:backend": "tsx watch src/index.ts", + "dev:backend": "NODE_OPTIONS=--use-system-ca tsx watch src/index.ts", "dev:frontend": "cd frontend && npm run dev", "build": "cd frontend && npm run build", "test": "vitest", diff --git a/src/api.test.ts b/src/api.test.ts index 38011af..ee6d6cc 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -31,7 +31,7 @@ describe('API Endpoints', () => { const { ProxyEngine } = await import('./proxy-engine.js'); const { createApp } = await import('./app.js'); - const engine = new ProxyEngine({ targetHost: 'localhost', targetPort: 8765, proxyPort: 0 }); + const engine = new ProxyEngine({ targetHost: 'localhost', targetPort: 8765, proxyPort: 0, targetScheme: 'http' }); const app = createApp(engine, TEST_DATA_DIR); server = await new Promise((resolve) => { @@ -51,6 +51,7 @@ describe('API Endpoints', () => { expect(status).toBe(200); expect(body.targetHost).toBe('localhost'); expect(body.targetPort).toBe(8765); + expect(body.targetScheme).toBe('http'); }); it('PUT /api/config updates config and persists to disk', async () => { @@ -69,6 +70,28 @@ describe('API Endpoints', () => { expect(saved.targetPort).toBe(8080); }); + it('PUT /api/config with targetScheme https updates correctly', async () => { + const { status, body } = await fetchJson(`${baseUrl}/api/config`, { + method: 'PUT', + body: JSON.stringify({ targetHost: 'api.anthropic.com', targetPort: 443, targetScheme: 'https' }), + }); + expect(status).toBe(200); + expect(body.targetScheme).toBe('https'); + expect(body.targetHost).toBe('api.anthropic.com'); + + const saved = JSON.parse(readFileSync(getConfigPath(TEST_DATA_DIR), 'utf-8')); + expect(saved.targetScheme).toBe('https'); + }); + + it('PUT /api/config ignores invalid targetScheme', async () => { + const { status, body } = await fetchJson(`${baseUrl}/api/config`, { + method: 'PUT', + body: JSON.stringify({ targetScheme: 'ftp' }), + }); + expect(status).toBe(200); + expect(body.targetScheme).toBe('http'); + }); + it('GET /api/proxy/status returns stopped initially', async () => { const { status, body } = await fetchJson(`${baseUrl}/api/proxy/status`); expect(status).toBe(200); diff --git a/src/app.ts b/src/app.ts index bff4d98..dc1b5e6 100644 --- a/src/app.ts +++ b/src/app.ts @@ -51,7 +51,7 @@ export function createApp(engine: ProxyEngine, dataDir: string = DATA_DIR, broad // Config app.get('/api/config', (_req: Request, res: Response) => { const cfg = engine.getConfig() - res.json({ targetHost: cfg.targetHost, targetPort: cfg.targetPort, proxyPort: cfg.proxyPort }) + res.json({ targetHost: cfg.targetHost, targetPort: cfg.targetPort, proxyPort: cfg.proxyPort, targetScheme: cfg.targetScheme }) }) app.put('/api/config', (req: Request, res: Response) => { @@ -59,14 +59,15 @@ export function createApp(engine: ProxyEngine, dataDir: string = DATA_DIR, broad res.status(409).json({ error: 'Cannot change config while proxy is running' }) return } - const { targetHost, targetPort } = req.body + const { targetHost, targetPort, targetScheme } = req.body const updates: any = {} if (targetHost) updates.targetHost = targetHost if (targetPort) updates.targetPort = parseInt(targetPort, 10) + if (targetScheme === 'http' || targetScheme === 'https') updates.targetScheme = targetScheme engine.updateConfig(updates) const cfg = engine.getConfig() saveProxyConfig(dataDir, cfg) - res.json({ targetHost: cfg.targetHost, targetPort: cfg.targetPort }) + res.json({ targetHost: cfg.targetHost, targetPort: cfg.targetPort, targetScheme: cfg.targetScheme }) }) // Proxy diff --git a/src/config-store.test.ts b/src/config-store.test.ts index 6dbb9aa..39f81b3 100644 --- a/src/config-store.test.ts +++ b/src/config-store.test.ts @@ -16,7 +16,7 @@ describe('config-store', () => { rmSync(TEST_DIR, { recursive: true, force: true }) }) - const envFallback = { targetHost: 'fallback.host', targetPort: 9999, proxyPort: 7777 } + const envFallback = { targetHost: 'fallback.host', targetPort: 9999, proxyPort: 7777, targetScheme: 'http' as const } it('returns env fallback when no config file exists', () => { const cfg = loadProxyConfig(TEST_DIR, envFallback) @@ -24,7 +24,7 @@ describe('config-store', () => { }) it('reads config from file when it exists', () => { - const saved = { targetHost: '10.0.0.1', targetPort: 8080, proxyPort: 8888 } + const saved = { targetHost: '10.0.0.1', targetPort: 8080, proxyPort: 8888, targetScheme: 'https' as const } saveProxyConfig(TEST_DIR, saved) const loaded = loadProxyConfig(TEST_DIR, envFallback) @@ -43,7 +43,7 @@ describe('config-store', () => { it('falls through on corrupt file', () => { const path = getConfigPath(TEST_DIR) - saveProxyConfig(TEST_DIR, { targetHost: 'x', targetPort: 1, proxyPort: 2 }) + saveProxyConfig(TEST_DIR, { targetHost: 'x', targetPort: 1, proxyPort: 2, targetScheme: 'http' }) // corrupt it const { writeFileSync } = require('fs') writeFileSync(path, '{invalid json') @@ -53,10 +53,28 @@ describe('config-store', () => { }) it('writes valid JSON to disk', () => { - const cfg = { targetHost: 'a.b.c', targetPort: 1111, proxyPort: 2222 } + const cfg = { targetHost: 'a.b.c', targetPort: 1111, proxyPort: 2222, targetScheme: 'http' as const } saveProxyConfig(TEST_DIR, cfg) const raw = readFileSync(getConfigPath(TEST_DIR), 'utf-8') expect(JSON.parse(raw)).toEqual(cfg) }) + + it('falls back to http scheme for legacy config without targetScheme', () => { + const legacy = { targetHost: 'old.host', targetPort: 8000, proxyPort: 8787 } as any + saveProxyConfig(TEST_DIR, legacy) + + const loaded = loadProxyConfig(TEST_DIR, envFallback) + expect(loaded.targetScheme).toBe('http') + }) + + it('saves and loads https scheme correctly', () => { + const cfg = { targetHost: 'api.anthropic.com', targetPort: 443, proxyPort: 8787, targetScheme: 'https' as const } + saveProxyConfig(TEST_DIR, cfg) + + const loaded = loadProxyConfig(TEST_DIR, envFallback) + expect(loaded.targetScheme).toBe('https') + expect(loaded.targetHost).toBe('api.anthropic.com') + expect(loaded.targetPort).toBe(443) + }) }) diff --git a/src/config-store.ts b/src/config-store.ts index de98f68..59aca4d 100644 --- a/src/config-store.ts +++ b/src/config-store.ts @@ -5,6 +5,7 @@ export interface StoredProxyConfig { targetHost: string targetPort: number proxyPort: number + targetScheme: 'http' | 'https' } const CONFIG_FILENAME = 'proxy-config.json' @@ -23,6 +24,7 @@ export function loadProxyConfig(dataDir: string, envFallback: StoredProxyConfig) targetHost: parsed.targetHost ?? envFallback.targetHost, targetPort: parsed.targetPort ?? envFallback.targetPort, proxyPort: parsed.proxyPort ?? envFallback.proxyPort, + targetScheme: parsed.targetScheme ?? envFallback.targetScheme, } } catch { // corrupt file, fall through diff --git a/src/index.ts b/src/index.ts index f4b9262..bc0dc46 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ const envDefaults = { targetHost: process.env.TARGET_HOST || '127.0.0.1', targetPort: parseInt(process.env.TARGET_PORT || '8000', 10), proxyPort: PROXY_PORT_ENV, + targetScheme: (process.env.TARGET_SCHEME === 'https' ? 'https' : 'http') as 'http' | 'https', }; const persisted = loadProxyConfig(DATA_DIR, envDefaults); @@ -29,6 +30,7 @@ const engine = new ProxyEngine({ targetHost: persisted.targetHost, targetPort: persisted.targetPort, proxyPort: persisted.proxyPort, + targetScheme: persisted.targetScheme, }); const server = createServer(); @@ -47,7 +49,7 @@ server.on('request', webApp); server.listen(WEB_PORT, () => { console.log(`Cache Hunter Web App running on http://localhost:${WEB_PORT}`); console.log(`Proxy port: ${persisted.proxyPort}`); - console.log(`Default target: ${persisted.targetHost}:${persisted.targetPort}`); + console.log(`Default target: ${persisted.targetScheme}://${persisted.targetHost}:${persisted.targetPort}`); console.log(`Data directory: ${DATA_DIR}`); finalizeStaleSessions() diff --git a/src/parse-api.test.ts b/src/parse-api.test.ts index 53a592c..04ad79b 100644 --- a/src/parse-api.test.ts +++ b/src/parse-api.test.ts @@ -114,4 +114,76 @@ describe('parseRequestBody', () => { }); }); +describe('parseRequestBody — Anthropic /v1/messages', () => { + it('parses basic messages with string system', () => { + const body = JSON.stringify({ + model: 'claude-opus-4-5', + system: 'You are helpful', + messages: [{ role: 'user', content: 'Hello' }], + }); + const result = parseRequestBody(body, '/v1/messages'); + expect(result.messages).toHaveLength(2); + expect(result.messages[0]).toEqual({ role: 'system', content: 'You are helpful' }); + expect(result.messages[1]).toEqual({ role: 'user', content: 'Hello' }); + }); + + it('parses messages without system field', () => { + const body = JSON.stringify({ + model: 'claude-opus-4-5', + messages: [{ role: 'user', content: 'Hi' }], + }); + const result = parseRequestBody(body, '/v1/messages'); + expect(result.messages).toHaveLength(1); + expect(result.messages[0]).toEqual({ role: 'user', content: 'Hi' }); + }); + + it('parses system as array of content blocks', () => { + const system = [{ type: 'text', text: 'Be concise' }]; + const body = JSON.stringify({ + model: 'claude-opus-4-5', + system, + messages: [{ role: 'user', content: 'Hi' }], + }); + const result = parseRequestBody(body, '/v1/messages'); + expect(result.messages[0]).toEqual({ role: 'system', content: system }); + }); + + it('includes tools as-is (Anthropic format)', () => { + const tools = [{ name: 'search', description: 'Web search', input_schema: { type: 'object', properties: {} } }]; + const body = JSON.stringify({ + model: 'claude-opus-4-5', + messages: [{ role: 'user', content: 'Search something' }], + tools, + }); + const result = parseRequestBody(body, '/v1/messages'); + expect(result.tools).toEqual(tools); + }); + + it('maps thinking budget_tokens to reasoningEffort', () => { + const body = JSON.stringify({ + model: 'claude-opus-4-5', + messages: [{ role: 'user', content: 'Think hard' }], + thinking: { type: 'enabled', budget_tokens: 5000 }, + }); + const result = parseRequestBody(body, '/v1/messages'); + expect(result.reasoningEffort).toBe('5000'); + }); + + it('returns no reasoningEffort when thinking is absent', () => { + const body = JSON.stringify({ + model: 'claude-opus-4-5', + messages: [{ role: 'user', content: 'Hello' }], + }); + const result = parseRequestBody(body, '/v1/messages'); + expect(result.reasoningEffort).toBeUndefined(); + }); + + it('handles missing messages field', () => { + const body = JSON.stringify({ model: 'claude-opus-4-5', system: 'Be helpful' }); + const result = parseRequestBody(body, '/v1/messages'); + expect(result.messages).toHaveLength(1); + expect(result.messages[0]).toEqual({ role: 'system', content: 'Be helpful' }); + }); +}); + diff --git a/src/parse-api.ts b/src/parse-api.ts index ef6cf54..63d936b 100644 --- a/src/parse-api.ts +++ b/src/parse-api.ts @@ -12,14 +12,29 @@ export function parseRequestBody(body: string, path: string): ParsedRequest { return { messages: [], tools: [] }; } - if (path === '/v1/responses') { + if (path.startsWith('/v1/responses')) { const messages = (parsed.input || []) .filter((item: any) => item.type === 'message') .map((item: any) => ({ ...item })); return { messages, tools: parsed.tools || [], reasoningEffort: parsed.reasoning_effort }; } - if (path === '/v1/chat/completions') { + if (path.startsWith('/v1/messages')) { + const sys = parsed.system + const systemMessages: Array> = sys + ? [{ role: 'system', content: sys }] + : [] + const userMessages = (parsed.messages || []).map((m: any) => ({ ...m })) + return { + messages: [...systemMessages, ...userMessages], + tools: parsed.tools || [], + reasoningEffort: parsed.thinking?.budget_tokens != null + ? String(parsed.thinking.budget_tokens) + : undefined, + } + } + + if (path.startsWith('/v1/chat/completions')) { return { messages: (parsed.messages || []).map((m: any) => ({ ...m })), tools: parsed.tools || [], diff --git a/src/proxy-engine.test.ts b/src/proxy-engine.test.ts index 5bf4d27..2b2596e 100644 --- a/src/proxy-engine.test.ts +++ b/src/proxy-engine.test.ts @@ -66,7 +66,7 @@ describe('ProxyEngine', () => { }) it('should start and stop', async () => { - const engine = new ProxyEngine({ targetHost: 'localhost', targetPort, proxyPort: 0 }) + const engine = new ProxyEngine({ targetHost: 'localhost', targetPort, proxyPort: 0, targetScheme: 'http' }) expect(engine.running).toBe(false) await engine.start() @@ -77,7 +77,7 @@ describe('ProxyEngine', () => { }) it('should emit start and stop events', async () => { - const engine = new ProxyEngine({ targetHost: 'localhost', targetPort, proxyPort: 0 }) + const engine = new ProxyEngine({ targetHost: 'localhost', targetPort, proxyPort: 0, targetScheme: 'http' }) const events: string[] = [] engine.on('start', () => events.push('start')) @@ -90,7 +90,7 @@ describe('ProxyEngine', () => { }) it('should emit request event when body is received', async () => { - const engine = new ProxyEngine({ targetHost: 'localhost', targetPort, proxyPort: 0 }) + const engine = new ProxyEngine({ targetHost: 'localhost', targetPort, proxyPort: 0, targetScheme: 'http' }) const requestEvents: any[] = [] engine.on('request', (evt) => requestEvents.push(evt)) @@ -127,7 +127,7 @@ describe('ProxyEngine', () => { }) it('should not emit request event when not capturing', async () => { - const engine = new ProxyEngine({ targetHost: 'localhost', targetPort, proxyPort: 0 }) + const engine = new ProxyEngine({ targetHost: 'localhost', targetPort, proxyPort: 0, targetScheme: 'http' }) const requestEvents: any[] = [] engine.on('request', (evt) => requestEvents.push(evt)) @@ -160,7 +160,7 @@ describe('ProxyEngine', () => { }) it('should toggle capture state', async () => { - const engine = new ProxyEngine({ targetHost: 'localhost', targetPort, proxyPort: 0 }) + const engine = new ProxyEngine({ targetHost: 'localhost', targetPort, proxyPort: 0, targetScheme: 'http' }) expect(engine.capturing).toBe(false) await engine.start() @@ -174,19 +174,19 @@ describe('ProxyEngine', () => { }) it('should error if starting proxy twice', async () => { - const engine = new ProxyEngine({ targetHost: 'localhost', targetPort, proxyPort: 0 }) + const engine = new ProxyEngine({ targetHost: 'localhost', targetPort, proxyPort: 0, targetScheme: 'http' }) await engine.start() await expect(engine.start()).rejects.toThrow('Proxy is already running') await engine.stop() }) it('should error if capturing without proxy running', () => { - const engine = new ProxyEngine({ targetHost: 'localhost', targetPort, proxyPort: 0 }) + const engine = new ProxyEngine({ targetHost: 'localhost', targetPort, proxyPort: 0, targetScheme: 'http' }) expect(() => engine.startCapture()).toThrow('Proxy must be running to capture') }) it('should update config only when not running', () => { - const engine = new ProxyEngine({ targetHost: 'localhost', targetPort: 8000, proxyPort: 8080 }) + const engine = new ProxyEngine({ targetHost: 'localhost', targetPort: 8000, proxyPort: 8080, targetScheme: 'http' }) engine.updateConfig({ targetHost: '10.0.0.1', targetPort: 9000 }) const cfg = engine.getConfig() expect(cfg.targetHost).toBe('10.0.0.1') @@ -194,7 +194,7 @@ describe('ProxyEngine', () => { }) it('should throw when updating config while running', async () => { - const engine = new ProxyEngine({ targetHost: 'localhost', targetPort, proxyPort: 0 }) + const engine = new ProxyEngine({ targetHost: 'localhost', targetPort, proxyPort: 0, targetScheme: 'http' }) await engine.start() expect(() => engine.updateConfig({ targetHost: 'other' })).toThrow('Cannot update config while proxy is running') await engine.stop() diff --git a/src/proxy-engine.ts b/src/proxy-engine.ts index 49dabc3..3dd6760 100644 --- a/src/proxy-engine.ts +++ b/src/proxy-engine.ts @@ -1,4 +1,5 @@ import { createServer, IncomingMessage, ServerResponse, Server, request as httpRequest, IncomingHttpHeaders } from 'http' +import { request as httpsRequest, Agent as HttpsAgent } from 'https' import { EventEmitter } from 'events' export function concatUtf8(chunks: readonly Buffer[]): { raw: Buffer; text: string } { @@ -10,6 +11,8 @@ export interface ProxyEngineConfig { targetHost: string targetPort: number proxyPort: number + targetScheme: 'http' | 'https' + _httpsAgent?: HttpsAgent } export interface ProxyRequestData { @@ -109,7 +112,7 @@ export class ProxyEngine extends EventEmitter { private async fetchActiveModel(): Promise { try { - const res = await fetch(`http://${this.config.targetHost}:${this.config.targetPort}/v1/models`) + const res = await fetch(`${this.config.targetScheme}://${this.config.targetHost}:${this.config.targetPort}/v1/models`) const data = await res.json() as { data: Array<{ id: string }> } if (data.data && data.data.length > 0) { this._activeModel = data.data[0].id @@ -137,7 +140,7 @@ export class ProxyEngine extends EventEmitter { id: requestId, timestamp: startTime, method: req.method || 'UNKNOWN', - path: req.url || '/', + path: req.url ? new URL(req.url, 'http://localhost').pathname : '/', headers: requestHeaders, body: requestBody, cache_salt: cacheSalt, @@ -149,11 +152,17 @@ export class ProxyEngine extends EventEmitter { } const targetPath = req.url || '/' - const targetUrl = `http://${this.config.targetHost}:${this.config.targetPort}${targetPath}` - - const proxyReq = httpRequest(targetUrl, { + const targetUrl = `${this.config.targetScheme}://${this.config.targetHost}:${this.config.targetPort}${targetPath}` + + const makeRequest = this.config.targetScheme === 'https' ? httpsRequest : httpRequest + const agent = this.config.targetScheme === 'https' + ? (this.config._httpsAgent ?? new HttpsAgent({ rejectUnauthorized: false })) + : undefined + const forwardHeaders = { ...req.headers, host: this.config.targetHost } + const proxyReq = makeRequest(targetUrl, { method: req.method, - headers: req.headers, + headers: forwardHeaders, + agent, }) proxyReq.on('response', (proxyRes) => { diff --git a/src/session-manager.ts b/src/session-manager.ts index a7f71ba..5311891 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -151,7 +151,7 @@ async function readSessionCompletions( const query = ` SELECT body, path FROM requests - WHERE path IN ('/v1/chat/completions', '/v1/responses') + WHERE (path LIKE '/v1/chat/completions%' OR path LIKE '/v1/responses%' OR path LIKE '/v1/messages%') ORDER BY timestamp `; const results = db.exec(query); @@ -208,7 +208,7 @@ export async function deleteSessionCall(id: string, callIndex: number): Promise< const idsResult = db.exec(` SELECT id FROM requests - WHERE path IN ('/v1/chat/completions', '/v1/responses') + WHERE (path LIKE '/v1/chat/completions%' OR path LIKE '/v1/responses%' OR path LIKE '/v1/messages%') ORDER BY timestamp `)