diff --git a/Cargo.lock b/Cargo.lock index c2261c95e..f8dd61b8e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -240,6 +240,321 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "aws-config" +version = "1.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50f156acdd2cf55f5aa53ee416c4ac851cf1222694506c0b1f78c85695e9ca9d" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 1.4.0", + "time", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "aws-credential-types" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + +[[package]] +name = "aws-lc-rs" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "aws-runtime" +version = "1.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dcd93c82209ac7413532388067dce79be5a8780c1786e5fae3df22e4dee2864" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http 1.4.0", + "http-body 1.0.1", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.103.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2249b81a2e73a8027c41c378463a81ec39b8510f184f2caab87de912af0f49b" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68dc0b907359b120170613b5c09ccc61304eac3998ff6274b97d93ee6490115a" +dependencies = [ + "aws-credential-types", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "form_urlencoded", + "hex", + "hmac", + "http 0.2.12", + "http 1.4.0", + "percent-encoding", + "sha2 0.11.0", + "time", + "tracing", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-http" +version = "0.63.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a2f165a7feee6f263028b899d0a181987f4fa7179a6411a32a439fba7c5f769" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2", + "http 1.4.0", + "hyper", + "hyper-rustls", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.62.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9648b0bb82a2eedd844052c6ad2a1a822d1f8e3adee5fbf668366717e428856a" +dependencies = [ + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06c2315d173edbf1920da8ba3a7189695827002e4c0fc961973ab1c54abca9c" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a56d79744fb3edb5d722ef79d86081e121d3b9422cb209eb03aea6aa4f21ebd" +dependencies = [ + "aws-smithy-types", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0504b1ab12debb5959e5165ee5fe97dd387e7aa7ea6a477bfd7635dfe769a4f5" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71a13df6ada0aafbf21a73bdfcdf9324cfa9df77d96b8446045be3cde61b42e" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api-macros", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "aws-smithy-types" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d73dbfbaa8e4bc57b9045137680b958d274823509a360abfd8e1d514d40c95c" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bbcaa9304ea40902d3d5f42a0428d1bd895a2b0f6999436fb279ffddc58ac" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "rustc_version", + "tracing", +] + [[package]] name = "axum" version = "0.7.9" @@ -252,8 +567,8 @@ dependencies = [ "base64 0.22.1", "bytes", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-util", @@ -287,8 +602,8 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", @@ -322,6 +637,16 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "base64ct" version = "1.8.3" @@ -387,6 +712,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bstr" version = "1.12.1" @@ -434,6 +768,16 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + [[package]] name = "bytesize" version = "1.3.3" @@ -546,6 +890,21 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" + [[package]] name = "color_quant" version = "1.1.0" @@ -608,6 +967,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -773,6 +1138,24 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "darling" version = "0.20.11" @@ -911,8 +1294,20 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "const-oid", + "crypto-common 0.2.1", + "ctutils", ] [[package]] @@ -956,6 +1351,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "either" version = "1.15.0" @@ -1170,6 +1571,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.32" @@ -1329,7 +1736,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.4.0", "indexmap", "slab", "tokio", @@ -1415,7 +1822,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", "thiserror 1.0.69", "tiktoken-rs", @@ -1441,6 +1848,10 @@ dependencies = [ name = "headroom-proxy" version = "0.1.0" dependencies = [ + "aws-config", + "aws-credential-types", + "aws-sigv4", + "aws-smithy-runtime-api", "axum", "bytes", "bytesize", @@ -1448,7 +1859,7 @@ dependencies = [ "futures", "futures-util", "headroom-core", - "http", + "http 1.4.0", "http-body-util", "humantime", "hyper", @@ -1458,7 +1869,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "thiserror 1.0.69", "tokio", "tokio-stream", @@ -1494,6 +1905,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hf-hub" version = "0.4.3" @@ -1501,7 +1918,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "629d8f3bbeda9d148036d6b0de0a3ab947abd08ce90626327fc3547a49d59d97" dependencies = [ "dirs", - "http", + "http 1.4.0", "indicatif 0.17.11", "libc", "log", @@ -1521,7 +1938,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aef3982638978efa195ff11b305f51f1f22f4f0a6cabee7af79b383ebee6a213" dependencies = [ "dirs", - "http", + "http 1.4.0", "indicatif 0.18.4", "libc", "log", @@ -1535,12 +1952,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "hmac-sha256" version = "1.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f" +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.0" @@ -1551,6 +1988,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -1558,7 +2006,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -1569,8 +2017,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -1592,6 +2040,15 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +[[package]] +name = "hybrid-array" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.9.0" @@ -1603,8 +2060,8 @@ dependencies = [ "futures-channel", "futures-core", "h2", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -1620,10 +2077,11 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http", + "http 1.4.0", "hyper", "hyper-util", "rustls", + "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", @@ -1656,8 +2114,8 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "hyper", "ipnet", "libc", @@ -2150,7 +2608,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", ] [[package]] @@ -2509,6 +2967,12 @@ dependencies = [ "ureq 3.3.0", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "parking_lot_core" version = "0.9.12" @@ -2555,6 +3019,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "pkg-config" version = "0.3.33" @@ -3078,6 +3548,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.10" @@ -3096,8 +3572,8 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-rustls", @@ -3177,6 +3653,15 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.4" @@ -3196,6 +3681,7 @@ version = "0.23.39" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" dependencies = [ + "aws-lc-rs", "log", "once_cell", "ring", @@ -3205,6 +3691,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -3221,6 +3719,7 @@ version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -3398,7 +3897,7 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", ] [[package]] @@ -3409,7 +3908,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -3923,8 +4433,8 @@ dependencies = [ "bitflags", "bytes", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "iri-string", "pin-project-lite", "tower", @@ -4036,7 +4546,7 @@ dependencies = [ "byteorder", "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand 0.8.6", @@ -4176,7 +4686,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" dependencies = [ "base64 0.22.1", - "http", + "http 1.4.0", "httparse", "log", ] @@ -4193,6 +4703,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" @@ -4257,6 +4773,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -4714,7 +5236,7 @@ dependencies = [ "base64 0.22.1", "deadpool", "futures", - "http", + "http 1.4.0", "http-body-util", "hyper", "hyper-util", @@ -4827,6 +5349,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + [[package]] name = "y4m" version = "0.8.0" diff --git a/Cargo.toml b/Cargo.toml index 4b472ef6d..98a4d251a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,3 +56,15 @@ axum = "0.7" tower = "0.5" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } pyo3 = "0.22" +# Phase D PR-D1: AWS SigV4 signing for native Bedrock InvokeModel route. +# `aws-sigv4` provides the canonical-request + signing-key implementation; +# `aws-config` resolves credentials from the standard provider chain +# (env vars, profiles, IMDS, ECS task role, etc); `aws-credential-types` +# exposes `Credentials` so the signer accepts whatever the chain returned. +aws-sigv4 = { version = "1", default-features = false, features = ["sign-http", "http1"] } +aws-config = { version = "1", default-features = false, features = ["behavior-version-latest", "rustls", "rt-tokio"] } +aws-credential-types = { version = "1", default-features = false } +# `Identity` lives in aws-smithy-runtime-api; the SigV4 builder +# accepts `&Identity`. Pinning the version explicitly avoids a +# silent semver bump from the transitive dep tree. +aws-smithy-runtime-api = { version = "1", default-features = false, features = ["client"] } diff --git a/crates/headroom-proxy/Cargo.toml b/crates/headroom-proxy/Cargo.toml index 61e507661..ff0c21358 100644 --- a/crates/headroom-proxy/Cargo.toml +++ b/crates/headroom-proxy/Cargo.toml @@ -41,6 +41,12 @@ humantime = "2" bytesize = "1" tokio-util = { version = "0.7" } headroom-core = { path = "../headroom-core" } +# Phase D PR-D1: native Bedrock InvokeModel route. SigV4 + AWS +# default credential chain. +aws-sigv4 = { workspace = true } +aws-config = { workspace = true } +aws-credential-types = { workspace = true } +aws-smithy-runtime-api = { workspace = true } [dev-dependencies] tower = { workspace = true, features = ["util"] } diff --git a/crates/headroom-proxy/src/bedrock/envelope.rs b/crates/headroom-proxy/src/bedrock/envelope.rs new file mode 100644 index 000000000..3f2b9a26f --- /dev/null +++ b/crates/headroom-proxy/src/bedrock/envelope.rs @@ -0,0 +1,239 @@ +//! Bedrock envelope: parse the body shape Bedrock expects, hand the +//! Anthropic-shape sub-body to the compressor, and re-emit with +//! `anthropic_version` preserved as the FIRST key. +//! +//! # Wire shape +//! +//! Bedrock InvokeModel for any `anthropic.claude-*` model expects: +//! +//! ```json +//! { +//! "anthropic_version": "bedrock-2023-05-31", +//! "messages": [...], +//! "max_tokens": 1024, +//! ...rest_of_anthropic_body +//! } +//! ``` +//! +//! `model` is in the URL path (`/model/{model}/invoke`), NOT the body +//! — the opposite of direct-Anthropic `/v1/messages`. `anthropic_version` +//! is REQUIRED and must be a literal Bedrock-recognized version +//! string (e.g. `"bedrock-2023-05-31"`). +//! +//! # Why key order matters +//! +//! Bedrock's request validator does NOT depend on key order, but our +//! cache-safety contract (`I1` in REALIGNMENT/02-architecture.md) is +//! that *unmodified* bytes round-trip byte-equal. If the compressor +//! returns `NoChange` we forward the original buffered bytes +//! verbatim — we never re-serialize. If the compressor returns +//! `Modified`, the byte slice it produced already preserves +//! key order from the input via the `preserve_order` feature on +//! `serde_json` (the workspace turns this on by default). +//! +//! This module's [`BedrockEnvelope`] is used ONLY for re-emitting +//! the envelope shape after compression. It calls the compressor +//! against the body bytes directly — there is no decode-then-encode +//! round trip on the no-change path. + +use bytes::Bytes; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; + +/// The literal `anthropic_version` field name. Bedrock-strict. +const ANTHROPIC_VERSION_KEY: &str = "anthropic_version"; + +/// Parsed Bedrock InvokeModel envelope. Holds the literal +/// `anthropic_version` string plus the rest of the body as a parsed +/// `serde_json::Value` (so callers can inspect / route it). The +/// original byte-equal body is also stashed for the no-change +/// passthrough path. +#[derive(Debug, Clone)] +pub struct BedrockEnvelope { + /// The literal `anthropic_version` string, e.g. + /// `"bedrock-2023-05-31"`. Required. + pub anthropic_version: String, + /// The full parsed body, including `anthropic_version`. Useful + /// for tests + logs; the compressor takes raw bytes. + pub body: Value, +} + +/// Errors surfaced when parsing a Bedrock envelope. +#[derive(Debug, Error)] +pub enum EnvelopeError { + /// Body was not valid JSON. + #[error("body is not valid JSON: {0}")] + NotJson(serde_json::Error), + /// Body was JSON but not a top-level object. + #[error("body is not a JSON object")] + NotObject, + /// `anthropic_version` field missing. + #[error("missing required `anthropic_version` field")] + MissingAnthropicVersion, + /// `anthropic_version` was present but not a string. + #[error("`anthropic_version` is not a string")] + AnthropicVersionNotString, +} + +impl BedrockEnvelope { + /// Parse a Bedrock envelope from raw JSON bytes. + /// + /// This does NOT mutate the bytes. Compression dispatch happens + /// elsewhere (the handler hands the same byte slice to + /// `compress_anthropic_request`). + pub fn parse(body: &[u8]) -> Result { + let value: Value = serde_json::from_slice(body).map_err(EnvelopeError::NotJson)?; + let obj = value.as_object().ok_or(EnvelopeError::NotObject)?; + let av = obj + .get(ANTHROPIC_VERSION_KEY) + .ok_or(EnvelopeError::MissingAnthropicVersion)?; + let av_str = av + .as_str() + .ok_or(EnvelopeError::AnthropicVersionNotString)? + .to_string(); + Ok(Self { + anthropic_version: av_str, + body: value, + }) + } + + /// Re-emit the envelope as JSON bytes with `anthropic_version` + /// preserved as the FIRST key. + /// + /// Used only after the compressor returns `Modified` and we need + /// to reassemble the body. With `serde_json`'s `preserve_order` + /// feature (workspace default), the parsed object already preserves + /// insertion order; we just need to make sure `anthropic_version` + /// is the first key. If the compressor's output already has it + /// first (which it will — the compressor only mutates the + /// `messages` content slot, not key ordering), we hand the bytes + /// back unchanged. + /// + /// `body` is the (possibly-compressed) JSON bytes returned by the + /// compression dispatcher. + /// + /// Returns `Ok(bytes)` on success. On any structural error, returns + /// the input bytes unchanged so the byte-fidelity contract is + /// preserved (the caller will surface the error). + pub fn ensure_anthropic_version_first(body: &[u8]) -> Result { + // Parse with preserve_order (workspace serde_json default). + let mut value: Value = serde_json::from_slice(body).map_err(EnvelopeError::NotJson)?; + let map = value.as_object_mut().ok_or(EnvelopeError::NotObject)?; + + // If `anthropic_version` is missing, that's a logical error + // for the Bedrock surface — surface it loudly. + if !map.contains_key(ANTHROPIC_VERSION_KEY) { + return Err(EnvelopeError::MissingAnthropicVersion); + } + + // Already first? Then `body` round-trips byte-equal — no work. + // Note: serde_json::Map iteration order, with preserve_order, + // is insertion order. We check the first key directly. + if map + .keys() + .next() + .map(|k| k.as_str() == ANTHROPIC_VERSION_KEY) + .unwrap_or(false) + { + return Ok(Bytes::copy_from_slice(body)); + } + + // Reorder: pull anthropic_version, drain rest, rebuild with + // anthropic_version first. This only runs when the compressor + // moved the field (it shouldn't, but if it ever does we + // reassert the invariant rather than ship a Bedrock-rejecting + // body). + let av = map + .remove(ANTHROPIC_VERSION_KEY) + .expect("contains_key guard above"); + let mut new_map = serde_json::Map::with_capacity(map.len() + 1); + new_map.insert(ANTHROPIC_VERSION_KEY.to_string(), av); + for (k, v) in std::mem::take(map) { + new_map.insert(k, v); + } + let rebuilt = Value::Object(new_map); + let bytes = serde_json::to_vec(&rebuilt).map_err(EnvelopeError::NotJson)?; + Ok(Bytes::from(bytes)) + } +} + +/// Helper for reading just the model id from a Bedrock URL path. +/// +/// Bedrock paths look like `/model/{model_id}/invoke`. The `{model_id}` +/// segment can contain dots (`anthropic.claude-3-haiku-20240307-v1:0`), +/// hyphens, colons, and digits — all of which are allowed in URL path +/// segments. We use Axum's `:model_id` capture; this helper exists for +/// callers who only have the raw path. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelPath { + pub model_id: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parses_minimal_envelope() { + let body = json!({ + "anthropic_version": "bedrock-2023-05-31", + "max_tokens": 16, + "messages": [{"role": "user", "content": "hi"}] + }); + let bytes = serde_json::to_vec(&body).unwrap(); + let env = BedrockEnvelope::parse(&bytes).expect("parses"); + assert_eq!(env.anthropic_version, "bedrock-2023-05-31"); + } + + #[test] + fn missing_anthropic_version_errors() { + let body = json!({"max_tokens": 16, "messages": []}); + let bytes = serde_json::to_vec(&body).unwrap(); + let err = BedrockEnvelope::parse(&bytes).expect_err("must error"); + assert!(matches!(err, EnvelopeError::MissingAnthropicVersion)); + } + + #[test] + fn anthropic_version_not_string_errors() { + let body = json!({"anthropic_version": 123, "max_tokens": 16}); + let bytes = serde_json::to_vec(&body).unwrap(); + let err = BedrockEnvelope::parse(&bytes).expect_err("must error"); + assert!(matches!(err, EnvelopeError::AnthropicVersionNotString)); + } + + #[test] + fn not_an_object_errors() { + let bytes = b"[1,2,3]"; + let err = BedrockEnvelope::parse(bytes).expect_err("must error"); + assert!(matches!(err, EnvelopeError::NotObject)); + } + + #[test] + fn invalid_json_errors() { + let bytes = b"not json"; + let err = BedrockEnvelope::parse(bytes).expect_err("must error"); + assert!(matches!(err, EnvelopeError::NotJson(_))); + } + + #[test] + fn ensure_first_no_op_when_already_first() { + let body = br#"{"anthropic_version":"bedrock-2023-05-31","max_tokens":16}"#; + let out = BedrockEnvelope::ensure_anthropic_version_first(body).unwrap(); + assert_eq!(&out[..], &body[..]); + } + + #[test] + fn ensure_first_reorders_when_not_first() { + // anthropic_version comes second. + let body = br#"{"max_tokens":16,"anthropic_version":"bedrock-2023-05-31"}"#; + let out = BedrockEnvelope::ensure_anthropic_version_first(body).unwrap(); + let out_str = std::str::from_utf8(&out).unwrap(); + // First key after `{"` must be `anthropic_version`. + assert!( + out_str.starts_with(r#"{"anthropic_version":"bedrock-2023-05-31""#), + "got {out_str}" + ); + } +} diff --git a/crates/headroom-proxy/src/bedrock/invoke.rs b/crates/headroom-proxy/src/bedrock/invoke.rs new file mode 100644 index 000000000..afa03b8f1 --- /dev/null +++ b/crates/headroom-proxy/src/bedrock/invoke.rs @@ -0,0 +1,555 @@ +//! POST `/model/{model_id}/invoke` handler — Phase D PR-D1. +//! +//! # Pipeline +//! +//! 1. Extract `{model_id}` from the path. The Bedrock convention is +//! `anthropic.claude-3-haiku-20240307-v1:0` — dot-separated +//! `.--`. +//! 2. If the vendor is `anthropic`, parse the body as a Bedrock +//! envelope (`{"anthropic_version": "...", ...rest}`) and run the +//! live-zone Anthropic compression dispatcher over the body bytes. +//! The dispatcher is the SAME one `/v1/messages` uses; Bedrock's +//! body shape is just Anthropic-without-the-`model`-field. +//! 3. Re-emit the (possibly compressed) body with `anthropic_version` +//! preserved as the first key. +//! 4. Build the upstream URL (`https://bedrock-runtime.{region}.amazonaws.com/model/{model}/invoke` +//! or operator override). +//! 5. Sign the (post-compression) body bytes with AWS SigV4. Sign +//! over `host`, `x-amz-date`, `x-amz-content-sha256` plus any +//! extra headers (`content-type`, `accept`). +//! 6. Forward to Bedrock; stream the response back to the client. +//! +//! # Failure modes +//! +//! - **Missing credentials**: log +//! `event=bedrock_credentials_missing` at WARN, return `500` with +//! a JSON error body. NEVER forwards an unsigned request. +//! - **Envelope parse failure**: log `event=bedrock_envelope_parse_error`, +//! pass the bytes through unchanged. Bedrock will reject anyway, +//! but the failure is the customer's, not ours — we just route. +//! This matches the Anthropic compression path's +//! `Outcome::Passthrough` behaviour. +//! - **Non-anthropic model**: skip compression, but still sign + +//! forward. Other vendors (Amazon Titan, Cohere, AI21, Meta) have +//! different body shapes that the proxy doesn't yet understand; +//! we pass them through opaquely. +//! - **SigV4 signing failure**: log +//! `event=bedrock_sigv4_failed`, return `500`. NEVER forwards +//! unsigned. + +use std::net::SocketAddr; +use std::time::SystemTime; + +use axum::body::Body; +use axum::extract::{ConnectInfo, Path, State}; +use axum::http::{HeaderMap, Method, StatusCode, Uri}; +use axum::response::{IntoResponse, Response}; +use bytes::Bytes; +use futures_util::StreamExt as _; +use http::HeaderName; +use url::Url; + +use crate::bedrock::envelope::BedrockEnvelope; +use crate::bedrock::sigv4::{sign_request, SigningInputs}; +use crate::compression::{ + compress_anthropic_request, Outcome as AnthropicOutcome, PassthroughReason, +}; +use crate::headers::filter_response_headers; +use crate::proxy::AppState; + +/// Anthropic vendor prefix as encoded in Bedrock model ids +/// (`anthropic.claude-3-haiku-...`). Literal-match per project rule +/// "no regexes for parsing the model ID". +const ANTHROPIC_VENDOR_PREFIX: &str = "anthropic."; + +/// AWS Bedrock Runtime DNS template. The `{}` placeholder is the +/// region. Only used when `Config::bedrock_endpoint` is `None`. +const BEDROCK_RUNTIME_HOST_TEMPLATE: &str = "bedrock-runtime.{region}.amazonaws.com"; + +/// Axum POST handler for `/model/{model_id}/invoke`. +/// +/// Buffers the body so the live-zone compressor + SigV4 signer can +/// inspect it. Both are required to be applied to the SAME byte slice +/// — the signer hashes whatever the forwarder will actually send. +pub async fn handle_invoke( + State(state): State, + ConnectInfo(client_addr): ConnectInfo, + Path(model_id): Path, + method: Method, + uri: Uri, + headers: HeaderMap, + body: Bytes, +) -> Response { + let _ = client_addr; // accepted for ConnectInfo extractor; not used directly today + let request_id = headers + .get("x-request-id") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + + tracing::info!( + event = "bedrock_invoke_received", + request_id = %request_id, + method = %method, + model_id = %model_id, + body_bytes = body.len(), + "bedrock invoke route received request" + ); + + let is_anthropic = model_id.starts_with(ANTHROPIC_VENDOR_PREFIX); + let outbound_body: Bytes = if is_anthropic { + run_anthropic_compression(&body, &state, &request_id) + } else { + tracing::info!( + event = "bedrock_compression_skipped", + request_id = %request_id, + model_id = %model_id, + reason = "non_anthropic_vendor", + "bedrock invoke: skipping live-zone compression for non-anthropic vendor" + ); + body.clone() + }; + + // Build the upstream URL based on configured endpoint or + // region-derived default. + let upstream_url = match build_bedrock_upstream(&state, &model_id, &uri, "invoke") { + Ok(u) => u, + Err(msg) => { + tracing::error!( + event = "bedrock_endpoint_invalid", + request_id = %request_id, + error = %msg, + "bedrock invoke: failed to construct upstream URL" + ); + return error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "bedrock_endpoint_invalid", + &msg, + ); + } + }; + + // Resolve credentials. No silent fallback: missing creds → 5xx. + let creds = match state.bedrock_credentials.as_ref() { + Some(c) => c.clone(), + None => { + tracing::warn!( + event = "bedrock_credentials_missing", + request_id = %request_id, + model_id = %model_id, + "bedrock invoke: refusing to forward without AWS credentials" + ); + return error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "bedrock_credentials_missing", + "AWS credentials not configured; refusing to forward unsigned", + ); + } + }; + + // Build the headers we sign + forward. Start from the inbound + // headers, drop the ones the upstream client manages, then sign. + let extra_signed: Vec<(String, String)> = collect_signed_headers(&headers, &upstream_url); + let extra_signed_refs: Vec<(&str, &str)> = extra_signed + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + + let sign_inputs = SigningInputs { + method: method.as_str(), + url: &upstream_url, + region: &state.config.bedrock_region, + credentials: creds.as_ref(), + body: &outbound_body, + extra_signed_headers: &extra_signed_refs, + time: SystemTime::now(), + }; + let signed = match sign_request(&sign_inputs) { + Ok(s) => s, + Err(e) => { + tracing::error!( + event = "bedrock_sigv4_failed", + request_id = %request_id, + model_id = %model_id, + error = %e, + "bedrock invoke: SigV4 signing failed; refusing to forward" + ); + return error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "bedrock_sigv4_failed", + &e.to_string(), + ); + } + }; + + // Compose the outgoing header map. Start with the headers we'll + // forward (filter out hop-by-hop / Host / Content-Length; + // reqwest sets those itself), then layer the SigV4 outputs on + // top — they replace any pre-existing copies of the same name. + let mut outbound_headers = HeaderMap::new(); + for (name, value) in extra_signed.iter() { + if let (Ok(n), Ok(v)) = ( + HeaderName::from_bytes(name.as_bytes()), + http::HeaderValue::from_str(value), + ) { + outbound_headers.insert(n, v); + } + } + for (name, value) in signed.entries.iter() { + if let (Ok(n), Ok(v)) = ( + HeaderName::from_bytes(name.as_bytes()), + http::HeaderValue::from_str(value), + ) { + outbound_headers.insert(n, v); + } + } + + // Forward. We surface upstream errors as 502; the byte path + // streams the response back to the client. + let reqwest_method = match reqwest::Method::from_bytes(method.as_str().as_bytes()) { + Ok(m) => m, + Err(e) => { + tracing::error!( + event = "bedrock_invalid_method", + request_id = %request_id, + error = %e, + "bedrock invoke: invalid HTTP method" + ); + return error_response( + StatusCode::BAD_REQUEST, + "bedrock_invalid_method", + &e.to_string(), + ); + } + }; + + let upstream_resp = state + .client + .request(reqwest_method, upstream_url.clone()) + .headers(outbound_headers) + .body(outbound_body.clone()) + .send() + .await; + + let upstream_resp = match upstream_resp { + Ok(r) => r, + Err(e) => { + tracing::warn!( + event = "bedrock_upstream_error", + request_id = %request_id, + error = %e, + "bedrock invoke: upstream request failed" + ); + let status = if e.is_timeout() { + StatusCode::GATEWAY_TIMEOUT + } else { + StatusCode::BAD_GATEWAY + }; + return error_response(status, "bedrock_upstream_error", &e.to_string()); + } + }; + + let status = + StatusCode::from_u16(upstream_resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); + let resp_headers = filter_response_headers(upstream_resp.headers()); + + tracing::info!( + event = "bedrock_invoke_forwarded", + request_id = %request_id, + model_id = %model_id, + upstream_status = status.as_u16(), + upstream_url = %upstream_url, + "bedrock invoke: response forwarded" + ); + + // Stream the response body back without buffering. + let stream = upstream_resp + .bytes_stream() + .map(|r| r.map_err(std::io::Error::other)); + let body_out = Body::from_stream(stream); + + let mut builder = Response::builder().status(status); + if let Some(h) = builder.headers_mut() { + h.extend(resp_headers); + if let Ok(v) = http::HeaderValue::from_str(&request_id) { + h.insert(HeaderName::from_static("x-request-id"), v); + } + } + builder.body(body_out).unwrap_or_else(|e| { + tracing::error!( + event = "bedrock_response_build_failed", + request_id = %request_id, + error = %e, + "bedrock invoke: failed to build response" + ); + Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(Body::from("internal handler error")) + .expect("static response") + }) +} + +/// Run the live-zone Anthropic compressor over a Bedrock-shape body. +/// +/// The compressor only inspects `messages` — it doesn't care that the +/// Bedrock body has `anthropic_version` instead of `model`. The +/// `Outcome::Compressed` body bytes still preserve key order via +/// `serde_json`'s `preserve_order` feature, so the caller's +/// re-emission step (`ensure_anthropic_version_first`) almost always +/// no-ops. We still call it as a defence-in-depth assertion that +/// the byte order is correct before signing. +fn run_anthropic_compression(body: &Bytes, state: &AppState, request_id: &str) -> Bytes { + // Validate envelope shape. If the body isn't a valid Bedrock + // envelope we still forward verbatim — the compressor would have + // refused too — but log loudly. + if let Err(e) = BedrockEnvelope::parse(body) { + tracing::warn!( + event = "bedrock_envelope_parse_error", + request_id = %request_id, + error = %e, + "bedrock invoke: envelope parse failed; passing body through unchanged" + ); + return body.clone(); + } + tracing::info!( + event = "bedrock_envelope_parsed", + request_id = %request_id, + body_bytes = body.len(), + "bedrock invoke: envelope validated; dispatching to live-zone compressor" + ); + + let outcome = compress_anthropic_request( + body, + state.config.compression_mode, + state.config.cache_control_auto_frozen, + request_id, + ); + match outcome { + AnthropicOutcome::NoCompression => body.clone(), + AnthropicOutcome::Passthrough { reason } => { + tracing::info!( + event = "bedrock_compression_passthrough", + request_id = %request_id, + reason = ?reason, + "bedrock invoke: live-zone dispatcher fell through to passthrough" + ); + // The compressor's passthrough variants all leave bytes + // unchanged. Forward the original. + let _ = (PassthroughReason::ModeOff, PassthroughReason::NoMessages); // pin types + body.clone() + } + AnthropicOutcome::Compressed { body: new_body, .. } => { + // Defence-in-depth: re-emit so anthropic_version is the + // first key. With preserve_order this is a no-op on the + // happy path. + match BedrockEnvelope::ensure_anthropic_version_first(&new_body) { + Ok(b) => b, + Err(e) => { + tracing::error!( + event = "bedrock_envelope_reemit_failed", + request_id = %request_id, + error = %e, + "bedrock invoke: failed to re-emit envelope; falling back to original body" + ); + body.clone() + } + } + } + } +} + +/// Build the upstream URL for the Bedrock route. Honours the +/// operator-supplied `bedrock_endpoint` first, falling back to the +/// region-derived default. The path/query portion is taken from the +/// original URI verbatim — Bedrock's path schema (`/model/{id}/{action}`) +/// is identical to the proxy's external path. +fn build_bedrock_upstream( + state: &AppState, + model_id: &str, + uri: &Uri, + action: &str, +) -> Result { + let base = match state.config.bedrock_endpoint.as_ref() { + Some(u) => u.clone(), + None => { + let host = + BEDROCK_RUNTIME_HOST_TEMPLATE.replace("{region}", &state.config.bedrock_region); + Url::parse(&format!("https://{host}/")) + .map_err(|e| format!("bedrock derived base URL parse error: {e}"))? + } + }; + // Compose the path. We trust the captured `model_id` (Axum + // already URL-decoded it) and append `/{action}`. + let path = format!( + "/model/{model_id}/{action}", + model_id = model_id, + action = action, + ); + let mut joined = base; + joined.set_path(&path); + if let Some(q) = uri.query() { + joined.set_query(Some(q)); + } + Ok(joined) +} + +/// Build the list of headers to sign + forward. Drops hop-by-hop, +/// `host`, `content-length` (reqwest manages those), `authorization` +/// (we replace it with the SigV4 output). Lower-cases names for +/// canonical-request consistency. +fn collect_signed_headers(headers: &HeaderMap, upstream_url: &Url) -> Vec<(String, String)> { + let mut out: Vec<(String, String)> = Vec::with_capacity(headers.len() + 1); + for (name, value) in headers.iter() { + let n = name.as_str().to_ascii_lowercase(); + if matches!( + n.as_str(), + "host" + | "content-length" + | "connection" + | "keep-alive" + | "proxy-authenticate" + | "proxy-authorization" + | "te" + | "trailers" + | "transfer-encoding" + | "upgrade" + | "authorization" + | "x-amz-date" + | "x-amz-content-sha256" + ) { + // Drop client-managed + signer-managed headers. + continue; + } + if n.starts_with("x-headroom-") { + // Internal headers are stripped from upstream traffic + // (PR-A5). The Bedrock route inherits the same default. + continue; + } + if let Ok(v) = value.to_str() { + out.push((n, v.to_string())); + } + } + // Signer requires `host` in the canonical request. Add it + // explicitly from the upstream URL — the inbound `host` header + // (the proxy's listening hostname) is wrong for the canonical + // request. + if let Some(host) = upstream_url.host_str() { + let host_value = match upstream_url.port() { + Some(p) => format!("{host}:{p}"), + None => host.to_string(), + }; + out.push(("host".to_string(), host_value)); + } + out +} + +fn error_response(status: StatusCode, event: &str, msg: &str) -> Response { + let body = serde_json::json!({ + "error": { + "type": event, + "message": msg, + } + }) + .to_string(); + let _ = event; + let mut resp = Response::builder() + .status(status) + .body(Body::from(body)) + .expect("static error response"); + resp.headers_mut().insert( + http::header::CONTENT_TYPE, + http::HeaderValue::from_static("application/json"), + ); + resp.into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn anthropic_vendor_prefix_match() { + assert!("anthropic.claude-3-haiku-20240307-v1:0".starts_with(ANTHROPIC_VENDOR_PREFIX)); + assert!("anthropic.claude-3-5-sonnet-20241022-v2:0".starts_with(ANTHROPIC_VENDOR_PREFIX)); + assert!(!"amazon.titan-text-express-v1".starts_with(ANTHROPIC_VENDOR_PREFIX)); + assert!(!"meta.llama3-70b-instruct-v1:0".starts_with(ANTHROPIC_VENDOR_PREFIX)); + } + + #[test] + fn build_upstream_uses_region_default() { + use crate::config::Config; + let mut config = Config::for_test(Url::parse("http://up:8080").unwrap()); + config.bedrock_region = "us-west-2".to_string(); + let state = AppState { + config: std::sync::Arc::new(config), + client: reqwest::Client::new(), + bedrock_credentials: None, + }; + let uri: Uri = "/model/anthropic.claude-3-haiku-20240307-v1:0/invoke" + .parse() + .unwrap(); + let url = build_bedrock_upstream( + &state, + "anthropic.claude-3-haiku-20240307-v1:0", + &uri, + "invoke", + ) + .unwrap(); + assert_eq!( + url.as_str(), + "https://bedrock-runtime.us-west-2.amazonaws.com/model/anthropic.claude-3-haiku-20240307-v1:0/invoke" + ); + } + + #[test] + fn build_upstream_honors_explicit_endpoint() { + use crate::config::Config; + let mut config = Config::for_test(Url::parse("http://up:8080").unwrap()); + config.bedrock_endpoint = Some(Url::parse("http://127.0.0.1:9999").unwrap()); + let state = AppState { + config: std::sync::Arc::new(config), + client: reqwest::Client::new(), + bedrock_credentials: None, + }; + let uri: Uri = "/model/anthropic.claude-3-haiku-20240307-v1:0/invoke" + .parse() + .unwrap(); + let url = build_bedrock_upstream( + &state, + "anthropic.claude-3-haiku-20240307-v1:0", + &uri, + "invoke", + ) + .unwrap(); + assert_eq!( + url.as_str(), + "http://127.0.0.1:9999/model/anthropic.claude-3-haiku-20240307-v1:0/invoke" + ); + } + + #[test] + fn collect_signed_headers_strips_client_managed() { + let mut headers = HeaderMap::new(); + headers.insert("content-type", "application/json".parse().unwrap()); + headers.insert("host", "proxy.example".parse().unwrap()); + headers.insert("authorization", "Bearer x".parse().unwrap()); + headers.insert("x-headroom-mode", "live".parse().unwrap()); + headers.insert("accept", "application/json".parse().unwrap()); + let upstream = + Url::parse("https://bedrock-runtime.us-east-1.amazonaws.com/model/x/invoke").unwrap(); + let out = collect_signed_headers(&headers, &upstream); + let names: Vec<&str> = out.iter().map(|(k, _)| k.as_str()).collect(); + assert!(names.contains(&"content-type")); + assert!(names.contains(&"accept")); + assert!(names.contains(&"host")); + assert!(!names.contains(&"authorization")); + assert!(!names.contains(&"x-headroom-mode")); + // host must be the upstream host, not the proxy host. + let host = out + .iter() + .find(|(k, _)| k == "host") + .map(|(_, v)| v.as_str()) + .unwrap(); + assert_eq!(host, "bedrock-runtime.us-east-1.amazonaws.com"); + } +} diff --git a/crates/headroom-proxy/src/bedrock/mod.rs b/crates/headroom-proxy/src/bedrock/mod.rs new file mode 100644 index 000000000..cf0be6cac --- /dev/null +++ b/crates/headroom-proxy/src/bedrock/mod.rs @@ -0,0 +1,52 @@ +//! Native AWS Bedrock InvokeModel route — Phase D PR-D1. +//! +//! # Why a separate module? +//! +//! The Python proxy currently routes Anthropic-on-Bedrock through the +//! `litellm` shim (`headroom/backends/litellm.py`). That shim +//! lossy-converts every request and response between Anthropic and +//! OpenAI shapes, dropping `thinking`, `redacted_thinking`, +//! `document`, `search_result`, `image`, `server_tool_use`, and +//! `mcp_tool_use` blocks (P4-37). It also hardcodes +//! `stop_sequence: null` (§11.1 violation) and re-wraps +//! `function_call.arguments` as a parsed JSON object (§4.4 — P4-43). +//! +//! Phase D rebuilds the Bedrock surface natively in Rust. PR-D1 +//! handles the **non-streaming** `POST /model/{model}/invoke` route: +//! +//! 1. Parse the Bedrock envelope (`{"anthropic_version": "...", +//! ...rest_of_anthropic_body}`). +//! 2. Route Anthropic-shape bodies through the live-zone compression +//! path (the same one `/v1/messages` uses). +//! 3. Re-emit the envelope with `anthropic_version` preserved as the +//! first key — Bedrock is strict about schema validation. +//! 4. Sign the **outgoing** body bytes with AWS SigV4 (after +//! compression) and forward to the configured Bedrock endpoint. +//! +//! # Cache safety +//! +//! The signed bytes are exactly the bytes Bedrock receives. If the +//! compressor mutated the body, the SigV4 signature is computed +//! against the post-compression bytes; the upstream verifier will +//! accept them. There is no "sign before compress" path — that would +//! produce a signature that doesn't match the wire payload. +//! +//! # Module layout +//! +//! - [`envelope`] — `BedrockEnvelope` parse + emit (preserves +//! `anthropic_version` ordering byte-equal). +//! - [`sigv4`] — AWS SigV4 signing helper. Wraps the `aws-sigv4` +//! crate with the project's no-fallback / structured-logging +//! policy. +//! - [`invoke`] — POST handler for `/model/{model}/invoke`. +//! +//! Streaming (`/model/{model}/invoke-with-response-stream`) is +//! Phase D PR-D2. + +pub mod envelope; +pub mod invoke; +pub mod sigv4; + +pub use envelope::{BedrockEnvelope, EnvelopeError}; +pub use invoke::handle_invoke; +pub use sigv4::{sign_request, SigV4Error, SigningInputs}; diff --git a/crates/headroom-proxy/src/bedrock/sigv4.rs b/crates/headroom-proxy/src/bedrock/sigv4.rs new file mode 100644 index 000000000..6fc23c5cf --- /dev/null +++ b/crates/headroom-proxy/src/bedrock/sigv4.rs @@ -0,0 +1,324 @@ +//! AWS SigV4 request signing for the Bedrock InvokeModel route. +//! +//! # What this module does +//! +//! Wraps the `aws-sigv4` crate with the project's policies: +//! +//! - **No silent fallbacks.** A failed sign-attempt returns a +//! structured error; the handler surfaces 5xx and logs an event. +//! We NEVER forward an unsigned request to Bedrock, because the +//! AWS endpoint will reject anyway and the user would see an +//! opaque 403. +//! - **Body bytes are hashed AFTER compression.** The signing +//! inputs include a `&[u8]` that is the EXACT byte slice the +//! forwarder will send upstream. There is no separate "hash the +//! pre-compression body" code path. +//! - **Structured logs at every decision point.** Every code path +//! emits `tracing::info!`/`warn!` with an `event = ...` field so +//! operators can confirm signing happened. +//! +//! # What this module deliberately does NOT do +//! +//! - It does not resolve credentials — that's `aws-config`'s job and +//! happens at app-startup time so per-request signing is cheap. +//! The handler holds the resolved [`aws_credential_types::Credentials`] +//! in `AppState` and passes them in. +//! - It does not buffer the body — callers buffer the body in the +//! compression gate (it's the same byte slice). +//! - It does not handle SigV4a (the cross-region variant). Bedrock +//! uses standard SigV4 per region. + +use std::time::SystemTime; + +use aws_credential_types::Credentials; +use aws_sigv4::http_request::{ + sign, PayloadChecksumKind, SignableBody, SignableRequest, SigningSettings, +}; +use aws_sigv4::sign::v4; +use aws_smithy_runtime_api::client::identity::Identity; +use thiserror::Error; +use url::Url; + +/// AWS service name used in the SigV4 string-to-sign for Bedrock. +/// Documented at +/// +pub const BEDROCK_SERVICE_NAME: &str = "bedrock"; + +/// Inputs needed to sign a Bedrock request. Borrows the body so we +/// avoid a copy on the hot path. +#[derive(Debug)] +pub struct SigningInputs<'a> { + /// HTTP method (always `"POST"` for InvokeModel today, but we + /// keep this explicit so a future GET-shaped surface doesn't + /// require redoing the call site). + pub method: &'a str, + /// Fully-qualified upstream URL (scheme + host + path + query). + /// Used for canonical-request URI normalization. + pub url: &'a Url, + /// AWS region the upstream endpoint lives in. + pub region: &'a str, + /// AWS credentials (resolved from the `aws-config` default chain + /// at app-startup time). + pub credentials: &'a Credentials, + /// Body bytes to sign. MUST be the exact bytes the forwarder + /// will send to Bedrock (post-compression). + pub body: &'a [u8], + /// Extra headers the canonical request must include in the + /// signed-headers list. The signer always includes `host`, + /// `x-amz-date`, and `x-amz-content-sha256`; callers add + /// anything else they want covered (e.g. `accept-encoding`, + /// `content-type`). + pub extra_signed_headers: &'a [(&'a str, &'a str)], + /// Time to use in the signature. Production uses + /// `SystemTime::now()`; tests pin a known time to make the + /// canonical request deterministic. + pub time: SystemTime, +} + +/// Headers that the signer will write into the outbound request. +/// The handler must add every entry to the upstream-bound HeaderMap +/// before sending — Bedrock validates each header against the +/// canonical request. +#[derive(Debug, Clone)] +pub struct SignedHeaders { + pub entries: Vec<(String, String)>, + /// Lowercase hex SHA-256 of the body. Surfaced for tests + logs. + pub signature: String, +} + +/// Errors surfaced by the signing path. +#[derive(Debug, Error)] +pub enum SigV4Error { + /// `aws-sigv4` rejected the request (URL parse, malformed header, + /// etc). + #[error("sigv4 signing failed: {0}")] + Sign(String), + /// The signing-params builder rejected the inputs (e.g. missing + /// region — should never happen because we validate at startup). + #[error("sigv4 builder error: {0}")] + Builder(String), +} + +/// Sign a Bedrock request and return the headers the handler must add +/// to the outbound request. +/// +/// # Cache safety +/// +/// The body bytes passed in MUST be the bytes the proxy is about to +/// send upstream. If the compressor mutated the body, those mutated +/// bytes are what get signed — Bedrock will accept because the +/// signature covers the wire payload, not the original. +/// +/// # Errors +/// +/// Returns [`SigV4Error::Sign`] when `aws-sigv4` rejects the request +/// (malformed URL, etc). Returns [`SigV4Error::Builder`] when the +/// signing-params builder rejects the inputs. +pub fn sign_request(inputs: &SigningInputs<'_>) -> Result { + // Build the identity wrapper around the credentials. Identity is + // the type the SigV4 signer accepts; it can in principle hold + // alternative auth schemes (Bearer, etc) but we only ever use it + // for AWS creds. + let identity: Identity = Identity::new(inputs.credentials.clone(), None); + + // Default settings + force `x-amz-content-sha256` into the + // canonical request. Bedrock validates the content hash to + // catch any in-flight body mutation; with `NoHeader` (the + // crate-level default) the signer would skip the header, + // which means a downstream gateway that DOES check it would + // 403. + let mut settings = SigningSettings::default(); + settings.payload_checksum_kind = PayloadChecksumKind::XAmzSha256; + + let signing_params = v4::SigningParams::builder() + .identity(&identity) + .region(inputs.region) + .name(BEDROCK_SERVICE_NAME) + .time(inputs.time) + .settings(settings) + .build() + .map_err(|e| SigV4Error::Builder(e.to_string()))? + .into(); + + // The signer needs the URL as a string; Url's Display impl is + // canonical RFC 3986 form which is what aws-sigv4 expects. + let url_string = inputs.url.to_string(); + + let signable = SignableRequest::new( + inputs.method, + url_string, + inputs.extra_signed_headers.iter().copied(), + SignableBody::Bytes(inputs.body), + ) + .map_err(|e| SigV4Error::Sign(e.to_string()))?; + + let signing_output = + sign(signable, &signing_params).map_err(|e| SigV4Error::Sign(e.to_string()))?; + + let signature = signing_output.signature().to_string(); + let (instructions, _signature) = signing_output.into_parts(); + let (header_entries, _query_params) = instructions.into_parts(); + let entries = header_entries + .into_iter() + .map(|h| (h.name().to_string(), h.value().to_string())) + .collect::>(); + + tracing::info!( + event = "sigv4_signed", + forwarder = "rust_proxy", + region = inputs.region, + service = BEDROCK_SERVICE_NAME, + method = inputs.method, + host = inputs.url.host_str().unwrap_or(""), + body_bytes = inputs.body.len(), + headers_added = entries.len(), + "bedrock request signed with sigv4" + ); + + Ok(SignedHeaders { entries, signature }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + fn fixed_time() -> SystemTime { + // 2026-05-03T12:00:00Z — pinned so canonical request is + // deterministic across runs. + SystemTime::UNIX_EPOCH + Duration::from_secs(1_777_910_400) + } + + fn fixture_credentials() -> Credentials { + Credentials::new("AKIA_TEST", "secret_test", None, None, "test") + } + + #[test] + fn signs_minimal_request_produces_expected_headers() { + let url = Url::parse( + "https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-3-haiku-20240307-v1:0/invoke", + ) + .unwrap(); + let creds = fixture_credentials(); + let body = br#"{"anthropic_version":"bedrock-2023-05-31","max_tokens":16,"messages":[]}"#; + let inputs = SigningInputs { + method: "POST", + url: &url, + region: "us-east-1", + credentials: &creds, + body, + extra_signed_headers: &[("content-type", "application/json")], + time: fixed_time(), + }; + let signed = sign_request(&inputs).expect("sigv4 sign"); + // The signer always emits `authorization`, `x-amz-date`, and + // `x-amz-content-sha256`. Confirm all three are present. + let names: Vec = signed + .entries + .iter() + .map(|(k, _)| k.to_ascii_lowercase()) + .collect(); + assert!( + names.iter().any(|n| n == "authorization"), + "must add authorization; got {names:?}" + ); + assert!( + names.iter().any(|n| n == "x-amz-date"), + "must add x-amz-date" + ); + assert!( + names.iter().any(|n| n == "x-amz-content-sha256"), + "must add x-amz-content-sha256" + ); + assert_eq!(signed.signature.len(), 64, "sigv4 signature is 32-byte hex"); + } + + #[test] + fn signature_is_deterministic_for_fixed_inputs() { + let url = Url::parse( + "https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-3-haiku-20240307-v1:0/invoke", + ) + .unwrap(); + let creds = fixture_credentials(); + let body = br#"{"anthropic_version":"bedrock-2023-05-31","max_tokens":16}"#; + let mk = || SigningInputs { + method: "POST", + url: &url, + region: "us-east-1", + credentials: &creds, + body, + extra_signed_headers: &[("content-type", "application/json")], + time: fixed_time(), + }; + let a = sign_request(&mk()).unwrap(); + let b = sign_request(&mk()).unwrap(); + assert_eq!(a.signature, b.signature); + } + + #[test] + fn changing_body_changes_signature() { + let url = Url::parse( + "https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-3-haiku-20240307-v1:0/invoke", + ) + .unwrap(); + let creds = fixture_credentials(); + let inputs_a = SigningInputs { + method: "POST", + url: &url, + region: "us-east-1", + credentials: &creds, + body: br#"{"anthropic_version":"bedrock-2023-05-31","max_tokens":16}"#, + extra_signed_headers: &[("content-type", "application/json")], + time: fixed_time(), + }; + let inputs_b = SigningInputs { + body: br#"{"anthropic_version":"bedrock-2023-05-31","max_tokens":32}"#, + ..SigningInputs { + method: "POST", + url: &url, + region: "us-east-1", + credentials: &creds, + body: &[], + extra_signed_headers: &[("content-type", "application/json")], + time: fixed_time(), + } + }; + let a = sign_request(&inputs_a).unwrap(); + let b = sign_request(&inputs_b).unwrap(); + assert_ne!( + a.signature, b.signature, + "different body bytes must yield different signatures" + ); + } + + #[test] + fn changing_region_changes_signature() { + let url = Url::parse( + "https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-3-haiku-20240307-v1:0/invoke", + ) + .unwrap(); + let creds = fixture_credentials(); + let body = br#"{"anthropic_version":"bedrock-2023-05-31"}"#; + let east = SigningInputs { + method: "POST", + url: &url, + region: "us-east-1", + credentials: &creds, + body, + extra_signed_headers: &[], + time: fixed_time(), + }; + let west = SigningInputs { + method: "POST", + url: &url, + region: "us-west-2", + credentials: &creds, + body, + extra_signed_headers: &[], + time: fixed_time(), + }; + let a = sign_request(&east).unwrap(); + let b = sign_request(&west).unwrap(); + assert_ne!(a.signature, b.signature); + } +} diff --git a/crates/headroom-proxy/src/config.rs b/crates/headroom-proxy/src/config.rs index 85076ca98..fee143c91 100644 --- a/crates/headroom-proxy/src/config.rs +++ b/crates/headroom-proxy/src/config.rs @@ -296,6 +296,61 @@ pub struct CliArgs { action = clap::ArgAction::Set, )] pub enable_conversations_passthrough: bool, + + /// Phase D PR-D1: enable the native Bedrock InvokeModel route. + /// When `true` (default), `POST /model/{model_id}/invoke` is + /// handled by the Rust `bedrock::invoke` handler — Anthropic-shape + /// bodies run through the live-zone compression path and the + /// proxy re-signs the request with SigV4 before forwarding to + /// the configured Bedrock endpoint. When `false`, the routes are + /// not mounted and requests fall through to the catch-all + /// (which forwards to `--upstream` byte-equal but does NOT + /// re-sign — operators MUST run an unsigned upstream that + /// happens to know what to do, otherwise this fails closed). + /// + /// Source priority: CLI flag → `HEADROOM_PROXY_ENABLE_BEDROCK_NATIVE` + /// env var → default (`true`). + #[arg( + long = "enable-bedrock-native", + env = "HEADROOM_PROXY_ENABLE_BEDROCK_NATIVE", + default_value_t = true, + action = clap::ArgAction::Set, + )] + pub enable_bedrock_native: bool, + + /// AWS region to use when signing Bedrock requests. Default + /// `us-east-1`. The Bedrock endpoint URL derived from this + /// region is `https://bedrock-runtime.{region}.amazonaws.com` + /// (override via `--bedrock-endpoint` for FIPS or VPC endpoints). + /// + /// Source priority: CLI flag → `HEADROOM_PROXY_BEDROCK_REGION` + /// env var → `AWS_REGION` env var → default (`us-east-1`). + #[arg( + long = "bedrock-region", + env = "HEADROOM_PROXY_BEDROCK_REGION", + default_value = "us-east-1" + )] + pub bedrock_region: String, + + /// Bedrock endpoint base URL. When unset (the common case), the + /// proxy derives `https://bedrock-runtime.{bedrock_region}.amazonaws.com` + /// from the configured region. Override for FIPS endpoints + /// (`bedrock-runtime-fips.{region}.amazonaws.com`), VPC endpoints, + /// or local-mock test setups. + /// + /// Source priority: CLI flag → `HEADROOM_PROXY_BEDROCK_ENDPOINT` + /// env var → derived-from-region. + #[arg(long = "bedrock-endpoint", env = "HEADROOM_PROXY_BEDROCK_ENDPOINT")] + pub bedrock_endpoint: Option, + + /// AWS profile name passed to the `aws-config` default credential + /// chain. When unset, the chain uses the default behaviour + /// (env vars → `[default]` profile → IMDS / ECS task role). + /// + /// Source priority: CLI flag → `HEADROOM_PROXY_AWS_PROFILE` + /// env var → `AWS_PROFILE` env var → default chain. + #[arg(long = "aws-profile", env = "HEADROOM_PROXY_AWS_PROFILE")] + pub aws_profile: Option, } fn parse_duration(s: &str) -> Result { @@ -350,6 +405,20 @@ pub struct Config { /// NOT gate compression of conversation items (that's /// C5+/B-phase territory). pub enable_conversations_passthrough: bool, + /// PR-D1: enable the native Bedrock InvokeModel route. Default + /// `true`. When disabled, the explicit Rust handlers are not + /// mounted; operators relying on the Python LiteLLM converter + /// keep their existing path. + pub enable_bedrock_native: bool, + /// PR-D1: AWS region used to sign Bedrock requests + (when no + /// explicit endpoint is set) derive the Bedrock endpoint URL. + pub bedrock_region: String, + /// PR-D1: Bedrock endpoint base URL. `None` means + /// "derive from region" (`https://bedrock-runtime.{region}.amazonaws.com`). + pub bedrock_endpoint: Option, + /// PR-D1: optional AWS profile name. When `None`, the default + /// credential chain (env → `[default]` profile → IMDS) is used. + pub aws_profile: Option, } impl Config { @@ -378,6 +447,10 @@ impl Config { strip_internal_headers: args.strip_internal_headers, enable_responses_streaming: args.enable_responses_streaming, enable_conversations_passthrough: args.enable_conversations_passthrough, + enable_bedrock_native: args.enable_bedrock_native, + bedrock_region: args.bedrock_region, + bedrock_endpoint: args.bedrock_endpoint, + aws_profile: args.aws_profile, } } @@ -408,6 +481,14 @@ impl Config { // production traffic will hit. enable_responses_streaming: true, enable_conversations_passthrough: true, + // PR-D1: bedrock route default-on so tests exercise + // it without per-test opt-in. Tests that set + // `bedrock_endpoint` to a wiremock URL get the full + // sign-and-forward path. + enable_bedrock_native: true, + bedrock_region: "us-east-1".to_string(), + bedrock_endpoint: None, + aws_profile: None, } } } diff --git a/crates/headroom-proxy/src/lib.rs b/crates/headroom-proxy/src/lib.rs index d5f094ece..f6f152891 100644 --- a/crates/headroom-proxy/src/lib.rs +++ b/crates/headroom-proxy/src/lib.rs @@ -1,6 +1,7 @@ //! headroom-proxy library: transparent reverse proxy in front of the Python //! Headroom proxy. Used by both `main.rs` and the integration tests. +pub mod bedrock; pub mod compression; pub mod config; pub mod error; diff --git a/crates/headroom-proxy/src/main.rs b/crates/headroom-proxy/src/main.rs index b9d8b57b1..19e748522 100644 --- a/crates/headroom-proxy/src/main.rs +++ b/crates/headroom-proxy/src/main.rs @@ -31,7 +31,37 @@ async fn main() -> Result<(), Box> { "headroom-proxy starting" ); - let state = AppState::new(config.clone())?; + let mut state = AppState::new(config.clone())?; + + // PR-D1: resolve AWS credentials at startup via the `aws-config` + // default chain. Loaded once so per-request signing is cheap. + // Failure is NOT fatal — the proxy may run in front of a non-AWS + // upstream — but the Bedrock invoke handler refuses to forward + // unsigned requests when `bedrock_credentials` is `None` + // (see `bedrock::invoke::handle_invoke`). + if config.enable_bedrock_native { + match load_bedrock_credentials(&config).await { + Ok(creds) => { + state = state.with_bedrock_credentials(creds); + tracing::info!( + event = "bedrock_credentials_loaded", + region = %config.bedrock_region, + profile = ?config.aws_profile, + "AWS credentials resolved for Bedrock SigV4 signing" + ); + } + Err(e) => { + tracing::warn!( + event = "bedrock_credentials_unavailable", + region = %config.bedrock_region, + profile = ?config.aws_profile, + error = %e, + "AWS credentials not available at startup; Bedrock invoke will 5xx until creds are configured" + ); + } + } + } + let app = build_app(state).into_make_service_with_connect_info::(); let listener = tokio::net::TcpListener::bind(config.listen).await?; @@ -64,6 +94,31 @@ fn init_tracing(level: &str) { .try_init(); } +/// PR-D1: resolve AWS credentials for Bedrock SigV4 signing. +/// +/// Uses the `aws-config` default chain (env vars → shared profile +/// file → IMDS / ECS task role). Honours `Config::aws_profile` when +/// set; otherwise the chain picks up `AWS_PROFILE` from the +/// environment automatically. +async fn load_bedrock_credentials( + config: &Config, +) -> Result> { + use aws_config::BehaviorVersion; + use aws_credential_types::provider::ProvideCredentials; + + let mut loader = aws_config::defaults(BehaviorVersion::latest()) + .region(aws_config::Region::new(config.bedrock_region.clone())); + if let Some(profile) = config.aws_profile.as_deref() { + loader = loader.profile_name(profile); + } + let aws_config = loader.load().await; + let creds_provider = aws_config + .credentials_provider() + .ok_or("no credentials provider configured")?; + let creds = creds_provider.provide_credentials().await?; + Ok(creds) +} + async fn shutdown_signal() { let ctrl_c = async { let _ = tokio::signal::ctrl_c().await; diff --git a/crates/headroom-proxy/src/proxy.rs b/crates/headroom-proxy/src/proxy.rs index 056e9a9af..c60ac631c 100644 --- a/crates/headroom-proxy/src/proxy.rs +++ b/crates/headroom-proxy/src/proxy.rs @@ -34,6 +34,14 @@ use crate::websocket::ws_handler; pub struct AppState { pub config: Arc, pub client: reqwest::Client, + /// PR-D1: AWS credentials resolved at startup via the + /// `aws-config` default chain. `None` when the proxy boots + /// without AWS creds available (operator running locally + /// against a non-Bedrock upstream); the Bedrock invoke handler + /// returns 5xx with a structured `event=bedrock_credentials_missing` + /// log so failures are LOUD — no silent fallback to unsigned + /// requests. + pub bedrock_credentials: Option>, } impl AppState { @@ -52,8 +60,19 @@ impl AppState { Ok(Self { config: Arc::new(config), client, + bedrock_credentials: None, }) } + + /// PR-D1: attach AWS credentials resolved out-of-band (via + /// `aws-config`'s default chain at startup). Returns the + /// modified state; intended to be chained off `AppState::new`. + /// Tests that don't exercise the Bedrock route can leave + /// credentials unset (the catch-all paths never read them). + pub fn with_bedrock_credentials(mut self, creds: aws_credential_types::Credentials) -> Self { + self.bedrock_credentials = Some(Arc::new(creds)); + self + } } /// Build the axum app. `/healthz` and `/healthz/upstream` are intercepted; @@ -84,6 +103,32 @@ pub fn build_app(state: AppState) -> Router { post(crate::handlers::responses::handle_responses), ); + // PR-D1: native AWS Bedrock InvokeModel route. Mounts only when + // `enable_bedrock_native` is on (default). The handler runs the + // live-zone compressor over Anthropic-shape bodies, signs with + // SigV4, and forwards to the configured Bedrock endpoint. The + // `/converse` route mounts the same handler — the wire shape is + // identical for `anthropic.claude-*` model IDs (Bedrock just + // accepts both legacy `invoke` and modern `converse` paths). + if state.config.enable_bedrock_native { + router = router + .route( + "/model/:model_id/invoke", + post(crate::bedrock::invoke::handle_invoke), + ) + .route( + "/model/:model_id/converse", + post(crate::bedrock::invoke::handle_invoke), + ); + } else { + tracing::warn!( + event = "bedrock_native_disabled", + "Bedrock native InvokeModel route disabled by \ + --enable-bedrock-native=false; Bedrock requests will fall \ + through to the catch-all (no SigV4 re-signing — fails closed)" + ); + } + // PR-C4: Conversations API (passthrough-with-instrumentation). // The flag is read once at app-build time so router shape // matches the configured policy. When disabled, requests still diff --git a/crates/headroom-proxy/tests/common/mod.rs b/crates/headroom-proxy/tests/common/mod.rs index 96b41c4b2..4f8976e8e 100644 --- a/crates/headroom-proxy/tests/common/mod.rs +++ b/crates/headroom-proxy/tests/common/mod.rs @@ -44,11 +44,28 @@ pub async fn start_proxy(upstream: &str) -> ProxyHandle { pub async fn start_proxy_with(upstream: &str, customize: F) -> ProxyHandle where F: FnOnce(&mut Config), +{ + start_proxy_with_state(upstream, customize, |s| s).await +} + +/// Start a proxy with both a Config customizer and an AppState +/// post-processor. PR-D1: tests that exercise the Bedrock route +/// inject credentials via `with_bedrock_credentials` here. +#[allow(dead_code)] +pub async fn start_proxy_with_state( + upstream: &str, + customize: F, + customize_state: G, +) -> ProxyHandle +where + F: FnOnce(&mut Config), + G: FnOnce(AppState) -> AppState, { let upstream_url: Url = upstream.parse().expect("valid upstream url"); let mut config = Config::for_test(upstream_url); customize(&mut config); let state = AppState::new(config.clone()).expect("app state"); + let state = customize_state(state); let app = build_app(state).into_make_service_with_connect_info::(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await diff --git a/crates/headroom-proxy/tests/integration_bedrock_invoke.rs b/crates/headroom-proxy/tests/integration_bedrock_invoke.rs new file mode 100644 index 000000000..449c0c832 --- /dev/null +++ b/crates/headroom-proxy/tests/integration_bedrock_invoke.rs @@ -0,0 +1,563 @@ +//! Integration tests for the native Bedrock InvokeModel route +//! (Phase D PR-D1). +//! +//! These tests boot the real Rust proxy in front of a wiremock +//! upstream that pretends to be the Bedrock runtime endpoint +//! (`https://bedrock-runtime.{region}.amazonaws.com`). The proxy is +//! configured with `bedrock_endpoint = wiremock_url` so SigV4-signed +//! requests are routed to the mock instead of real AWS — no live +//! AWS dependency. +//! +//! Coverage matrix (per PR-D1 spec, REALIGNMENT/06-phase-D-bedrock-vertex.md): +//! +//! 1. `native_envelope_round_trip_byte_equal` — small body, +//! compression-mode off; bytes round-trip byte-equal upstream. +//! 2. `sigv4_signed_correctly_after_compression` — confirms the +//! `authorization` header arrives at upstream and the +//! `x-amz-content-sha256` matches the (post-compression) body. +//! 3. `thinking_block_preserved_through_bedrock` — Anthropic +//! `thinking` block round-trips byte-equal with compression off. +//! Validates the live-zone dispatcher doesn't strip the block. +//! 4. `redacted_thinking_preserved` — `redacted_thinking` block +//! round-trips byte-equal. +//! 5. `document_block_preserved` — `document` block round-trips. +//! 6. `tool_result_array_with_image_preserved` — `tool_result` content +//! array containing a base64 `image` block round-trips byte-equal +//! when compression is off. +//! 7. `stop_sequence_null_only_when_present` — mock the upstream to +//! return a Bedrock-shape response that does NOT include +//! `stop_sequence`; the proxy must not inject a `null` value for +//! it. (This is an end-to-end check that Phase D doesn't regress +//! P4-37's hardcoded null.) +//! 8. `tool_use_input_byte_equal_preserves_key_order` — `tool_use.input` +//! object keys must arrive in the same order they were sent +//! (the `serde_json::preserve_order` feature backs this). + +mod common; + +use aws_credential_types::Credentials; +use common::start_proxy_with_state; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::sync::{Arc, Mutex}; +use url::Url; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +/// What we capture from each upstream request — body + the +/// authorization-shaped headers we care about. +#[derive(Default, Clone, Debug)] +struct CapturedRequest { + body: Option>, + authorization: Option, + x_amz_date: Option, + x_amz_content_sha256: Option, + host: Option, + content_type: Option, +} + +type Capture = Arc>; + +const TEST_MODEL: &str = "anthropic.claude-3-haiku-20240307-v1:0"; + +async fn mount_capture_invoke(upstream: &MockServer, response_body: &str) -> Capture { + let captured: Capture = Arc::new(Mutex::new(CapturedRequest::default())); + let captured_clone = captured.clone(); + let response_body = response_body.to_string(); + let model = TEST_MODEL.to_string(); + Mock::given(method("POST")) + .and(path(format!("/model/{model}/invoke"))) + .respond_with(move |req: &wiremock::Request| { + let mut c = captured_clone.lock().unwrap(); + c.body = Some(req.body.clone()); + c.authorization = req + .headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + c.x_amz_date = req + .headers + .get("x-amz-date") + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + c.x_amz_content_sha256 = req + .headers + .get("x-amz-content-sha256") + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + c.host = req + .headers + .get("host") + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + c.content_type = req + .headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + ResponseTemplate::new(200).set_body_string(response_body.clone()) + }) + .mount(upstream) + .await; + captured +} + +fn sha256_hex(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + hasher + .finalize() + .iter() + .fold(String::with_capacity(64), |mut acc, b| { + use std::fmt::Write as _; + let _ = write!(acc, "{b:02x}"); + acc + }) +} + +#[track_caller] +fn assert_byte_equal_sha256(inbound: &[u8], received: &[u8]) { + let inbound_hash = sha256_hex(inbound); + let received_hash = sha256_hex(received); + assert_eq!( + inbound.len(), + received.len(), + "byte length mismatch: inbound={}, upstream-received={}", + inbound.len(), + received.len(), + ); + assert_eq!( + inbound_hash, received_hash, + "SHA-256 mismatch: inbound={inbound_hash}, upstream-received={received_hash}", + ); +} + +fn test_credentials() -> Credentials { + Credentials::new( + "AKIAEXAMPLEAKIDFORTEST", + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + None, + None, + "test", + ) +} + +/// Boot a proxy pointed at the wiremock upstream as the Bedrock +/// endpoint. The fake-upstream URL goes into `bedrock_endpoint`; +/// the regular `upstream` field is set to a sentinel because the +/// Bedrock route bypasses `forward_http`. +async fn bedrock_proxy( + upstream: &MockServer, + customize: impl FnOnce(&mut headroom_proxy::Config), +) -> common::ProxyHandle { + let endpoint: Url = upstream.uri().parse().unwrap(); + start_proxy_with_state( + &upstream.uri(), + |c| { + c.bedrock_endpoint = Some(endpoint); + customize(c); + }, + |s| s.with_bedrock_credentials(test_credentials()), + ) + .await +} + +#[tokio::test] +async fn native_envelope_round_trip_byte_equal() { + // Compression off → body must arrive byte-equal at upstream. + let upstream = MockServer::start().await; + let captured = mount_capture_invoke(&upstream, r#"{"id":"msg_x","content":[]}"#).await; + let proxy = bedrock_proxy(&upstream, |c| { + c.compression = true; + c.compression_mode = headroom_proxy::config::CompressionMode::Off; + }) + .await; + + let payload = json!({ + "anthropic_version": "bedrock-2023-05-31", + "max_tokens": 64, + "messages": [ + {"role": "user", "content": "hi"} + ] + }); + let body = serde_json::to_vec(&payload).unwrap(); + let resp = reqwest::Client::new() + .post(format!("{}/model/{TEST_MODEL}/invoke", proxy.url())) + .header("content-type", "application/json") + .body(body.clone()) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + + let got = captured.lock().unwrap().clone(); + assert_byte_equal_sha256(&body, got.body.as_deref().unwrap()); + proxy.shutdown().await; +} + +#[tokio::test] +async fn sigv4_signed_correctly_after_compression() { + // The signature must cover the bytes that actually hit upstream. + // We confirm: (a) `authorization` is present, (b) + // `x-amz-content-sha256` matches sha256(body received by upstream). + let upstream = MockServer::start().await; + let captured = mount_capture_invoke(&upstream, r#"{"id":"msg_x","content":[]}"#).await; + let proxy = bedrock_proxy(&upstream, |c| { + c.compression = true; + c.compression_mode = headroom_proxy::config::CompressionMode::LiveZone; + }) + .await; + + let payload = json!({ + "anthropic_version": "bedrock-2023-05-31", + "max_tokens": 64, + "messages": [{"role": "user", "content": "hi"}] + }); + let body = serde_json::to_vec(&payload).unwrap(); + let resp = reqwest::Client::new() + .post(format!("{}/model/{TEST_MODEL}/invoke", proxy.url())) + .header("content-type", "application/json") + .body(body) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + + let got = captured.lock().unwrap().clone(); + let auth = got.authorization.expect("authorization header present"); + assert!( + auth.starts_with("AWS4-HMAC-SHA256 "), + "authorization must be SigV4-shape; got {auth}" + ); + assert!( + auth.contains("Credential=AKIAEXAMPLEAKIDFORTEST/"), + "authorization must reference the test access key id; got {auth}" + ); + assert!( + auth.contains("/bedrock/aws4_request"), + "authorization scope must reference the bedrock service; got {auth}" + ); + assert!(got.x_amz_date.is_some(), "x-amz-date must be present"); + let body_received = got.body.expect("upstream got body"); + let expected_sha = sha256_hex(&body_received); + assert_eq!( + got.x_amz_content_sha256.as_deref(), + Some(expected_sha.as_str()), + "x-amz-content-sha256 must match sha256 of bytes the upstream received" + ); + proxy.shutdown().await; +} + +#[tokio::test] +async fn thinking_block_preserved_through_bedrock() { + // Anthropic `thinking` block: cache hot zone item. With + // compression OFF, body round-trips byte-equal upstream. (The + // dispatcher only mutates the live zone — the latest user + // message — so even with compression on, an assistant `thinking` + // block is left alone. We pin the byte-equal contract to the + // off-mode path because that's what the litellm Python shim + // would have lost — P4-37 evidence.) + let upstream = MockServer::start().await; + let captured = mount_capture_invoke(&upstream, r#"{"id":"msg_x","content":[]}"#).await; + let proxy = bedrock_proxy(&upstream, |c| { + c.compression_mode = headroom_proxy::config::CompressionMode::Off; + }) + .await; + + let payload = json!({ + "anthropic_version": "bedrock-2023-05-31", + "max_tokens": 1024, + "messages": [ + {"role": "user", "content": "What's 2+2?"}, + { + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "The user is asking a basic arithmetic question. 2+2=4.", + "signature": "EpYBCkYIBRgCKkAhello_world_signature_payload=" + }, + {"type": "text", "text": "4"} + ] + } + ] + }); + let body = serde_json::to_vec(&payload).unwrap(); + let resp = reqwest::Client::new() + .post(format!("{}/model/{TEST_MODEL}/invoke", proxy.url())) + .header("content-type", "application/json") + .body(body.clone()) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + + let got = captured.lock().unwrap().clone(); + assert_byte_equal_sha256(&body, got.body.as_deref().unwrap()); + let parsed: Value = serde_json::from_slice(got.body.as_deref().unwrap()).unwrap(); + assert_eq!(parsed["messages"][1]["content"][0]["type"], "thinking"); + assert_eq!( + parsed["messages"][1]["content"][0]["signature"], + "EpYBCkYIBRgCKkAhello_world_signature_payload=" + ); + proxy.shutdown().await; +} + +#[tokio::test] +async fn redacted_thinking_preserved() { + // `redacted_thinking` blocks: opaque encrypted payloads from + // the model. Must round-trip BYTE-EQUAL — the proxy never + // inspects them. + let upstream = MockServer::start().await; + let captured = mount_capture_invoke(&upstream, r#"{"id":"msg_x","content":[]}"#).await; + let proxy = bedrock_proxy(&upstream, |c| { + c.compression_mode = headroom_proxy::config::CompressionMode::Off; + }) + .await; + + let payload = json!({ + "anthropic_version": "bedrock-2023-05-31", + "max_tokens": 1024, + "messages": [ + {"role": "user", "content": "Hi"}, + { + "role": "assistant", + "content": [ + { + "type": "redacted_thinking", + "data": "EuYBCogBAaR_o9XJEnEx_3Q9d5z9_redacted_payload" + }, + {"type": "text", "text": "Hello!"} + ] + } + ] + }); + let body = serde_json::to_vec(&payload).unwrap(); + let resp = reqwest::Client::new() + .post(format!("{}/model/{TEST_MODEL}/invoke", proxy.url())) + .header("content-type", "application/json") + .body(body.clone()) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + + let got = captured.lock().unwrap().clone(); + assert_byte_equal_sha256(&body, got.body.as_deref().unwrap()); + let parsed: Value = serde_json::from_slice(got.body.as_deref().unwrap()).unwrap(); + assert_eq!( + parsed["messages"][1]["content"][0]["type"], + "redacted_thinking" + ); + assert_eq!( + parsed["messages"][1]["content"][0]["data"], + "EuYBCogBAaR_o9XJEnEx_3Q9d5z9_redacted_payload" + ); + proxy.shutdown().await; +} + +#[tokio::test] +async fn document_block_preserved() { + // `document` block (PDF or text-document attachment). Has nested + // `source.media_type` + `source.data` (base64). The litellm + // Python shim drops these silently; the Rust route must NOT. + let upstream = MockServer::start().await; + let captured = mount_capture_invoke(&upstream, r#"{"id":"msg_x","content":[]}"#).await; + let proxy = bedrock_proxy(&upstream, |c| { + c.compression_mode = headroom_proxy::config::CompressionMode::Off; + }) + .await; + + let payload = json!({ + "anthropic_version": "bedrock-2023-05-31", + "max_tokens": 256, + "messages": [{ + "role": "user", + "content": [ + { + "type": "document", + "source": { + "type": "base64", + "media_type": "application/pdf", + "data": "JVBERi0xLjQKJfbk/N8KMSAwIG9iago8PAovVHlwZSAvQ2F0YWxvZwo+PgplbmRvYmoK" + }, + "title": "Quarterly Report", + "context": "Q4 2025 financial summary" + }, + {"type": "text", "text": "Summarize."} + ] + }] + }); + let body = serde_json::to_vec(&payload).unwrap(); + let resp = reqwest::Client::new() + .post(format!("{}/model/{TEST_MODEL}/invoke", proxy.url())) + .header("content-type", "application/json") + .body(body.clone()) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + + let got = captured.lock().unwrap().clone(); + assert_byte_equal_sha256(&body, got.body.as_deref().unwrap()); + let parsed: Value = serde_json::from_slice(got.body.as_deref().unwrap()).unwrap(); + let doc = &parsed["messages"][0]["content"][0]; + assert_eq!(doc["type"], "document"); + assert_eq!(doc["source"]["media_type"], "application/pdf"); + assert_eq!(doc["title"], "Quarterly Report"); + proxy.shutdown().await; +} + +#[tokio::test] +async fn tool_result_array_with_image_preserved() { + // `tool_result.content` is an array; one element is an `image` + // block with base64 source. The litellm shim flattened these + // to a single string (P4-37). The Rust route must keep the + // array shape verbatim. + let upstream = MockServer::start().await; + let captured = mount_capture_invoke(&upstream, r#"{"id":"msg_x","content":[]}"#).await; + let proxy = bedrock_proxy(&upstream, |c| { + c.compression_mode = headroom_proxy::config::CompressionMode::Off; + }) + .await; + + let payload = json!({ + "anthropic_version": "bedrock-2023-05-31", + "max_tokens": 256, + "messages": [ + {"role": "user", "content": "Take a screenshot"}, + { + "role": "assistant", + "content": [{ + "type": "tool_use", + "id": "toolu_xyz", + "name": "screenshot", + "input": {"region": "full"} + }] + }, + { + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": "toolu_xyz", + "content": [ + {"type": "text", "text": "Captured at 2026-05-03T12:00:00Z"}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP8z8DwHwAFAQH/9zJEHwAAAABJRU5ErkJggg==" + } + } + ] + }] + } + ] + }); + let body = serde_json::to_vec(&payload).unwrap(); + let resp = reqwest::Client::new() + .post(format!("{}/model/{TEST_MODEL}/invoke", proxy.url())) + .header("content-type", "application/json") + .body(body.clone()) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + + let got = captured.lock().unwrap().clone(); + assert_byte_equal_sha256(&body, got.body.as_deref().unwrap()); + let parsed: Value = serde_json::from_slice(got.body.as_deref().unwrap()).unwrap(); + let tool_result = &parsed["messages"][2]["content"][0]; + assert_eq!(tool_result["type"], "tool_result"); + let inner = &tool_result["content"]; + assert!(inner.is_array(), "tool_result.content must remain an array"); + assert_eq!(inner[1]["type"], "image"); + assert_eq!(inner[1]["source"]["media_type"], "image/png"); + proxy.shutdown().await; +} + +#[tokio::test] +async fn stop_sequence_null_only_when_present() { + // Synthesise a Bedrock-shape response that does NOT include + // `stop_sequence` and confirm the proxy doesn't add `null` for + // it. P4-37: the litellm Python shim hardcoded `stop_sequence: + // null` in the converted response shape; the Rust path must not + // do that — Bedrock's response is forwarded verbatim. + let upstream = MockServer::start().await; + let response_no_stop_sequence = r#"{"id":"msg_a","type":"message","role":"assistant","model":"claude-3-haiku-20240307","content":[{"type":"text","text":"hi"}],"stop_reason":"end_turn","usage":{"input_tokens":3,"output_tokens":1}}"#; + let _captured = mount_capture_invoke(&upstream, response_no_stop_sequence).await; + let proxy = bedrock_proxy(&upstream, |c| { + c.compression_mode = headroom_proxy::config::CompressionMode::Off; + }) + .await; + + let payload = json!({ + "anthropic_version": "bedrock-2023-05-31", + "max_tokens": 16, + "messages": [{"role": "user", "content": "hi"}] + }); + let body = serde_json::to_vec(&payload).unwrap(); + let resp = reqwest::Client::new() + .post(format!("{}/model/{TEST_MODEL}/invoke", proxy.url())) + .header("content-type", "application/json") + .body(body) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let resp_text = resp.text().await.unwrap(); + let resp_parsed: Value = serde_json::from_str(&resp_text).unwrap(); + assert!( + resp_parsed.get("stop_sequence").is_none(), + "stop_sequence must NOT be present on the response when upstream omitted it; got {resp_text}" + ); + assert_eq!(resp_parsed["stop_reason"], "end_turn"); + proxy.shutdown().await; +} + +#[tokio::test] +async fn tool_use_input_byte_equal_preserves_key_order() { + // `tool_use.input` is a JSON object whose key order must + // round-trip exactly. P4-43: the litellm shim parsed + // `function.arguments` into a dict and re-stringified, breaking + // key order. The Rust path uses serde_json's preserve_order + // feature throughout — confirm the bytes arrive byte-equal. + let upstream = MockServer::start().await; + let captured = mount_capture_invoke(&upstream, r#"{"id":"msg_x","content":[]}"#).await; + let proxy = bedrock_proxy(&upstream, |c| { + c.compression_mode = headroom_proxy::config::CompressionMode::Off; + }) + .await; + + // Hand-craft the body bytes so we can pin exact key order. + // BTreeMap-default serialization would alphabetize the keys + // (city < country < units); we send the opposite so any + // accidental re-encode shows up as a byte mismatch. + let body = br#"{"anthropic_version":"bedrock-2023-05-31","max_tokens":64,"messages":[{"role":"assistant","content":[{"type":"tool_use","id":"toolu_zoom","name":"get_weather","input":{"units":"metric","country":"FR","city":"Paris"}}]}]}"#; + + let resp = reqwest::Client::new() + .post(format!("{}/model/{TEST_MODEL}/invoke", proxy.url())) + .header("content-type", "application/json") + .body(body.to_vec()) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + + let got = captured.lock().unwrap().clone(); + let received = got.body.expect("upstream got body"); + assert_byte_equal_sha256(body, &received); + + // Defensive: sanity-check the input key order by looking at the + // raw substring. + let received_str = std::str::from_utf8(&received).unwrap(); + let units_pos = received_str.find("\"units\"").expect("units present"); + let country_pos = received_str.find("\"country\"").expect("country present"); + let city_pos = received_str.find("\"city\"").expect("city present"); + assert!( + units_pos < country_pos && country_pos < city_pos, + "tool_use.input key order must be units→country→city; got: {received_str}" + ); + proxy.shutdown().await; +}