diff --git a/scripts/diff_render.py b/scripts/diff_render.py new file mode 100644 index 0000000..de47c41 --- /dev/null +++ b/scripts/diff_render.py @@ -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() diff --git a/src/rust/Cargo.lock b/src/rust/Cargo.lock new file mode 100644 index 0000000..0c60ddb --- /dev/null +++ b/src/rust/Cargo.lock @@ -0,0 +1,2105 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "axum-macros", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "claude-image-proxy" +version = "0.1.0" +dependencies = [ + "ab_glyph", + "anyhow", + "axum", + "base64", + "bytes", + "clap", + "dashmap", + "once_cell", + "png", + "reqwest", + "serde", + "serde_json", + "sha2", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.11.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/src/rust/Cargo.toml b/src/rust/Cargo.toml new file mode 100644 index 0000000..6540028 --- /dev/null +++ b/src/rust/Cargo.toml @@ -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" diff --git a/src/rust/assets/JetBrainsMono-Regular.ttf b/src/rust/assets/JetBrainsMono-Regular.ttf new file mode 100644 index 0000000..dff66cc Binary files /dev/null and b/src/rust/assets/JetBrainsMono-Regular.ttf differ diff --git a/src/rust/examples/diff_render.rs b/src/rust/examples/diff_render.rs new file mode 100644 index 0000000..ecf6816 --- /dev/null +++ b/src/rust/examples/diff_render.rs @@ -0,0 +1,48 @@ +//! Diff harness companion: read a JSON spec {name: text, ...}, render each via +//! the Rust pipeline, write PNGs to /_.png, then emit +//! /manifest.json mapping name -> [filenames]. +//! +//! Run: cargo run --release --example diff_render -- + +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 "))? + .into(); + let out_dir: PathBuf = args + .next() + .ok_or_else(|| anyhow::anyhow!("usage: diff_render "))? + .into(); + + let spec_bytes = std::fs::read(&spec_path)?; + let spec: BTreeMap = serde_json::from_slice(&spec_bytes)?; + + let font = AtlasFont::load(5.0)?; + + let mut manifest: BTreeMap> = 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(()) +} diff --git a/src/rust/examples/render_sample.rs b/src/rust/examples/render_sample.rs new file mode 100644 index 0000000..749e78b --- /dev/null +++ b/src/rust/examples/render_sample.rs @@ -0,0 +1,23 @@ +//! Render a sample text → PNG so we can visually inspect + OCR-test. +//! Run: cargo run --example render_sample -- [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::>()); + + std::fs::write(&out, &pngs[0].bytes)?; + println!("wrote {} ({} bytes)", out, pngs[0].bytes.len()); + Ok(()) +} diff --git a/src/rust/src/font.rs b/src/rust/src/font.rs new file mode 100644 index 0000000..f1d0d31 --- /dev/null +++ b/src/rust/src/font.rs @@ -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>, + 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 { + 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; + } + } + } + } +} diff --git a/src/rust/src/lib.rs b/src/rust/src/lib.rs new file mode 100644 index 0000000..1ff9b45 --- /dev/null +++ b/src/rust/src/lib.rs @@ -0,0 +1,2 @@ +pub mod font; +pub mod render; diff --git a/src/rust/src/main.rs b/src/rust/src/main.rs new file mode 100644 index 0000000..cd4ac9c --- /dev/null +++ b/src/rust/src/main.rs @@ -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 + 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 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, + pub client: reqwest::Client, + pub render_cache: Arc>>, + pub stats: Arc, + pub font: Arc, +} + +#[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 diff --git a/src/rust/src/proxy.rs b/src/rust/src/proxy.rs new file mode 100644 index 0000000..773dca4 --- /dev/null +++ b/src/rust/src/proxy.rs @@ -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, + 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>); +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 { + self.0.lock().unwrap().iter().cloned().collect() + } +} + +pub async fn serve(port: u16, state: AppState) -> anyhow::Result<()> { + let recent: std::sync::Arc = 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)>, +) -> 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)>, + 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 { + // Try plain JSON first + if let Ok(v) = serde_json::from_slice::(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, 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) +} diff --git a/src/rust/src/render.rs b/src/rust/src/render.rs new file mode 100644 index 0000000..78b4f9c --- /dev/null +++ b/src/rust/src/render.rs @@ -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, + pub width: u32, + pub height: u32, +} + +pub fn render_chunks(font: &AtlasFont, text: &str) -> Vec { + // Minify: rstrip + collapse blank-line runs. + let mut lines_raw: Vec = 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 = 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 = 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 { + let mut out: Vec = 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"); + } + } +} diff --git a/src/rust/src/stats.rs b/src/rust/src/stats.rs new file mode 100644 index 0000000..294607b --- /dev/null +++ b/src/rust/src/stats.rs @@ -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, +} diff --git a/src/rust/src/transform.rs b/src/rust/src/transform.rs new file mode 100644 index 0000000..5cf1ca3 --- /dev/null +++ b/src/rust/src/transform.rs @@ -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>, +} + +#[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, + 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, 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=;" + // 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 = 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::() + 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 = 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 = match content_val { + Value::String(s) => vec![json!({"type": "text", "text": s})], + Value::Array(arr) => arr, + _ => vec![], + }; + + // Optionally compress blocks (no cache_control to stay under cap). + if cfg.compress_reminders { + let mut new_content: Vec = 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("") && 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 = 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) { + 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 = Vec::new(); + let mut kept: Vec = 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>, + font: &AtlasFont, + text: &str, +) -> Vec { + 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 { + 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 +}