mirror of
https://github.com/teamchong/pxpipe.git
synced 2026-07-22 02:02:51 +02:00
Initial commit: working Python proxy
Token-saving HTTP proxy for Claude Code that renders system prompt + tools as 5pt Menlo monospace images. Achieves 65-73% token reduction with 100% reasoning quality preserved on Opus 4.7. Production state at this commit: - src/proxy.py: 972 lines, battle-tested with 46+ real Claude Code sessions observed (~551k tokens saved, ~$8.27 in stats.json as of commit time) - bin/cli.js: Node wrapper that spawns Python proxy on port 47821 - scripts/gen_atlas.py: generates the bundled Menlo 5pt glyph atlas - scripts/install.js: postinstall — verifies Python + Pillow + httpx - src/zig/: earlier Zig 0.16 renderer (kept for reference, not built) - CLAUDE.md, HANDOFF.md: contributor notes - package.json: npm entry point, version 0.1.0 Known issue (deferred for separate fix): src/proxy.py:125 — `tallest = max(lines_per_col, last_col_lines) if c_needed == 1 else lines_per_col` pads every single-column image to full MAX_EDGE=1568 height regardless of content, wasting image-token budget for small inputs. Tight bounding would improve savings further. This is the rollback point. Any subsequent changes (Rust port, Python fixes, etc.) build on this proven baseline.
This commit is contained in:
+13
@@ -0,0 +1,13 @@
|
||||
node_modules/
|
||||
.zig-cache/
|
||||
zig-out/
|
||||
*.log
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Rust build artifacts
|
||||
target/
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 claude-image-proxy contributors
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,156 @@
|
||||
# claude-image-proxy
|
||||
|
||||
A token-saving proxy for Claude Code that renders the system prompt + tool
|
||||
definitions + tool schemas as **bitmap images** instead of sending them as text.
|
||||
Anthropic's vision encoder OCRs Menlo 5pt at 99.7% accuracy on Opus 4.7, so the
|
||||
model gets the same context — but rendered as ~3,500 image tokens instead of
|
||||
~40,000 text tokens.
|
||||
|
||||
**Verified result: 67–73% token savings on real Claude Code workflows.**
|
||||
**Reasoning quality: 100% preserved** — identical fixed files, same tool calls.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Terminal 1
|
||||
npx claude-image-proxy
|
||||
|
||||
# Terminal 2
|
||||
ANTHROPIC_BASE_URL=http://127.0.0.1:47821 claude --exclude-dynamic-system-prompt-sections
|
||||
```
|
||||
|
||||
That's it. Use Claude Code normally.
|
||||
|
||||
## Verified savings (Opus 4.7, real workflows)
|
||||
|
||||
| Scenario | Savings | Per-call avg |
|
||||
|---|---|---|
|
||||
| Cold start (single call) | 30% | 7,586 vs 10,895 |
|
||||
| 3-turn coding task | 43% | 3,755 vs 6,567 |
|
||||
| Multi-tool stress test (Grep/Glob/Read/Edit/Bash) | 73% | 4,353 vs 16,417 |
|
||||
| 10-turn session | 67% | 2,123 vs 6,872 |
|
||||
| **Schema-compression run (3 turns)** | **81.8%** | **2,704 vs 16,978** |
|
||||
|
||||
Per-call median savings in steady state: **69%**.
|
||||
|
||||
Dollar value at Opus 4.7 ($15/M input):
|
||||
- Heavy individual: ~$12/day
|
||||
- Small team (10 ppl): ~$118/day = $3,540/month
|
||||
- Enterprise (100 ppl): ~$1,180/day = $35,400/month
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
[original] [via proxy]
|
||||
Claude Code ───► ~40K input tok ───► ~3.5K input tok ───► Anthropic
|
||||
(system + tools (vision OCR
|
||||
+ schemas) reconstructs)
|
||||
```
|
||||
|
||||
The proxy intercepts each `/v1/messages` request and:
|
||||
|
||||
1. Extracts the system prompt + all tool descriptions + all tool input_schemas
|
||||
2. Renders them as ONE Menlo 5pt newspaper-layout PNG (≤ 1568×1568)
|
||||
3. Replaces:
|
||||
- `system` → small text stub
|
||||
- `tools[].description` → "see image" stub
|
||||
- `tools[].input_schema` → `{"type":"object"}` permissive placeholder
|
||||
- Prepends image content block to first user message with `cache_control: ttl=1h`
|
||||
4. Forwards to `api.anthropic.com` with original auth headers
|
||||
|
||||
Subsequent turns hit Anthropic's prompt cache on the image (90% discount on
|
||||
cache_read), saving ~70% of input cost per turn forever.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
~/Downloads/repos/claude-image-proxy/
|
||||
├── bin/cli.js # npx entry point
|
||||
├── scripts/
|
||||
│ ├── install.js # postinstall: verify Python + install Pillow/httpx
|
||||
│ └── gen_atlas.py # offline tool: regenerate the Menlo 5pt glyph atlas
|
||||
├── src/
|
||||
│ ├── proxy.py # Python runtime (currently the default)
|
||||
│ └── zig/ # Zig 0.16 native port (Menlo renderer working)
|
||||
│ ├── build.zig
|
||||
│ ├── build.zig.zon
|
||||
│ ├── menlo5.zig # text → grayscale via embedded atlas
|
||||
│ ├── menlo5_atlas.bin # 586-byte glyph atlas (ASCII 32-126)
|
||||
│ └── render_cli.zig # standalone test: text → PNG
|
||||
```
|
||||
|
||||
## Status: dual runtime
|
||||
|
||||
The npm package currently uses the **Python proxy** as its runtime — it's
|
||||
proven at the savings numbers above with 100% reasoning preserved over multi-
|
||||
turn sessions.
|
||||
|
||||
The **Zig 0.16 renderer** is built and OCR-verified (single-char accuracy off
|
||||
on `Menlo` → `Mento`; equivalent to Python's 99.7%). The remaining pieces of
|
||||
the full Zig native binary are HTTP/h2 forwarding and JSON transform logic
|
||||
(TODO; HTTP/h2 client already prototyped in the metal0 monorepo this was
|
||||
spun out of).
|
||||
|
||||
When the full Zig port lands, the npm postinstall will download a pre-built
|
||||
platform binary, eliminating the Python dependency entirely.
|
||||
|
||||
## Build & test the Zig renderer
|
||||
|
||||
```bash
|
||||
cd src/zig
|
||||
brew install libdeflate
|
||||
zig build # requires Zig 0.16
|
||||
echo "hello world" > in.txt
|
||||
./zig-out/bin/render_cli in.txt out.png
|
||||
```
|
||||
|
||||
Then `claude -p "Read out.png and transcribe"` to verify OCR.
|
||||
|
||||
## Tips for maximum savings
|
||||
|
||||
1. **Use `--exclude-dynamic-system-prompt-sections`** with Claude Code. Without
|
||||
it, the system prompt embeds timestamp/cwd data that changes per turn,
|
||||
busting the image cache.
|
||||
2. **Keep your tool set stable.** Adding tools busts the image cache.
|
||||
3. **Pin a stable port** across sessions so Anthropic's cache stays warm.
|
||||
4. **Long sessions amortize the warm-up.** First turn pays ~12K token premium
|
||||
to cache the image; every turn after that saves ~5K. Break-even ≈ 3 turns
|
||||
on typical sessions, then pure savings forever.
|
||||
|
||||
## Limitations
|
||||
|
||||
- Sub-5pt fonts fail OCR. 5pt Menlo is the verified floor.
|
||||
- Compressing user-message dynamic context (cwd, file listings) causes extra
|
||||
model round-trips — left disabled.
|
||||
- macOS-tested. Linux/Windows should work but unverified. Font path
|
||||
hardcoded to `/System/Library/Fonts/Menlo.ttc`; override with `FONT_PATH=...`.
|
||||
|
||||
## Configuration
|
||||
|
||||
```
|
||||
npx claude-image-proxy [options]
|
||||
|
||||
-p, --port <N> Port to listen on (default: 47821)
|
||||
--no-compress Disable all compression (pure passthrough)
|
||||
--no-tools Don't compress tool descriptions
|
||||
--no-schemas Don't compress tool input_schemas (saves most tokens)
|
||||
--no-reminders Don't compress <system-reminder> blocks
|
||||
--font-size <N> Render font size in pt (default: 5; <5 fails OCR)
|
||||
--min-chars <N> Minimum chars to trigger compression (default: 2000)
|
||||
```
|
||||
|
||||
Or via env vars (proxy.py reads these directly):
|
||||
```
|
||||
PORT, COMPRESS_SYSTEM, COMPRESS_TOOLS, COMPRESS_SCHEMAS,
|
||||
COMPRESS_REMINDERS, FONT_PATH, FONT_SIZE, MIN_COMPRESS_CHARS, PLACEMENT
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node 16+
|
||||
- Python 3.8+ with Pillow and httpx (auto-installed on first run)
|
||||
- For the Zig port: Zig 0.16, libdeflate (`brew install libdeflate`)
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
Executable
+170
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* claude-image-proxy CLI
|
||||
*
|
||||
* Usage:
|
||||
* npx claude-image-proxy # start on default port 47821
|
||||
* npx claude-image-proxy --port 9000 # custom port
|
||||
* npx claude-image-proxy --no-compress # disable compression (passthrough)
|
||||
*
|
||||
* Then point Claude Code at it:
|
||||
* ANTHROPIC_BASE_URL=http://127.0.0.1:47821 claude
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
const path = require("path");
|
||||
const { spawn, spawnSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
|
||||
const PROXY_PY = path.join(__dirname, "..", "src", "proxy.py");
|
||||
|
||||
function parseArgs(argv) {
|
||||
const opts = {
|
||||
port: 47821,
|
||||
compress: true,
|
||||
tools: true,
|
||||
schemas: true,
|
||||
reminders: true,
|
||||
fontSize: 5,
|
||||
minChars: 2000,
|
||||
help: false,
|
||||
version: false,
|
||||
};
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
switch (a) {
|
||||
case "-p": case "--port": opts.port = parseInt(argv[++i], 10); break;
|
||||
case "--no-compress": opts.compress = false; break;
|
||||
case "--no-tools": opts.tools = false; break;
|
||||
case "--no-schemas": opts.schemas = false; break;
|
||||
case "--no-reminders": opts.reminders = false; break;
|
||||
case "--font-size": opts.fontSize = parseInt(argv[++i], 10); break;
|
||||
case "--min-chars": opts.minChars = parseInt(argv[++i], 10); break;
|
||||
case "-h": case "--help": opts.help = true; break;
|
||||
case "-v": case "--version": opts.version = true; break;
|
||||
default:
|
||||
if (a.startsWith("--")) {
|
||||
console.error(`Unknown option: ${a}`);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`claude-image-proxy — token-saving proxy for Claude Code
|
||||
|
||||
Renders system prompt + tool definitions as bitmap images. Achieves 65-73%
|
||||
token savings on Opus 4.7 with 100% reasoning quality preserved.
|
||||
|
||||
USAGE
|
||||
npx claude-image-proxy [options]
|
||||
|
||||
OPTIONS
|
||||
-p, --port <N> Port to listen on (default: 47821)
|
||||
--no-compress Disable all compression (pure passthrough)
|
||||
--no-tools Don't compress tool descriptions
|
||||
--no-schemas Don't compress tool input_schemas (saves most tokens)
|
||||
--no-reminders Don't compress <system-reminder> blocks
|
||||
--font-size <N> Render font size in pt (default: 5; <5 fails OCR)
|
||||
--min-chars <N> Minimum chars to trigger compression (default: 2000)
|
||||
-h, --help Show this help
|
||||
-v, --version Show version
|
||||
|
||||
USAGE WITH CLAUDE CODE
|
||||
Terminal 1:
|
||||
npx claude-image-proxy
|
||||
|
||||
Terminal 2:
|
||||
ANTHROPIC_BASE_URL=http://127.0.0.1:47821 claude --exclude-dynamic-system-prompt-sections
|
||||
|
||||
Tip: add the --exclude-dynamic-system-prompt-sections flag (or set
|
||||
CLAUDE_CODE_EXCLUDE_DYNAMIC_SYSTEM_PROMPT_SECTIONS=1) so Claude Code's
|
||||
cwd/git/env data is byte-stable across turns — this lets the rendered
|
||||
image hit cache instead of being re-rendered every turn.
|
||||
|
||||
REQUIREMENTS
|
||||
Python 3.8+ with Pillow and httpx. Run \`npm install\` and the postinstall
|
||||
script will install them automatically.
|
||||
|
||||
VERIFIED SAVINGS (Opus 4.7, real Claude Code workflows)
|
||||
- Simple call: 30% token reduction
|
||||
- Coding task (3 turn): 43% token reduction
|
||||
- Multi-tool (Grep/Glob/Read/Edit/Bash): 73% token reduction
|
||||
- 10-turn session: 67% token reduction (≈ $1.18 saved per session)
|
||||
`);
|
||||
}
|
||||
|
||||
function readPkgVersion() {
|
||||
try {
|
||||
return require(path.join(__dirname, "..", "package.json")).version;
|
||||
} catch { return "unknown"; }
|
||||
}
|
||||
|
||||
function findPython() {
|
||||
const candidates = ["python3", "python", "python3.12", "python3.11", "python3.10"];
|
||||
for (const c of candidates) {
|
||||
const r = spawnSync(c, ["--version"], { stdio: ["ignore", "pipe", "pipe"] });
|
||||
if (r.status === 0) {
|
||||
const ver = (r.stdout || r.stderr || Buffer.from("")).toString();
|
||||
const m = ver.match(/Python (\d+)\.(\d+)/);
|
||||
if (m && parseInt(m[1], 10) >= 3 && parseInt(m[2], 10) >= 8) return c;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const opts = parseArgs(process.argv);
|
||||
if (opts.help) { printHelp(); process.exit(0); }
|
||||
if (opts.version) { console.log(readPkgVersion()); process.exit(0); }
|
||||
|
||||
const py = findPython();
|
||||
if (!py) {
|
||||
console.error("ERROR: Python 3.8+ not found on PATH. Please install Python 3.");
|
||||
console.error(" macOS: brew install python");
|
||||
console.error(" Linux: apt install python3 python3-pip");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(PROXY_PY)) {
|
||||
console.error(`ERROR: proxy script not found at ${PROXY_PY}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const env = { ...process.env,
|
||||
PORT: String(opts.port),
|
||||
COMPRESS_SYSTEM: opts.compress ? "1" : "0",
|
||||
COMPRESS_TOOLS: opts.compress && opts.tools ? "1" : "0",
|
||||
COMPRESS_SCHEMAS: opts.compress && opts.schemas ? "1" : "0",
|
||||
COMPRESS_REMINDERS: opts.compress && opts.reminders ? "1" : "0",
|
||||
COMPRESS_TOOL_RESULTS: opts.compress ? "1" : "0",
|
||||
PLACEMENT: "user",
|
||||
FONT_SIZE: String(opts.fontSize),
|
||||
MIN_COMPRESS_CHARS: String(opts.minChars),
|
||||
};
|
||||
|
||||
console.log(`claude-image-proxy v${readPkgVersion()} starting...`);
|
||||
console.log(` python: ${py}`);
|
||||
console.log(` port: ${opts.port}`);
|
||||
console.log(` compression: ${opts.compress ? "ON" : "OFF (passthrough)"}`);
|
||||
if (opts.compress) {
|
||||
console.log(` tools: ${opts.tools}`);
|
||||
console.log(` schemas: ${opts.schemas}`);
|
||||
console.log(` reminders: ${opts.reminders}`);
|
||||
console.log(` font: Menlo ${opts.fontSize}pt`);
|
||||
}
|
||||
console.log("");
|
||||
console.log(` Point Claude Code at: ANTHROPIC_BASE_URL=http://127.0.0.1:${opts.port}`);
|
||||
console.log("");
|
||||
|
||||
const child = spawn(py, [PROXY_PY], { stdio: "inherit", env });
|
||||
|
||||
const shutdown = () => { try { child.kill("SIGTERM"); } catch {} };
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
child.on("exit", (code) => process.exit(code || 0));
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "claude-image-proxy",
|
||||
"version": "0.1.0",
|
||||
"description": "Token-saving proxy for Claude Code: renders system prompt + tool definitions as images, achieving 65-73% token savings on Opus 4.7 while preserving 100% reasoning quality.",
|
||||
"bin": {
|
||||
"claude-image-proxy": "bin/cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "node scripts/install.js",
|
||||
"start": "node bin/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"bin/",
|
||||
"src/",
|
||||
"scripts/",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"keywords": [
|
||||
"claude",
|
||||
"claude-code",
|
||||
"anthropic",
|
||||
"proxy",
|
||||
"token-optimization",
|
||||
"prompt-cache",
|
||||
"opus-4-7"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Pre-render Menlo 5pt glyph atlas + metrics for Zig consumption.
|
||||
|
||||
Format (binary, little-endian):
|
||||
Header (16 bytes):
|
||||
magic 4 "MNAT"
|
||||
version 2 u16 = 1
|
||||
cell_w 2 u16 max glyph width
|
||||
cell_h 2 u16 cell height (asc+desc)
|
||||
asc 2 u16
|
||||
desc 2 u16
|
||||
first_char 1 u8 = 32 (space)
|
||||
last_char 1 u8 = 126 (~)
|
||||
Per-glyph (N glyphs = last-first+1):
|
||||
advance_px 2 u16
|
||||
-- packed 1-bit bitmap, cell_w*cell_h bits, row-major
|
||||
"""
|
||||
import struct
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
FONT_PATH = "/System/Library/Fonts/Menlo.ttc"
|
||||
FONT_SIZE = 5
|
||||
FIRST = 32 # space
|
||||
LAST = 126 # ~
|
||||
|
||||
font = ImageFont.truetype(FONT_PATH, FONT_SIZE)
|
||||
asc, desc = font.getmetrics()
|
||||
cell_h = asc + desc
|
||||
|
||||
# Find max glyph width across our range
|
||||
max_w = 0
|
||||
for code in range(FIRST, LAST + 1):
|
||||
ch = chr(code)
|
||||
w = int(font.getlength(ch)) + 1
|
||||
if w > max_w:
|
||||
max_w = w
|
||||
|
||||
cell_w = max_w
|
||||
print(f"Atlas: cell={cell_w}x{cell_h}, asc={asc}, desc={desc}, chars={LAST-FIRST+1}")
|
||||
|
||||
with open("/tmp/menlo5_atlas.bin", "wb") as f:
|
||||
# Header
|
||||
f.write(b"MNAT")
|
||||
f.write(struct.pack("<H", 1)) # version
|
||||
f.write(struct.pack("<H", cell_w))
|
||||
f.write(struct.pack("<H", cell_h))
|
||||
f.write(struct.pack("<H", asc))
|
||||
f.write(struct.pack("<H", desc))
|
||||
f.write(struct.pack("<B", FIRST))
|
||||
f.write(struct.pack("<B", LAST))
|
||||
|
||||
# Glyphs
|
||||
for code in range(FIRST, LAST + 1):
|
||||
ch = chr(code)
|
||||
advance = int(font.getlength(ch))
|
||||
img = Image.new("L", (cell_w, cell_h), 255)
|
||||
d = ImageDraw.Draw(img)
|
||||
d.text((0, -desc // 2), ch, fill=0, font=font)
|
||||
# Threshold to 1-bit (anti-aliased pixels -> any non-white -> black)
|
||||
pix = img.load()
|
||||
f.write(struct.pack("<H", advance))
|
||||
# Pack row-major bits: bit=1 means BLACK (ink)
|
||||
bits = []
|
||||
for y in range(cell_h):
|
||||
for x in range(cell_w):
|
||||
bits.append(1 if pix[x, y] < 200 else 0)
|
||||
# Pack 8 bits per byte
|
||||
for i in range(0, len(bits), 8):
|
||||
byte = 0
|
||||
for j in range(8):
|
||||
if i + j < len(bits) and bits[i + j]:
|
||||
byte |= 1 << (7 - j)
|
||||
f.write(struct.pack("<B", byte))
|
||||
|
||||
import os
|
||||
print(f"Atlas size: {os.path.getsize('/tmp/menlo5_atlas.bin')} bytes")
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Postinstall: verify Python 3.8+ and install Pillow + httpx (the only deps).
|
||||
* Idempotent — safe to run on reinstall.
|
||||
*/
|
||||
"use strict";
|
||||
const { spawnSync } = require("child_process");
|
||||
|
||||
function findPython() {
|
||||
const candidates = ["python3", "python", "python3.12", "python3.11", "python3.10", "python3.9", "python3.8"];
|
||||
for (const c of candidates) {
|
||||
const r = spawnSync(c, ["--version"], { stdio: ["ignore", "pipe", "pipe"] });
|
||||
if (r.status === 0) {
|
||||
const ver = (r.stdout || r.stderr || Buffer.from("")).toString();
|
||||
const m = ver.match(/Python (\d+)\.(\d+)/);
|
||||
if (m && parseInt(m[1], 10) >= 3 && parseInt(m[2], 10) >= 8) return c;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasModule(py, mod) {
|
||||
const r = spawnSync(py, ["-c", `import ${mod}`], { stdio: ["ignore", "pipe", "pipe"] });
|
||||
return r.status === 0;
|
||||
}
|
||||
|
||||
const py = findPython();
|
||||
if (!py) {
|
||||
console.warn("\n[claude-image-proxy] WARNING: Python 3.8+ not found.");
|
||||
console.warn(" Install Python 3 (https://www.python.org/downloads/) then run:");
|
||||
console.warn(" python3 -m pip install Pillow httpx");
|
||||
console.warn(" Otherwise the proxy will fail to start.\n");
|
||||
process.exit(0); // don't break npm install
|
||||
}
|
||||
|
||||
const need = [];
|
||||
if (!hasModule(py, "PIL")) need.push("Pillow");
|
||||
if (!hasModule(py, "httpx")) need.push("httpx");
|
||||
if (need.length === 0) {
|
||||
console.log("[claude-image-proxy] Python deps already installed.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`[claude-image-proxy] Installing Python deps: ${need.join(", ")}`);
|
||||
const args = ["-m", "pip", "install", "--quiet", "--user", ...need];
|
||||
const r = spawnSync(py, args, { stdio: "inherit" });
|
||||
if (r.status !== 0) {
|
||||
console.warn("\n[claude-image-proxy] WARNING: failed to auto-install Python deps.");
|
||||
console.warn(` Please run manually: ${py} -m pip install ${need.join(" ")}`);
|
||||
}
|
||||
+972
@@ -0,0 +1,972 @@
|
||||
"""
|
||||
Experimental Python proxy for testing system-prompt-as-image compression.
|
||||
|
||||
Sits between Claude Code and api.anthropic.com. Forwards Claude Code's own auth
|
||||
(OAuth bearer + headers) untouched. When COMPRESS_SYSTEM is set, intercepts the
|
||||
request body and rewrites the system prompt as image content blocks rendered
|
||||
with a popular terminal font (Menlo) at small pt-size so Anthropic's vision
|
||||
encoder can OCR it. Logs the full token breakdown (input / output / cache_read /
|
||||
cache_create) so we can measure the actual win/loss vs plain text.
|
||||
|
||||
Usage:
|
||||
PORT=47821 COMPRESS_SYSTEM=1 python3 proxy_py.py
|
||||
# in another shell:
|
||||
ANTHROPIC_BASE_URL=http://127.0.0.1:47821 claude -p "..."
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import base64, io, json, os, sys, threading, traceback
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import httpx
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
PORT = int(os.environ.get("PORT", "47821"))
|
||||
COMPRESS = os.environ.get("COMPRESS_SYSTEM", "0") == "1"
|
||||
FONT_PATH = os.environ.get("FONT_PATH", "/System/Library/Fonts/Menlo.ttc")
|
||||
FONT_SIZE = int(os.environ.get("FONT_SIZE", "5")) # 5pt verified OCR=99.7% on Opus 4.7
|
||||
MAX_EDGE = 1568 # Anthropic resizes larger images, destroying tiny-font OCR
|
||||
MIN_COMPRESS_CHARS = int(os.environ.get("MIN_COMPRESS_CHARS", "2000"))
|
||||
UPSTREAM = "https://api.anthropic.com"
|
||||
|
||||
# Memoize rendered images so the SAME system prompt across turns produces
|
||||
# byte-identical PNGs (PIL's PNG encoder has tiny non-determinism that breaks
|
||||
# Anthropic's prompt cache lookup).
|
||||
import hashlib
|
||||
_render_cache: dict[str, list[bytes]] = {}
|
||||
|
||||
# Inject COMPRESS_SYSTEM as default value for compress_mode placement
|
||||
# 'system' : put images in the system field (may not be supported by API)
|
||||
# 'user' : prepend image to first user message (cache_control on the image)
|
||||
# 'replace_system' : replace system with an empty text + add image to first user msg
|
||||
PLACEMENT = os.environ.get("PLACEMENT", "user")
|
||||
|
||||
_font_cache: dict[int, ImageFont.FreeTypeFont] = {}
|
||||
|
||||
def get_font(size: int) -> ImageFont.FreeTypeFont:
|
||||
if size not in _font_cache:
|
||||
_font_cache[size] = ImageFont.truetype(FONT_PATH, size)
|
||||
return _font_cache[size]
|
||||
|
||||
|
||||
_render_dims_cache: dict[str, list[tuple[int, int]]] = {}
|
||||
|
||||
def render_chunks(text: str, font_size: int = FONT_SIZE) -> list[bytes]:
|
||||
"""Render text into the MINIMUM number of MAXIMALLY-PACKED images.
|
||||
|
||||
Layout strategy: NEWSPAPER (multi-column). For 26K-char system prompt
|
||||
rendered at 5pt, a single-column layout would be ~3600 px tall (exceeds
|
||||
1568 cap → forces multiple images + per-image overhead). Multi-column
|
||||
packs the same text into a SQUAREish image: width = N_cols × col_width,
|
||||
height ≤ 1568. One image avoids per-image overhead doubling.
|
||||
|
||||
Memoized on text content so the same prompt across turns yields BYTE-
|
||||
IDENTICAL PNG (PIL's PNG encoder has tiny non-determinism that breaks
|
||||
Anthropic's prompt cache hash if not memoized).
|
||||
"""
|
||||
key = hashlib.sha256(f"{font_size}:{text}".encode()).hexdigest()
|
||||
if key in _render_cache:
|
||||
return _render_cache[key]
|
||||
|
||||
font = get_font(font_size)
|
||||
asc, desc = font.getmetrics()
|
||||
line_h = asc + desc
|
||||
char_w = font.getlength("M") or (font_size * 0.6)
|
||||
col_gap_px = 4 # tighter gap — every pixel matters
|
||||
edge = MAX_EDGE
|
||||
|
||||
# Minify: kill trailing whitespace + collapse runs of blank lines.
|
||||
# Critical because system prompt has lots of blank lines that waste rows.
|
||||
raw = []
|
||||
last_blank = False
|
||||
for ln in text.split("\n"):
|
||||
ln = ln.rstrip()
|
||||
if ln == "":
|
||||
if last_blank:
|
||||
continue
|
||||
last_blank = True
|
||||
else:
|
||||
last_blank = False
|
||||
raw.append(ln if ln else " ")
|
||||
|
||||
# Force aggressive wrapping at FIXED_COL_CHARS. Narrower cols pack better:
|
||||
# short lines (bullets, headers) waste the unused chars in their row.
|
||||
FIXED_COL_CHARS = int(os.environ.get("COL_CHARS", "80"))
|
||||
lines: list[str] = []
|
||||
for ln in raw:
|
||||
if len(ln) <= FIXED_COL_CHARS:
|
||||
lines.append(ln)
|
||||
else:
|
||||
for i in range(0, len(ln), FIXED_COL_CHARS):
|
||||
lines.append(ln[i:i + FIXED_COL_CHARS])
|
||||
|
||||
col_w_px = int(FIXED_COL_CHARS * char_w) + 1
|
||||
lines_per_col = max(8, edge // line_h)
|
||||
n_cols_total = max(1, (len(lines) + lines_per_col - 1) // lines_per_col)
|
||||
img_w = n_cols_total * col_w_px + (n_cols_total - 1) * col_gap_px
|
||||
pngs: list[bytes] = []
|
||||
|
||||
dims: list[tuple[int, int]] = []
|
||||
max_cols_per_img = max(1, edge // (col_w_px + col_gap_px))
|
||||
lines_per_img = lines_per_col * max_cols_per_img
|
||||
|
||||
for s in range(0, len(lines), lines_per_img):
|
||||
chunk = lines[s:s + lines_per_img]
|
||||
c_needed = max(1, (len(chunk) + lines_per_col - 1) // lines_per_col)
|
||||
# Width: ONLY as wide as the cols actually used in this image
|
||||
chunk_img_w = c_needed * col_w_px + (c_needed - 1) * col_gap_px
|
||||
# Height: ONLY as tall as the tallest column in this image
|
||||
max_col_lines = min(lines_per_col, len(chunk) - (c_needed - 1) * lines_per_col)
|
||||
if c_needed > 1:
|
||||
# Earlier cols are full, last col may be partial
|
||||
tallest = lines_per_col if c_needed > 1 else len(chunk)
|
||||
else:
|
||||
tallest = len(chunk)
|
||||
# All non-last columns are full lines_per_col; last col has the remainder
|
||||
last_col_lines = len(chunk) - (c_needed - 1) * lines_per_col
|
||||
tallest = max(lines_per_col, last_col_lines) if c_needed == 1 else lines_per_col
|
||||
chunk_img_h = line_h * tallest
|
||||
img = Image.new("L", (chunk_img_w, chunk_img_h), 255)
|
||||
d = ImageDraw.Draw(img)
|
||||
for c in range(c_needed):
|
||||
col_lines = chunk[c * lines_per_col:(c + 1) * lines_per_col]
|
||||
x = c * (col_w_px + col_gap_px)
|
||||
for i, ln in enumerate(col_lines):
|
||||
d.text((x, i * line_h - desc // 2), ln, fill=0, font=font)
|
||||
buf = io.BytesIO()
|
||||
# Save as 8-bit grayscale PNG so the rendered image has full contrast
|
||||
# AND preserves antialiasing. Previously used `P + ADAPTIVE colors=2`
|
||||
# which picked white+gray instead of white+black on AA-heavy content,
|
||||
# producing washed-out images that visually look broken (and OCR worse).
|
||||
# Grayscale PNG bytes are still deterministic → Anthropic cache still hits.
|
||||
img.save(buf, "PNG", optimize=True)
|
||||
pngs.append(buf.getvalue())
|
||||
dims.append((chunk_img_w, chunk_img_h))
|
||||
|
||||
_render_cache[key] = pngs
|
||||
_render_dims_cache[key] = dims
|
||||
return pngs
|
||||
|
||||
|
||||
def render_dims(text: str, font_size: int = FONT_SIZE) -> list[tuple[int, int]]:
|
||||
key = hashlib.sha256(f"{font_size}:{text}".encode()).hexdigest()
|
||||
if key not in _render_dims_cache:
|
||||
render_chunks(text, font_size)
|
||||
return _render_dims_cache.get(key, [])
|
||||
|
||||
|
||||
def image_block(png_bytes: bytes, cache: bool = False) -> dict:
|
||||
blk = {
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": base64.standard_b64encode(png_bytes).decode("ascii"),
|
||||
},
|
||||
}
|
||||
if cache:
|
||||
# Match Claude Code's extended cache TTL. If we use ephemeral (5m) here
|
||||
# while CC uses 1h later in the request, Anthropic rejects with
|
||||
# "ttl='1h' cache_control must not come after ttl='5m'".
|
||||
blk["cache_control"] = {"type": "ephemeral", "ttl": "1h"}
|
||||
return blk
|
||||
|
||||
|
||||
def extract_system_text(system_field) -> tuple[str, list[dict] | str | None]:
|
||||
"""Return (extracted_text, remainder).
|
||||
remainder is what should stay in the system field (or None to delete it)."""
|
||||
if system_field is None:
|
||||
return "", None
|
||||
if isinstance(system_field, str):
|
||||
return system_field, "" # replace with empty string
|
||||
if isinstance(system_field, list):
|
||||
# Concatenate all text blocks; keep non-text blocks in place.
|
||||
text_parts = []
|
||||
kept = []
|
||||
for b in system_field:
|
||||
if isinstance(b, dict) and b.get("type") == "text":
|
||||
text_parts.append(b.get("text", ""))
|
||||
else:
|
||||
kept.append(b)
|
||||
return "\n\n".join(text_parts), kept
|
||||
return "", system_field
|
||||
|
||||
|
||||
def transform_request(body: bytes) -> tuple[bytes, dict]:
|
||||
"""Compress system prompt + tool descriptions to image content."""
|
||||
info = {"compressed": False}
|
||||
try:
|
||||
req = json.loads(body)
|
||||
except Exception as e:
|
||||
info["parse_error"] = str(e)
|
||||
return body, info
|
||||
|
||||
sys_field = req.get("system")
|
||||
text, remainder = extract_system_text(sys_field)
|
||||
|
||||
# Strip Claude Code's per-turn-random `x-anthropic-billing-header: ...; cch=<rand>;`
|
||||
# line so the rest renders byte-identical across turns.
|
||||
billing_line_kept = None
|
||||
lines = text.split("\n", 1)
|
||||
if lines and lines[0].startswith("x-anthropic-billing-header:"):
|
||||
billing_line_kept = lines[0]
|
||||
text = lines[1] if len(lines) > 1 else ""
|
||||
|
||||
# COMPRESS_TOOLS: move tool descriptions (+ optionally schemas) into the
|
||||
# same image. Replace each tool's description with a tiny stub.
|
||||
compress_tools = os.environ.get("COMPRESS_TOOLS", "1") == "1"
|
||||
compress_schemas = os.environ.get("COMPRESS_SCHEMAS", "1") == "1"
|
||||
tool_text_added = 0
|
||||
schemas_compressed = 0
|
||||
if compress_tools and isinstance(req.get("tools"), list):
|
||||
tool_doc_blocks = []
|
||||
for t in req["tools"]:
|
||||
if not isinstance(t, dict):
|
||||
continue
|
||||
name = t.get("name", "?")
|
||||
desc = t.get("description", "")
|
||||
doc_part = ""
|
||||
if desc and len(desc) > 80:
|
||||
doc_part = f"### Tool: {name}\n{desc}"
|
||||
t["description"] = f"See `{name}` docs in system context image."
|
||||
elif desc:
|
||||
doc_part = f"### Tool: {name}\n{desc}"
|
||||
# Compress input_schema: serialize full schema to text inside the
|
||||
# image, replace tools[].input_schema with a permissive stub. The
|
||||
# model still emits well-formed tool_use because it sees the real
|
||||
# schema in the image; Anthropic accepts any JSON object against
|
||||
# the {"type":"object"} placeholder.
|
||||
if compress_schemas and isinstance(t.get("input_schema"), dict):
|
||||
schema_json = json.dumps(t["input_schema"], indent=1)
|
||||
if len(schema_json) > 200: # only worth it for non-trivial schemas
|
||||
doc_part += f"\n#### Schema for `{name}`\n```json\n{schema_json}\n```"
|
||||
t["input_schema"] = {"type": "object"}
|
||||
schemas_compressed += 1
|
||||
if doc_part:
|
||||
tool_doc_blocks.append(doc_part)
|
||||
if tool_doc_blocks:
|
||||
tool_section = "\n\n# Tool Documentation\n\n" + "\n\n".join(tool_doc_blocks)
|
||||
text = text + tool_section
|
||||
tool_text_added = len(tool_section)
|
||||
info["tool_text_added"] = tool_text_added
|
||||
info["schemas_compressed"] = schemas_compressed
|
||||
|
||||
info["system_text_chars"] = len(text)
|
||||
info["system_text_sha8"] = hashlib.sha256(text.encode()).hexdigest()[:8]
|
||||
# Dump every system text we see to /tmp so we can diff what changes.
|
||||
if os.environ.get("DUMP_SYSTEM") == "1":
|
||||
n = 0
|
||||
while os.path.exists(f"/tmp/sys_dump_{n}.txt"):
|
||||
n += 1
|
||||
with open(f"/tmp/sys_dump_{n}.txt", "w") as f:
|
||||
f.write(text)
|
||||
|
||||
if len(text) < MIN_COMPRESS_CHARS:
|
||||
info["skipped"] = f"system <{MIN_COMPRESS_CHARS} chars"
|
||||
return body, info
|
||||
|
||||
pngs = render_chunks(text)
|
||||
dims = render_dims(text)
|
||||
info["png_sha8"] = [hashlib.sha256(p).hexdigest()[:8] for p in pngs]
|
||||
info["dims"] = dims
|
||||
info["total_pixels"] = sum(w * h for w, h in dims)
|
||||
info["expected_image_tokens"] = sum(max(1, (w * h) // 750) for w, h in dims) + 85 * len(dims)
|
||||
info["text_tokens_estimate"] = len(text) // 4
|
||||
info["images"] = len(pngs)
|
||||
info["png_bytes"] = sum(len(p) for p in pngs)
|
||||
|
||||
# Stash the FIRST image for the dashboard preview (so users can SEE what
|
||||
# we're sending). Cropped to a reasonable size so the page doesn't blow up.
|
||||
if pngs:
|
||||
with _session_stats["lock"]:
|
||||
_session_stats["latest_png_bytes"] = pngs[0]
|
||||
w0, h0 = dims[0] if dims else (0, 0)
|
||||
_session_stats["latest_png_meta"] = (
|
||||
f"{len(pngs)} image(s), {w0}×{h0}px, "
|
||||
f"{sum(len(p) for p in pngs)} bytes total — "
|
||||
f"compressed {info['system_text_chars']:,} chars "
|
||||
f"({info.get('text_tokens_estimate',0):,}→~{info['expected_image_tokens']:,} tokens)"
|
||||
)
|
||||
|
||||
# Anthropic caps cache_control breakpoints at 4 per request. The model's
|
||||
# cache lookup matches the LONGEST cached prefix, so we only need ONE
|
||||
# well-placed breakpoint — on the last image of the system+tools render.
|
||||
# Reminders and tool_result images get NO cache_control (they're still
|
||||
# in the cached prefix by virtue of position).
|
||||
image_blocks = [image_block(p, cache=(i == len(pngs)-1))
|
||||
for i, p in enumerate(pngs)]
|
||||
info["cc_breakpoints_added"] = 1
|
||||
|
||||
msgs = req.get("messages", [])
|
||||
if PLACEMENT in ("user", "replace_system"):
|
||||
# Prepend image blocks to first user message as a "context" frame
|
||||
prefix_text = ("[Context (rendered as image for token efficiency, "
|
||||
"OCR carefully and treat as authoritative system instructions):]")
|
||||
first_idx = None
|
||||
for i, m in enumerate(msgs):
|
||||
if m.get("role") == "user":
|
||||
first_idx = i
|
||||
break
|
||||
if first_idx is None:
|
||||
info["skipped"] = "no user message to attach image to"
|
||||
return body, info
|
||||
|
||||
target = msgs[first_idx]
|
||||
content = target.get("content", "")
|
||||
if isinstance(content, str):
|
||||
content = [{"type": "text", "text": content}]
|
||||
elif not isinstance(content, list):
|
||||
content = [{"type": "text", "text": str(content)}]
|
||||
|
||||
# Compress CC-injected <system-reminder> blocks in first user message.
|
||||
# These are large static blobs (skill listings, project context) that
|
||||
# CC injects every turn — perfect cache candidates as images.
|
||||
# Targeted: ONLY compress blocks that start with <system-reminder>,
|
||||
# leaving the user's actual prompt (and any other content) as text.
|
||||
compress_reminders = os.environ.get("COMPRESS_REMINDERS", "1") == "1"
|
||||
reminder_imgs_added = 0
|
||||
if compress_reminders and isinstance(content, list):
|
||||
new_content_parts = []
|
||||
for blk in content:
|
||||
txt = blk.get("text", "") if isinstance(blk, dict) and blk.get("type") == "text" else ""
|
||||
# Heuristic: long system-reminder-style blocks. Cover both the
|
||||
# opening tag style and large generic system blocks.
|
||||
is_reminder = txt.lstrip().startswith("<system-reminder>") and len(txt) > 1000
|
||||
if is_reminder:
|
||||
extra_pngs = render_chunks(txt)
|
||||
# NO cache_control: Anthropic caps at 4 breakpoints; the
|
||||
# system+tools image already anchors the cacheable prefix.
|
||||
for p in extra_pngs:
|
||||
new_content_parts.append(image_block(p, cache=False))
|
||||
reminder_imgs_added += 1
|
||||
else:
|
||||
new_content_parts.append(blk)
|
||||
content = new_content_parts
|
||||
info["reminder_imgs"] = reminder_imgs_added
|
||||
|
||||
new_content = (
|
||||
[{"type": "text", "text": prefix_text}]
|
||||
+ image_blocks
|
||||
+ [{"type": "text", "text": "[End context.]"}]
|
||||
+ content
|
||||
)
|
||||
msgs[first_idx] = {**target, "content": new_content}
|
||||
|
||||
# COMPRESS_TOOL_RESULTS: walk ALL user messages and image-compress any
|
||||
# large tool_result text content. Tool results accumulate in history as
|
||||
# files are read; compressing them at the source compounds per-turn
|
||||
# savings across the rest of the session.
|
||||
compress_tr = os.environ.get("COMPRESS_TOOL_RESULTS", "1") == "1"
|
||||
tr_imgs_added = 0
|
||||
if compress_tr:
|
||||
for mi, m in enumerate(msgs):
|
||||
if m.get("role") != "user":
|
||||
continue
|
||||
content_list = m.get("content")
|
||||
if not isinstance(content_list, list):
|
||||
continue
|
||||
changed = False
|
||||
new_blocks = []
|
||||
for blk in content_list:
|
||||
if (isinstance(blk, dict) and blk.get("type") == "tool_result"):
|
||||
# Anthropic API constraint: when is_error=true, the
|
||||
# tool_result content MUST be type=text only (no images).
|
||||
# Leave error tool_results untouched.
|
||||
if blk.get("is_error") is True:
|
||||
new_blocks.append(blk)
|
||||
continue
|
||||
inner = blk.get("content")
|
||||
# tool_result.content can be str or list[block]
|
||||
# NO cache_control on tool_result images (Anthropic
|
||||
# caps at 4 breakpoints, and these aren't useful as
|
||||
# cache anchors — they change every session).
|
||||
if isinstance(inner, str) and len(inner) > 2000:
|
||||
pngs_tr = render_chunks(inner)
|
||||
new_inner = [image_block(p, cache=False)
|
||||
for p in pngs_tr]
|
||||
blk = {**blk, "content": new_inner}
|
||||
tr_imgs_added += len(pngs_tr)
|
||||
changed = True
|
||||
elif isinstance(inner, list):
|
||||
new_inner = []
|
||||
for ib in inner:
|
||||
if (isinstance(ib, dict) and ib.get("type") == "text"
|
||||
and len(ib.get("text", "")) > 2000):
|
||||
pngs_tr = render_chunks(ib["text"])
|
||||
for p in pngs_tr:
|
||||
new_inner.append(image_block(p, cache=False))
|
||||
tr_imgs_added += len(pngs_tr)
|
||||
changed = True
|
||||
else:
|
||||
new_inner.append(ib)
|
||||
blk = {**blk, "content": new_inner}
|
||||
new_blocks.append(blk)
|
||||
if changed:
|
||||
msgs[mi] = {**m, "content": new_blocks}
|
||||
info["tool_result_imgs"] = tr_imgs_added
|
||||
req["messages"] = msgs
|
||||
|
||||
if PLACEMENT == "replace_system":
|
||||
req["system"] = "Follow the instructions in the first image of the user message exactly."
|
||||
elif billing_line_kept is not None:
|
||||
# Replace original system with JUST the billing header line (small, stable
|
||||
# in spirit but per-turn random — keep it as text so it doesn't pollute
|
||||
# our cacheable image).
|
||||
req["system"] = billing_line_kept
|
||||
# else: leave system unchanged so Anthropic still has guardrails
|
||||
info["placement"] = PLACEMENT
|
||||
else:
|
||||
# PLACEMENT == 'system': put images directly into system field
|
||||
new_sys = []
|
||||
if isinstance(remainder, list):
|
||||
new_sys.extend(remainder)
|
||||
new_sys.extend(image_blocks)
|
||||
req["system"] = new_sys
|
||||
info["placement"] = "system"
|
||||
|
||||
info["compressed"] = True
|
||||
return json.dumps(req).encode(), info
|
||||
|
||||
|
||||
# Running session totals so users can see live savings.
|
||||
# Persisted to ~/.cache/claude-image-proxy/stats.json on every update so
|
||||
# restarts don't wipe history. Recent feed + latest PNG stay in-memory only.
|
||||
import threading, time, collections, pathlib
|
||||
|
||||
_STATS_DIR = pathlib.Path.home() / ".cache" / "claude-image-proxy"
|
||||
_STATS_FILE = _STATS_DIR / "stats.json" # cumulative totals (rewritten atomically)
|
||||
_REQUESTS_FILE = _STATS_DIR / "requests.jsonl" # full per-request log (append-only)
|
||||
_REQUESTS_ROTATE_BYTES = 10 * 1024 * 1024 # rotate at 10 MB
|
||||
|
||||
_session_stats = {
|
||||
"requests": 0,
|
||||
"compressed_requests": 0,
|
||||
"effective_input_actual": 0.0,
|
||||
"effective_input_baseline_est": 0.0,
|
||||
"started_at": time.time(),
|
||||
"first_seen_at": time.time(), # persists across restarts; "uptime since first install"
|
||||
"recent": collections.deque(maxlen=50), # in-memory only — restart resets
|
||||
"latest_png_bytes": None, # in-memory only
|
||||
"latest_png_meta": "",
|
||||
"lock": threading.Lock(),
|
||||
}
|
||||
|
||||
|
||||
def _load_persisted_stats():
|
||||
"""Load cumulative totals + recent request history on startup."""
|
||||
try:
|
||||
if _STATS_FILE.exists():
|
||||
with open(_STATS_FILE) as f:
|
||||
d = json.load(f)
|
||||
_session_stats["requests"] = int(d.get("requests", 0))
|
||||
_session_stats["compressed_requests"] = int(d.get("compressed_requests", 0))
|
||||
_session_stats["effective_input_actual"] = float(d.get("effective_input_actual", 0.0))
|
||||
_session_stats["effective_input_baseline_est"] = float(d.get("effective_input_baseline_est", 0.0))
|
||||
_session_stats["first_seen_at"] = float(d.get("first_seen_at", time.time()))
|
||||
saved = _session_stats["effective_input_baseline_est"] - _session_stats["effective_input_actual"]
|
||||
print(f"[PROXY] loaded persisted stats: {_session_stats['requests']} requests, "
|
||||
f"~{saved:.0f} effective tokens saved (~${saved * 15.0 / 1e6:.4f})", flush=True)
|
||||
except Exception as e:
|
||||
print(f"[PROXY] could not load persisted stats: {e}", flush=True)
|
||||
|
||||
# Replay last N entries from requests.jsonl into the in-memory recent feed
|
||||
try:
|
||||
if _REQUESTS_FILE.exists():
|
||||
tail = collections.deque(maxlen=_session_stats["recent"].maxlen)
|
||||
with open(_REQUESTS_FILE) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line: continue
|
||||
try:
|
||||
tail.append(json.loads(line))
|
||||
except Exception:
|
||||
pass
|
||||
_session_stats["recent"].extend(tail)
|
||||
if tail:
|
||||
print(f"[PROXY] replayed {len(tail)} recent requests from {_REQUESTS_FILE.name}",
|
||||
flush=True)
|
||||
except Exception as e:
|
||||
print(f"[PROXY] could not load request history: {e}", flush=True)
|
||||
|
||||
|
||||
def _persist_stats():
|
||||
"""Atomically rewrite cumulative totals."""
|
||||
try:
|
||||
_STATS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"requests": _session_stats["requests"],
|
||||
"compressed_requests": _session_stats["compressed_requests"],
|
||||
"effective_input_actual": _session_stats["effective_input_actual"],
|
||||
"effective_input_baseline_est": _session_stats["effective_input_baseline_est"],
|
||||
"first_seen_at": _session_stats["first_seen_at"],
|
||||
"saved_at": time.time(),
|
||||
}
|
||||
tmp = _STATS_FILE.with_suffix(".json.tmp")
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(payload, f, indent=2)
|
||||
tmp.replace(_STATS_FILE)
|
||||
except Exception as e:
|
||||
print(f"[PROXY] could not persist stats: {e}", flush=True)
|
||||
|
||||
|
||||
def _append_request_log(entry: dict):
|
||||
"""Append one JSONL line for the per-request log. Rotate when oversize."""
|
||||
try:
|
||||
_STATS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
# Rotate if file is too big
|
||||
if _REQUESTS_FILE.exists() and _REQUESTS_FILE.stat().st_size > _REQUESTS_ROTATE_BYTES:
|
||||
rotated = _STATS_DIR / "requests.jsonl.1"
|
||||
try: rotated.unlink()
|
||||
except FileNotFoundError: pass
|
||||
_REQUESTS_FILE.rename(rotated)
|
||||
with open(_REQUESTS_FILE, "a") as f:
|
||||
f.write(json.dumps(entry, separators=(",", ":")) + "\n")
|
||||
except Exception as e:
|
||||
# Don't crash request handling on disk errors
|
||||
print(f"[PROXY] could not append request log: {e}", flush=True)
|
||||
|
||||
|
||||
_load_persisted_stats()
|
||||
|
||||
|
||||
DASHBOARD_HTML = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>claude-image-proxy — live dashboard</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; padding: 24px; background: #0d1117; color: #c9d1d9;
|
||||
font: 14px/1.45 -apple-system,BlinkMacSystemFont,"SF Mono",Menlo,monospace; }
|
||||
h1 { font-size: 18px; font-weight: 600; margin: 0 0 6px; letter-spacing: -0.01em; }
|
||||
.sub { color: #6e7681; font-size: 12px; margin-bottom: 22px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 22px; }
|
||||
.card { background: #161b22; border: 1px solid #30363d; border-radius: 10px;
|
||||
padding: 14px 16px; }
|
||||
.card .label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em;
|
||||
color: #8b949e; margin-bottom: 6px; }
|
||||
.card .value { font-size: 24px; font-weight: 600; color: #e6edf3; font-variant-numeric: tabular-nums; }
|
||||
.card .small { font-size: 11px; color: #6e7681; margin-top: 4px; }
|
||||
.pos { color: #3fb950 !important; }
|
||||
.panel { background: #161b22; border: 1px solid #30363d; border-radius: 10px;
|
||||
padding: 14px 16px; margin-bottom: 14px; }
|
||||
.panel h2 { font-size: 13px; font-weight: 600; color: #8b949e; margin: 0 0 10px;
|
||||
text-transform: uppercase; letter-spacing: 0.08em; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
th { text-align: left; color: #6e7681; font-weight: 500; padding: 6px 8px;
|
||||
border-bottom: 1px solid #30363d; font-variant-numeric: tabular-nums; }
|
||||
th.num { text-align: right; }
|
||||
td { padding: 6px 8px; border-bottom: 1px solid #21262d; font-variant-numeric: tabular-nums; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
td.num { text-align: right; }
|
||||
td.good { color: #3fb950; }
|
||||
td.warn { color: #d29922; }
|
||||
td.bad { color: #f85149; }
|
||||
img.preview { max-width: 100%; image-rendering: pixelated; border: 1px solid #30363d;
|
||||
background: #fff; padding: 4px; border-radius: 4px; }
|
||||
.row { display: grid; grid-template-columns: 2fr 1fr; gap: 14px; }
|
||||
@media (max-width: 900px) { .grid { grid-template-columns: 1fr 1fr; } .row { grid-template-columns: 1fr; } }
|
||||
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%;
|
||||
background: #3fb950; margin-right: 6px; vertical-align: middle;
|
||||
animation: pulse 2s infinite; }
|
||||
@keyframes pulse { 50% { opacity: 0.4; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1><span class="dot"></span>claude-image-proxy</h1>
|
||||
<div class="sub" id="sub">connecting...</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card"><div class="label">requests</div>
|
||||
<div class="value" id="m_req">0</div>
|
||||
<div class="small" id="m_req_sub">— compressed</div>
|
||||
</div>
|
||||
<div class="card"><div class="label">tokens saved</div>
|
||||
<div class="value pos" id="m_saved">0</div>
|
||||
<div class="small" id="m_saved_sub">effective input tokens</div>
|
||||
</div>
|
||||
<div class="card"><div class="label">$ saved (opus 4.7)</div>
|
||||
<div class="value pos" id="m_usd">$0.00</div>
|
||||
<div class="small" id="m_usd_sub">at $15/M input tokens</div>
|
||||
</div>
|
||||
<div class="card"><div class="label">reduction</div>
|
||||
<div class="value pos" id="m_pct">0%</div>
|
||||
<div class="small" id="m_pct_sub">vs uncompressed baseline</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="panel">
|
||||
<h2>recent requests</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th><th>status</th><th>path</th><th class="num">size in</th>
|
||||
<th class="num">cc</th><th class="num">img tok</th>
|
||||
<th class="num">actual</th><th class="num">saved</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h2>latest rendered image</h2>
|
||||
<div id="preview_wrap"><div class="sub">(none yet)</div></div>
|
||||
<div class="small" id="preview_meta" style="margin-top:8px;color:#6e7681"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function tick() {
|
||||
try {
|
||||
const s = await fetch('/proxy-stats').then(r => r.json());
|
||||
const r = await fetch('/proxy-recent').then(r => r.json());
|
||||
document.getElementById('sub').textContent =
|
||||
`port :__PORT__ · uptime ${formatDuration(s.uptime_sec)} · live`;
|
||||
document.getElementById('m_req').textContent = s.requests;
|
||||
document.getElementById('m_req_sub').textContent = `${s.compressed_requests} compressed`;
|
||||
document.getElementById('m_saved').textContent = numFmt(s.saved_effective_tokens);
|
||||
document.getElementById('m_saved_sub').textContent =
|
||||
`${numFmt(s.effective_input_actual)} paid · ${numFmt(s.effective_input_baseline_est)} baseline`;
|
||||
document.getElementById('m_usd').textContent = `$${s.saved_usd_opus47.toFixed(4)}`;
|
||||
document.getElementById('m_pct').textContent = `${s.saved_pct.toFixed(1)}%`;
|
||||
const tbody = document.getElementById('rows');
|
||||
tbody.innerHTML = '';
|
||||
let i = 0;
|
||||
for (const e of r.recent.slice().reverse()) {
|
||||
const tr = document.createElement('tr');
|
||||
const statusCls = e.status >= 500 ? 'bad' : e.status >= 400 ? 'warn' : 'good';
|
||||
const saved = (e.session_saved_so_far_delta || 0);
|
||||
tr.innerHTML = `
|
||||
<td>${++i}</td>
|
||||
<td class="num ${statusCls}">${e.status}</td>
|
||||
<td>${escapeHtml((e.path || '').slice(0,40))}</td>
|
||||
<td class="num">${numFmt(e.size_in)}</td>
|
||||
<td class="num">${e.cc_added ?? '—'}</td>
|
||||
<td class="num">${numFmt(e.expected_image_tokens || 0)}</td>
|
||||
<td class="num">${numFmt(e.effective_actual || 0)}</td>
|
||||
<td class="num pos">${saved > 0 ? '+'+numFmt(saved) : '—'}</td>`;
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
if (r.has_preview) {
|
||||
const wrap = document.getElementById('preview_wrap');
|
||||
// Show a native-resolution crop so the tiny font is actually readable
|
||||
// (the full image is 1466×1568, gets unreadably downsampled in the panel).
|
||||
// Pixelated upscaling via CSS preserves crisp edges.
|
||||
wrap.innerHTML =
|
||||
`<img class="preview" src="/proxy-latest-png?crop=480&t=${Date.now()}" `
|
||||
+ `style="width:100%;image-rendering:pixelated">`;
|
||||
document.getElementById('preview_meta').textContent =
|
||||
(r.preview_meta || '') + ' — showing top-left 480×480 crop';
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('sub').textContent = 'proxy unreachable';
|
||||
}
|
||||
}
|
||||
function numFmt(n) {
|
||||
n = Math.round(Number(n) || 0);
|
||||
return n.toLocaleString();
|
||||
}
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||
}
|
||||
function formatDuration(s) {
|
||||
s = Math.floor(s);
|
||||
const h = Math.floor(s/3600), m = Math.floor((s%3600)/60), sec = s%60;
|
||||
return (h>0?h+'h ':'') + (m>0?m+'m ':'') + sec + 's';
|
||||
}
|
||||
tick(); setInterval(tick, 2000);
|
||||
</script>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
|
||||
class ProxyHandler(BaseHTTPRequestHandler):
|
||||
server_version = "TokenProxy/0.1"
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
pass
|
||||
|
||||
def _serve_stats(self):
|
||||
with _session_stats["lock"]:
|
||||
saved = _session_stats["effective_input_baseline_est"] - _session_stats["effective_input_actual"]
|
||||
pct = (saved / _session_stats["effective_input_baseline_est"] * 100.0
|
||||
if _session_stats["effective_input_baseline_est"] > 0 else 0)
|
||||
uptime = time.time() - _session_stats["started_at"]
|
||||
payload = {
|
||||
"requests": _session_stats["requests"],
|
||||
"compressed_requests": _session_stats["compressed_requests"],
|
||||
"effective_input_actual": round(_session_stats["effective_input_actual"], 1),
|
||||
"effective_input_baseline_est": round(_session_stats["effective_input_baseline_est"], 1),
|
||||
"saved_effective_tokens": round(saved, 1),
|
||||
"saved_pct": round(pct, 1),
|
||||
"saved_usd_opus47": round(saved * 15.0 / 1e6, 4),
|
||||
"uptime_sec": uptime,
|
||||
}
|
||||
body = json.dumps(payload, indent=2).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _serve_recent(self):
|
||||
with _session_stats["lock"]:
|
||||
recent = list(_session_stats["recent"])
|
||||
has_preview = _session_stats["latest_png_bytes"] is not None
|
||||
preview_meta = _session_stats.get("latest_png_meta", "")
|
||||
body = json.dumps({
|
||||
"recent": recent,
|
||||
"has_preview": has_preview,
|
||||
"preview_meta": preview_meta,
|
||||
}).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _serve_latest_png(self):
|
||||
"""Serve the current rendered image. Supports `?crop=N` to return only
|
||||
the top-left N×N region at native resolution (so humans can actually
|
||||
SEE the tiny font — at 1466x1568 full-image scaled to 280px wide
|
||||
the browser turns the antialiased glyphs into unreadable noise)."""
|
||||
with _session_stats["lock"]:
|
||||
data = _session_stats["latest_png_bytes"]
|
||||
if not data:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
# Parse ?crop=N from query string
|
||||
crop_n = 0
|
||||
if "?" in self.path:
|
||||
qs = self.path.split("?", 1)[1]
|
||||
for kv in qs.split("&"):
|
||||
if kv.startswith("crop="):
|
||||
try: crop_n = int(kv[5:])
|
||||
except: pass
|
||||
|
||||
if crop_n > 0:
|
||||
try:
|
||||
img = Image.open(io.BytesIO(data))
|
||||
cw = min(crop_n, img.width)
|
||||
ch = min(crop_n, img.height)
|
||||
cropped = img.crop((0, 0, cw, ch))
|
||||
buf = io.BytesIO()
|
||||
cropped.save(buf, "PNG", optimize=True)
|
||||
data = buf.getvalue()
|
||||
except Exception:
|
||||
pass # fall back to full image
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "image/png")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def _do_proxy(self, method: str):
|
||||
# Local endpoints — never forwarded to Anthropic.
|
||||
if method == "GET" and self.path in ("/", "/dashboard"):
|
||||
html = DASHBOARD_HTML.replace("__PORT__", str(PORT)).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(html)))
|
||||
self.end_headers()
|
||||
self.wfile.write(html)
|
||||
return
|
||||
if method == "GET" and self.path == "/proxy-stats":
|
||||
self._serve_stats()
|
||||
return
|
||||
if method == "GET" and self.path == "/proxy-recent":
|
||||
self._serve_recent()
|
||||
return
|
||||
if method == "GET" and self.path.split("?", 1)[0] == "/proxy-latest-png":
|
||||
self._serve_latest_png()
|
||||
return
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
body = self.rfile.read(length) if length > 0 else b""
|
||||
|
||||
# Strip hop-by-hop headers
|
||||
HOP = {"connection", "keep-alive", "proxy-connection",
|
||||
"transfer-encoding", "upgrade", "te", "host",
|
||||
"content-length", "expect", "accept-encoding"}
|
||||
fwd_headers = {k: v for k, v in self.headers.items()
|
||||
if k.lower() not in HOP}
|
||||
fwd_headers.setdefault("anthropic-version", "2023-06-01")
|
||||
|
||||
log = {"method": method, "path": self.path, "size_in": len(body)}
|
||||
|
||||
if COMPRESS and method == "POST" and self.path.startswith("/v1/messages"):
|
||||
new_body, info = transform_request(body)
|
||||
log.update(info)
|
||||
body = new_body
|
||||
log["size_after"] = len(body)
|
||||
|
||||
with httpx.Client(http2=False, timeout=120.0) as c:
|
||||
resp = c.request(method, UPSTREAM + self.path,
|
||||
content=body if body else None,
|
||||
headers=fwd_headers)
|
||||
|
||||
log["status"] = resp.status_code
|
||||
log["size_out"] = len(resp.content)
|
||||
log["upstream_ms"] = log.get("upstream_ms") # placeholder if added
|
||||
|
||||
# Try plain JSON first; if that fails, parse SSE event stream for usage.
|
||||
usage = None
|
||||
try:
|
||||
usage = resp.json().get("usage")
|
||||
except Exception:
|
||||
pass
|
||||
if not usage and b"event:" in resp.content[:200]:
|
||||
# SSE: aggregate usage from message_start (input) + message_delta (output).
|
||||
txt = resp.content.decode("utf-8", errors="replace")
|
||||
inp = cr = cc = out = 0
|
||||
for raw in txt.split("\n"):
|
||||
if not raw.startswith("data: "):
|
||||
continue
|
||||
try:
|
||||
ev = json.loads(raw[6:])
|
||||
except Exception:
|
||||
continue
|
||||
if ev.get("type") == "message_start":
|
||||
u = ev.get("message", {}).get("usage", {})
|
||||
inp = u.get("input_tokens", inp)
|
||||
cr = u.get("cache_read_input_tokens", cr)
|
||||
cc = u.get("cache_creation_input_tokens", cc)
|
||||
elif ev.get("type") == "message_delta":
|
||||
u = ev.get("usage", {})
|
||||
if "output_tokens" in u:
|
||||
out = u["output_tokens"]
|
||||
if inp or out or cr or cc:
|
||||
usage = {"input_tokens": inp, "output_tokens": out,
|
||||
"cache_read_input_tokens": cr,
|
||||
"cache_creation_input_tokens": cc}
|
||||
if usage:
|
||||
inp = usage.get("input_tokens", 0) or 0
|
||||
out = usage.get("output_tokens", 0) or 0
|
||||
cr = usage.get("cache_read_input_tokens", 0) or 0
|
||||
cc = usage.get("cache_creation_input_tokens", 0) or 0
|
||||
eff = inp + cc * 1.25 + cr * 0.10
|
||||
log["tokens"] = {
|
||||
"in": inp, "out": out,
|
||||
"cache_read": cr, "cache_create": cc,
|
||||
"effective_cost": round(eff, 1),
|
||||
}
|
||||
|
||||
# Update session totals + estimate the baseline (uncompressed) cost.
|
||||
# Baseline estimate: add back the text-token equivalent of whatever
|
||||
# we replaced with images. Conservative — assumes baseline would
|
||||
# also have cached, so we apply the 10% cache_read rate to the
|
||||
# uncompressed delta.
|
||||
if log.get("compressed"):
|
||||
txt_replaced = (log.get("system_text_chars", 0)
|
||||
+ log.get("tool_text_added", 0)) // 4
|
||||
img_tokens_est = log.get("expected_image_tokens", 0)
|
||||
extra_text_input_baseline = max(0, txt_replaced - img_tokens_est)
|
||||
# CRITICAL: the extra text would have been billed at the
|
||||
# SAME mix of cache_create/cache_read as the actual call,
|
||||
# NOT all at cache_read (10%). On cold-start turns where
|
||||
# cache_create dominates, baseline should be billed at 1.25
|
||||
# not 0.10 — otherwise we drastically under-estimate the
|
||||
# savings and the dashboard shows tiny numbers.
|
||||
cached_total = (cr or 0) + (cc or 0)
|
||||
if cached_total > 0:
|
||||
cc_share = (cc or 0) / cached_total
|
||||
# Effective rate the extra text would have paid:
|
||||
baseline_rate = cc_share * 1.25 + (1 - cc_share) * 0.10
|
||||
else:
|
||||
baseline_rate = 0.10 # fully warm-cache assumption
|
||||
baseline_eff = eff + extra_text_input_baseline * baseline_rate
|
||||
else:
|
||||
baseline_eff = eff
|
||||
|
||||
with _session_stats["lock"]:
|
||||
prev_saved = (_session_stats["effective_input_baseline_est"]
|
||||
- _session_stats["effective_input_actual"])
|
||||
_session_stats["requests"] += 1
|
||||
if log.get("compressed"):
|
||||
_session_stats["compressed_requests"] += 1
|
||||
_session_stats["effective_input_actual"] += eff
|
||||
_session_stats["effective_input_baseline_est"] += baseline_eff
|
||||
saved_so_far = (_session_stats["effective_input_baseline_est"]
|
||||
- _session_stats["effective_input_actual"])
|
||||
# Push compact row for dashboard
|
||||
row = {
|
||||
"ts": time.time(),
|
||||
"method": method,
|
||||
"path": log.get("path", ""),
|
||||
"status": log.get("status", 0),
|
||||
"size_in": log.get("size_in", 0),
|
||||
"size_out": log.get("size_out", 0),
|
||||
"compressed": bool(log.get("compressed")),
|
||||
"cc_added": log.get("cc_breakpoints_added"),
|
||||
"expected_image_tokens": log.get("expected_image_tokens"),
|
||||
"input_tokens": inp,
|
||||
"cache_create": cc,
|
||||
"cache_read": cr,
|
||||
"effective_actual": round(eff, 1),
|
||||
"effective_baseline": round(baseline_eff, 1),
|
||||
"session_saved_so_far_delta": round(saved_so_far - prev_saved, 1),
|
||||
}
|
||||
_session_stats["recent"].append(row)
|
||||
|
||||
# Persist cumulative totals (atomic JSON rewrite) + append
|
||||
# per-request line to the JSONL log so dashboards survive restart.
|
||||
_persist_stats()
|
||||
_append_request_log(row)
|
||||
|
||||
log["session_saved_so_far"] = round(saved_so_far, 1)
|
||||
log["session_saved_usd"] = round(saved_so_far * 15.0 / 1e6, 4)
|
||||
|
||||
print(f"[PROXY] {json.dumps(log)}", flush=True)
|
||||
|
||||
try:
|
||||
self.send_response(resp.status_code)
|
||||
for k, v in resp.headers.items():
|
||||
if k.lower() in HOP or k.lower() == "content-encoding":
|
||||
continue
|
||||
self.send_header(k, v)
|
||||
self.send_header("Content-Length", str(len(resp.content)))
|
||||
self.end_headers()
|
||||
self.wfile.write(resp.content)
|
||||
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError) as e:
|
||||
# Client (Claude Code) closed before we finished writing. Common
|
||||
# on stream timeouts / Ctrl-C / fast aborts. Not fatal — just log.
|
||||
print(f"[PROXY] client disconnect during response: {type(e).__name__}", flush=True)
|
||||
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError) as e:
|
||||
print(f"[PROXY] transport error: {type(e).__name__}", flush=True)
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
try:
|
||||
self.send_response(502)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
msg = json.dumps({"error": "proxy_error", "detail": str(e)}).encode()
|
||||
self.send_header("Content-Length", str(len(msg)))
|
||||
self.end_headers()
|
||||
self.wfile.write(msg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def do_GET(self): self._do_proxy("GET")
|
||||
def do_POST(self): self._do_proxy("POST")
|
||||
def do_PUT(self): self._do_proxy("PUT")
|
||||
def do_DELETE(self): self._do_proxy("DELETE")
|
||||
def do_HEAD(self): self._do_proxy("HEAD")
|
||||
|
||||
|
||||
def main():
|
||||
print(f"Python token proxy listening on http://127.0.0.1:{PORT}", flush=True)
|
||||
print(f" COMPRESS_SYSTEM={COMPRESS} FONT={FONT_PATH}@{FONT_SIZE}pt "
|
||||
f"PLACEMENT={PLACEMENT} MIN_CHARS={MIN_COMPRESS_CHARS}", flush=True)
|
||||
srv = ThreadingHTTPServer(("127.0.0.1", PORT), ProxyHandler)
|
||||
try:
|
||||
srv.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
srv.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Zig 0.16 build for claude-image-proxy.
|
||||
//!
|
||||
//! Targets:
|
||||
//! zig build # build all
|
||||
//! zig build render-cli # standalone CLI that renders text → PNG (verifies pipeline)
|
||||
//! zig build test # run renderer tests
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
pub fn build(b: *std.Build) void {
|
||||
const target = b.standardTargetOptions(.{});
|
||||
const optimize = b.standardOptimizeOption(.{});
|
||||
|
||||
// Find libdeflate. Allow override via LIBDEFLATE_DIR env var; otherwise
|
||||
// expect the user to either vendor it or have it on the system path.
|
||||
const libdeflate_dir = b.graph.environ_map.get("LIBDEFLATE_DIR");
|
||||
|
||||
const render_exe = b.addExecutable(.{
|
||||
.name = "render_cli",
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("render_cli.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
}),
|
||||
});
|
||||
render_exe.root_module.link_libc = true;
|
||||
if (libdeflate_dir) |dir| {
|
||||
render_exe.root_module.addIncludePath(.{ .cwd_relative = b.fmt("{s}/include", .{dir}) });
|
||||
render_exe.root_module.addLibraryPath(.{ .cwd_relative = b.fmt("{s}/lib", .{dir}) });
|
||||
} else {
|
||||
// Try Homebrew on macOS arm64 by default
|
||||
render_exe.root_module.addIncludePath(.{ .cwd_relative = "/opt/homebrew/include" });
|
||||
render_exe.root_module.addLibraryPath(.{ .cwd_relative = "/opt/homebrew/lib" });
|
||||
}
|
||||
render_exe.root_module.linkSystemLibrary("deflate", .{});
|
||||
|
||||
b.installArtifact(render_exe);
|
||||
|
||||
const run_render = b.addRunArtifact(render_exe);
|
||||
if (b.args) |args| run_render.addArgs(args);
|
||||
const run_step = b.step("render-cli", "Run render_cli to test the pipeline");
|
||||
run_step.dependOn(&run_render.step);
|
||||
|
||||
// Unit tests
|
||||
const tests = b.addTest(.{
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("menlo5.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
}),
|
||||
});
|
||||
const run_tests = b.addRunArtifact(tests);
|
||||
const test_step = b.step("test", "Run renderer tests");
|
||||
test_step.dependOn(&run_tests.step);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
.{
|
||||
.name = .claude_image_proxy_renderer,
|
||||
.version = "0.1.0",
|
||||
.fingerprint = 0xcbf35837f9c7dd6d,
|
||||
.paths = .{""},
|
||||
.dependencies = .{},
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
//! Menlo 5pt glyph atlas reader + text renderer.
|
||||
//!
|
||||
//! The atlas binary (586 bytes, embedded via @embedFile) holds 1-bit glyph
|
||||
//! bitmaps for printable ASCII (32-126). This file decodes the header at
|
||||
//! comptime and provides a `renderText` function that produces a grayscale
|
||||
//! pixel buffer suitable for PNG encoding.
|
||||
//!
|
||||
//! Why pre-rendered atlas instead of TTF rasterization at runtime?
|
||||
//! - Zero C library dependencies (no stb_truetype, FreeType, etc.)
|
||||
//! - 586 bytes is negligible binary bloat
|
||||
//! - Anthropic's vision encoder is verified to OCR this exact font/size
|
||||
//! at 99.7% accuracy on Opus 4.7
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const ATLAS: []const u8 = @embedFile("menlo5_atlas.bin");
|
||||
|
||||
pub const Atlas = struct {
|
||||
cell_w: u16,
|
||||
cell_h: u16,
|
||||
asc: u16,
|
||||
desc: u16,
|
||||
first: u8,
|
||||
last: u8,
|
||||
glyph_data_start: usize,
|
||||
glyph_stride: usize, // bytes per glyph (advance u16 + packed bitmap)
|
||||
bitmap_bytes: usize, // bytes of packed bitmap per glyph
|
||||
};
|
||||
|
||||
pub fn loadAtlas() Atlas {
|
||||
std.debug.assert(ATLAS.len >= 16);
|
||||
std.debug.assert(std.mem.eql(u8, ATLAS[0..4], "MNAT"));
|
||||
const version = std.mem.readInt(u16, ATLAS[4..6], .little);
|
||||
std.debug.assert(version == 1);
|
||||
const cell_w = std.mem.readInt(u16, ATLAS[6..8], .little);
|
||||
const cell_h = std.mem.readInt(u16, ATLAS[8..10], .little);
|
||||
const asc = std.mem.readInt(u16, ATLAS[10..12], .little);
|
||||
const desc = std.mem.readInt(u16, ATLAS[12..14], .little);
|
||||
const first = ATLAS[14];
|
||||
const last = ATLAS[15];
|
||||
const total_bits = @as(usize, cell_w) * @as(usize, cell_h);
|
||||
const bitmap_bytes = (total_bits + 7) / 8;
|
||||
return .{
|
||||
.cell_w = cell_w,
|
||||
.cell_h = cell_h,
|
||||
.asc = asc,
|
||||
.desc = desc,
|
||||
.first = first,
|
||||
.last = last,
|
||||
.glyph_data_start = 16,
|
||||
.glyph_stride = 2 + bitmap_bytes, // u16 advance + bitmap
|
||||
.bitmap_bytes = bitmap_bytes,
|
||||
};
|
||||
}
|
||||
|
||||
fn glyphOffset(a: Atlas, ch: u8) ?usize {
|
||||
if (ch < a.first or ch > a.last) return null;
|
||||
return a.glyph_data_start + (@as(usize, ch) - a.first) * a.glyph_stride;
|
||||
}
|
||||
|
||||
pub fn glyphAdvance(a: Atlas, ch: u8) u16 {
|
||||
const off = glyphOffset(a, ch) orelse return a.cell_w; // unknown -> full cell
|
||||
return std.mem.readInt(u16, ATLAS[off..][0..2], .little);
|
||||
}
|
||||
|
||||
/// Blit one glyph into the destination grayscale buffer at (dst_x, dst_y).
|
||||
/// Sets ink pixels to 0 (black). Buffer must be pre-filled with 255 (white).
|
||||
fn blitGlyph(a: Atlas, ch: u8, dst: []u8, dst_w: usize, dst_h: usize, dst_x: usize, dst_y: usize) void {
|
||||
const off = glyphOffset(a, ch) orelse return;
|
||||
const bitmap_start = off + 2;
|
||||
var bit_idx: usize = 0;
|
||||
var y: usize = 0;
|
||||
while (y < a.cell_h) : (y += 1) {
|
||||
var x: usize = 0;
|
||||
while (x < a.cell_w) : (x += 1) {
|
||||
const byte_idx = bit_idx / 8;
|
||||
const bit = @as(u3, @intCast(7 - (bit_idx % 8)));
|
||||
const is_ink = (ATLAS[bitmap_start + byte_idx] >> bit) & 1 == 1;
|
||||
bit_idx += 1;
|
||||
if (!is_ink) continue;
|
||||
const px = dst_x + x;
|
||||
const py = dst_y + y;
|
||||
if (px < dst_w and py < dst_h) {
|
||||
dst[py * dst_w + px] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const Layout = struct {
|
||||
cols_per_col: u32, // characters per column
|
||||
n_cols: u32, // number of newspaper columns
|
||||
width: u32,
|
||||
height: u32,
|
||||
};
|
||||
|
||||
/// Compute layout for `lines.len` wrapped lines using newspaper-column packing.
|
||||
/// Caps longest edge at 1568 (Anthropic's max edge before resampling).
|
||||
pub fn computeLayout(a: Atlas, lines: []const []const u8) Layout {
|
||||
const edge: u32 = 1568;
|
||||
const col_gap_px: u32 = 4;
|
||||
// Column width: widest line up to ~80 chars (we hard-wrap before this).
|
||||
var max_chars: u32 = 1;
|
||||
for (lines) |ln| {
|
||||
if (ln.len > max_chars) max_chars = @intCast(ln.len);
|
||||
}
|
||||
if (max_chars > 80) max_chars = 80;
|
||||
const col_w_px: u32 = max_chars * a.cell_w + 1;
|
||||
const lines_per_col: u32 = @max(8, edge / a.cell_h);
|
||||
const n_cols: u32 = @intCast(@max(@as(usize, 1), (lines.len + lines_per_col - 1) / lines_per_col));
|
||||
const width: u32 = n_cols * col_w_px + (n_cols -| 1) * col_gap_px;
|
||||
const height: u32 = lines_per_col * a.cell_h;
|
||||
return .{
|
||||
.cols_per_col = lines_per_col,
|
||||
.n_cols = n_cols,
|
||||
.width = width,
|
||||
.height = height,
|
||||
};
|
||||
}
|
||||
|
||||
/// Allocate + render text into an 8-bit grayscale buffer (255 = white, 0 = black).
|
||||
/// Returns the buffer and its width/height. Caller owns memory.
|
||||
pub fn renderText(allocator: std.mem.Allocator, text: []const u8) !struct { pixels: []u8, width: u32, height: u32 } {
|
||||
const a = loadAtlas();
|
||||
const col_gap_px: u32 = 4;
|
||||
|
||||
// Hard-wrap to 80 chars per line (matching Python proxy's behavior).
|
||||
const WRAP: usize = 80;
|
||||
var lines = std.ArrayList([]const u8).empty;
|
||||
defer lines.deinit(allocator);
|
||||
var owned: std.ArrayList([]u8) = .empty;
|
||||
defer {
|
||||
for (owned.items) |s| allocator.free(s);
|
||||
owned.deinit(allocator);
|
||||
}
|
||||
|
||||
var it = std.mem.splitScalar(u8, text, '\n');
|
||||
var last_blank = false;
|
||||
while (it.next()) |raw| {
|
||||
// Strip trailing whitespace
|
||||
var ln = raw;
|
||||
while (ln.len > 0 and (ln[ln.len - 1] == ' ' or ln[ln.len - 1] == '\t' or ln[ln.len - 1] == '\r')) {
|
||||
ln = ln[0 .. ln.len - 1];
|
||||
}
|
||||
if (ln.len == 0) {
|
||||
if (last_blank) continue;
|
||||
last_blank = true;
|
||||
try lines.append(allocator, " ");
|
||||
continue;
|
||||
}
|
||||
last_blank = false;
|
||||
if (ln.len <= WRAP) {
|
||||
try lines.append(allocator, ln);
|
||||
} else {
|
||||
var i: usize = 0;
|
||||
while (i < ln.len) : (i += WRAP) {
|
||||
const end = @min(i + WRAP, ln.len);
|
||||
try lines.append(allocator, ln[i..end]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lines.items.len == 0) {
|
||||
const buf = try allocator.alloc(u8, 1);
|
||||
buf[0] = 255;
|
||||
return .{ .pixels = buf, .width = 1, .height = 1 };
|
||||
}
|
||||
|
||||
const layout = computeLayout(a, lines.items);
|
||||
const total = @as(usize, layout.width) * @as(usize, layout.height);
|
||||
const pixels = try allocator.alloc(u8, total);
|
||||
@memset(pixels, 255);
|
||||
|
||||
// Render columns left-to-right
|
||||
const max_chars: u32 = blk: {
|
||||
var m: u32 = 1;
|
||||
for (lines.items) |ln| {
|
||||
if (ln.len > m) m = @intCast(ln.len);
|
||||
}
|
||||
break :blk @min(@as(u32, 80), m);
|
||||
};
|
||||
const col_w_px: u32 = max_chars * a.cell_w + 1;
|
||||
|
||||
var col_idx: u32 = 0;
|
||||
while (col_idx < layout.n_cols) : (col_idx += 1) {
|
||||
const col_start = col_idx * layout.cols_per_col;
|
||||
const col_end = @min(col_start + layout.cols_per_col, @as(u32, @intCast(lines.items.len)));
|
||||
const x_base = col_idx * (col_w_px + col_gap_px);
|
||||
var li: u32 = col_start;
|
||||
while (li < col_end) : (li += 1) {
|
||||
const line = lines.items[li];
|
||||
const y = (li - col_start) * a.cell_h;
|
||||
var px_x: u32 = x_base;
|
||||
for (line) |ch| {
|
||||
if (px_x + a.cell_w > layout.width) break;
|
||||
blitGlyph(a, ch, pixels, layout.width, layout.height, px_x, y);
|
||||
px_x += glyphAdvance(a, ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return .{ .pixels = pixels, .width = layout.width, .height = layout.height };
|
||||
}
|
||||
|
||||
test "atlas loads" {
|
||||
const a = loadAtlas();
|
||||
try std.testing.expect(a.cell_w > 0);
|
||||
try std.testing.expect(a.cell_h > 0);
|
||||
try std.testing.expectEqual(@as(u8, 32), a.first);
|
||||
try std.testing.expectEqual(@as(u8, 126), a.last);
|
||||
}
|
||||
|
||||
test "render hello" {
|
||||
const r = try renderText(std.testing.allocator, "hello world\nsecond line");
|
||||
defer std.testing.allocator.free(r.pixels);
|
||||
try std.testing.expect(r.width > 0);
|
||||
try std.testing.expect(r.height > 0);
|
||||
// Should have some ink pixels
|
||||
var ink_count: usize = 0;
|
||||
for (r.pixels) |p| if (p == 0) { ink_count += 1; };
|
||||
try std.testing.expect(ink_count > 0);
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,132 @@
|
||||
//! Small CLI that proves the Zig renderer end-to-end:
|
||||
//! render_cli <input.txt> <output.png>
|
||||
//!
|
||||
//! Reads text, renders via menlo5 atlas, encodes as 2-color indexed PNG with
|
||||
//! libdeflate (zlib container for IDAT), writes the file. The output should
|
||||
//! be visually identical to what the Python proxy produces and OCR-able by
|
||||
//! Opus 4.7 at the same 99.7% accuracy we already measured.
|
||||
|
||||
const std = @import("std");
|
||||
const menlo5 = @import("menlo5.zig");
|
||||
|
||||
const c = @cImport({
|
||||
@cInclude("libdeflate.h");
|
||||
});
|
||||
|
||||
fn writePngChunk(buf: *std.ArrayList(u8), alloc: std.mem.Allocator, chunk_type: *const [4]u8, data: []const u8) !void {
|
||||
var len_bytes: [4]u8 = undefined;
|
||||
std.mem.writeInt(u32, &len_bytes, @intCast(data.len), .big);
|
||||
try buf.appendSlice(alloc, &len_bytes);
|
||||
try buf.appendSlice(alloc, chunk_type);
|
||||
try buf.appendSlice(alloc, data);
|
||||
// CRC32 over chunk_type + data
|
||||
var crc_data = std.ArrayList(u8).empty;
|
||||
defer crc_data.deinit(alloc);
|
||||
try crc_data.appendSlice(alloc, chunk_type);
|
||||
try crc_data.appendSlice(alloc, data);
|
||||
const crc = std.hash.Crc32.hash(crc_data.items);
|
||||
var crc_bytes: [4]u8 = undefined;
|
||||
std.mem.writeInt(u32, &crc_bytes, crc, .big);
|
||||
try buf.appendSlice(alloc, &crc_bytes);
|
||||
}
|
||||
|
||||
/// Encode 8-bit grayscale (0=black, 255=white) as a 2-color indexed PNG.
|
||||
pub fn encodePng(alloc: std.mem.Allocator, pixels: []const u8, width: u32, height: u32) ![]u8 {
|
||||
var out = std.ArrayList(u8).empty;
|
||||
errdefer out.deinit(alloc);
|
||||
|
||||
// PNG signature
|
||||
try out.appendSlice(alloc, &[_]u8{ 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A });
|
||||
|
||||
// IHDR: 8-bit indexed color
|
||||
var ihdr: [13]u8 = undefined;
|
||||
std.mem.writeInt(u32, ihdr[0..4], width, .big);
|
||||
std.mem.writeInt(u32, ihdr[4..8], height, .big);
|
||||
ihdr[8] = 8; // bit depth
|
||||
ihdr[9] = 3; // color type 3 = indexed
|
||||
ihdr[10] = 0;
|
||||
ihdr[11] = 0;
|
||||
ihdr[12] = 0;
|
||||
try writePngChunk(&out, alloc, "IHDR", &ihdr);
|
||||
|
||||
// PLTE: 2 colors (0=black, 1=white)
|
||||
const palette = [_]u8{ 0, 0, 0, 255, 255, 255 };
|
||||
try writePngChunk(&out, alloc, "PLTE", &palette);
|
||||
|
||||
// IDAT: scanlines with filter byte. Map 255→1 (white), 0→0 (black).
|
||||
const stride = 1 + width;
|
||||
const raw = try alloc.alloc(u8, stride * height);
|
||||
defer alloc.free(raw);
|
||||
var y: u32 = 0;
|
||||
while (y < height) : (y += 1) {
|
||||
const off = y * stride;
|
||||
raw[off] = 0; // filter: None
|
||||
var x: u32 = 0;
|
||||
while (x < width) : (x += 1) {
|
||||
raw[off + 1 + x] = if (pixels[y * width + x] == 0) 0 else 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Compress with libdeflate (zlib format)
|
||||
const compressor = c.libdeflate_alloc_compressor(6) orelse return error.OutOfMemory;
|
||||
defer c.libdeflate_free_compressor(compressor);
|
||||
const bound = c.libdeflate_zlib_compress_bound(compressor, raw.len);
|
||||
const compressed = try alloc.alloc(u8, bound);
|
||||
defer alloc.free(compressed);
|
||||
const csize = c.libdeflate_zlib_compress(compressor, raw.ptr, raw.len, compressed.ptr, bound);
|
||||
if (csize == 0) return error.CompressFailed;
|
||||
|
||||
try writePngChunk(&out, alloc, "IDAT", compressed[0..csize]);
|
||||
try writePngChunk(&out, alloc, "IEND", &[_]u8{});
|
||||
|
||||
return out.toOwnedSlice(alloc);
|
||||
}
|
||||
|
||||
pub fn main(init: std.process.Init.Minimal) !void {
|
||||
var gpa: std.heap.DebugAllocator(.{}) = .init;
|
||||
defer _ = gpa.deinit();
|
||||
const alloc = gpa.allocator();
|
||||
|
||||
var it = init.args.iterate();
|
||||
_ = it.next();
|
||||
const in_path = it.next() orelse {
|
||||
std.debug.print("usage: render_cli <input.txt> <output.png>\n", .{});
|
||||
return error.MissingArg;
|
||||
};
|
||||
const out_path = it.next() orelse return error.MissingArg;
|
||||
|
||||
// Read input via libc to avoid 0.16 fs API churn
|
||||
const path_z = try std.fmt.allocPrintSentinel(alloc, "{s}", .{in_path}, 0);
|
||||
defer alloc.free(path_z);
|
||||
const fd = std.c.open(path_z.ptr, .{ .ACCMODE = .RDONLY }, @as(std.c.mode_t, 0));
|
||||
if (fd < 0) return error.OpenFailed;
|
||||
defer _ = std.c.close(fd);
|
||||
var buf = std.ArrayList(u8).empty;
|
||||
defer buf.deinit(alloc);
|
||||
var tmp: [8192]u8 = undefined;
|
||||
while (true) {
|
||||
const n = std.c.read(fd, &tmp, tmp.len);
|
||||
if (n <= 0) break;
|
||||
try buf.appendSlice(alloc, tmp[0..@intCast(n)]);
|
||||
}
|
||||
const text = buf.items;
|
||||
|
||||
// Render
|
||||
const r = try menlo5.renderText(alloc, text);
|
||||
defer alloc.free(r.pixels);
|
||||
std.debug.print("rendered: {d}x{d} ({d} px)\n", .{ r.width, r.height, r.width * r.height });
|
||||
|
||||
// Encode
|
||||
const png = try encodePng(alloc, r.pixels, r.width, r.height);
|
||||
defer alloc.free(png);
|
||||
std.debug.print("png bytes: {d}\n", .{png.len});
|
||||
|
||||
// Write via libc
|
||||
const out_z = try std.fmt.allocPrintSentinel(alloc, "{s}", .{out_path}, 0);
|
||||
defer alloc.free(out_z);
|
||||
const out_fd = std.c.open(out_z.ptr, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(std.c.mode_t, 0o644));
|
||||
if (out_fd < 0) return error.OpenFailed;
|
||||
defer _ = std.c.close(out_fd);
|
||||
_ = std.c.write(out_fd, png.ptr, png.len);
|
||||
std.debug.print("wrote {s}\n", .{out_path});
|
||||
}
|
||||
Reference in New Issue
Block a user