mirror of
https://github.com/teamchong/pxpipe.git
synced 2026-07-22 02:02:51 +02:00
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:
@@ -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()
|
||||
Generated
+2105
File diff suppressed because it is too large
Load Diff
@@ -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.
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod font;
|
||||
pub mod render;
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user