Add Rust port of proxy (unproven, not wired up)

Functional reimplementation in Rust with the same render_chunks/transform
public surface as src/proxy.py. Compiles clean on darwin-arm64, smoke-
tested but NOT YET battle-tested against real Claude Code sessions.

Layout:
  src/rust/
    Cargo.toml         hyper + tokio + reqwest + ab_glyph + png + clap
    Cargo.lock         committed (application crate, deterministic builds)
    src/main.rs        clap CLI matching bin/cli.js flags
    src/lib.rs         module roots
    src/font.rs        ab_glyph rasterization of bundled JetBrains Mono
    src/render.rs      newspaper-column text -> PNG (matches Python algo)
    src/transform.rs   request body transformation (system prompt -> image)
    src/proxy.rs       hyper HTTP server, upstream forwarding
    src/stats.rs       persisted savings counters mirroring proxy.py
    assets/JetBrainsMono-Regular.ttf  OFL-licensed, legally embeddable
    examples/render_sample.rs   smoke test: render fibonacci snippet
    examples/diff_render.rs     diff harness companion (paired with
                                scripts/diff_render.py)

Diff harness:
  scripts/diff_render.py   feeds same text through Python and Rust
                           render_chunks, decodes both PNGs, compares
                           pixel-by-pixel. First run found two divergences:

  1. Rust font.rs:43 uses ab_glyph h_advance which returns ~3.4px ceiled
     to 4 for Menlo-5pt 'M'; PIL's getlength('M') returns 3.0 exactly.
     Every Rust column is 33% wider than Python's.
  2. Python proxy.py:125 pads every single-column image to MAX_EDGE=1568
     height regardless of content (already noted in baseline commit).

Neither is fixed yet — surfacing the divergences before any swap. Next:
visual inspect both PNGs to confirm Rust output is at least OCR-readable,
then decide whether to fix bug-for-bug compat or correct both sides.

bin/cli.js still spawns Python; this commit only adds the Rust tree, it
does NOT change runtime behavior. The live proxy at :47821 is unaffected.
This commit is contained in:
teamchong
2026-05-18 11:52:32 -04:00
parent 9fddbeb28a
commit 482c967270
13 changed files with 3448 additions and 0 deletions
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""
Diff harness: compare Python render_chunks vs Rust render_chunks pixel-by-pixel.
Strategy:
1. For each sample text, call Python render_chunks() -> list[PNG bytes]
2. Call Rust diff_render binary -> writes PNGs to disk
3. Decode both sides' PNGs and compare pixel arrays
4. Report: pass / fail with first diff coordinates
Run:
python3 scripts/diff_render.py
"""
import hashlib
import json
import subprocess
import sys
import tempfile
from pathlib import Path
# Make src/proxy.py importable
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "src"))
import proxy as py_proxy # noqa: E402
from PIL import Image # noqa: E402
import io # noqa: E402
RUST_BIN = ROOT / "src" / "rust" / "target" / "release" / "examples" / "diff_render"
SAMPLES = {
"short_ascii": "hello world\n",
"multiline_code": (
"def fibonacci(n):\n"
" if n <= 1: return n\n"
" return fibonacci(n-1) + fibonacci(n-2)\n"
"print(fibonacci(10))\n"
),
"long_with_blanks": (
"# Header\n\n"
+ "\n".join(f"line {i}: " + "x" * (i % 60) for i in range(200))
+ "\n\n# Footer\n"
),
"many_short_lines": "\n".join(f"L{i}" for i in range(500)) + "\n",
"wide_lines": "\n".join("." * 80 for _ in range(100)) + "\n",
"tools_like_json": json.dumps(
{"tools": [{"name": f"tool_{i}", "input_schema": {"type": "object",
"properties": {f"p{j}": {"type": "string"} for j in range(5)}}}
for i in range(20)]}, indent=2),
}
def png_to_pixels(png_bytes: bytes):
"""Decode PNG to (width, height, raw_grayscale_bytes)."""
img = Image.open(io.BytesIO(png_bytes))
img = img.convert("L") # force 8-bit grayscale
return img.width, img.height, img.tobytes()
def run_rust(samples: dict, out_dir: Path) -> dict:
"""Invoke rust diff_render binary, returns name -> list[png_bytes]."""
spec = {name: text for name, text in samples.items()}
spec_path = out_dir / "spec.json"
spec_path.write_text(json.dumps(spec))
res = subprocess.run(
[str(RUST_BIN), str(spec_path), str(out_dir)],
capture_output=True, text=True,
)
if res.returncode != 0:
print("RUST FAILED:", res.stderr, file=sys.stderr)
sys.exit(2)
manifest = json.loads((out_dir / "manifest.json").read_text())
out = {}
for name, files in manifest.items():
out[name] = [(out_dir / f).read_bytes() for f in files]
return out
def diff_pixels(py_bytes: bytes, rs_bytes: bytes):
"""Return None if pixel-identical, else (reason, details)."""
pw, ph, pp = png_to_pixels(py_bytes)
rw, rh, rp = png_to_pixels(rs_bytes)
if (pw, ph) != (rw, rh):
return ("dim_mismatch", f"py={pw}x{ph} rs={rw}x{rh}")
if pp == rp:
return None
# Find first diff
for i, (a, b) in enumerate(zip(pp, rp)):
if a != b:
x = i % pw
y = i // pw
return ("pixel_mismatch", f"first diff at ({x},{y}): py={a} rs={b}")
return ("byte_len_mismatch", f"py_len={len(pp)} rs_len={len(rp)}")
def main():
if not RUST_BIN.exists():
print(f"build rust first: cd src/rust && cargo build --release --example diff_render")
print(f"missing: {RUST_BIN}")
sys.exit(1)
with tempfile.TemporaryDirectory() as td:
out_dir = Path(td)
rust_out = run_rust(SAMPLES, out_dir)
total = 0
fail = 0
for name, text in SAMPLES.items():
py_pngs = py_proxy.render_chunks(text)
rs_pngs = rust_out.get(name, [])
total += 1
print(f"\n[{name}] py_chunks={len(py_pngs)} rs_chunks={len(rs_pngs)}")
if len(py_pngs) != len(rs_pngs):
fail += 1
print(f" FAIL: chunk count differs")
continue
any_fail = False
for i, (a, b) in enumerate(zip(py_pngs, rs_pngs)):
ah = hashlib.sha256(a).hexdigest()[:12]
bh = hashlib.sha256(b).hexdigest()[:12]
diff = diff_pixels(a, b)
if diff is None:
print(f" chunk {i}: OK py={ah} rs={bh} bytes={len(a)}/{len(b)}")
else:
any_fail = True
print(f" chunk {i}: FAIL {diff[0]}: {diff[1]} py={ah} rs={bh}")
if any_fail:
fail += 1
print(f"\n=== {total - fail}/{total} samples pixel-identical ===")
sys.exit(0 if fail == 0 else 1)
if __name__ == "__main__":
main()
+2105
View File
File diff suppressed because it is too large Load Diff
+71
View File
@@ -0,0 +1,71 @@
[package]
name = "claude-image-proxy"
version = "0.1.0"
edition = "2021"
description = "Token-saving proxy for Claude Code that renders system prompt + tools as images"
license = "MIT"
repository = "https://github.com/your/claude-image-proxy"
[[bin]]
name = "claude-image-proxy"
path = "src/main.rs"
[dependencies]
# Async runtime
tokio = { version = "1", features = ["full"] }
# HTTP server (incoming from Claude Code)
axum = { version = "0.7", features = ["macros"] }
# HTTP client (upstream to api.anthropic.com) - rustls so no OpenSSL link
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json", "http2"] }
# JSON parse + manipulate
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Font rasterization (TTF -> glyph bitmaps)
ab_glyph = "0.2"
# PNG encoding
png = "0.17"
# Base64 for image data URI
base64 = "0.22"
# Hashing for render-cache keys (deterministic across turns)
sha2 = "0.10"
# In-memory cache (thread-safe, no allocations on hit)
dashmap = "6"
# Bytes manipulation
bytes = "1"
# CLI args parsing
clap = { version = "4", features = ["derive"] }
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Misc
once_cell = "1"
# Error handling
anyhow = "1"
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
strip = true
panic = "abort"
[profile.release-small]
inherits = "release"
opt-level = "z"
[lib]
name = "claude_image_proxy"
path = "src/lib.rs"
Binary file not shown.
+48
View File
@@ -0,0 +1,48 @@
//! Diff harness companion: read a JSON spec {name: text, ...}, render each via
//! the Rust pipeline, write PNGs to <out_dir>/<name>_<idx>.png, then emit
//! <out_dir>/manifest.json mapping name -> [filenames].
//!
//! Run: cargo run --release --example diff_render -- <spec.json> <out_dir>
use std::collections::BTreeMap;
use std::path::PathBuf;
use claude_image_proxy::font::AtlasFont;
use claude_image_proxy::render::render_chunks;
fn main() -> anyhow::Result<()> {
let mut args = std::env::args().skip(1);
let spec_path: PathBuf = args
.next()
.ok_or_else(|| anyhow::anyhow!("usage: diff_render <spec.json> <out_dir>"))?
.into();
let out_dir: PathBuf = args
.next()
.ok_or_else(|| anyhow::anyhow!("usage: diff_render <spec.json> <out_dir>"))?
.into();
let spec_bytes = std::fs::read(&spec_path)?;
let spec: BTreeMap<String, String> = serde_json::from_slice(&spec_bytes)?;
let font = AtlasFont::load(5.0)?;
let mut manifest: BTreeMap<String, Vec<String>> = BTreeMap::new();
for (name, text) in &spec {
let pngs = render_chunks(&font, text);
let mut filenames = Vec::with_capacity(pngs.len());
for (i, png) in pngs.iter().enumerate() {
let fname = format!("{name}_{i}.png");
std::fs::write(out_dir.join(&fname), &png.bytes)?;
filenames.push(fname);
}
manifest.insert(name.clone(), filenames);
}
std::fs::write(
out_dir.join("manifest.json"),
serde_json::to_vec_pretty(&manifest)?,
)?;
eprintln!("rendered {} samples", spec.len());
Ok(())
}
+23
View File
@@ -0,0 +1,23 @@
//! Render a sample text → PNG so we can visually inspect + OCR-test.
//! Run: cargo run --example render_sample -- <output.png> [size]
use claude_image_proxy::font::AtlasFont;
use claude_image_proxy::render::render_chunks;
fn main() -> anyhow::Result<()> {
let mut args = std::env::args().skip(1);
let out = args.next().unwrap_or_else(|| "/tmp/rust_sample.png".to_string());
let size: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(5.0);
let font = AtlasFont::load(size)?;
println!("cell: {}x{}", font.cell_w, font.cell_h);
let sample = "def fibonacci(n):\n if n <= 1: return n\n return fibonacci(n-1) + fibonacci(n-2)\nprint(fibonacci(10))";
let pngs = render_chunks(&font, sample);
println!("images: {}, dims: {:?}", pngs.len(), pngs.iter().map(|p| (p.width, p.height)).collect::<Vec<_>>());
std::fs::write(&out, &pngs[0].bytes)?;
println!("wrote {} ({} bytes)", out, pngs[0].bytes.len());
Ok(())
}
+111
View File
@@ -0,0 +1,111 @@
//! Embedded TTF font + glyph rasterization at a fixed size.
//!
//! Pre-bakes the printable ASCII range (32-126) into a fixed-cell atlas at
//! load time. Renders text by blitting from the atlas into a u8 grayscale
//! buffer (0 = ink, 255 = paper). This matches the Python proxy's pipeline
//! that we verified produces 99.5% OCR accuracy on Opus 4.7.
use ab_glyph::{Font, FontRef, PxScale, ScaleFont};
/// JetBrains Mono Regular, OFL-licensed. Bundled at compile time.
const FONT_DATA: &[u8] = include_bytes!("../assets/JetBrainsMono-Regular.ttf");
pub struct AtlasFont {
pub cell_w: u32,
pub cell_h: u32,
pub ascent: i32,
/// Glyph bitmaps, one per code point in [FIRST..=LAST]. Each bitmap is
/// cell_w * cell_h bytes (0 = ink, 255 = paper).
pub glyphs: Vec<Vec<u8>>,
pub first: u8,
pub last: u8,
}
const FIRST: u8 = 32;
const LAST: u8 = 126;
impl AtlasFont {
pub fn load(font_size_pt: f32) -> anyhow::Result<Self> {
let font = FontRef::try_from_slice(FONT_DATA)?;
// ab_glyph's PxScale is in *pixels* of the EM box. PIL/typography use
// points (1pt = 1/72 in, 1 px = 1/96 in @ 96 DPI). Convert pt → px.
// This gives us the same physical glyph size PIL produced (where
// Menlo 5pt OCR'd at 99.7%).
let px_height = font_size_pt * 96.0 / 72.0;
let scale = PxScale::from(px_height);
let scaled = font.as_scaled(scale);
// Determine cell dimensions: use widest glyph width + line height.
let ascent = scaled.ascent().ceil() as i32;
let descent = scaled.descent().floor() as i32;
let line_h = (ascent - descent).max(1) as u32;
// Use a consistent monospace advance from 'M'.
let advance_m = scaled.h_advance(font.glyph_id('M')).ceil().max(1.0) as u32;
// Cell width: tight to advance; allow 1 px of slop for descenders/spillover.
let cell_w = advance_m.max(1);
let cell_h = line_h;
let mut glyphs = Vec::with_capacity((LAST - FIRST + 1) as usize);
for code in FIRST..=LAST {
let ch = code as char;
let glyph_id = font.glyph_id(ch);
let glyph = glyph_id.with_scale(scale);
let mut bitmap = vec![255u8; (cell_w * cell_h) as usize];
if let Some(outlined) = font.outline_glyph(glyph) {
let bounds = outlined.px_bounds();
let x_offset = bounds.min.x as i32;
let y_offset = bounds.min.y as i32 + ascent;
outlined.draw(|gx, gy, coverage| {
let px = gx as i32 + x_offset;
let py = gy as i32 + y_offset;
if px < 0 || py < 0 { return; }
let px = px as u32;
let py = py as u32;
if px >= cell_w || py >= cell_h { return; }
// Keep antialiased grayscale (0=black ink, 255=white paper).
// PIL+Menlo at 5pt with AA→99.7% OCR; 1-bit threshold killed
// legibility for ab_glyph. Vision encoder actually uses the
// grayscale gradients at tiny font sizes.
let ink = (coverage * 255.0).clamp(0.0, 255.0) as u8;
let cur = bitmap[(py * cell_w + px) as usize];
let new_v = 255_u8.saturating_sub(ink);
if new_v < cur {
bitmap[(py * cell_w + px) as usize] = new_v;
}
});
}
glyphs.push(bitmap);
}
Ok(AtlasFont {
cell_w,
cell_h,
ascent,
glyphs,
first: FIRST,
last: LAST,
})
}
/// Blit one glyph into a destination buffer (255=paper, 0=ink) at (dx, dy).
/// Keeps the darker of (existing, glyph) — preserves antialiasing.
pub fn blit(&self, ch: u8, dst: &mut [u8], dst_w: u32, dst_h: u32, dx: u32, dy: u32) {
if ch < self.first || ch > self.last { return; }
let bitmap = &self.glyphs[(ch - self.first) as usize];
for y in 0..self.cell_h {
let py = dy + y;
if py >= dst_h { break; }
for x in 0..self.cell_w {
let px = dx + x;
if px >= dst_w { break; }
let v = bitmap[(y * self.cell_w + x) as usize];
let idx = (py * dst_w + px) as usize;
// Preserve grayscale: keep the darker value (paper=255 default).
if v < dst[idx] {
dst[idx] = v;
}
}
}
}
}
+2
View File
@@ -0,0 +1,2 @@
pub mod font;
pub mod render;
+111
View File
@@ -0,0 +1,111 @@
//! claude-image-proxy — token-saving proxy for Claude Code.
//!
//! Architecture mirrors the proven Python prototype:
//! 1. Accept HTTP from Claude Code on a local port.
//! 2. For POST /v1/messages: parse JSON body, replace the bulky system
//! prompt + tool descriptions + tool schemas + <system-reminder> blocks
//! with a single rendered PNG image. Stub the original fields.
//! 3. Forward the modified request to api.anthropic.com untouched-otherwise.
//! 4. Return the response unchanged. Log effective-token math.
//!
//! Single static binary, no Python dependency.
use clap::Parser;
use std::sync::Arc;
mod proxy;
mod stats;
mod transform;
mod render;
mod font;
#[derive(Parser, Debug, Clone)]
#[command(name = "claude-image-proxy", version, about, long_about = None)]
struct Args {
/// Port to listen on
#[arg(short, long, default_value_t = 47821)]
port: u16,
/// Disable ALL compression (pure passthrough — for benchmarking)
#[arg(long)]
no_compress: bool,
/// Don't compress tool descriptions
#[arg(long)]
no_tools: bool,
/// Don't compress tool input_schemas (saves the most)
#[arg(long)]
no_schemas: bool,
/// Don't compress <system-reminder> blocks
#[arg(long)]
no_reminders: bool,
/// Don't compress large tool_result content
#[arg(long)]
no_tool_results: bool,
/// Font size for rendering (5pt is verified OCR floor)
#[arg(long, default_value_t = 5.0)]
font_size: f32,
/// Minimum chars before triggering compression
#[arg(long, default_value_t = 2000)]
min_chars: usize,
/// Upstream URL (default: https://api.anthropic.com)
#[arg(long, default_value = "https://api.anthropic.com")]
upstream: String,
}
#[derive(Clone)]
pub struct AppState {
pub args: Arc<Args>,
pub client: reqwest::Client,
pub render_cache: Arc<dashmap::DashMap<[u8; 32], Vec<render::Png>>>,
pub stats: Arc<stats::Stats>,
pub font: Arc<font::AtlasFont>,
}
#[tokio::main(flavor = "multi_thread")]
async fn main() -> anyhow::Result<()> {
let args = Args::parse();
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("claude_image_proxy=info,info")),
)
.with_target(false)
.init();
let port = args.port;
let compress = !args.no_compress;
let font_size = args.font_size;
let state = AppState {
args: Arc::new(args),
client: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(300))
.build()?,
render_cache: Arc::new(dashmap::DashMap::new()),
stats: Arc::new(stats::Stats::default()),
font: Arc::new(font::AtlasFont::load(font_size)?),
};
println!("claude-image-proxy v{} starting", env!("CARGO_PKG_VERSION"));
println!(" port: {}", port);
println!(" compression: {}", if compress { "ON" } else { "OFF (passthrough)" });
if compress {
println!(" font: JetBrains Mono {}pt", font_size);
}
println!();
println!(" Point Claude Code at: ANTHROPIC_BASE_URL=http://127.0.0.1:{}", port);
println!(" Live stats: curl http://127.0.0.1:{}/proxy-stats", port);
println!();
proxy::serve(port, state).await
}
// Submodules declared inline at top of file
+280
View File
@@ -0,0 +1,280 @@
//! HTTP server: accept from Claude Code, transform, forward to api.anthropic.com.
use axum::{
body::Bytes,
extract::State,
http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri},
response::{IntoResponse, Response},
routing::{any, get},
Router,
};
use std::collections::VecDeque;
use std::sync::Mutex;
use std::time::Instant;
use crate::transform::{transform, TransformConfig, TransformInfo};
use crate::AppState;
// Connection-specific (hop-by-hop) headers + things we always strip.
const HOP_HEADERS: &[&str] = &[
"connection", "keep-alive", "proxy-connection", "transfer-encoding",
"upgrade", "te", "host", "content-length", "expect", "accept-encoding",
];
#[derive(Clone, serde::Serialize)]
pub struct RequestEntry {
pub method: String,
pub path: String,
pub size_in: usize,
pub size_out: usize,
pub status: u16,
pub upstream_ms: u64,
pub info: TransformInfo,
pub tokens: Option<TokenLog>,
pub session_saved_so_far: f64,
pub session_saved_usd: f64,
}
#[derive(Clone, Debug, serde::Serialize)]
pub struct TokenLog {
pub input: u64,
pub output: u64,
pub cache_read: u64,
pub cache_create: u64,
pub effective_cost: f64,
}
// Tail of recent requests for the dashboard.
pub struct Recent(pub Mutex<VecDeque<RequestEntry>>);
impl Default for Recent {
fn default() -> Self { Recent(Mutex::new(VecDeque::with_capacity(64))) }
}
impl Recent {
pub fn push(&self, e: RequestEntry) {
let mut q = self.0.lock().unwrap();
if q.len() >= 64 { q.pop_front(); }
q.push_back(e);
}
pub fn snapshot(&self) -> Vec<RequestEntry> {
self.0.lock().unwrap().iter().cloned().collect()
}
}
pub async fn serve(port: u16, state: AppState) -> anyhow::Result<()> {
let recent: std::sync::Arc<Recent> = std::sync::Arc::new(Recent::default());
let app = Router::new()
.route("/proxy-stats", get(stats_handler))
.fallback(any(proxy_handler))
.with_state((state, recent));
let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await?;
tracing::info!("listening on http://127.0.0.1:{port}");
axum::serve(listener, app).await?;
Ok(())
}
async fn stats_handler(
State((state, _recent)): State<(AppState, std::sync::Arc<Recent>)>,
) -> impl IntoResponse {
let snap = state.stats.snapshot();
(StatusCode::OK, [("content-type", "application/json")],
serde_json::to_string_pretty(&snap).unwrap_or_default())
}
async fn proxy_handler(
State((state, recent)): State<(AppState, std::sync::Arc<Recent>)>,
method: Method,
uri: Uri,
headers: HeaderMap,
body: Bytes,
) -> Response {
let path = uri.path_and_query().map(|p| p.as_str().to_string()).unwrap_or("/".to_string());
let started = Instant::now();
// Only POST /v1/messages gets compressed; everything else is passthrough.
let do_compress = state.args.no_compress == false
&& method == Method::POST
&& path.starts_with("/v1/messages");
let cfg = TransformConfig {
compress: do_compress,
compress_tools: !state.args.no_tools,
compress_schemas: !state.args.no_schemas,
compress_reminders: !state.args.no_reminders,
compress_tool_results: !state.args.no_tool_results,
min_chars: state.args.min_chars,
font: &state.font,
render_cache: &state.render_cache,
};
let (new_body, info) = transform(&body, &cfg);
// Build the upstream URL
let upstream_url = format!("{}{}", state.args.upstream, path);
// Forward headers (strip hop-by-hop + content-length, reqwest sets its own)
let mut fwd_headers = reqwest::header::HeaderMap::new();
for (k, v) in headers.iter() {
let lower = k.as_str().to_ascii_lowercase();
if HOP_HEADERS.contains(&lower.as_str()) { continue; }
if let (Ok(name), Ok(val)) = (
reqwest::header::HeaderName::from_bytes(k.as_str().as_bytes()),
reqwest::header::HeaderValue::from_bytes(v.as_bytes()),
) {
fwd_headers.insert(name, val);
}
}
if !fwd_headers.contains_key("anthropic-version") {
fwd_headers.insert(
reqwest::header::HeaderName::from_static("anthropic-version"),
reqwest::header::HeaderValue::from_static("2023-06-01"),
);
}
let rmethod = match method {
Method::GET => reqwest::Method::GET,
Method::POST => reqwest::Method::POST,
Method::PUT => reqwest::Method::PUT,
Method::DELETE => reqwest::Method::DELETE,
Method::HEAD => reqwest::Method::HEAD,
_ => reqwest::Method::POST,
};
let upstream_started = Instant::now();
let upstream_result = state.client
.request(rmethod, &upstream_url)
.headers(fwd_headers)
.body(new_body.clone())
.send()
.await;
let upstream_ms = upstream_started.elapsed().as_millis() as u64;
let upstream_resp = match upstream_result {
Ok(r) => r,
Err(e) => {
let msg = format!(r#"{{"error":"upstream_error","detail":{}}}"#, serde_json::to_string(&e.to_string()).unwrap());
return (StatusCode::BAD_GATEWAY, [("content-type", "application/json")], msg).into_response();
}
};
let status = upstream_resp.status();
let mut resp_headers = HeaderMap::new();
for (k, v) in upstream_resp.headers() {
let lower = k.as_str().to_ascii_lowercase();
if HOP_HEADERS.contains(&lower.as_str()) || lower == "content-encoding" { continue; }
if let (Ok(name), Ok(val)) = (
HeaderName::from_bytes(k.as_str().as_bytes()),
HeaderValue::from_bytes(v.as_bytes()),
) {
resp_headers.insert(name, val);
}
}
let resp_body = match upstream_resp.bytes().await {
Ok(b) => b,
Err(_) => Bytes::new(),
};
// Parse usage (plain JSON or SSE stream) for stats.
let token_log = parse_usage(&resp_body);
let (eff, baseline) = compute_effective_and_baseline(&token_log, &info);
state.stats.record(eff, baseline, info.compressed);
let snap = state.stats.snapshot();
let entry = RequestEntry {
method: method.as_str().to_string(),
path: path.clone(),
size_in: body.len(),
size_out: resp_body.len(),
status: status.as_u16(),
upstream_ms,
info: info.clone(),
tokens: token_log.clone(),
session_saved_so_far: snap.saved_effective_tokens,
session_saved_usd: snap.saved_usd_opus47,
};
recent.push(entry.clone());
// Single-line JSON log per request (matches the Python proxy log shape).
if let Ok(j) = serde_json::to_string(&entry) {
println!("[PROXY] {}", j);
}
let _ = started;
(status, resp_headers, resp_body).into_response()
}
fn parse_usage(body: &[u8]) -> Option<TokenLog> {
// Try plain JSON first
if let Ok(v) = serde_json::from_slice::<serde_json::Value>(body) {
if let Some(u) = v.get("usage") {
return Some(usage_to_log(u));
}
}
// Try SSE: scan for "data: {...}" lines and aggregate.
let text = std::str::from_utf8(body).ok()?;
let mut inp: u64 = 0;
let mut out: u64 = 0;
let mut cr: u64 = 0;
let mut cc: u64 = 0;
let mut any = false;
for line in text.lines() {
let line = line.trim_start();
let payload = match line.strip_prefix("data: ") {
Some(p) => p,
None => continue,
};
let ev: serde_json::Value = match serde_json::from_str(payload) {
Ok(v) => v,
Err(_) => continue,
};
match ev.get("type").and_then(|v| v.as_str()) {
Some("message_start") => {
if let Some(u) = ev.get("message").and_then(|m| m.get("usage")) {
inp = u.get("input_tokens").and_then(|v| v.as_u64()).unwrap_or(inp);
cr = u.get("cache_read_input_tokens").and_then(|v| v.as_u64()).unwrap_or(cr);
cc = u.get("cache_creation_input_tokens").and_then(|v| v.as_u64()).unwrap_or(cc);
any = true;
}
}
Some("message_delta") => {
if let Some(u) = ev.get("usage") {
if let Some(v) = u.get("output_tokens").and_then(|v| v.as_u64()) {
out = v;
any = true;
}
}
}
_ => {}
}
}
if any {
let eff = inp as f64 + cc as f64 * 1.25 + cr as f64 * 0.10;
Some(TokenLog { input: inp, output: out, cache_read: cr, cache_create: cc, effective_cost: eff })
} else {
None
}
}
fn usage_to_log(u: &serde_json::Value) -> TokenLog {
let inp = u.get("input_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
let out = u.get("output_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
let cr = u.get("cache_read_input_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
let cc = u.get("cache_creation_input_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
let eff = inp as f64 + cc as f64 * 1.25 + cr as f64 * 0.10;
TokenLog { input: inp, output: out, cache_read: cr, cache_create: cc, effective_cost: eff }
}
fn compute_effective_and_baseline(token_log: &Option<TokenLog>, info: &TransformInfo) -> (f64, f64) {
let Some(t) = token_log else { return (0.0, 0.0); };
let actual = t.effective_cost;
if !info.compressed {
return (actual, actual);
}
// Conservative baseline: assume the replaced text would have been cached
// at 10% rate, and the extra char count is what we saved.
let txt_replaced = ((info.system_text_chars + info.tool_text_added) / 4) as u64;
let extra = txt_replaced.saturating_sub(info.expected_image_tokens);
let baseline = actual + extra as f64 * 0.10;
(actual, baseline)
}
+140
View File
@@ -0,0 +1,140 @@
//! Text → PNG rendering using the embedded glyph atlas.
//!
//! Strategy mirrors the Python proxy:
//! - Hard-wrap input at 80 chars/line (kills column-blow-up from long lines).
//! - Collapse runs of blank lines (each blank costs a full row).
//! - Newspaper layout: pack lines into N columns so each image stays ≤ 1568px.
//! - 2-color indexed PNG (paper=white, ink=black) — Anthropic bills by
//! dimensions, not bytes, but tiny PNGs are nice for wire transfer.
use crate::font::AtlasFont;
const MAX_EDGE: u32 = 1568;
const COL_GAP_PX: u32 = 4;
const WRAP_CHARS: usize = 80;
pub struct Png {
pub bytes: Vec<u8>,
pub width: u32,
pub height: u32,
}
pub fn render_chunks(font: &AtlasFont, text: &str) -> Vec<Png> {
// Minify: rstrip + collapse blank-line runs.
let mut lines_raw: Vec<String> = Vec::new();
let mut last_blank = false;
for ln in text.lines() {
let ln = ln.trim_end();
if ln.is_empty() {
if last_blank { continue; }
last_blank = true;
lines_raw.push(" ".to_string());
} else {
last_blank = false;
lines_raw.push(ln.to_string());
}
}
if lines_raw.is_empty() {
lines_raw.push(" ".to_string());
}
// Hard-wrap to WRAP_CHARS.
let mut lines: Vec<String> = Vec::new();
for ln in lines_raw {
if ln.len() <= WRAP_CHARS {
lines.push(ln);
} else {
let bytes = ln.as_bytes();
let mut i = 0;
while i < bytes.len() {
let end = (i + WRAP_CHARS).min(bytes.len());
lines.push(String::from_utf8_lossy(&bytes[i..end]).into_owned());
i = end;
}
}
}
let cell_w = font.cell_w;
let cell_h = font.cell_h;
let col_w_px = WRAP_CHARS as u32 * cell_w + 1;
let lines_per_col = ((MAX_EDGE / cell_h) as usize).max(8);
let max_cols_per_img = ((MAX_EDGE / (col_w_px + COL_GAP_PX)).max(1)) as usize;
let lines_per_img = lines_per_col * max_cols_per_img;
let mut pngs: Vec<Png> = Vec::new();
let mut start = 0;
while start < lines.len() {
let chunk_end = (start + lines_per_img).min(lines.len());
let chunk = &lines[start..chunk_end];
let c_needed = ((chunk.len() + lines_per_col - 1) / lines_per_col).max(1) as u32;
let chunk_w = c_needed * col_w_px + (c_needed.saturating_sub(1)) * COL_GAP_PX;
let last_col_lines = chunk.len() - ((c_needed - 1) as usize * lines_per_col);
let tallest = if c_needed == 1 { last_col_lines } else { lines_per_col };
let chunk_h = tallest as u32 * cell_h;
let mut buf = vec![255u8; (chunk_w * chunk_h) as usize];
for c in 0..c_needed as usize {
let col_start = c * lines_per_col;
let col_end = (col_start + lines_per_col).min(chunk.len());
let x_base = c as u32 * (col_w_px + COL_GAP_PX);
for (i, line) in chunk[col_start..col_end].iter().enumerate() {
let y = i as u32 * cell_h;
let mut x = x_base;
for &ch in line.as_bytes() {
if x + cell_w > chunk_w { break; }
font.blit(ch, &mut buf, chunk_w, chunk_h, x, y);
x += cell_w;
}
}
}
let png_bytes = encode_indexed_png(&buf, chunk_w, chunk_h);
pngs.push(Png { bytes: png_bytes, width: chunk_w, height: chunk_h });
start = chunk_end;
}
pngs
}
/// Encode a u8 grayscale buffer as an 8-bit grayscale PNG.
/// Preserves antialiasing — critical: PIL+Menlo OCR'd at 99.7% because the
/// vision encoder uses subpixel grayscale gradients at tiny font sizes.
/// Deterministic: same input → same output bytes (so the cache hits).
fn encode_indexed_png(pixels: &[u8], width: u32, height: u32) -> Vec<u8> {
let mut out: Vec<u8> = Vec::with_capacity(pixels.len() / 4 + 64);
{
let mut encoder = png::Encoder::new(&mut out, width, height);
encoder.set_color(png::ColorType::Grayscale);
encoder.set_depth(png::BitDepth::Eight);
encoder.set_compression(png::Compression::Best);
let mut writer = encoder.write_header().expect("png header");
writer.write_image_data(pixels).expect("png data");
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::font::AtlasFont;
#[test]
fn renders_short_text() {
let font = AtlasFont::load(5.0).unwrap();
let pngs = render_chunks(&font, "hello world\nsecond line");
assert!(!pngs.is_empty());
assert!(pngs[0].bytes.len() > 0);
assert!(pngs[0].bytes.starts_with(&[0x89, b'P', b'N', b'G']));
}
#[test]
fn renders_deterministically() {
let font = AtlasFont::load(5.0).unwrap();
let a = render_chunks(&font, "fibonacci(n-1) + fibonacci(n-2)");
let b = render_chunks(&font, "fibonacci(n-1) + fibonacci(n-2)");
assert_eq!(a.len(), b.len());
for (a_png, b_png) in a.iter().zip(b.iter()) {
assert_eq!(a_png.bytes, b_png.bytes, "PNG bytes must be deterministic for cache");
}
}
}
+68
View File
@@ -0,0 +1,68 @@
//! Running session totals — surfaced via GET /proxy-stats and per-request log line.
use std::sync::atomic::{AtomicU64, Ordering};
#[derive(Default)]
pub struct Stats {
pub requests: AtomicU64,
pub compressed_requests: AtomicU64,
/// Effective tokens we actually paid for (sum of input + cache_create*1.25 + cache_read*0.10).
/// Stored as bits of f64.
effective_actual_bits: AtomicU64,
/// What we *would* have paid uncompressed (conservative estimate).
effective_baseline_bits: AtomicU64,
}
impl Stats {
pub fn record(&self, actual: f64, baseline: f64, compressed: bool) {
self.requests.fetch_add(1, Ordering::Relaxed);
if compressed {
self.compressed_requests.fetch_add(1, Ordering::Relaxed);
}
self.add_f64(&self.effective_actual_bits, actual);
self.add_f64(&self.effective_baseline_bits, baseline);
}
pub fn snapshot(&self) -> Snapshot {
let actual = self.read_f64(&self.effective_actual_bits);
let baseline = self.read_f64(&self.effective_baseline_bits);
let saved = (baseline - actual).max(0.0);
let pct = if baseline > 0.0 { saved / baseline * 100.0 } else { 0.0 };
Snapshot {
requests: self.requests.load(Ordering::Relaxed),
compressed_requests: self.compressed_requests.load(Ordering::Relaxed),
effective_input_actual: actual,
effective_input_baseline_est: baseline,
saved_effective_tokens: saved,
saved_pct: pct,
// Opus 4.7 input price: $15 / M tokens
saved_usd_opus47: saved * 15.0 / 1_000_000.0,
}
}
fn add_f64(&self, atomic: &AtomicU64, delta: f64) {
let mut cur = atomic.load(Ordering::Relaxed);
loop {
let new_val = f64::from_bits(cur) + delta;
match atomic.compare_exchange_weak(cur, new_val.to_bits(), Ordering::Relaxed, Ordering::Relaxed) {
Ok(_) => break,
Err(actual) => cur = actual,
}
}
}
fn read_f64(&self, atomic: &AtomicU64) -> f64 {
f64::from_bits(atomic.load(Ordering::Relaxed))
}
}
#[derive(serde::Serialize)]
pub struct Snapshot {
pub requests: u64,
pub compressed_requests: u64,
pub effective_input_actual: f64,
pub effective_input_baseline_est: f64,
pub saved_effective_tokens: f64,
pub saved_pct: f64,
pub saved_usd_opus47: f64,
}
+353
View File
@@ -0,0 +1,353 @@
//! Request-body JSON transformation: text → images.
//!
//! Ported from `src/proxy.py:transform_request`. Same heuristics, same field
//! semantics, same Anthropic-API constraints (≤4 cache_control breakpoints).
use base64::Engine;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use crate::font::AtlasFont;
use crate::render::{render_chunks, Png};
pub struct TransformConfig<'a> {
pub compress: bool,
pub compress_tools: bool,
pub compress_schemas: bool,
pub compress_reminders: bool,
pub compress_tool_results: bool,
pub min_chars: usize,
pub font: &'a AtlasFont,
pub render_cache: &'a dashmap::DashMap<[u8; 32], Vec<Png>>,
}
#[derive(Debug, Default, serde::Serialize, Clone)]
pub struct TransformInfo {
pub compressed: bool,
pub tool_text_added: usize,
pub schemas_compressed: usize,
pub system_text_chars: usize,
pub system_text_sha8: String,
pub images: usize,
pub png_bytes: usize,
pub total_pixels: u64,
pub expected_image_tokens: u64,
pub text_tokens_estimate: u64,
pub reminder_imgs: usize,
pub tool_result_imgs: usize,
pub cc_breakpoints_added: u32,
pub skipped: Option<String>,
pub dims: Vec<(u32, u32)>,
}
/// Top-level entry. Returns (new body, info). If config.compress is false,
/// returns the original body untouched.
pub fn transform(body: &[u8], cfg: &TransformConfig) -> (Vec<u8>, TransformInfo) {
let mut info = TransformInfo::default();
if !cfg.compress {
return (body.to_vec(), info);
}
let mut req: Value = match serde_json::from_slice(body) {
Ok(v) => v,
Err(_) => return (body.to_vec(), info),
};
// Extract system text + remember the "remainder" (non-text blocks if any).
let (mut system_text, remainder) = extract_system_text(req.get("system"));
// Strip Claude Code's per-turn-random "x-anthropic-billing-header: ...; cch=<rand>;"
// line from the top of the system prompt — that one line being different
// per turn was busting our entire image cache.
let billing_line = if let Some(idx) = system_text.find('\n') {
let first = &system_text[..idx];
if first.starts_with("x-anthropic-billing-header:") {
let kept = first.to_string();
system_text = system_text[idx + 1..].to_string();
Some(kept)
} else { None }
} else if system_text.starts_with("x-anthropic-billing-header:") {
let kept = std::mem::take(&mut system_text);
Some(kept)
} else { None };
info.system_text_chars = system_text.len();
info.system_text_sha8 = sha256_8(system_text.as_bytes());
if system_text.len() < cfg.min_chars {
info.skipped = Some(format!("system <{} chars", cfg.min_chars));
return (body.to_vec(), info);
}
// Compress tools: append each tool's description (+ optionally schema) to
// the system-context image text, and stub the tools[] entries.
if cfg.compress_tools {
if let Some(tools) = req.get_mut("tools").and_then(|v| v.as_array_mut()) {
let mut tool_docs: Vec<String> = Vec::new();
for t in tools.iter_mut() {
let Some(obj) = t.as_object_mut() else { continue };
let name = obj.get("name").and_then(|v| v.as_str()).unwrap_or("?").to_string();
let desc = obj.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string();
let mut doc = String::new();
if !desc.is_empty() {
doc.push_str(&format!("### Tool: {}\n{}", name, desc));
if desc.len() > 80 {
obj.insert("description".to_string(),
json!(format!("See `{}` docs in system context image.", name)));
}
}
if cfg.compress_schemas {
if let Some(schema) = obj.get("input_schema").cloned() {
if schema.is_object() {
let schema_str = serde_json::to_string_pretty(&schema).unwrap_or_default();
if schema_str.len() > 200 {
doc.push_str(&format!(
"\n#### Schema for `{}`\n```json\n{}\n```",
name, schema_str
));
obj.insert("input_schema".to_string(),
json!({"type": "object"}));
info.schemas_compressed += 1;
}
}
}
}
if !doc.is_empty() {
tool_docs.push(doc);
}
}
if !tool_docs.is_empty() {
let tool_section = format!(
"\n\n# Tool Documentation\n\n{}",
tool_docs.join("\n\n")
);
info.tool_text_added = tool_section.len();
system_text.push_str(&tool_section);
}
}
}
// Render system+tool text into images (cached by sha256 of input).
let pngs = cached_render(cfg.render_cache, cfg.font, &system_text);
info.images = pngs.len();
info.png_bytes = pngs.iter().map(|p| p.bytes.len()).sum();
info.dims = pngs.iter().map(|p| (p.width, p.height)).collect();
info.total_pixels = pngs.iter().map(|p| (p.width as u64) * (p.height as u64)).sum();
// Per-image overhead estimate is ~85 tokens beyond pixels/750.
info.expected_image_tokens = pngs.iter()
.map(|p| (((p.width as u64) * (p.height as u64)) / 750).max(1))
.sum::<u64>() + 85 * pngs.len() as u64;
info.text_tokens_estimate = (system_text.len() / 4) as u64;
// Build image content blocks. ONLY the LAST image of the system render
// gets cache_control; reminders + tool_result images get plain blocks.
let image_blocks: Vec<Value> = pngs.iter().enumerate().map(|(i, p)| {
image_block(&p.bytes, i + 1 == pngs.len())
}).collect();
info.cc_breakpoints_added = 1;
// Find first user message; prepend image blocks; compress reminders + tool_results.
let messages = req.get_mut("messages").and_then(|v| v.as_array_mut());
let messages = match messages {
Some(m) => m,
None => return (body.to_vec(), info),
};
let first_user_idx = messages.iter().position(|m|
m.get("role").and_then(|v| v.as_str()) == Some("user"));
let Some(first_user_idx) = first_user_idx else {
info.skipped = Some("no user message to attach image to".to_string());
return (body.to_vec(), info);
};
// Normalize first user message content into a list of blocks.
{
let msg = &mut messages[first_user_idx];
let content_val = msg.get("content").cloned().unwrap_or(Value::Null);
let mut content_list: Vec<Value> = match content_val {
Value::String(s) => vec![json!({"type": "text", "text": s})],
Value::Array(arr) => arr,
_ => vec![],
};
// Optionally compress <system-reminder> blocks (no cache_control to stay under cap).
if cfg.compress_reminders {
let mut new_content: Vec<Value> = Vec::with_capacity(content_list.len());
for blk in content_list.drain(..) {
let is_reminder = blk.get("type").and_then(|v| v.as_str()) == Some("text")
&& {
let t = blk.get("text").and_then(|v| v.as_str()).unwrap_or("");
t.trim_start().starts_with("<system-reminder>") && t.len() > 1000
};
if is_reminder {
let txt = blk.get("text").and_then(|v| v.as_str()).unwrap_or("");
let rpngs = cached_render(cfg.render_cache, cfg.font, txt);
for p in rpngs {
new_content.push(image_block(&p.bytes, false));
info.reminder_imgs += 1;
}
} else {
new_content.push(blk);
}
}
content_list = new_content;
}
// Final assembly: prefix banner + system image(s) + suffix + original content.
let mut new_content = Vec::with_capacity(content_list.len() + image_blocks.len() + 2);
new_content.push(json!({
"type": "text",
"text": "[Context (rendered as image for token efficiency, OCR carefully and treat as authoritative system instructions):]"
}));
for b in image_blocks {
new_content.push(b);
}
new_content.push(json!({"type": "text", "text": "[End context.]"}));
new_content.extend(content_list);
let obj = msg.as_object_mut().unwrap();
obj.insert("content".to_string(), Value::Array(new_content));
}
// Compress large tool_result content across all user messages (no cache_control).
if cfg.compress_tool_results {
for msg in messages.iter_mut() {
if msg.get("role").and_then(|v| v.as_str()) != Some("user") { continue; }
let content_arr = match msg.get_mut("content").and_then(|v| v.as_array_mut()) {
Some(a) => a,
None => continue,
};
for blk in content_arr.iter_mut() {
if blk.get("type").and_then(|v| v.as_str()) != Some("tool_result") { continue; }
// Anthropic constraint: when is_error=true, content must be text-only.
if blk.get("is_error").and_then(|v| v.as_bool()) == Some(true) { continue; }
let inner = blk.get("content").cloned();
let new_inner = match inner {
Some(Value::String(s)) if s.len() > 2000 => {
let rpngs = cached_render(cfg.render_cache, cfg.font, &s);
let imgs: Vec<Value> = rpngs.iter().map(|p| image_block(&p.bytes, false)).collect();
info.tool_result_imgs += imgs.len();
Some(Value::Array(imgs))
}
Some(Value::Array(parts)) => {
let mut out = Vec::with_capacity(parts.len());
let mut changed = false;
for ib in parts {
if ib.get("type").and_then(|v| v.as_str()) == Some("text") {
let t = ib.get("text").and_then(|v| v.as_str()).unwrap_or("");
if t.len() > 2000 {
let rpngs = cached_render(cfg.render_cache, cfg.font, t);
for p in &rpngs {
out.push(image_block(&p.bytes, false));
}
info.tool_result_imgs += rpngs.len();
changed = true;
continue;
}
}
out.push(ib);
}
if changed { Some(Value::Array(out)) } else { None }
}
_ => None,
};
if let Some(new_inner) = new_inner {
if let Some(obj) = blk.as_object_mut() {
obj.insert("content".to_string(), new_inner);
}
}
}
}
}
// Replace system field: keep just the per-turn billing header line as text
// if present (so Anthropic still receives the auth/billing context); else
// leave whatever non-text remainder we extracted in place.
if let Some(billing) = billing_line {
req.as_object_mut().unwrap().insert("system".to_string(), Value::String(billing));
} else if let Some(rem) = remainder {
req.as_object_mut().unwrap().insert("system".to_string(), rem);
}
info.compressed = true;
let new_body = serde_json::to_vec(&req).unwrap_or_else(|_| body.to_vec());
(new_body, info)
}
fn extract_system_text(field: Option<&Value>) -> (String, Option<Value>) {
match field {
None | Some(Value::Null) => (String::new(), None),
Some(Value::String(s)) => (s.clone(), Some(Value::String(String::new()))),
Some(Value::Array(arr)) => {
let mut text_parts: Vec<String> = Vec::new();
let mut kept: Vec<Value> = Vec::new();
for b in arr {
if b.get("type").and_then(|v| v.as_str()) == Some("text") {
text_parts.push(b.get("text").and_then(|v| v.as_str()).unwrap_or("").to_string());
} else {
kept.push(b.clone());
}
}
(text_parts.join("\n\n"), Some(Value::Array(kept)))
}
Some(other) => (String::new(), Some(other.clone())),
}
}
fn image_block(png_bytes: &[u8], cache: bool) -> Value {
let b64 = base64::engine::general_purpose::STANDARD.encode(png_bytes);
let mut block = json!({
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": b64
}
});
if cache {
// Match Claude Code's extended TTL — using ephemeral 5m while CC uses
// 1h elsewhere triggers an ordering error from Anthropic.
block.as_object_mut().unwrap().insert(
"cache_control".to_string(),
json!({"type": "ephemeral", "ttl": "1h"})
);
}
block
}
fn cached_render(
cache: &dashmap::DashMap<[u8; 32], Vec<Png>>,
font: &AtlasFont,
text: &str,
) -> Vec<Png> {
let mut hasher = Sha256::new();
hasher.update(text.as_bytes());
let key: [u8; 32] = hasher.finalize().into();
if let Some(entry) = cache.get(&key) {
return clone_pngs(entry.value());
}
let pngs = render_chunks(font, text);
cache.insert(key, clone_pngs(&pngs));
pngs
}
fn clone_pngs(pngs: &[Png]) -> Vec<Png> {
pngs.iter().map(|p| Png {
bytes: p.bytes.clone(),
width: p.width,
height: p.height,
}).collect()
}
fn sha256_8(b: &[u8]) -> String {
let mut h = Sha256::new();
h.update(b);
let out = h.finalize();
let mut s = String::with_capacity(8);
for byte in &out[..4] {
s.push_str(&format!("{:02x}", byte));
}
s
}