Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ The setup script configures these variables:

## How It Works

1. **Proxy** intercepts HTTPS traffic to `api.anthropic.com` and `api.claude.ai`
1. **Proxy** intercepts HTTPS traffic to `api.anthropic.com` or your local llm API endpoint
2. **Interceptor** parses Claude API request/response format
3. **SSE Parser** handles streaming responses in real-time
4. **WebSocket** broadcasts events to connected dashboard clients
Expand Down
163 changes: 163 additions & 0 deletions packages/proxy/src/endpoint-detector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { readFileSync, existsSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';

/**
* Endpoint detection configuration
* Reads Claude Code configuration to determine which endpoint to use
*/

/**
* Endpoint information returned by detectEndpoint()
*/
export interface EndpointInfo {
url: string;
source: 'env_var' | 'claude_settings' | 'default';
isLocalLlm: boolean;
}

/**
* Reads ANTHROPIC_BASE_URL environment variable
* @returns The URL if set, null otherwise
*/
export function readEnvVar(): string | null {
const url = process.env.ANTHROPIC_BASE_URL;
if (url && url.trim() !== '') {
return url.trim();
}
return null;
}

/**
* Reads ~/.claude/settings.json and extracts ANTHROPIC_BASE_URL field
* @returns The URL if found, null otherwise
*/
export function readClaudeSettings(): string | null {
try {
const settingsPath = join(homedir(), '.claude', 'settings.json');
if (!existsSync(settingsPath)) {
return null;
}

const content = readFileSync(settingsPath, 'utf-8');
const settings = JSON.parse(content);

if (settings.env && settings.env.ANTHROPIC_BASE_URL) {
const url = String(settings.env.ANTHROPIC_BASE_URL);
if (url && url.trim() !== '') {
return url.trim();
}
}
} catch (error) {
// If settings.json is malformed or unreadable, return null
console.debug('Failed to read or parse Claude settings:', error);
}

return null;
}

/**
* Determines the endpoint URL based on configuration sources
* Priority:
* 1. ANTHROPIC_BASE_URL environment variable
* 2. ~/.claude/settings.json _ANTHROPIC_BASE_URL field
* 3. Default to https://api.anthropic.com/v1/messages
*
* @returns EndpointInfo with URL, source, and isLocalLlm flag
*/
export function determineEndpoint(): EndpointInfo {
// Check environment variable first
const envUrl = readEnvVar();
if (envUrl) {
return {
url: envUrl,
source: 'env_var',
isLocalLlm: isLocalLlmUrl(envUrl),
};
}

// Check Claude settings file
const settingsUrl = readClaudeSettings();
if (settingsUrl) {
return {
url: settingsUrl,
source: 'claude_settings',
isLocalLlm: isLocalLlmUrl(settingsUrl),
};
}

// Default to Claude API
return {
url: 'https://api.anthropic.com/v1/messages',
source: 'default',
isLocalLlm: false,
};
}

/**
* Alias for determineEndpoint() for compatibility
* @returns EndpointInfo
*/
export function detectEndpoint(): EndpointInfo {
return determineEndpoint();
}

/**
* Evaluates whether a URL points to the local machine or an address within a local network.
* @param url The URL to check
* @returns true if the URL belongs to localhost or a local network, false otherwise
*/
function isLocalLlmUrl(url: string): boolean {
try {
// Add "http://" if no protocol is specified to prevent the URL parser from failing
const urlString = url.startsWith('http') ? url : `http://${url}`;
const parsed = new URL(urlString);
const hostname = parsed.hostname;

// 1. Check the local machine itself (localhost and loopback addresses)
if (hostname === 'localhost' || hostname === '::1' || hostname.startsWith('127.')) {
return true;
}

// 2. Check common local and corporate domain endings
// .local (mDNS), .lan, .corp, .internal, .home, .test (RFC 2606)
if (
hostname.endsWith('.local') ||
hostname.endsWith('.lan') ||
hostname.endsWith('.corp') ||
hostname.endsWith('.internal') ||
hostname.endsWith('.home') ||
hostname.endsWith('.test')
) {
return true;
}

// 3. Check private local network IP addresses (according to RFC 1918 standard)

// 10.x.x.x subnet
if (hostname.startsWith('10.')) {
return true;
}

// 192.168.x.x subnet
if (hostname.startsWith('192.168.')) {
return true;
}

// Subnet from 172.16.x.x to 172.31.x.x
if (hostname.startsWith('172.')) {
const parts = hostname.split('.');
const secondOctet = parseInt(parts[1], 10);
// Check that the second number in the IP address is between 16 and 31
if (secondOctet >= 16 && secondOctet <= 31) {
return true;
}
}

// If no conditions are met, it is considered an external address
return false;
} catch {
// If the URL is so malformed that the parser fails, assume it's external
return false;
}
}
13 changes: 10 additions & 3 deletions packages/proxy/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import { Command } from "commander";
import chalk from "chalk";
import open from "open";
import { loadOrGenerateCA, getCAPath } from "./ca.js";
import { createProxy } from "./proxy.js";
import { createProxy, type ProxyServer } from "./proxy.js";
import { WiretapWebSocketServer } from "./websocket.js";
import { createSetupServer, getSetupCommand } from "./setup-server.js";
import { createUIServer } from "./ui-server.js";
import { detectEndpoint, type EndpointInfo } from "./endpoint-detector.js";

const VERSION = "1.0.10";

Expand Down Expand Up @@ -51,8 +52,13 @@ async function main() {
// Load or generate CA certificate
const ca = await loadOrGenerateCA();

// Detect endpoint (env var > settings.json > default)
const endpointInfo: EndpointInfo = detectEndpoint();
console.log(chalk.gray("Endpoint:"), endpointInfo.source, chalk.cyan(endpointInfo.url));

// Start WebSocket server
const wsServer = new WiretapWebSocketServer({ port: wsPort });
wsServer.setEndpointInfo(endpointInfo);
console.log(
chalk.green("✓"),
`WebSocket server started on port ${chalk.cyan(wsPort)}`,
Expand All @@ -63,10 +69,11 @@ async function main() {
port: proxyPort,
ca,
wsServer,
});
endpointInfo,
}) as ProxyServer;

// Start setup server (for terminal eval command)
const setupServer = createSetupServer(proxyPort);
const setupServer = createSetupServer(proxyPort, endpointInfo);

// Start UI server (serves bundled dashboard)
const uiServer = createUIServer({ port: uiPort });
Expand Down
29 changes: 22 additions & 7 deletions packages/proxy/src/interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
ClaudeRequest,
ClaudeResponse,
InterceptedRequest,
EndpointInfo,
} from './types.js';
import { SSEStreamParser, reconstructResponseFromEvents } from './parser.js';
import type { WiretapWebSocketServer } from './websocket.js';
Expand All @@ -18,24 +19,27 @@ const CLAUDE_MESSAGES_PATH = '/v1/messages';

export class ClaudeInterceptor {
private wsServer: WiretapWebSocketServer;
private endpointInfo: EndpointInfo;
private activeRequests: Map<string, {
request: InterceptedRequest;
parser: SSEStreamParser;
}> = new Map();

constructor(wsServer: WiretapWebSocketServer) {
constructor(wsServer: WiretapWebSocketServer, endpointInfo?: EndpointInfo) {
this.wsServer = wsServer;
this.endpointInfo = endpointInfo || {
url: 'https://api.anthropic.com/v1/messages',
source: 'default',
isLocalLlm: false,
};
}

isClaudeRequest(request: CompletedRequest): boolean {
const host = request.headers.host || new URL(request.url).host;
const path = new URL(request.url).pathname;
const method = request.method;

return (
CLAUDE_API_HOSTS.some((h) => host.includes(h)) &&
path.includes(CLAUDE_MESSAGES_PATH) &&
request.method === 'POST'
);
// Check for Claude API path and POST method only (not host-based filtering)
return path.includes(CLAUDE_MESSAGES_PATH) && method === 'POST';
}

async handleRequest(request: CompletedRequest): Promise<string | null> {
Expand Down Expand Up @@ -108,6 +112,13 @@ export class ClaudeInterceptor {
hasTools ? chalk.yellow(`+ ${requestBody.tools!.length} tools`) : '',
isStreaming ? chalk.magenta('streaming') : ''
);

// Log endpoint info
console.log(
chalk.gray(' Endpoint:'),
chalk.cyan(this.endpointInfo.url),
chalk.gray(`(${this.endpointInfo.source})`)
);
}

return requestId;
Expand Down Expand Up @@ -298,4 +309,8 @@ export class ClaudeInterceptor {
getActiveRequestCount(): number {
return this.activeRequests.size;
}

getEndpointInfo(): EndpointInfo {
return this.endpointInfo;
}
}
47 changes: 23 additions & 24 deletions packages/proxy/src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import * as mockttp from 'mockttp';
import chalk from 'chalk';
import { gunzipSync, brotliDecompressSync } from 'zlib';
import type { CAConfig } from './ca.js';
import { ClaudeInterceptor, CLAUDE_API_HOSTS } from './interceptor.js';
import { ClaudeInterceptor } from './interceptor.js';
import { detectEndpoint, type EndpointInfo } from './endpoint-detector.js';
import type { WiretapWebSocketServer } from './websocket.js';

function decompressBody(buffer: Buffer, contentEncoding: string | undefined): string {
Expand All @@ -22,29 +23,25 @@ function decompressBody(buffer: Buffer, contentEncoding: string | undefined): st
return buffer.toString('utf-8');
}

function isAnthropicHost(url: string): boolean {
try {
const host = new URL(url).host;
return CLAUDE_API_HOSTS.some((h) => host.includes(h));
} catch {
return false;
}
}

export interface ProxyOptions {
port: number;
ca: CAConfig;
wsServer: WiretapWebSocketServer;
endpointInfo?: EndpointInfo;
}

export interface ProxyServer {
server: mockttp.Mockttp;
interceptor: ClaudeInterceptor;
endpointInfo: EndpointInfo;
stop: () => Promise<void>;
}

export async function createProxy(options: ProxyOptions): Promise<ProxyServer> {
const { port, ca, wsServer } = options;
const { port, ca, wsServer, endpointInfo: providedEndpointInfo } = options;

// Determine endpoint if not provided
const endpointInfo = providedEndpointInfo || detectEndpoint();

const server = mockttp.getLocal({
https: {
Expand All @@ -55,27 +52,28 @@ export async function createProxy(options: ProxyOptions): Promise<ProxyServer> {

const interceptor = new ClaudeInterceptor(wsServer);

// Track request IDs for matching requests to responses (only for Anthropic requests)
// Track request IDs for matching requests to responses
const requestIds = new Map<string, string>();

// All requests pass through, but only Anthropic API requests are intercepted
// All requests pass through - we intercept Claude API traffic based on path
await server
.forAnyRequest()
.thenPassThrough({
beforeRequest: async (request) => {
// Quick check - skip non-Anthropic hosts immediately
if (!isAnthropicHost(request.url)) {
return {};
}

const requestId = await interceptor.handleRequest(request);
if (requestId) {
requestIds.set(request.id, requestId);
// Check if this is a Claude API request (based on path, not host)
const path = new URL(request.url).pathname;
const isClaudeRequest = path.includes('/v1/messages') && request.method === 'POST';

if (isClaudeRequest) {
const requestId = await interceptor.handleRequest(request);
if (requestId) {
requestIds.set(request.id, requestId);
}
}
return {};
},
beforeResponse: async (response) => {
// Only process if we have a tracked request ID (i.e., it was an Anthropic request)
// Only process if we have a tracked request ID
const requestId = requestIds.get(response.id);
if (!requestId) {
return {};
Expand Down Expand Up @@ -109,12 +107,13 @@ export async function createProxy(options: ProxyOptions): Promise<ProxyServer> {
await server.start(port);

console.log(chalk.green('✓'), `Proxy server started on port ${chalk.cyan(port)}`);
console.log(chalk.gray(' Intercepting:'), CLAUDE_API_HOSTS.join(', '));
console.log(chalk.gray(' All other traffic: transparent passthrough'));
console.log(chalk.gray(' Endpoint:'), endpointInfo.source, chalk.cyan(endpointInfo.url));
console.log(chalk.gray(' All Claude API traffic: intercepted and displayed in UI'));

return {
server,
interceptor,
endpointInfo,
stop: async () => {
await server.stop();
console.log(chalk.gray('○'), 'Proxy server stopped');
Expand Down
Loading