0-README.md
· 85 B · Markdown
Raw
```bash
python3 proxy.py
```
```
ANTHROPIC_BASE_URL=http://127.0.0.1:8787 claude
```
python3 proxy.py
ANTHROPIC_BASE_URL=http://127.0.0.1:8787 claude
proxy.py
· 4.7 KiB · Python
Raw
#!/usr/bin/env python3
"""Logging HTTP origin proxy for Claude Code. LOCAL test aid only.
Claude is pointed at this proxy via ANTHROPIC_BASE_URL; every request is
logged and forwarded to the real Anthropic backend untouched. Optionally the
remote-settings endpoint is answered with a local mock payload (--mock) —
note a custom base URL makes real remote settings ineligible, so the mock is
mainly for manual curl testing, not the live settings fetch.
Usage:
python3 proxy.py # listen 127.0.0.1:8787
python3 proxy.py --mock --port 8788 # answer settings GET with mock
python3 proxy.py --upstream http://localhost:9000
Then: ANTHROPIC_BASE_URL=http://127.0.0.1:8787 claude -p ...
"""
import argparse, hashlib, json, re, time
from urllib.parse import urlparse
import http.client
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
LOG = lambda *a: print(f"[{time.strftime('%H:%M:%S')}]", *a, flush=True)
MOCK_SETTINGS = {
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"companyAnnouncements": [
"PROXY-INJECTED announcement - this came from your local passthrough proxy, not Anthropic."
],
"permissions": {
"allow": ["Bash(echo *)", "Read(./**)"],
"deny": [],
"ask": [],
"disableBypassPermissionsMode": "off"
}
}
HOP_HEADERS = {"connect", "keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailers", "transfer-encoding", "upgrade", "host", "proxy-connection"}
def route(server, method: str, path: str, headers, body: bytes, wfile):
upstream = server.upstream
LOG(f"{method} {path} Host={headers.get('Host', '?')} -> {upstream}")
# Mock the settings endpoint.
if server.mock and method == "GET" and re.search(r"/api/claude_code/settings\b", path):
payload = server.mock_body
wfile.write(b"HTTP/1.1 200 OK\r\n")
wfile.write(b"Content-Type: application/json\r\n")
wfile.write(f'ETag: "{hashlib.sha256(payload).hexdigest()}"\r\n'.encode())
wfile.write(f"Content-Length: {len(payload)}\r\n".encode())
wfile.write(b"\r\n")
wfile.write(payload)
wfile.flush()
LOG(f" -> MOCK 200 (settings) len={len(payload)}")
return
# Forward to upstream.
u = urlparse(upstream)
conn = (http.client.HTTPSConnection(u.netloc, timeout=30) if u.scheme == "https"
else http.client.HTTPConnection(u.netloc, timeout=30))
hs = {k: v for k, v in headers.items() if k.lower() not in HOP_HEADERS}
try:
conn.request(method, path, body=body or None, headers=hs)
r = conn.getresponse()
rbody = r.read()
LOG(f" -> {r.status} {r.reason} len={len(rbody)}")
wfile.write(f"HTTP/1.1 {r.status} {r.reason}\r\n".encode())
for k, v in r.getheaders():
if k.lower() not in HOP_HEADERS:
wfile.write(f"{k}: {v}\r\n".encode())
# Delimit the body ourselves so the client never hangs on keep-alive.
wfile.write(f"Content-Length: {len(rbody)}\r\n".encode())
wfile.write(b"Connection: close\r\n\r\n")
wfile.write(rbody)
wfile.flush()
except Exception as e:
LOG(f" !! forward error: {e}")
wfile.write(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n")
wfile.flush()
finally:
conn.close()
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, fmt, *args):
pass
def do_GET(self):
route(self.server, "GET", self.path, self.headers, None, self.wfile)
def do_HEAD(self):
route(self.server, "HEAD", self.path, self.headers, None, self.wfile)
def do_POST(self):
route(self.server, "POST", self.path, self.headers, self._read_body(), self.wfile)
def _read_body(self):
cl = int(self.headers.get("Content-Length") or 0)
return self.rfile.read(cl) if cl else b""
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=8787)
ap.add_argument("--upstream", default="https://api.anthropic.com")
ap.add_argument("--mock", action="store_true",
help="answer GET /api/claude_code/settings with a mock payload")
args = ap.parse_args()
srv = ThreadingHTTPServer((args.host, args.port), Handler)
srv.upstream = args.upstream
srv.mock = args.mock
srv.mock_body = json.dumps(MOCK_SETTINGS).encode()
LOG(f"listening on http://{args.host}:{args.port} upstream={args.upstream} mock={args.mock}")
try:
srv.serve_forever()
except KeyboardInterrupt:
print("\nbye")
srv.server_close()
if __name__ == "__main__":
main()
| 1 | #!/usr/bin/env python3 |
| 2 | """Logging HTTP origin proxy for Claude Code. LOCAL test aid only. |
| 3 | |
| 4 | Claude is pointed at this proxy via ANTHROPIC_BASE_URL; every request is |
| 5 | logged and forwarded to the real Anthropic backend untouched. Optionally the |
| 6 | remote-settings endpoint is answered with a local mock payload (--mock) — |
| 7 | note a custom base URL makes real remote settings ineligible, so the mock is |
| 8 | mainly for manual curl testing, not the live settings fetch. |
| 9 | |
| 10 | Usage: |
| 11 | python3 proxy.py # listen 127.0.0.1:8787 |
| 12 | python3 proxy.py --mock --port 8788 # answer settings GET with mock |
| 13 | python3 proxy.py --upstream http://localhost:9000 |
| 14 | |
| 15 | Then: ANTHROPIC_BASE_URL=http://127.0.0.1:8787 claude -p ... |
| 16 | """ |
| 17 | import argparse, hashlib, json, re, time |
| 18 | from urllib.parse import urlparse |
| 19 | import http.client |
| 20 | from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer |
| 21 | |
| 22 | LOG = lambda *a: print(f"[{time.strftime('%H:%M:%S')}]", *a, flush=True) |
| 23 | |
| 24 | MOCK_SETTINGS = { |
| 25 | "$schema": "https://json.schemastore.org/claude-code-settings.json", |
| 26 | "companyAnnouncements": [ |
| 27 | "PROXY-INJECTED announcement - this came from your local passthrough proxy, not Anthropic." |
| 28 | ], |
| 29 | "permissions": { |
| 30 | "allow": ["Bash(echo *)", "Read(./**)"], |
| 31 | "deny": [], |
| 32 | "ask": [], |
| 33 | "disableBypassPermissionsMode": "off" |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | HOP_HEADERS = {"connect", "keep-alive", "proxy-authenticate", "proxy-authorization", |
| 38 | "te", "trailers", "transfer-encoding", "upgrade", "host", "proxy-connection"} |
| 39 | |
| 40 | |
| 41 | def route(server, method: str, path: str, headers, body: bytes, wfile): |
| 42 | upstream = server.upstream |
| 43 | LOG(f"{method} {path} Host={headers.get('Host', '?')} -> {upstream}") |
| 44 | |
| 45 | # Mock the settings endpoint. |
| 46 | if server.mock and method == "GET" and re.search(r"/api/claude_code/settings\b", path): |
| 47 | payload = server.mock_body |
| 48 | wfile.write(b"HTTP/1.1 200 OK\r\n") |
| 49 | wfile.write(b"Content-Type: application/json\r\n") |
| 50 | wfile.write(f'ETag: "{hashlib.sha256(payload).hexdigest()}"\r\n'.encode()) |
| 51 | wfile.write(f"Content-Length: {len(payload)}\r\n".encode()) |
| 52 | wfile.write(b"\r\n") |
| 53 | wfile.write(payload) |
| 54 | wfile.flush() |
| 55 | LOG(f" -> MOCK 200 (settings) len={len(payload)}") |
| 56 | return |
| 57 | |
| 58 | # Forward to upstream. |
| 59 | u = urlparse(upstream) |
| 60 | conn = (http.client.HTTPSConnection(u.netloc, timeout=30) if u.scheme == "https" |
| 61 | else http.client.HTTPConnection(u.netloc, timeout=30)) |
| 62 | hs = {k: v for k, v in headers.items() if k.lower() not in HOP_HEADERS} |
| 63 | try: |
| 64 | conn.request(method, path, body=body or None, headers=hs) |
| 65 | r = conn.getresponse() |
| 66 | rbody = r.read() |
| 67 | LOG(f" -> {r.status} {r.reason} len={len(rbody)}") |
| 68 | wfile.write(f"HTTP/1.1 {r.status} {r.reason}\r\n".encode()) |
| 69 | for k, v in r.getheaders(): |
| 70 | if k.lower() not in HOP_HEADERS: |
| 71 | wfile.write(f"{k}: {v}\r\n".encode()) |
| 72 | # Delimit the body ourselves so the client never hangs on keep-alive. |
| 73 | wfile.write(f"Content-Length: {len(rbody)}\r\n".encode()) |
| 74 | wfile.write(b"Connection: close\r\n\r\n") |
| 75 | wfile.write(rbody) |
| 76 | wfile.flush() |
| 77 | except Exception as e: |
| 78 | LOG(f" !! forward error: {e}") |
| 79 | wfile.write(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n") |
| 80 | wfile.flush() |
| 81 | finally: |
| 82 | conn.close() |
| 83 | |
| 84 | |
| 85 | class Handler(BaseHTTPRequestHandler): |
| 86 | protocol_version = "HTTP/1.1" |
| 87 | |
| 88 | def log_message(self, fmt, *args): |
| 89 | pass |
| 90 | |
| 91 | def do_GET(self): |
| 92 | route(self.server, "GET", self.path, self.headers, None, self.wfile) |
| 93 | |
| 94 | def do_HEAD(self): |
| 95 | route(self.server, "HEAD", self.path, self.headers, None, self.wfile) |
| 96 | |
| 97 | def do_POST(self): |
| 98 | route(self.server, "POST", self.path, self.headers, self._read_body(), self.wfile) |
| 99 | |
| 100 | def _read_body(self): |
| 101 | cl = int(self.headers.get("Content-Length") or 0) |
| 102 | return self.rfile.read(cl) if cl else b"" |
| 103 | |
| 104 | |
| 105 | def main(): |
| 106 | ap = argparse.ArgumentParser() |
| 107 | ap.add_argument("--host", default="127.0.0.1") |
| 108 | ap.add_argument("--port", type=int, default=8787) |
| 109 | ap.add_argument("--upstream", default="https://api.anthropic.com") |
| 110 | ap.add_argument("--mock", action="store_true", |
| 111 | help="answer GET /api/claude_code/settings with a mock payload") |
| 112 | args = ap.parse_args() |
| 113 | |
| 114 | srv = ThreadingHTTPServer((args.host, args.port), Handler) |
| 115 | srv.upstream = args.upstream |
| 116 | srv.mock = args.mock |
| 117 | srv.mock_body = json.dumps(MOCK_SETTINGS).encode() |
| 118 | |
| 119 | LOG(f"listening on http://{args.host}:{args.port} upstream={args.upstream} mock={args.mock}") |
| 120 | try: |
| 121 | srv.serve_forever() |
| 122 | except KeyboardInterrupt: |
| 123 | print("\nbye") |
| 124 | srv.server_close() |
| 125 | |
| 126 | |
| 127 | if __name__ == "__main__": |
| 128 | main() |
| 129 |