diff --git a/.gitignore b/.gitignore index ad67955..892bcf6 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,8 @@ target # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# switchyard-demo rendered/runtime artifacts +hack/switchyard-demo/praxis.yaml +hack/switchyard-demo/server.log +hack/switchyard-demo/.auth-block.yaml diff --git a/Cargo.lock b/Cargo.lock index 9909104..1885d53 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,379 +3,5058 @@ version = 4 [[package]] -name = "autocfg" -version = "1.5.1" +name = "adler2" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] -name = "bit-set" -version = "0.8.0" +name = "ahash" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ - "bit-vec", + "cfg-if", + "const-random", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", ] [[package]] -name = "bit-vec" -version = "0.8.0" +name = "aho-corasick" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] [[package]] -name = "bitflags" -version = "2.13.1" +name = "aliasable" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" [[package]] -name = "cfg-if" -version = "1.0.4" +name = "alloc-no-stdlib" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] -name = "errno" -version = "0.3.14" +name = "alloc-stdlib" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" dependencies = [ - "libc", - "windows-sys", + "alloc-no-stdlib", ] [[package]] -name = "experimental-probe" -version = "0.1.0" +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ - "proptest", - "tracing", + "libc", ] [[package]] -name = "fastrand" -version = "2.5.0" +name = "anstream" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] [[package]] -name = "fnv" -version = "1.0.7" +name = "anstyle" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] -name = "getrandom" -version = "0.3.4" +name = "anstyle-parse" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", + "utf8parse", ] [[package]] -name = "getrandom" -version = "0.4.3" +name = "anstyle-query" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", + "windows-sys 0.61.2", ] [[package]] -name = "libc" -version = "0.2.189" +name = "anstyle-wincon" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] [[package]] -name = "linux-raw-sys" -version = "0.12.1" +name = "anyhow" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] -name = "num-traits" -version = "0.2.19" +name = "arc-swap" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" dependencies = [ - "autocfg", + "rustversion", ] [[package]] -name = "once_cell" -version = "1.21.4" +name = "arrayvec" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "asn1-rs" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] [[package]] -name = "ppv-lite86" -version = "0.2.21" +name = "asn1-rs-derive" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" dependencies = [ - "zerocopy", + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", ] [[package]] -name = "proc-macro2" -version = "1.0.107" +name = "asn1-rs-impl" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ - "unicode-ident", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "proptest" -version = "1.11.0" +name = "async-stream" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" dependencies = [ - "bit-set", - "bit-vec", - "bitflags", - "num-traits", - "rand", - "rand_chacha", - "rand_xorshift", - "regex-syntax", - "rusty-fork", - "tempfile", - "unarray", + "async-stream-impl", + "futures-core", + "pin-project-lite", ] [[package]] -name = "quick-error" -version = "1.2.3" +name = "async-stream-impl" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] -name = "quote" -version = "1.0.47" +name = "async-trait" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", + "quote", + "syn 3.0.3", ] [[package]] -name = "r-efi" -version = "5.3.0" +name = "atoi" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] [[package]] -name = "r-efi" -version = "6.0.0" +name = "atomic-waker" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] -name = "rand" -version = "0.9.5" +name = "autocfg" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha", - "rand_core", -] +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] -name = "rand_chacha" -version = "0.9.0" +name = "aws-lc-rs" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" dependencies = [ - "ppv-lite86", - "rand_core", + "aws-lc-sys", + "zeroize", ] [[package]] -name = "rand_core" -version = "0.9.5" +name = "aws-lc-sys" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" dependencies = [ - "getrandom 0.3.4", + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", ] [[package]] -name = "rand_xorshift" -version = "0.4.0" +name = "base64" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" -dependencies = [ - "rand_core", -] +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] -name = "regex-syntax" -version = "0.8.11" +name = "base64" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] -name = "rustix" -version = "1.1.4" +name = "bit-set" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", + "bit-vec", ] [[package]] -name = "rusty-fork" -version = "0.3.1" +name = "bit-vec" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "fnv", - "quick-error", - "tempfile", - "wait-timeout", + "digest 0.10.7", ] [[package]] -name = "syn" -version = "2.0.119" +name = "block-buffer" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "generic-array", ] [[package]] -name = "tempfile" -version = "3.27.0" +name = "block-buffer" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ - "fastrand", - "getrandom 0.4.3", - "once_cell", - "rustix", - "windows-sys", + "hybrid-array", ] [[package]] -name = "tracing" -version = "0.1.44" +name = "borrow-or-share" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + +[[package]] +name = "brotli" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640d25bc63c50fb1f0b545ffd80207d2e10a4c965530809b40ba3386825c391" dependencies = [ - "pin-project-lite", - "tracing-attributes", - "tracing-core", + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", ] [[package]] -name = "tracing-attributes" -version = "0.1.31" +name = "brotli-decompressor" +version = "2.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +checksum = "4e2e4afe60d7dd600fdd3de8d0f08c2b7ec039712e3b6137ff98b7004e82de4f" dependencies = [ - "proc-macro2", - "quote", - "syn", + "alloc-no-stdlib", + "alloc-stdlib", ] [[package]] -name = "tracing-core" -version = "0.1.36" +name = "bstr" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" dependencies = [ - "once_cell", + "memchr", + "regex-automata", + "serde_core", ] [[package]] -name = "unarray" -version = "0.1.4" +name = "bumpalo" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] -name = "unicode-ident" -version = "1.0.24" +name = "bytecount" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" [[package]] -name = "wait-timeout" -version = "0.2.1" +name = "byteorder" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "camino" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" dependencies = [ - "libc", + "serde_core", ] [[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" +name = "cargo-platform" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba" dependencies = [ - "wit-bindgen", + "serde", + "serde_core", ] [[package]] -name = "windows-link" -version = "0.2.1" +name = "cargo_metadata" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.20", +] [[package]] -name = "windows-sys" -version = "0.61.2" +name = "cc" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" dependencies = [ - "windows-link", + "find-msvc-tools", + "jobserver", + "libc", + "shlex", ] [[package]] -name = "wit-bindgen" -version = "0.57.1" +name = "cfg-if" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "zerocopy" -version = "0.8.56" +name = "cfg_aliases" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ - "zerocopy-derive", + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", ] [[package]] -name = "zerocopy-derive" -version = "0.8.56" +name = "chrono" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +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 = "daemonix" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0b747381562a10fd2104e9333ad72fe5158d79de81b64426fc9e229c6d3fb38" +dependencies = [ + "libc", +] + +[[package]] +name = "daggy" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70def8d72740e44d9f676d8dab2c933a236663d86dd24319b57a2bed4d694774" +dependencies = [ + "petgraph", +] + +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +dependencies = [ + "serde", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "evmap" +version = "11.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8874945f036109c72242964c1174cf99434e30cfa45bf45fedc983f50046f8" +dependencies = [ + "hashbag", + "left-right", + "smallvec", +] + +[[package]] +name = "experimental-probe" +version = "0.1.0" +dependencies = [ + "proptest", + "tracing", +] + +[[package]] +name = "fancy-regex" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fancy-regex" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "476de73bddf2ef8490aa4ee8f1cf40b430bf1d56c48c22080e5186952cd580e6" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "libz-ng-sys", + "miniz_oxide", +] + +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "flurry" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf5efcf77a4da27927d3ab0509dec5b0954bb3bc59da5a1de9e52642ebd4cdf9" +dependencies = [ + "ahash", + "num_cpus", + "parking_lot", + "seize", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbag" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7040a10f52cba493ddb09926e15d10a9d8a28043708a405931fe4c6f19fac064" + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +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 = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + +[[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 = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inotify" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +dependencies = [ + "bitflags", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonptr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85019623956752c8dd1f04a7b05d066187e0c9b217454246d8397d2a4893cc83" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "jsonschema" +version = "0.49.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ec8a241beed129f06114aa68007e905ca350e7baeb6e17a7631bb7978d91b2" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex 0.19.0", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "jsonschema-regex", + "jsonschema-value", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "serde", + "serde_json", + "strum 0.28.0", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-regex" +version = "0.49.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91994f45017ed5e66aa8e59b8415f4cb033a6380d7200387b7cf117595fbdf85" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "jsonschema-value" +version = "0.49.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ec7637f83e510868ae6ed625f7ebfbbde4554ee8ce49854caa5126a8b9b9ecb" +dependencies = [ + "ahash", + "bytecount", + "fraction", + "num-cmp", + "num-traits", + "serde_json", +] + +[[package]] +name = "kqueue" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "left-right" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bc015ded5d9b3054dbbdb63332cdd6ee42352ccef19e911e25117490e2f48ee" +dependencies = [ + "crossbeam-utils", + "loom", + "slab", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libyaml-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e126dda6f34391ab7b444f9922055facc83c07a910da3eb16f1e4d9c45dc777" + +[[package]] +name = "libz-ng-sys" +version = "1.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879917b256f6317769b9f374b435805a8697013098aacce5a38ac106cd6a9469" +dependencies = [ + "cmake", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "metrics" +version = "0.24.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89550ee9f79e88fef3119de263694973a8adb26c21d75322164fb8c493039fe2" +dependencies = [ + "portable-atomic", + "rapidhash", +] + +[[package]] +name = "metrics-exporter-prometheus" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1db0d8f1fc9e62caebd0319e11eaec5822b0186c171568f0480b46a0137f9108" +dependencies = [ + "base64 0.22.1", + "evmap", + "indexmap 2.14.0", + "metrics", + "metrics-util", + "quanta", + "thiserror 2.0.20", +] + +[[package]] +name = "metrics-util" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96f8722f8562635f92f8ed992f26df0532266eb03d5202607c20c0d7e9745e13" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", + "hashbrown 0.16.1", + "metrics", + "quanta", + "rand 0.9.5", + "rand_xoshiro", + "rapidhash", + "sketches-ddsketch", +] + +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "no_debug" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f23a60c850e1144fc1dd9435152e0cfdc7dd18725350b4243584118013a52a4" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "opentelemetry" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror 2.0.20", +] + +[[package]] +name = "ouroboros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" +dependencies = [ + "aliasable", + "ouroboros_macro", + "static_assertions", +] + +[[package]] +name = "ouroboros_macro" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap 2.14.0", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "praxis-ai-apis" +version = "0.2.0" +source = "git+https://github.com/praxis-proxy/ai?rev=2d07c828e64e4f1f0bd80297478a6d19648c0cf3#2d07c828e64e4f1f0bd80297478a6d19648c0cf3" +dependencies = [ + "async-trait", + "base64 0.23.1", + "bytes", + "dashmap 6.2.1", + "futures", + "http", + "percent-encoding", + "praxis-proxy-core", + "praxis-proxy-filter", + "quixotic-plecostomus-core", + "reqwest", + "rmcp", + "secrecy", + "serde", + "serde_json", + "sqlx", + "thiserror 2.0.20", + "tiktoken-rs", + "tokio", + "tracing", + "url", + "utoipa", + "yaml_serde", +] + +[[package]] +name = "praxis-ai-build-support" +version = "0.2.0" +source = "git+https://github.com/praxis-proxy/ai?rev=2d07c828e64e4f1f0bd80297478a6d19648c0cf3#2d07c828e64e4f1f0bd80297478a6d19648c0cf3" +dependencies = [ + "cargo_metadata", +] + +[[package]] +name = "praxis-ai-filters" +version = "0.2.0" +source = "git+https://github.com/praxis-proxy/ai?rev=2d07c828e64e4f1f0bd80297478a6d19648c0cf3#2d07c828e64e4f1f0bd80297478a6d19648c0cf3" +dependencies = [ + "arc-swap", + "async-trait", + "base64 0.23.1", + "bytes", + "chrono", + "dashmap 6.2.1", + "futures", + "http", + "metrics", + "notify", + "praxis-ai-apis", + "praxis-proxy-core", + "praxis-proxy-filter", + "reqwest", + "serde", + "serde_json", + "serde_json_canonicalizer", + "sha2 0.11.0", + "tokio", + "tokio-util", + "tracing", + "yaml_serde", + "zeroize", +] + +[[package]] +name = "praxis-ai-proxy" +version = "0.2.0" +source = "git+https://github.com/praxis-proxy/ai?rev=2d07c828e64e4f1f0bd80297478a6d19648c0cf3#2d07c828e64e4f1f0bd80297478a6d19648c0cf3" +dependencies = [ + "cargo_metadata", + "clap", + "nix", + "notify", + "praxis-ai-apis", + "praxis-ai-build-support", + "praxis-ai-filters", + "praxis-proxy-core", + "praxis-proxy-filter", + "praxis-proxy-protocol", + "praxis-proxy-tls", + "serde", + "tikv-jemallocator", + "tokio", + "tokio-util", + "tracing", + "yaml_serde", +] + +[[package]] +name = "praxis-proxy-core" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cdde88169a2237ad74e0a41211c8ed08a04ed183ad64f18574000009e19232" +dependencies = [ + "bytes", + "dashmap 6.2.1", + "http", + "praxis-proxy-tls", + "quixotic-plecostomus-core", + "quixotic-plecostomus-http", + "rand 0.10.2", + "regex", + "serde", + "thiserror 2.0.20", + "tokio", + "tracing", + "tracing-subscriber", + "yaml_serde", +] + +[[package]] +name = "praxis-proxy-filter" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0844747ee66c06b060ae145b201a31ec828670bf95d38adb6058b483d4050c0" +dependencies = [ + "async-trait", + "bytes", + "dashmap 6.2.1", + "http", + "metrics", + "percent-encoding", + "praxis-proxy-core", + "praxis-proxy-tls", + "quixotic-plecostomus-core", + "rand 0.10.2", + "regex", + "secrecy", + "serde", + "serde_json", + "smallvec", + "thiserror 2.0.20", + "tokio", + "tracing", + "yaml_serde", + "zeroize", +] + +[[package]] +name = "praxis-proxy-protocol" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6504d74ea05b2fc0ba9e934e845b8c40febc6c929194d5ca18c148cdd5f5bb7" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "futures", + "http", + "metrics", + "metrics-exporter-prometheus", + "praxis-proxy-core", + "praxis-proxy-filter", + "praxis-proxy-tls", + "quixotic-plecostomus-core", + "quixotic-plecostomus-http", + "quixotic-plecostomus-proxy", + "serde_json", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "praxis-proxy-tls" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "671e446adbc6edb635bf2af81b712abf628fc1ecce74799515fd860eba38247d" +dependencies = [ + "arc-swap", + "notify", + "rustls", + "rustls-pemfile", + "serde", + "thiserror 2.0.20", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "version_check", + "yansi", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.5", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quixotic-plecostomus-cache" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a820a1a3e59644b38bc03606b638b41f1c5a474215efcec37f94b15871a7e1c" +dependencies = [ + "ahash", + "async-trait", + "blake2", + "bstr", + "bytes", + "hex", + "http", + "httparse", + "httpdate", + "indexmap 1.9.3", + "log", + "lru", + "once_cell", + "parking_lot", + "quixotic-plecostomus-core", + "quixotic-plecostomus-error", + "quixotic-plecostomus-header-serde", + "quixotic-plecostomus-http", + "quixotic-plecostomus-lru", + "quixotic-plecostomus-timeout", + "rand 0.8.7", + "regex", + "rmp", + "rmp-serde", + "serde", + "strum 0.26.3", + "tokio", +] + +[[package]] +name = "quixotic-plecostomus-core" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d6d9e04414ba6819c51ec210eddf17865c505431a492c86c6356338c2b4f60" +dependencies = [ + "ahash", + "async-trait", + "brotli", + "bstr", + "bytes", + "chrono", + "clap", + "daemonix", + "daggy", + "derivative", + "flate2", + "flurry", + "futures", + "h2", + "http", + "httparse", + "httpdate", + "libc", + "log", + "nix", + "once_cell", + "openssl-probe 0.1.6", + "ouroboros", + "parking_lot", + "percent-encoding", + "quixotic-plecostomus-error", + "quixotic-plecostomus-http", + "quixotic-plecostomus-pool", + "quixotic-plecostomus-runtime", + "quixotic-plecostomus-rustls", + "quixotic-plecostomus-timeout", + "rand 0.8.7", + "regex", + "serde", + "serde_yaml", + "sfv", + "socket2", + "strum 0.26.3", + "strum_macros 0.26.4", + "tokio", + "tokio-stream", + "tokio-test", + "unicase", + "windows-sys 0.59.0", + "x509-parser", + "zstd", +] + +[[package]] +name = "quixotic-plecostomus-error" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8af30024450d6dc4785ccadf33e7cfd91f837bc6a226f133f9e77b397b4206c5" + +[[package]] +name = "quixotic-plecostomus-header-serde" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fe7db0e7899a6b2d2cf06e71adacfad4c1bcfe3b5f238494dd3b3eafb2c1b1" +dependencies = [ + "bytes", + "http", + "httparse", + "quixotic-plecostomus-error", + "quixotic-plecostomus-http", + "thread_local", + "zstd", + "zstd-safe", +] + +[[package]] +name = "quixotic-plecostomus-http" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f337131150ff271246df2584da8bdb0280b8424646d2c76e3926faa70409dc9" +dependencies = [ + "bytes", + "http", + "quixotic-plecostomus-error", +] + +[[package]] +name = "quixotic-plecostomus-lru" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec55d3bcbd56e52b3424bc62739a237d43c14df8ed04aaf294ae187b60da348a" +dependencies = [ + "arrayvec", + "hashbrown 0.17.1", + "parking_lot", + "rand 0.8.7", +] + +[[package]] +name = "quixotic-plecostomus-pool" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6383d2998b2298a0f34bb8a7330f5fea4bb0fb55671db2dc788b5f703c2009f" +dependencies = [ + "crossbeam-queue", + "dashmap 5.5.3", + "futures", + "log", + "lru", + "parking_lot", + "quixotic-plecostomus-timeout", + "tokio", +] + +[[package]] +name = "quixotic-plecostomus-proxy" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a953dcda74a2c196497ddc7892e571cdaa0bdb8acc8f7afb6d7b97339b28c658" +dependencies = [ + "async-trait", + "bytes", + "clap", + "futures", + "h2", + "http", + "log", + "once_cell", + "quixotic-plecostomus-cache", + "quixotic-plecostomus-core", + "quixotic-plecostomus-error", + "quixotic-plecostomus-http", + "rand 0.8.7", + "regex", + "tokio", +] + +[[package]] +name = "quixotic-plecostomus-runtime" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51621ff504cfde18409889d6d5b8773e0b0de0e4defa610006a36c27adc06a10" +dependencies = [ + "log", + "once_cell", + "rand 0.8.7", + "serde", + "thread_local", + "tokio", +] + +[[package]] +name = "quixotic-plecostomus-rustls" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc6ba2674ecab001d9ebdc6dc9944326857c07f9c65e7c0779f25db17f96ee84" +dependencies = [ + "log", + "no_debug", + "quixotic-plecostomus-error", + "ring", + "rustls", + "rustls-native-certs 0.7.3", + "rustls-pemfile", + "rustls-pki-types", + "tokio-rustls", +] + +[[package]] +name = "quixotic-plecostomus-timeout" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f76d183bcbdae3c0f4b0547e0fb7afdc658853a5e7421bbae1b59e48c25e667" +dependencies = [ + "once_cell", + "parking_lot", + "pin-project-lite", + "thread_local", + "tokio", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "rand_xoshiro" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "rapidhash" +version = "4.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e" +dependencies = [ + "rustversion", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "referencing" +version = "0.49.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6efa2154ea6f5ce0fdecdd2a8d18f2fa1a39a8fbba91564f555a592e4dce8278" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.17.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmcp" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f17072af977b0f86f714dbd64b3d37d0715bb63064f9d13483f0a1775813374" +dependencies = [ + "base64 0.23.1", + "bytes", + "chrono", + "futures", + "http", + "indexmap 2.14.0", + "pin-project-lite", + "reqwest", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rust_decimal" +version = "1.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" +dependencies = [ + "arrayvec", + "num-traits", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe 0.1.6", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework 2.11.1", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework 3.7.0", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs 0.8.4", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework 3.7.0", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "ryu-js" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "serde", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "seize" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "689224d06523904ebcc9b482c6a3f4f7fb396096645c4cd10c0d2ff7371a34d3" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_json_canonicalizer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe52319a927259afbfa5180c5157cd8167edfd3e8c254f9558c7fef44c5649f2" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sfv" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fa1f336066b758b7c9df34ed049c0e693a426afe2b27ff7d5b14f410ab1a132" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "rust_decimal", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "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]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "sketches-ddsketch" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "sqlx" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64 0.22.1", + "bytes", + "cfg-if", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink", + "indexmap 2.14.0", + "log", + "memchr", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots", +] + +[[package]] +name = "sqlx-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck 0.5.0", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.119", + "tokio", + "url", +] + +[[package]] +name = "sqlx-postgres" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "rand 0.10.2", + "serde", + "serde_json", + "sha2 0.11.0", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.20", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" +dependencies = [ + "atoi", + "flume", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "thiserror 2.0.20", + "tracing", + "url", +] + +[[package]] +name = "sse-stream" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c123f296ade4ec4b8b0f6162116e6629f5146922ca5ab40ca9d3c2e73ab4761e" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros 0.26.4", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "switchyard-filters" +version = "0.1.0" +dependencies = [ + "async-trait", + "bytes", + "dashmap 6.2.1", + "futures", + "http", + "praxis-proxy-core", + "praxis-proxy-filter", + "quixotic-plecostomus-core", + "serde", + "serde_json", + "switchyard-libsy", + "switchyard-protocol", + "switchyard-translation", + "thiserror 2.0.20", + "tokio", + "tracing", + "yaml_serde", +] + +[[package]] +name = "switchyard-libsy" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85f12e1ffefa168604044ccf78535a34dcde38eb59653ac27465e44113cd848c" +dependencies = [ + "async-trait", + "futures", + "jsonptr", + "jsonschema", + "opentelemetry", + "parking_lot", + "rand 0.10.2", + "serde", + "serde_json", + "switchyard-protocol", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tracing", + "tracing-opentelemetry", +] + +[[package]] +name = "switchyard-protocol" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8be18d2d2c600ba679651f9e31ec9eb9dff96060a078c39e50e42ccd4e2318" +dependencies = [ + "async-trait", + "futures", + "http", + "serde", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "switchyard-server" +version = "0.1.0" +dependencies = [ + "cargo_metadata", + "praxis-ai-build-support", + "praxis-ai-proxy", + "praxis-proxy-core", + "praxis-proxy-filter", + "switchyard-filters", +] + +[[package]] +name = "switchyard-translation" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40cc4f9517e2fa6ecf11c5ffaace82d04689d088ed3095631609e46d2a3f940" +dependencies = [ + "async-stream", + "futures", + "serde", + "serde_json", + "switchyard-protocol", + "thiserror 2.0.20", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiktoken-rs" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "027853bbf8c7763b77c5c595f1c271c7d536ced7d6f83452911b944621e57fc2" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bstr", + "fancy-regex 0.17.0", + "lazy_static", + "regex", + "rustc-hash", +] + +[[package]] +name = "tikv-jemalloc-sys" +version = "0.7.1+5.3.1-0-g81034ce1f1373e37dc865038e1bc8eeecf559ce8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2825c78386b4ae0314074867860ba9577875de945f05992c38815cbec327f0" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "tikv-jemallocator" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "249f09e49ab1609436f34c776e84231bead18d6a955f119f939bdc1d847561bd" +dependencies = [ + "libc", + "tikv-jemalloc-sys", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-test" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545" +dependencies = [ + "futures-core", + "tokio", + "tokio-stream", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-opentelemetry" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adbc64cba7137545b8044cb1fe9814f7aacf3c6b5f9b45be8bb5db538befdb26" +dependencies = [ + "js-sys", + "opentelemetry", + "smallvec", + "tracing", + "tracing-core", + "tracing-log", + "tracing-subscriber", + "web-time", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "utoipa" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "yaml_serde" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b729a08a9a6be689bbad3e2bf8015926db54b6622cc89c3a5f7dc174b9e918" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "libyaml-rs", + "ryu", + "serde", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "serde", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", ] diff --git a/Cargo.toml b/Cargo.toml index c688f8f..88a19d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,12 @@ [workspace] resolver = "3" -members = ["crates/experimental-probe"] +members = ["crates/experimental-probe", "crates/switchyard-filters", "crates/switchyard-server"] [workspace.package] version = "0.1.0" edition = "2024" -rust-version = "1.96" +# 1.96.1 (not the template's 1.96): the switchyard-* crates pin MSRV 1.96.1. +rust-version = "1.96.1" license = "MIT" [workspace.dependencies] diff --git a/Containerfile b/Containerfile index 490258a..b2f439d 100644 --- a/Containerfile +++ b/Containerfile @@ -21,10 +21,18 @@ WORKDIR /src COPY Cargo.toml Cargo.lock ./ COPY crates/experimental-probe/Cargo.toml crates/experimental-probe/Cargo.toml +COPY crates/switchyard-filters/Cargo.toml crates/switchyard-filters/Cargo.toml +COPY crates/switchyard-server/Cargo.toml crates/switchyard-server/Cargo.toml +# Every workspace member's manifest must resolve, so stub a +# source file for each (lib crates get lib.rs, bin crates main.rs). RUN mkdir -p crates/experimental-probe/src \ && echo '//! stub' > crates/experimental-probe/src/lib.rs \ - && printf '//! stub\nfn main() {}\n' > crates/experimental-probe/src/main.rs + && printf '//! stub\nfn main() {}\n' > crates/experimental-probe/src/main.rs \ + && mkdir -p crates/switchyard-filters/src \ + && echo '//! stub' > crates/switchyard-filters/src/lib.rs \ + && mkdir -p crates/switchyard-server/src \ + && printf '//! stub\nfn main() {}\n' > crates/switchyard-server/src/main.rs RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/src/target \ diff --git a/clippy.toml b/clippy.toml index 75013e5..f50d014 100644 --- a/clippy.toml +++ b/clippy.toml @@ -11,9 +11,20 @@ future-size-threshold = 4096 type-complexity-threshold = 200 stack-size-threshold = 16384 avoid-breaking-exported-api = false -msrv = "1.96" +msrv = "1.96.1" # ".." keeps clippy's built-in defaults; extend per project (product names, protocol acronyms) -doc-valid-idents = ["..", "IPv4", "IPv6", "gRPC", "OpenTelemetry", "MiB", "GiB", "KiB"] +doc-valid-idents = [ + "..", + "IPv4", + "IPv6", + "gRPC", + "OpenTelemetry", + "MiB", + "GiB", + "KiB", + "OpenAI", + "NeMo", +] disallowed-methods = [ { path = "std::thread::sleep", reason = "use tokio::time::sleep in async context" }, { path = "std::io::stdin", reason = "a server must not read interactive input" }, diff --git a/crates/switchyard-filters/Cargo.toml b/crates/switchyard-filters/Cargo.toml new file mode 100644 index 0000000..6ce4931 --- /dev/null +++ b/crates/switchyard-filters/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "switchyard-filters" +description = "Experimental Praxis AI filters built around NVIDIA NeMo Switchyard" +repository = "https://github.com/praxis-proxy/experimental" +homepage = "https://github.com/praxis-proxy/experimental" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish = false + +# Auto-discovery marker consumed by praxis-ai's build-time filter discovery +# (praxis-ai `server/build.rs` + `praxis-ai-build-support`). The table is +# intentionally empty; its presence is the whole signal. +[package.metadata.praxis-filters] + +[lints] +workspace = true + +[dependencies] +async-trait = "0.1.89" +bytes = "1.12.1" +dashmap = "6.2.1" +futures = "0.3.31" +http = "1.5.0" +serde = { workspace = true } +serde_json = "1.0.151" +serde_yaml = { package = "yaml_serde", version = "0.10.4" } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["net", "time"] } +tracing = { workspace = true } + +# Praxis core (crates.io). Keep in lockstep with praxis-proxy/ai's pins so the +# `FilterRegistry` type unifies to a single version across this workspace and the +# git-pinned praxis-ai-proxy server crate. +praxis-filter = { version = "0.5.2", package = "praxis-proxy-filter" } +praxis-core = { version = "0.5.2", package = "praxis-proxy-core" } +# The pingora fork praxis builds on; needed only for `HttpPeer` (judge callout +# targets). Keep in lockstep with praxis-proxy-filter's own pin. +pingora-core = { version = "0.8.2", package = "quixotic-plecostomus-core", features = ["rustls"] } + +# NVIDIA NeMo Switchyard (pre-alpha; decision library). Pinned exactly: +# =0.2.0 == git tag v0.2.0 == commit 1fc9ab887d1c663b0048ae24d5f473d15ed8daaa. +# Any bump is a deliberate change: re-run deny/audit and re-verify the API +# surface documented in docs/proposals and the module docs here. +switchyard-libsy = "=0.2.0" +switchyard-protocol = "=0.2.0" +switchyard-translation = "=0.2.0" + +[dev-dependencies] +# Loopback mock judge + echo upstreams keep the integration tests hermetic. +tokio = { workspace = true, features = ["net", "time", "rt-multi-thread", "macros"] } diff --git a/crates/switchyard-filters/src/lib.rs b/crates/switchyard-filters/src/lib.rs new file mode 100644 index 0000000..ce3515c --- /dev/null +++ b/crates/switchyard-filters/src/lib.rs @@ -0,0 +1,17 @@ +//! Experimental Praxis AI filters built around NVIDIA NeMo Switchyard. +//! +//! This crate is discovered at build time by praxis-ai's +//! `[package.metadata.praxis-filters]` auto-discovery (see praxis-ai +//! `server/build.rs` and `praxis-ai-build-support`). The generated +//! registration code calls [`register_filters`], which is emitted by the +//! [`praxis_filter::export_filters!`] macro invoked below. +//! +//! It ships the `switchyard_route` filter (praxis-proxy/experimental#2): +//! Capability-mode Mixture-of-Models routing on top of `switchyard-libsy` +//! v0.2.0. See the `switchyard` module docs for the design. + +mod switchyard; + +praxis_filter::export_filters! { + http "switchyard_route" => switchyard::SwitchyardRouteFilter::from_config, +} diff --git a/crates/switchyard-filters/src/switchyard.rs b/crates/switchyard-filters/src/switchyard.rs new file mode 100644 index 0000000..d59949f --- /dev/null +++ b/crates/switchyard-filters/src/switchyard.rs @@ -0,0 +1,365 @@ +//! The `switchyard_route` filter: Capability-mode Mixture-of-Models routing. +//! +//! Embeds NVIDIA NeMo Switchyard's `LlmTaskClassifier` (Capability mode) in +//! the Praxis request path as a **decision-only** router: one cheap judge +//! callout classifies the request, Switchyard names an abstract tier tag +//! (`weak` / `strong`), and the filter resolves that tag through its own +//! config table into a real `(cluster, model)` pair — rewriting the body's +//! `model` field *and* selecting the cluster. The answer is served once, by +//! the real upstream; the filter never serves it. +//! +//! # The two-phase pipeline +//! +//! With `BodyMode::StreamBuffer`, `on_request_body` runs **before** +//! `on_request`. The decision and the body rewrite happen in the body phase; +//! the chosen cluster is stashed in filter metadata and applied to +//! `ctx.cluster` in `on_request`. +//! +//! # The no-downgrade guarantee +//! +//! Once a session routes to `strong` it must never silently drop to `weak`. +//! Switchyard v0.2.0 cannot provide this (its affinity is a +//! first-decision-wins latch and its state is neither durable nor seedable), +//! so the host owns it, in two layers: +//! +//! 1. **Don't overwrite on failure:** on *any* failure the request passes through with the client's own `model` +//! untouched and no cluster set — the filter can never cause a downgrade by clobbering a good model. +//! 2. **Session floor** (`floor.rs`): an in-process `session → tier` ratchet; every decision is clamped to `max(floor, +//! decision)`. Optionally the below-floor tier is also excluded inside Switchyard. +//! +//! A durable floor store (Redis/KV) is a planned follow-up; until then the +//! strict ratchet holds per replica and layer 1 covers state loss. +//! +//! # YAML +//! +//! ```yaml +//! filter: switchyard_route +//! judge: +//! endpoint: "http://judge.internal:8000/v1/chat/completions" +//! model: qwen3-judge +//! timeout_ms: 2000 +//! max_response_bytes: 65536 +//! # Optional credential for a hosted judge. The secret lives only in the +//! # environment; config names the variable holding it. Omit for a keyless +//! # (e.g. local vLLM/Ollama) judge. +//! auth: +//! value_env: OPENAI_API_KEY # env var holding the token +//! header: authorization # default: authorization +//! scheme: Bearer # default: Bearer ("" sends the raw value) +//! threshold: 0.5 +//! targets: +//! weak: +//! cluster: local-vllm +//! model: qwen2.5-7b +//! strong: +//! cluster: openai-frontier +//! model: gpt-4o +//! session_floor: +//! enabled: true +//! ttl_secs: 3600 +//! exclude_below: true +//! on_failure: open +//! max_body_bytes: 1048576 +//! session_header: x-switchyard-session-id +//! ``` + +mod algorithm; +mod config; +mod error; +mod floor; +mod judge; +mod peer; +mod steps; +mod translate; + +#[cfg(test)] +#[expect(clippy::allow_attributes, reason = "blanket test-module suppressions")] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::too_many_lines, + clippy::inconsistent_struct_constructor, + reason = "unwrap/panic and long literals are acceptable in tests" +)] +mod tests; + +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; + +use async_trait::async_trait; +use bytes::Bytes; +use praxis_filter::{BodyAccess, BodyMode, FilterAction, FilterError, HttpFilter, HttpFilterContext, Rejection}; +use switchyard_libsy::Algorithm; +use switchyard_protocol::{Context, Metadata, Request, WireFormat}; +use tracing::{debug, warn}; + +use self::{ + config::{FailureMode, RouteConfig, Tier}, + error::RouteError, + floor::{InMemorySessionFloor, SessionFloorStore as _}, + peer::{JudgeEndpoint, SubRequestJudge}, +}; + +/// Metadata key carrying the chosen cluster from the body phase to `on_request`. +const METADATA_CLUSTER: &str = "switchyard_route.cluster"; +/// Metadata key recording the chosen tier tag. +const METADATA_TIER: &str = "switchyard_route.tier"; +/// Metadata key recording the model written into the body. +const METADATA_MODEL: &str = "switchyard_route.model"; +/// Metadata key recording why routing was skipped. +const METADATA_ERROR: &str = "switchyard_route.error"; +/// Header that marks a session's final turn and evicts its floor. +const SESSION_FINAL_HEADER: &str = "x-switchyard-session-final"; +/// Byte cap for the error metadata value (praxis drops larger values). +const METADATA_VALUE_LIMIT: usize = 256; + +/// The `switchyard_route` HTTP filter. +pub(crate) struct SwitchyardRouteFilter { + /// Validated filter configuration. + config: RouteConfig, + /// The Capability-mode classifier, built once at config time. + algorithm: Arc, + /// The judge endpoint, parsed once at config time. + judge_endpoint: JudgeEndpoint, + /// Host-owned no-downgrade ratchet; `None` when disabled. + floor: Option, +} + +impl SwitchyardRouteFilter { + /// Creates the filter from parsed YAML config. + /// + /// # Errors + /// + /// Returns a [`FilterError`] when the YAML is invalid or Switchyard + /// rejects the classifier configuration. + pub(crate) fn from_config(config: &serde_yaml::Value) -> Result, FilterError> { + let parsed = config::parse_config(config)?; + let algorithm = algorithm::build_algorithm(&parsed)?; + let judge_endpoint = JudgeEndpoint::from_config(&parsed.judge)?; + let floor = parsed + .session_floor + .enabled + .then(|| InMemorySessionFloor::new(Duration::from_secs(parsed.session_floor.ttl_secs))); + Ok(Box::new(Self { + config: parsed, + algorithm, + judge_endpoint, + floor, + })) + } + + /// Runs the full routing pass: parse, decide, clamp, rewrite, stash. + async fn route(&self, ctx: &mut HttpFilterContext<'_>, body: &mut Option) -> Result<(), RouteError> { + let mut value = parse_body(body.as_ref())?; + let format = translate::detect_format(ctx.request.uri.path(), &value)?; + let session = self.session_id(ctx); + let now = Instant::now(); + let floor = self.session_floor(session.as_deref(), now); + let chosen = if self.ratchet_holds(floor) { + // Escalation ratchet: a session already floored at the top tier can + // only stay there, so the judge verdict is predetermined. Skip the + // callout entirely rather than pay to re-derive it and discard it. + debug!("switchyard_route: escalation ratchet held; judge skipped"); + Tier::Strong + } else { + let tag = self.decide(ctx, format, &value, floor).await?; + let tier = Tier::from_tag(&tag).ok_or(RouteError::UnknownTier(tag))?; + floor.map_or(tier, |held| held.max(tier)) + }; + self.commit_floor(ctx, session.as_deref(), chosen, now); + self.apply(ctx, body, &mut value, chosen) + } + + /// Runs Switchyard's step stream to a routed decision tag. + async fn decide( + &self, + ctx: &HttpFilterContext<'_>, + format: WireFormat, + value: &serde_json::Value, + floor: Option, + ) -> Result { + let client = ctx.subrequest_client.ok_or(RouteError::MissingSubrequestClient)?; + let llm_request = translate::decode_for_judge(format, value)?; + let request = Request { + llm_request, + raw_request: None, + metadata: Some(Metadata::from_headers(&ctx.request.headers)), + }; + let mut switchyard_context = Context::default(); + if floor == Some(Tier::Strong) && self.config.session_floor.exclude_below { + switchyard_context.exclude_target(Tier::Weak.tag()); + } + let transport = SubRequestJudge::new(client, &self.judge_endpoint, &self.config.judge); + let stream = Arc::clone(&self.algorithm).run_stream(switchyard_context, request, None); + steps::decide(stream, &self.config.judge.model, &transport).await + } + + /// Applies a clamped decision: rewrites the body model and stashes the + /// cluster (plus observability keys) in filter metadata. + fn apply( + &self, + ctx: &mut HttpFilterContext<'_>, + body: &mut Option, + value: &mut serde_json::Value, + chosen: Tier, + ) -> Result<(), RouteError> { + let target = self.config.targets.tier(chosen); + let bytes = translate::rewrite_model(value, &target.model)?; + *body = Some(Bytes::from(bytes)); + ctx.set_metadata(METADATA_CLUSTER, target.cluster.clone()); + ctx.set_metadata(METADATA_TIER, chosen.tag()); + ctx.set_metadata(METADATA_MODEL, target.model.clone()); + debug!( + tier = chosen.tag(), + cluster = %target.cluster, + model = %target.model, + "switchyard_route: routed" + ); + Ok(()) + } + + /// Whether the escalation ratchet lets us skip the judge for this turn: + /// enabled, and the session is already floored at the top tier. + fn ratchet_holds(&self, floor: Option) -> bool { + self.config.session_floor.escalation_ratchet && floor == Some(Tier::Strong) + } + + /// Reads the session id from the configured header, if present. + fn session_id(&self, ctx: &HttpFilterContext<'_>) -> Option { + let value = ctx.request.headers.get(self.config.session_header.as_str())?; + let text = value.to_str().ok()?.trim(); + (!text.is_empty()).then(|| text.to_owned()) + } + + /// The live floor for this session, when the ratchet is enabled. + fn session_floor(&self, session: Option<&str>, now: Instant) -> Option { + self.floor.as_ref()?.floor(session?, now) + } + + /// Ratchets the floor after a successful decision, or evicts it on the + /// session's final turn. + fn commit_floor(&self, ctx: &HttpFilterContext<'_>, session: Option<&str>, chosen: Tier, now: Instant) { + let (Some(store), Some(session)) = (self.floor.as_ref(), session) else { + return; + }; + if session_final(&ctx.request.headers) { + store.evict(session); + } else { + store.commit(session, chosen, now); + } + } + + /// The single failure path: pass through unmodified (open) or 503 (closed). + fn fail(&self, ctx: &mut HttpFilterContext<'_>, route_error: &RouteError) -> FilterAction { + warn!(error = %route_error, "switchyard_route: routing unavailable"); + match self.config.on_failure { + FailureMode::Open => { + ctx.set_metadata(METADATA_ERROR, truncate(&route_error.to_string(), METADATA_VALUE_LIMIT)); + FilterAction::Continue + }, + FailureMode::Closed => { + FilterAction::Reject(Rejection::status(503).with_body("switchyard_route: routing unavailable")) + }, + } + } +} + +#[async_trait] +impl HttpFilter for SwitchyardRouteFilter { + fn name(&self) -> &'static str { + "switchyard_route" + } + + /// This filter satisfies the pipeline's "cluster-selecting filter before + /// `load_balancer`" requirement. + fn selects_cluster(&self) -> bool { + true + } + + fn selected_clusters(&self) -> Vec { + let weak = self.config.targets.weak.cluster.clone(); + let strong = self.config.targets.strong.cluster.clone(); + if weak == strong { vec![weak] } else { vec![weak, strong] } + } + + fn needs_request_context(&self) -> bool { + true + } + + fn request_body_access(&self) -> BodyAccess { + BodyAccess::ReadWrite + } + + fn request_body_mode(&self) -> BodyMode { + BodyMode::StreamBuffer { + max_bytes: Some(self.config.max_body_bytes), + } + } + + async fn on_request(&self, ctx: &mut HttpFilterContext<'_>) -> Result { + if ctx.cluster.is_some() { + debug!("switchyard_route: cluster already set; preserving"); + return Ok(FilterAction::Continue); + } + let cluster: Option> = ctx.get_metadata(METADATA_CLUSTER).map(Arc::from); + if let Some(cluster) = cluster { + ctx.cluster = Some(cluster); + } + Ok(FilterAction::Continue) + } + + async fn on_request_body( + &self, + ctx: &mut HttpFilterContext<'_>, + body: &mut Option, + end_of_stream: bool, + ) -> Result { + if !end_of_stream { + return Ok(FilterAction::Continue); + } + match self.route(ctx, body).await { + Ok(()) => Ok(FilterAction::Continue), + Err(route_error) => Ok(self.fail(ctx, &route_error)), + } + } +} + +/// Parses the buffered body into a JSON object. +fn parse_body(body: Option<&Bytes>) -> Result { + let raw = body + .filter(|bytes| !bytes.is_empty()) + .ok_or(RouteError::Body("is missing or empty"))?; + let value: serde_json::Value = serde_json::from_slice(raw).map_err(|err| RouteError::Json(err.to_string()))?; + if value.is_object() { + Ok(value) + } else { + Err(RouteError::Body("is not a JSON object")) + } +} + +/// Whether this turn is marked as the session's last. +fn session_final(headers: &http::HeaderMap) -> bool { + headers + .get(SESSION_FINAL_HEADER) + .and_then(|value| value.to_str().ok()) + .is_some_and(|text| matches!(text.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on")) +} + +/// Truncates to at most `max_bytes` bytes on a character boundary. +fn truncate(text: &str, max_bytes: usize) -> String { + let mut out = String::new(); + for character in text.chars() { + let Some(total) = out.len().checked_add(character.len_utf8()) else { + break; + }; + if total > max_bytes { + break; + } + out.push(character); + } + out +} diff --git a/crates/switchyard-filters/src/switchyard/algorithm.rs b/crates/switchyard-filters/src/switchyard/algorithm.rs new file mode 100644 index 0000000..e06d092 --- /dev/null +++ b/crates/switchyard-filters/src/switchyard/algorithm.rs @@ -0,0 +1,62 @@ +//! Assembly of the Switchyard Capability-mode classifier. + +use std::sync::Arc; + +use praxis_filter::FilterError; +use switchyard_libsy::{ + Algorithm, ClassifierContractConfig, LlmClassifierConfig, LlmTarget, LlmTaskClassifier, TaskClassifierConfig, +}; + +use super::config::{RouteConfig, Tier}; + +/// Switchyard `semantic_name` for the judge target. +pub(crate) const JUDGE_TAG: &str = "judge"; + +/// Builds the Capability-mode classifier from validated filter config. +/// +/// Built **once** at `from_config` time: the algorithm owns per-session state +/// machinery (with an internal hourly sweeper task spawned on first use) that +/// must not be re-created per request — and none of it affects routing while +/// `session_affinity` stays `false`, which it must. Plain `session_affinity` +/// wires Switchyard's `AffinityRouter::new()`, a first-decision-wins latch that +/// would pin a session to whatever it decided first (including `weak`) — the +/// wrong shape for a no-downgrade guarantee. Switchyard *does* ship a directional +/// escalation latch (`AffinityRouter::with_latch_only(["strong"])`), but it keys +/// on request metadata and lives inside the classifier cascade, whereas this +/// filter drives the algorithm per call via `run_stream` and keeps session state +/// in `floor.rs`, keyed by the session header. So the floor owns the +/// no-downgrade guarantee, and the optional judge-skip on an already-strong +/// session (`session_floor.escalation_ratchet`) is the same escalation-latch +/// behaviour expressed against that store. +/// +/// # Errors +/// +/// Returns a [`FilterError`] when Switchyard rejects the classifier config. +pub(crate) fn build_algorithm(config: &RouteConfig) -> Result, FilterError> { + let classifier_config = LlmClassifierConfig::Capability { + judge_target: target(JUDGE_TAG), + efficient_target: target(Tier::Weak.tag()), + capable_target: target(Tier::Strong.tag()), + config: TaskClassifierConfig { + base_threshold: config.threshold, + session_affinity: false, + contract: ClassifierContractConfig::default(), + ..TaskClassifierConfig::default() + }, + }; + let classifier = LlmTaskClassifier::new(classifier_config) + .map_err(|err| -> FilterError { format!("switchyard_route: classifier config rejected: {err}").into() })?; + Ok(Arc::new(classifier)) +} + +/// Builds a client-less [`LlmTarget`] for `tag`. +/// +/// No target carries an `llm_client`: in `run_stream` mode the driver always +/// offloads every model call to the step stream, so the filter serves the +/// judge itself (and never serves the answer call). +fn target(tag: &str) -> LlmTarget { + LlmTarget { + semantic_name: tag.to_owned(), + llm_client: None, + } +} diff --git a/crates/switchyard-filters/src/switchyard/config.rs b/crates/switchyard-filters/src/switchyard/config.rs new file mode 100644 index 0000000..da4b27c --- /dev/null +++ b/crates/switchyard-filters/src/switchyard/config.rs @@ -0,0 +1,612 @@ +//! Configuration surface for the `switchyard_route` filter. +//! +//! The tag→(cluster, model) table is the heart of the filter: Switchyard only +//! ever sees the abstract tier tags (`weak` / `strong` / `judge`), and this +//! module owns their resolution to a real Praxis cluster and upstream model +//! name. Swapping what `strong` means is a config-only change. + +use std::time::Duration; + +use praxis_filter::FilterError; +use serde::Deserialize; + +/// Longest permitted session-floor TTL (7 days), mirroring the bounded-TTL +/// convention of the stock `intelligent_route` session affinity. +const MAX_TTL_SECS: u64 = 604_800; + +/// Longest permitted judge callout deadline (10 minutes). +const MAX_TIMEOUT_MS: u64 = 600_000; + +/// The abstract routing tier ladder, ordered weakest first. +/// +/// The `Ord` derive is load-bearing: the session-floor clamp +/// (`max(floor, decision)`) relies on `Weak < Strong`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum Tier { + /// The efficient (cheaper) tier, fed to Switchyard as `efficient_target`. + Weak, + /// The capable (stronger) tier, fed to Switchyard as `capable_target`. + Strong, +} + +impl Tier { + /// The Switchyard `semantic_name` tag for this tier. + pub(crate) fn tag(self) -> &'static str { + match self { + Self::Weak => "weak", + Self::Strong => "strong", + } + } + + /// Parses a Switchyard `semantic_name` tag back into a tier. + pub(crate) fn from_tag(tag: &str) -> Option { + match tag { + "weak" => Some(Self::Weak), + "strong" => Some(Self::Strong), + _ => None, + } + } +} + +/// Judge callout authentication. +/// +/// A hosted OpenAI-compatible judge (OpenAI, Together, Fireworks, …) requires +/// a bearer token. The secret itself is **never** written into YAML: config +/// names the environment variable holding it (`value_env`), and the value is +/// resolved once at filter construction time so a missing secret fails fast +/// rather than silently sending an unauthenticated callout. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct JudgeAuthConfig { + /// Environment variable holding the credential (e.g. `OPENAI_API_KEY`). + pub(crate) value_env: String, + /// Header the credential is sent in. + #[serde(default = "default_auth_header")] + pub(crate) header: String, + /// Prefix prepended to the resolved value (e.g. `Bearer`). An empty string + /// sends the raw credential; a trailing space is added automatically. + #[serde(default = "default_auth_scheme")] + pub(crate) scheme: String, +} + +/// Judge (classifier) callout configuration. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct JudgeConfig { + /// Absolute URL of the judge's OpenAI-compatible chat-completions endpoint. + pub(crate) endpoint: String, + /// Model name substituted into the classifier request sent to the judge. + pub(crate) model: String, + /// Optional credential for a hosted judge; absent for keyless endpoints. + #[serde(default)] + pub(crate) auth: Option, + /// Deadline in milliseconds covering DNS resolution and the HTTP exchange. + #[serde(default = "default_judge_timeout_ms")] + pub(crate) timeout_ms: u64, + /// Maximum accepted judge response size in bytes. + #[serde(default = "default_judge_max_response_bytes")] + pub(crate) max_response_bytes: usize, +} + +impl JudgeConfig { + /// The callout deadline as a [`Duration`]. + pub(crate) fn timeout(&self) -> Duration { + Duration::from_millis(self.timeout_ms) + } +} + +/// A single routing tier's resolution: which Praxis cluster to dial and which +/// real model name to write into the forwarded request body. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct TargetConfig { + /// Praxis cluster name assigned to `ctx.cluster`. + pub(crate) cluster: String, + /// Upstream model name written into the request body's `model` field. + pub(crate) model: String, +} + +/// The tag→(cluster, model) table. Exactly two tiers: Capability mode is a +/// binary efficient/capable split by construction. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct TargetsConfig { + /// Resolution for the `weak` (efficient) tier. + pub(crate) weak: TargetConfig, + /// Resolution for the `strong` (capable) tier. + pub(crate) strong: TargetConfig, +} + +impl TargetsConfig { + /// Resolves a tier to its configured target. + pub(crate) fn tier(&self, tier: Tier) -> &TargetConfig { + match tier { + Tier::Weak => &self.weak, + Tier::Strong => &self.strong, + } + } +} + +/// The host-owned no-downgrade ratchet (session floor) configuration. +/// +/// The three flags are independent on/off deserialization knobs a YAML author +/// sets directly, so plain `bool`s are the honest shape here; folding them into +/// two-variant enums would only obscure the config surface. +#[expect( + clippy::struct_excessive_bools, + reason = "independent YAML config toggles, not a state machine" +)] +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct SessionFloorConfig { + /// Whether the in-process session floor is maintained at all. + #[serde(default = "default_true")] + pub(crate) enabled: bool, + /// Inactivity eviction window in seconds (aligns with Switchyard's 1h). + #[serde(default = "default_floor_ttl_secs")] + pub(crate) ttl_secs: u64, + /// Also bar the below-floor tier inside Switchyard via + /// `Context::exclude_target` (belt-and-suspenders with the post-hoc clamp). + #[serde(default = "default_true")] + pub(crate) exclude_below: bool, + /// Skip the judge callout entirely once a session is floored at the top + /// tier, since its verdict is then predetermined by the clamp. + /// + /// Off by default: Switchyard's own Capability routing judges every turn, + /// and so does this filter unless you opt in. When enabled this is the + /// filter-side equivalent of Switchyard's escalation latch + /// (`AffinityRouter::with_latch_only(["strong"])`): a session that has + /// reached `strong` can only stay `strong`, so re-judging it spends a judge + /// call to re-derive an answer the floor already forces. Turning this on + /// stops that spend at the cost of the (already-decided) per-turn verdict in + /// the logs. Has no effect unless the floor is `enabled`. + #[serde(default)] + pub(crate) escalation_ratchet: bool, +} + +impl Default for SessionFloorConfig { + fn default() -> Self { + Self { + enabled: true, + ttl_secs: default_floor_ttl_secs(), + exclude_below: true, + escalation_ratchet: false, + } + } +} + +/// What to do with the request when a routing decision cannot be produced. +/// +/// Configured via the `on_failure` key — NOT `failure_mode`, which is a +/// structural key of Praxis's pipeline `FilterEntry` wrapper and is stripped +/// by `parse_filter_config` before the filter ever sees it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum FailureMode { + /// Pass the request through **unmodified** — never force a tier, so a + /// broken optimizer can neither 5xx the request nor downgrade a session. + Open, + /// Reject with 503 for deployments that would rather fail than route + /// un-optimized. + Closed, +} + +/// Parsed and validated `switchyard_route` configuration. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct RouteConfig { + /// The classifier callout — required; the judge is the whole point. + pub(crate) judge: JudgeConfig, + /// Capability classifier decision threshold (`base_threshold`). + #[serde(default = "default_threshold")] + pub(crate) threshold: f64, + /// The tag→(cluster, model) resolution table. + pub(crate) targets: TargetsConfig, + /// Host-owned no-downgrade ratchet settings. + #[serde(default)] + pub(crate) session_floor: SessionFloorConfig, + /// Behaviour when no routing decision can be produced (see [`FailureMode`] + /// for why the key is `on_failure`). + #[serde(default = "default_failure_mode")] + pub(crate) on_failure: FailureMode, + /// `StreamBuffer` cap for the buffered request body, in bytes. + #[serde(default = "default_max_body_bytes")] + pub(crate) max_body_bytes: usize, + /// Header carrying the session id the floor is keyed by. + #[serde(default = "default_session_header")] + pub(crate) session_header: String, +} + +/// Serde default: `true`. +fn default_true() -> bool { + true +} + +/// Serde default for [`JudgeAuthConfig::header`]: `authorization`. +fn default_auth_header() -> String { + "authorization".to_owned() +} + +/// Serde default for [`JudgeAuthConfig::scheme`]: `Bearer`. +fn default_auth_scheme() -> String { + "Bearer".to_owned() +} + +/// Serde default for [`JudgeConfig::timeout_ms`]: 2 seconds. +fn default_judge_timeout_ms() -> u64 { + 2_000 +} + +/// Serde default for [`JudgeConfig::max_response_bytes`]: 64 KiB. +fn default_judge_max_response_bytes() -> usize { + 65_536 +} + +/// Serde default for [`RouteConfig::threshold`]. +fn default_threshold() -> f64 { + 0.5 +} + +/// Serde default for [`SessionFloorConfig::ttl_secs`]: 1 hour. +fn default_floor_ttl_secs() -> u64 { + 3_600 +} + +/// Serde default for [`RouteConfig::on_failure`]: fail open. +fn default_failure_mode() -> FailureMode { + FailureMode::Open +} + +/// Serde default for [`RouteConfig::max_body_bytes`]: 1 MiB. +fn default_max_body_bytes() -> usize { + 1_048_576 +} + +/// Serde default for [`RouteConfig::session_header`]: Switchyard's native +/// session id header. +fn default_session_header() -> String { + "x-switchyard-session-id".to_owned() +} + +/// Builds a config-shaped [`FilterError`]. +fn config_error(message: &str) -> FilterError { + format!("switchyard_route: {message}").into() +} + +/// Validates the judge callout settings. +/// +/// # Errors +/// +/// Returns a [`FilterError`] when the endpoint is not an absolute http(s) URL, +/// the model is empty, or a limit is out of range. +fn validate_judge(judge: &JudgeConfig) -> Result<(), FilterError> { + let uri: http::Uri = judge + .endpoint + .parse() + .map_err(|parse_error| config_error(&format!("judge.endpoint is not a valid URL: {parse_error}")))?; + match uri.scheme_str() { + Some("http" | "https") => {}, + Some(other) => { + return Err(config_error(&format!( + "judge.endpoint has unsupported scheme '{other}'" + ))); + }, + None => return Err(config_error("judge.endpoint must be an absolute http(s) URL")), + } + if uri.authority().is_none() { + return Err(config_error("judge.endpoint is missing a host")); + } + if judge.model.trim().is_empty() { + return Err(config_error("judge.model must not be empty")); + } + if judge.timeout_ms == 0 || judge.timeout_ms > MAX_TIMEOUT_MS { + return Err(config_error(&format!("judge.timeout_ms must be 1-{MAX_TIMEOUT_MS}"))); + } + if judge.max_response_bytes == 0 { + return Err(config_error("judge.max_response_bytes must be at least 1")); + } + if let Some(auth) = judge.auth.as_ref() { + validate_auth(auth)?; + } + Ok(()) +} + +/// Validates the judge authentication block. +/// +/// # Errors +/// +/// Returns a [`FilterError`] when the env var name is empty or the header name +/// is not a valid HTTP header. The credential value is resolved (and its +/// presence checked) later, at filter construction time. +fn validate_auth(auth: &JudgeAuthConfig) -> Result<(), FilterError> { + if auth.value_env.trim().is_empty() { + return Err(config_error("judge.auth.value_env must not be empty")); + } + if http::header::HeaderName::from_bytes(auth.header.as_bytes()).is_err() { + return Err(config_error("judge.auth.header is not a valid header name")); + } + Ok(()) +} + +/// Validates one tier's target table entry. +/// +/// # Errors +/// +/// Returns a [`FilterError`] when the cluster or model is empty. +fn validate_target(tier: Tier, target: &TargetConfig) -> Result<(), FilterError> { + if target.cluster.trim().is_empty() { + return Err(config_error(&format!( + "targets.{}.cluster must not be empty", + tier.tag() + ))); + } + if target.model.trim().is_empty() { + return Err(config_error(&format!("targets.{}.model must not be empty", tier.tag()))); + } + Ok(()) +} + +/// Validates the remaining scalar settings. +/// +/// # Errors +/// +/// Returns a [`FilterError`] when the threshold, floor TTL, body cap, or +/// session header is out of range or malformed. +fn validate_scalars(config: &RouteConfig) -> Result<(), FilterError> { + if !(0.0..=1.0).contains(&config.threshold) { + return Err(config_error("threshold must be within [0, 1]")); + } + if config.session_floor.enabled + && (config.session_floor.ttl_secs == 0 || config.session_floor.ttl_secs > MAX_TTL_SECS) + { + return Err(config_error(&format!( + "session_floor.ttl_secs must be 1-{MAX_TTL_SECS}" + ))); + } + if config.max_body_bytes == 0 { + return Err(config_error("max_body_bytes must be at least 1")); + } + if http::header::HeaderName::from_bytes(config.session_header.as_bytes()).is_err() { + return Err(config_error("session_header is not a valid header name")); + } + Ok(()) +} + +/// Parses and validates the filter's YAML configuration. +/// +/// # Errors +/// +/// Returns a [`FilterError`] when the YAML does not deserialize into +/// [`RouteConfig`] or any field fails validation. +pub(crate) fn parse_config(config: &serde_yaml::Value) -> Result { + let parsed: RouteConfig = praxis_filter::parse_filter_config("switchyard_route", config)?; + validate_judge(&parsed.judge)?; + validate_target(Tier::Weak, &parsed.targets.weak)?; + validate_target(Tier::Strong, &parsed.targets.strong)?; + validate_scalars(&parsed)?; + Ok(parsed) +} + +#[cfg(test)] +#[expect(clippy::allow_attributes, reason = "blanket test-module suppressions")] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + reason = "unwrap/panic are acceptable in tests" +)] +mod tests { + use super::{FailureMode, RouteConfig, Tier, parse_config}; + + /// A minimal valid YAML config exercising the serde defaults. + const MINIMAL: &str = " +judge: + endpoint: http://judge.internal:8000/v1/chat/completions + model: qwen3-judge +targets: + weak: + cluster: local-vllm + model: qwen2.5-7b + strong: + cluster: openai-frontier + model: gpt-4o +"; + + /// Parses YAML into a config, panicking on failure. + fn parse(yaml: &str) -> RouteConfig { + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + parse_config(&value).unwrap() + } + + /// Parses YAML expecting a validation failure, returning the message. + fn parse_err(yaml: &str) -> String { + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + parse_config(&value).unwrap_err().to_string() + } + + #[test] + fn minimal_config_applies_defaults() { + let config = parse(MINIMAL); + assert!( + (config.threshold - 0.5).abs() < f64::EPSILON, + "default threshold is 0.5" + ); + assert_eq!(config.on_failure, FailureMode::Open, "default failure mode is open"); + assert_eq!(config.max_body_bytes, 1_048_576, "default body cap is 1 MiB"); + assert_eq!( + config.session_header, "x-switchyard-session-id", + "default session header" + ); + assert!(config.session_floor.enabled, "session floor defaults on"); + assert_eq!(config.session_floor.ttl_secs, 3_600, "default floor TTL is 1h"); + assert!(config.session_floor.exclude_below, "exclude_below defaults on"); + assert!( + !config.session_floor.escalation_ratchet, + "escalation ratchet defaults off (Switchyard vanilla: judge every turn)" + ); + assert_eq!(config.judge.timeout_ms, 2_000, "default judge timeout"); + assert_eq!(config.judge.max_response_bytes, 65_536, "default judge response cap"); + } + + #[test] + fn full_config_round_trips() { + let yaml = " +judge: + endpoint: https://judge.internal/v1/chat/completions + model: judge-model + timeout_ms: 500 + max_response_bytes: 1024 +threshold: 0.75 +targets: + weak: { cluster: c-weak, model: m-weak } + strong: { cluster: c-strong, model: m-strong } +session_floor: + enabled: true + ttl_secs: 60 + exclude_below: false +on_failure: closed +max_body_bytes: 2048 +session_header: x-my-session +"; + let config = parse(yaml); + assert_eq!(config.on_failure, FailureMode::Closed, "failure mode parsed"); + assert_eq!(config.targets.tier(Tier::Weak).cluster, "c-weak", "weak cluster parsed"); + assert_eq!( + config.targets.tier(Tier::Strong).model, + "m-strong", + "strong model parsed" + ); + assert!(!config.session_floor.exclude_below, "exclude_below parsed"); + assert_eq!(config.session_header, "x-my-session", "session header parsed"); + } + + #[test] + fn escalation_ratchet_round_trips_when_enabled() { + let yaml = format!("{MINIMAL}\nsession_floor:\n escalation_ratchet: true\n"); + let config = parse(&yaml); + assert!( + config.session_floor.escalation_ratchet, + "escalation_ratchet opts in when set" + ); + assert!( + config.session_floor.enabled, + "the floor stays on so the ratchet has a floor to key off" + ); + } + + #[test] + fn missing_judge_is_rejected() { + let yaml = " +targets: + weak: { cluster: a, model: b } + strong: { cluster: c, model: d } +"; + let message = parse_err(yaml); + assert!( + message.contains("judge"), + "error should name the missing field: {message}" + ); + } + + #[test] + fn unknown_keys_are_rejected() { + let yaml = format!("{MINIMAL}\nunknown_key: true\n"); + let message = parse_err(&yaml); + assert!(message.contains("unknown"), "unknown keys must be rejected: {message}"); + } + + #[test] + fn out_of_range_threshold_is_rejected() { + let yaml = format!("{MINIMAL}\nthreshold: 1.5\n"); + let message = parse_err(&yaml); + assert!(message.contains("threshold"), "threshold must be validated: {message}"); + } + + #[test] + fn bad_judge_endpoint_is_rejected() { + let yaml = MINIMAL.replace("http://judge.internal:8000/v1/chat/completions", "judge.internal"); + let message = parse_err(&yaml); + assert!( + message.contains("endpoint"), + "relative endpoint must be rejected: {message}" + ); + } + + #[test] + fn empty_target_model_is_rejected() { + let yaml = MINIMAL.replace("model: gpt-4o", "model: \"\""); + let message = parse_err(&yaml); + assert!( + message.contains("targets.strong.model"), + "empty target model must be rejected: {message}" + ); + } + + #[test] + fn zero_floor_ttl_is_rejected() { + let yaml = format!("{MINIMAL}\nsession_floor:\n ttl_secs: 0\n"); + let message = parse_err(&yaml); + assert!(message.contains("ttl_secs"), "zero TTL must be rejected: {message}"); + } + + /// A valid config whose judge carries an auth block; `{AUTH}` is the auth + /// body, indented under `judge:`. + fn with_auth(auth_body: &str) -> String { + format!( + "judge:\n endpoint: https://api.openai.com/v1/chat/completions\n model: gpt-4o-mini\n auth:\n{auth_body}targets:\n weak: {{ cluster: a, model: b }}\n strong: {{ cluster: c, model: d }}\n" + ) + } + + #[test] + fn judge_auth_round_trips_with_defaults() { + let config = parse(&with_auth(" value_env: OPENAI_API_KEY\n")); + let auth = config.judge.auth.expect("auth block parsed"); + assert_eq!(auth.value_env, "OPENAI_API_KEY", "env var name parsed"); + assert_eq!(auth.header, "authorization", "header defaults to authorization"); + assert_eq!(auth.scheme, "Bearer", "scheme defaults to Bearer"); + } + + #[test] + fn empty_auth_env_is_rejected() { + let message = parse_err(&with_auth(" value_env: \"\"\n")); + assert!( + message.contains("value_env"), + "empty credential env var must be rejected: {message}" + ); + } + + #[test] + fn invalid_auth_header_is_rejected() { + let message = parse_err(&with_auth( + " value_env: OPENAI_API_KEY\n header: \"bad header\"\n", + )); + assert!( + message.contains("auth.header"), + "invalid auth header name must be rejected: {message}" + ); + } + + #[test] + fn invalid_session_header_is_rejected() { + let yaml = format!("{MINIMAL}\nsession_header: \"bad header\"\n"); + let message = parse_err(&yaml); + assert!( + message.contains("session_header"), + "invalid header must be rejected: {message}" + ); + } + + #[test] + fn tier_order_and_tags_round_trip() { + assert!(Tier::Weak < Tier::Strong, "the floor clamp relies on Weak < Strong"); + assert_eq!(Tier::from_tag("weak"), Some(Tier::Weak), "weak tag round-trips"); + assert_eq!(Tier::from_tag("strong"), Some(Tier::Strong), "strong tag round-trips"); + assert_eq!(Tier::from_tag("judge"), None, "non-tier tags map to None"); + assert_eq!( + Tier::Weak.max(Tier::Strong), + Tier::Strong, + "max picks the stronger tier" + ); + } +} diff --git a/crates/switchyard-filters/src/switchyard/error.rs b/crates/switchyard-filters/src/switchyard/error.rs new file mode 100644 index 0000000..a14f76f --- /dev/null +++ b/crates/switchyard-filters/src/switchyard/error.rs @@ -0,0 +1,43 @@ +//! Failure vocabulary for the `switchyard_route` filter. +//! +//! Every variant funnels into the filter's single failure path: under +//! `failure_mode: open` the request passes through **unmodified** (the +//! no-downgrade safety net — see the module docs on `switchyard.rs`); under +//! `failure_mode: closed` it is rejected with 503. + +use thiserror::Error; + +/// Everything that can prevent a routing decision. +#[derive(Debug, Error)] +pub(crate) enum RouteError { + /// The buffered request body was missing, empty, or the wrong shape. + #[error("request body {0}")] + Body(&'static str), + /// The request body was not valid JSON. + #[error("request body is not valid JSON: {0}")] + Json(String), + /// Neither the path nor the body shape identified a supported wire format. + #[error("unrecognized wire format")] + UnknownFormat, + /// The body could not be decoded into the Switchyard IR for the judge. + #[error("request translation failed: {0}")] + Translation(String), + /// The rewritten body could not be re-serialized. + #[error("body re-serialization failed: {0}")] + Serialize(String), + /// The server did not provide a `SubRequestClient` for the judge callout. + #[error("subrequest client unavailable")] + MissingSubrequestClient, + /// The judge callout failed (encode, transport, decode, or respond). + #[error("judge callout failed: {0}")] + Judge(String), + /// The Switchyard run surfaced an error step. + #[error("switchyard run failed: {0}")] + Run(String), + /// The step stream ended without a routed decision. + #[error("switchyard produced no routing decision")] + NoDecision, + /// The routed decision named a tag outside the configured tier table. + #[error("switchyard selected unknown tier '{0}'")] + UnknownTier(String), +} diff --git a/crates/switchyard-filters/src/switchyard/floor.rs b/crates/switchyard-filters/src/switchyard/floor.rs new file mode 100644 index 0000000..abf5668 --- /dev/null +++ b/crates/switchyard-filters/src/switchyard/floor.rs @@ -0,0 +1,209 @@ +//! The host-owned session floor: a one-way tier ratchet per session. +//! +//! Why this lives in the filter and not in Switchyard: Capability mode's +//! classifier is per-request/stateless, and Switchyard's `session_affinity` +//! is a *first-decision-wins* latch — it freezes turn 1's tier in either +//! direction, so it can pin a session to `weak` and block a needed upgrade. +//! Switchyard's state is also in-process, TTL-bound, and not seedable from +//! outside. The no-downgrade guarantee ("once a session reaches `strong` it +//! never silently drops back to `weak`") therefore has to be owned host-side: +//! the filter clamps every decision to `max(floor, decision)` and ratchets +//! the floor upward only. +//! +//! Honesty note: this cache has the same loss profile as Switchyard's own +//! state — a restart, failover, or replica hop wipes it. What protects those +//! cases is the filter's don't-overwrite-on-failure rule (pass through +//! unmodified), which degrades to "route as the request already asked", never +//! "downgrade below what the client asked". A durable out-of-process backend +//! behind [`SessionFloorStore`] is the planned follow-up. + +use std::time::{Duration, Instant}; + +use dashmap::DashMap; + +use super::config::Tier; + +/// Entry count that triggers an expired-entry sweep during a commit. +const SWEEP_LEN: usize = 8_192; + +/// Storage seam for the session floor, so a durable out-of-process adapter +/// (Redis / generic KV) can slot in behind the same contract later. +pub(crate) trait SessionFloorStore: Send + Sync { + /// The live committed floor for `session`, if any. + fn floor(&self, session: &str, now: Instant) -> Option; + /// Ratchets the floor for `session` up to at least `tier` (never down) + /// and refreshes its inactivity TTL. + fn commit(&self, session: &str, tier: Tier, now: Instant); + /// Drops the floor for `session` (end-of-session eviction). + fn evict(&self, session: &str); +} + +/// A committed floor and its last-activity timestamp. +#[derive(Debug, Clone, Copy)] +struct FloorEntry { + /// The committed tier floor. + tier: Tier, + /// Last commit time; the inactivity TTL counts from here. + touched: Instant, +} + +impl FloorEntry { + /// Whether this entry's inactivity TTL has elapsed at `now`. + fn expired(&self, now: Instant, ttl: Duration) -> bool { + now.duration_since(self.touched) >= ttl + } +} + +/// In-process [`SessionFloorStore`] backed by a concurrent map. +/// +/// Expired entries are dropped lazily on access, plus a bulk sweep whenever a +/// commit finds the map at [`SWEEP_LEN`] entries or more. +#[derive(Debug)] +pub(crate) struct InMemorySessionFloor { + /// Committed floors keyed by session id. + entries: DashMap, + /// Inactivity window after which a floor is dropped. + ttl: Duration, +} + +impl InMemorySessionFloor { + /// Creates an empty floor store with the given inactivity TTL. + pub(crate) fn new(ttl: Duration) -> Self { + Self { + entries: DashMap::new(), + ttl, + } + } + + /// Bulk-drops expired entries once the map grows past [`SWEEP_LEN`]. + fn sweep_if_full(&self, now: Instant) { + if self.entries.len() >= SWEEP_LEN { + self.entries.retain(|_, entry| !entry.expired(now, self.ttl)); + } + } +} + +impl SessionFloorStore for InMemorySessionFloor { + fn floor(&self, session: &str, now: Instant) -> Option { + let entry = self.entries.get(session)?; + if entry.expired(now, self.ttl) { + drop(entry); + self.entries.remove(session); + return None; + } + Some(entry.tier) + } + + fn commit(&self, session: &str, tier: Tier, now: Instant) { + self.sweep_if_full(now); + let mut entry = self + .entries + .entry(session.to_owned()) + .or_insert(FloorEntry { tier, touched: now }); + if entry.expired(now, self.ttl) { + // A dead floor must not resurrect: restart from this turn's tier. + *entry = FloorEntry { tier, touched: now }; + } else { + entry.tier = entry.tier.max(tier); + entry.touched = now; + } + } + + fn evict(&self, session: &str) { + self.entries.remove(session); + } +} + +#[cfg(test)] +#[expect(clippy::allow_attributes, reason = "blanket test-module suppressions")] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + reason = "unwrap/panic are acceptable in tests" +)] +mod tests { + use std::time::{Duration, Instant}; + + use super::{InMemorySessionFloor, SessionFloorStore as _, Tier}; + + /// One-hour TTL used across the tests. + const TTL: Duration = Duration::from_secs(3_600); + + #[test] + fn ratchet_moves_up_and_never_down() { + let store = InMemorySessionFloor::new(TTL); + let now = Instant::now(); + store.commit("session-a", Tier::Weak, now); + assert_eq!(store.floor("session-a", now), Some(Tier::Weak), "weak floor committed"); + store.commit("session-a", Tier::Strong, now); + assert_eq!(store.floor("session-a", now), Some(Tier::Strong), "upgrade committed"); + store.commit("session-a", Tier::Weak, now); + assert_eq!( + store.floor("session-a", now), + Some(Tier::Strong), + "a weak commit must not lower a strong floor" + ); + } + + #[test] + fn sessions_are_isolated() { + let store = InMemorySessionFloor::new(TTL); + let now = Instant::now(); + store.commit("session-a", Tier::Strong, now); + assert_eq!(store.floor("session-b", now), None, "other sessions hold no floor"); + } + + #[test] + fn eviction_drops_the_floor() { + let store = InMemorySessionFloor::new(TTL); + let now = Instant::now(); + store.commit("session-a", Tier::Strong, now); + store.evict("session-a"); + assert_eq!(store.floor("session-a", now), None, "evicted session starts fresh"); + } + + #[test] + fn ttl_expires_the_floor() { + let store = InMemorySessionFloor::new(TTL); + let now = Instant::now(); + store.commit("session-a", Tier::Strong, now); + let later = now.checked_add(TTL).unwrap(); + assert_eq!(store.floor("session-a", later), None, "expired floor is gone"); + assert_eq!( + store.floor("session-a", now), + None, + "the expired entry is removed, not merely hidden" + ); + } + + #[test] + fn commit_after_expiry_restarts_from_new_tier() { + let store = InMemorySessionFloor::new(TTL); + let now = Instant::now(); + store.commit("session-a", Tier::Strong, now); + let later = now.checked_add(TTL).unwrap(); + store.commit("session-a", Tier::Weak, later); + assert_eq!( + store.floor("session-a", later), + Some(Tier::Weak), + "a dead strong floor must not resurrect past its TTL" + ); + } + + #[test] + fn commit_refreshes_the_ttl() { + let store = InMemorySessionFloor::new(TTL); + let now = Instant::now(); + store.commit("session-a", Tier::Strong, now); + let midway = now.checked_add(TTL.checked_div(2).unwrap()).unwrap(); + store.commit("session-a", Tier::Strong, midway); + let past_first_deadline = now.checked_add(TTL).unwrap(); + assert_eq!( + store.floor("session-a", past_first_deadline), + Some(Tier::Strong), + "activity extends the floor's life" + ); + } +} diff --git a/crates/switchyard-filters/src/switchyard/judge.rs b/crates/switchyard-filters/src/switchyard/judge.rs new file mode 100644 index 0000000..2ea7d21 --- /dev/null +++ b/crates/switchyard-filters/src/switchyard/judge.rs @@ -0,0 +1,69 @@ +//! Serving the judge (classifier) callout. +//! +//! Switchyard fully prepares the judge request — packaged classifier system +//! prompt, trimmed messages, JSON-schema `response_format`, and output token +//! cap. This module only transports it: encode onto the OpenAI chat wire with +//! the configured judge model, POST it through the [`JudgeTransport`] seam, +//! decode the reply, and deliver it back into the run. + +use async_trait::async_trait; +use bytes::Bytes; +use switchyard_libsy::CallLlmRequest; +use switchyard_protocol::{LlmResponse, Response, WireFormat}; + +use super::error::RouteError; + +/// Transport seam for the judge HTTP exchange, so the step loop is testable +/// without a network. +#[async_trait] +pub(crate) trait JudgeTransport: Send + Sync { + /// POSTs an encoded judge request body and returns the raw response body. + async fn execute(&self, body: Bytes) -> Result; +} + +/// Serves one judge `CallLlm` step through `transport`. +/// +/// # Errors +/// +/// Returns [`RouteError::Judge`] on any encode, transport, decode, or respond +/// failure. The caller treats that as the filter's single failure path rather +/// than responding `Err` into the run: Switchyard would fold a judge error +/// into an ambiguous verdict and default a tier itself, and the host — not +/// the library — owns the failure outcome (pass through unmodified). +pub(crate) async fn serve_judge( + call: Box, + judge_model: &str, + transport: &dyn JudgeTransport, +) -> Result<(), RouteError> { + let request_body = encode_judge_request(&call, judge_model)?; + let response_body = transport.execute(request_body).await?; + let response = decode_judge_response(&response_body)?; + call.respond(Ok(response)) + .map_err(|err| RouteError::Judge(format!("failed to deliver judge response: {err}"))) +} + +/// Encodes the prepared judge request onto the OpenAI chat wire, overriding +/// the model (the prepared request inherits the *inbound* model name) and +/// forcing a non-streaming exchange. +fn encode_judge_request(call: &CallLlmRequest, judge_model: &str) -> Result { + let mut llm_request = call.get_request().llm_request.clone(); + llm_request.model = Some(judge_model.to_owned()); + llm_request.stream = false; + let wire = switchyard_translation::encode_request(&llm_request, WireFormat::OpenAiChat) + .map_err(|err| RouteError::Judge(format!("request encoding failed: {err}")))?; + let bytes = + serde_json::to_vec(&wire).map_err(|err| RouteError::Judge(format!("request serialization failed: {err}")))?; + Ok(Bytes::from(bytes)) +} + +/// Decodes an OpenAI chat completion reply into a Switchyard [`Response`]. +fn decode_judge_response(body: &Bytes) -> Result { + let value: serde_json::Value = + serde_json::from_slice(body).map_err(|err| RouteError::Judge(format!("response is not JSON: {err}")))?; + let aggregated = switchyard_translation::decode_aggregated_response(&value, WireFormat::OpenAiChat) + .map_err(|err| RouteError::Judge(format!("response translation failed: {err}")))?; + Ok(Response { + llm_response: LlmResponse::Agg(aggregated), + metadata: None, + }) +} diff --git a/crates/switchyard-filters/src/switchyard/peer.rs b/crates/switchyard-filters/src/switchyard/peer.rs new file mode 100644 index 0000000..190849a --- /dev/null +++ b/crates/switchyard-filters/src/switchyard/peer.rs @@ -0,0 +1,344 @@ +//! Judge endpoint resolution and the production [`JudgeTransport`]. +//! +//! Filters cannot address a Praxis cluster from a sub-request — callouts +//! target explicit URLs. This is a minimal reimplementation of praxis-ai's +//! (crate-private) `execute_url`: parse the URL once at config time, resolve +//! every address at call time, try each until one connects, preserve the URL +//! authority as `Host`, and bound the whole exchange with one deadline. + +use std::{net::SocketAddr, time::Duration}; + +use async_trait::async_trait; +use bytes::Bytes; +use pingora_core::upstreams::peer::HttpPeer; +use praxis_core::subrequest::{SubRequest, SubRequestClient, SubRequestError, SubResponse}; +use praxis_filter::FilterError; + +use super::{ + config::{JudgeAuthConfig, JudgeConfig}, + error::RouteError, + judge::JudgeTransport, +}; + +/// A judge endpoint parsed once at configuration time. +#[derive(Debug, Clone)] +pub(crate) struct JudgeEndpoint { + /// Whether to establish a TLS connection. + tls: bool, + /// DNS hostname or literal address. + host: String, + /// Destination TCP port. + port: u16, + /// TLS server name; empty for cleartext HTTP. + sni: String, + /// Original URL authority, preserved as the `Host` header. + authority: http::HeaderValue, + /// Path and query sent to the judge. + uri: http::Uri, + /// Resolved `(header, value)` credential; `None` for a keyless judge. + auth: Option<(http::HeaderName, http::HeaderValue)>, +} + +impl JudgeEndpoint { + /// Parses the judge URL and resolves its optional credential from the + /// environment, so both are ready before the first request. + /// + /// # Errors + /// + /// Returns a [`FilterError`] when the URL is relative, uses a scheme other + /// than http/https, carries a malformed authority or path, or when the + /// configured credential environment variable is unset or malformed. + pub(crate) fn from_config(judge: &JudgeConfig) -> Result { + let mut endpoint = Self::parse_url(&judge.endpoint)?; + endpoint.auth = judge.auth.as_ref().map(resolve_auth).transpose()?; + Ok(endpoint) + } + + /// Parses an absolute http(s) URL into its connection components. + fn parse_url(endpoint: &str) -> Result { + let parsed: http::Uri = endpoint + .parse() + .map_err(|err| endpoint_error(&format!("not a valid URL: {err}")))?; + let tls = parse_tls(&parsed)?; + let authority = parsed.authority().ok_or_else(|| endpoint_error("missing host"))?; + let host = authority + .host() + .trim_start_matches('[') + .trim_end_matches(']') + .to_owned(); + let port = authority.port_u16().unwrap_or(if tls { 443 } else { 80 }); + let sni = if tls { host.clone() } else { String::new() }; + let authority = http::HeaderValue::from_str(authority.as_str()) + .map_err(|err| endpoint_error(&format!("invalid authority: {err}")))?; + let uri: http::Uri = parsed + .path_and_query() + .map_or("/", http::uri::PathAndQuery::as_str) + .parse() + .map_err(|err| endpoint_error(&format!("bad path: {err}")))?; + Ok(Self { + tls, + host, + port, + sni, + authority, + uri, + auth: None, + }) + } +} + +/// Resolves a credential from the environment into a ready `(header, value)`. +/// +/// # Errors +/// +/// Returns a [`FilterError`] when the environment variable is unset or empty, +/// the header name is invalid, or the assembled value is not a legal header +/// value (which would leak nothing, since the message never echoes the secret). +fn resolve_auth(auth: &JudgeAuthConfig) -> Result<(http::HeaderName, http::HeaderValue), FilterError> { + let secret = std::env::var(&auth.value_env) + .ok() + .filter(|value| !value.is_empty()) + .ok_or_else(|| auth_error(&format!("credential env var '{}' is unset or empty", auth.value_env)))?; + assemble_auth(auth, &secret) +} + +/// Assembles a `(header, value)` credential from an already-resolved secret. +/// +/// Split from the environment lookup so the header/scheme assembly is testable +/// without mutating process-global state (workspace lints forbid `unsafe`, and +/// `std::env::set_var` is `unsafe`). +/// +/// # Errors +/// +/// Returns a [`FilterError`] when the header name is invalid or the assembled +/// value is not a legal header value; the message never echoes the secret. +fn assemble_auth(auth: &JudgeAuthConfig, secret: &str) -> Result<(http::HeaderName, http::HeaderValue), FilterError> { + let header = http::HeaderName::from_bytes(auth.header.as_bytes()) + .map_err(|err| auth_error(&format!("invalid header name: {err}")))?; + let rendered = if auth.scheme.is_empty() { + secret.to_owned() + } else { + format!("{} {secret}", auth.scheme) + }; + let mut value = http::HeaderValue::from_str(&rendered) + .map_err(|_err| auth_error("resolved credential is not a valid header value"))?; + value.set_sensitive(true); + Ok((header, value)) +} + +/// Maps the URL scheme to a TLS decision. +fn parse_tls(parsed: &http::Uri) -> Result { + match parsed.scheme_str() { + Some("https") => Ok(true), + Some("http") => Ok(false), + Some(other) => Err(endpoint_error(&format!("unsupported scheme '{other}'"))), + None => Err(endpoint_error("must be an absolute http(s) URL")), + } +} + +/// Builds an endpoint-shaped [`FilterError`]. +fn endpoint_error(message: &str) -> FilterError { + format!("switchyard_route: judge.endpoint {message}").into() +} + +/// Builds an auth-shaped [`FilterError`]; never echoes the credential value. +fn auth_error(message: &str) -> FilterError { + format!("switchyard_route: judge.auth {message}").into() +} + +/// The production [`JudgeTransport`]: resolves the parsed endpoint and +/// executes the callout through the server-shared per-request +/// [`SubRequestClient`]. +pub(crate) struct SubRequestJudge<'req> { + /// The per-request sub-request client borrowed from the filter context. + client: &'req SubRequestClient, + /// The parsed judge endpoint. + endpoint: &'req JudgeEndpoint, + /// Deadline covering DNS resolution and the complete HTTP exchange. + timeout: Duration, + /// Response size cap forwarded to the client. + max_response_bytes: usize, +} + +impl<'req> SubRequestJudge<'req> { + /// Assembles the transport from per-request and config-time parts. + pub(crate) fn new(client: &'req SubRequestClient, endpoint: &'req JudgeEndpoint, judge: &JudgeConfig) -> Self { + Self { + client, + endpoint, + timeout: judge.timeout(), + max_response_bytes: judge.max_response_bytes, + } + } + + /// Builds the POST sub-request carrying the encoded judge body. + fn build_request(&self, body: Bytes) -> SubRequest { + let mut headers = http::HeaderMap::new(); + headers.insert(http::header::HOST, self.endpoint.authority.clone()); + headers.insert( + http::header::CONTENT_TYPE, + http::HeaderValue::from_static("application/json"), + ); + if let Some((name, value)) = self.endpoint.auth.as_ref() { + headers.insert(name.clone(), value.clone()); + } + SubRequest { + method: http::Method::POST, + uri: self.endpoint.uri.clone(), + headers, + body, + } + } + + /// Resolves the endpoint and tries each address until one connects. + /// + /// (`SubRequestError` is `#[non_exhaustive]`, so the catch-all arm is + /// required and lint-exempt; only `Connect` drives address fallback.) + async fn execute_resolved(&self, request: &SubRequest, addrs: &[SocketAddr]) -> Result { + let mut last_connect_error: Option = None; + for addr in addrs { + let peer = HttpPeer::new(*addr, self.endpoint.tls, self.endpoint.sni.clone()); + match self + .client + .execute(&peer, request, self.max_response_bytes, self.timeout, None) + .await + { + Ok(response) => return check_status(response), + Err(SubRequestError::Connect(message)) => last_connect_error = Some(message), + Err(other) => return Err(RouteError::Judge(other.to_string())), + } + } + let host = &self.endpoint.host; + Err(RouteError::Judge(last_connect_error.map_or_else( + || format!("no addresses resolved for {host}"), + |message| format!("all resolved addresses for {host} failed: {message}"), + ))) + } + + /// Resolves DNS and executes; the caller enforces the overall deadline. + async fn execute_inner(&self, body: Bytes) -> Result { + let addrs = resolve(&self.endpoint.host, self.endpoint.port).await?; + let request = self.build_request(body); + self.execute_resolved(&request, &addrs).await + } +} + +#[async_trait] +impl JudgeTransport for SubRequestJudge<'_> { + async fn execute(&self, body: Bytes) -> Result { + tokio::time::timeout(self.timeout, self.execute_inner(body)) + .await + .map_err(|_elapsed| RouteError::Judge("deadline exceeded".to_owned()))? + } +} + +/// Resolves every address for the host so connects can fall back. +async fn resolve(host: &str, port: u16) -> Result, RouteError> { + let addrs: Vec = tokio::net::lookup_host((host, port)) + .await + .map_err(|err| RouteError::Judge(format!("DNS resolution failed for {host}: {err}")))? + .collect(); + if addrs.is_empty() { + return Err(RouteError::Judge(format!("no addresses resolved for {host}"))); + } + Ok(addrs) +} + +/// Accepts 2xx judge replies and rejects everything else. +fn check_status(response: SubResponse) -> Result { + let status = response.status; + if (200..=299).contains(&status) { + Ok(response.body) + } else { + Err(RouteError::Judge(format!("judge returned HTTP {status}"))) + } +} + +#[cfg(test)] +#[expect(clippy::allow_attributes, reason = "blanket test-module suppressions")] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + reason = "unwrap/panic are acceptable in tests" +)] +mod tests { + use super::{JudgeEndpoint, assemble_auth, resolve_auth}; + use crate::switchyard::config::JudgeAuthConfig; + + /// An auth config with the given header and scheme (env var name unused by + /// the pure-assembly tests). + fn auth(header: &str, scheme: &str) -> JudgeAuthConfig { + JudgeAuthConfig { + value_env: "UNUSED".to_owned(), + header: header.to_owned(), + scheme: scheme.to_owned(), + } + } + + #[test] + fn https_endpoints_parse_with_tls_and_default_port() { + let endpoint = JudgeEndpoint::parse_url("https://judge.internal/v1/chat/completions").unwrap(); + assert!(endpoint.tls, "https enables TLS"); + assert_eq!(endpoint.port, 443, "https defaults to 443"); + assert_eq!(endpoint.sni, "judge.internal", "SNI mirrors the host"); + assert_eq!(endpoint.uri.path(), "/v1/chat/completions", "path preserved"); + assert!(endpoint.auth.is_none(), "URL parse leaves auth unresolved"); + } + + #[test] + fn http_endpoints_parse_with_explicit_port() { + let endpoint = JudgeEndpoint::parse_url("http://127.0.0.1:8000/v1/chat/completions?tag=x").unwrap(); + assert!(!endpoint.tls, "http stays cleartext"); + assert_eq!(endpoint.port, 8_000, "explicit port preserved"); + assert!(endpoint.sni.is_empty(), "no SNI for cleartext"); + assert_eq!(endpoint.authority.to_str().unwrap(), "127.0.0.1:8000", "authority kept"); + assert_eq!(endpoint.uri.query(), Some("tag=x"), "query preserved"); + } + + #[test] + fn bad_endpoints_are_rejected() { + assert!( + JudgeEndpoint::parse_url("ftp://judge/v1").is_err(), + "non-http scheme rejected" + ); + assert!( + JudgeEndpoint::parse_url("/v1/chat/completions").is_err(), + "relative URL rejected" + ); + assert!(JudgeEndpoint::parse_url("http://").is_err(), "missing host rejected"); + } + + #[test] + fn bearer_credential_prefixes_the_value_and_is_sensitive() { + let (name, value) = assemble_auth(&auth("authorization", "Bearer"), "sk-secret").unwrap(); + assert_eq!(name.as_str(), "authorization", "header name honoured"); + assert_eq!(value.to_str().unwrap(), "Bearer sk-secret", "scheme prefixes the value"); + assert!(value.is_sensitive(), "credential is marked sensitive"); + } + + #[test] + fn an_empty_scheme_sends_the_raw_credential() { + let (name, value) = assemble_auth(&auth("x-api-key", ""), "raw-token").unwrap(); + assert_eq!(name.as_str(), "x-api-key", "custom header honoured"); + assert_eq!(value.to_str().unwrap(), "raw-token", "no prefix with an empty scheme"); + } + + #[test] + fn an_unset_credential_is_rejected() { + // A name unlikely to exist in any environment this test runs in. + let mut missing = auth("authorization", "Bearer"); + missing.value_env = "SWITCHYARD_TEST_JUDGE_KEY_DEFINITELY_ABSENT_9f3c".to_owned(); + let error = resolve_auth(&missing).unwrap_err(); + let message = error.to_string(); + assert!( + message.contains("unset or empty"), + "a missing secret must fail fast: {message}" + ); + assert!( + message.contains("judge.auth"), + "auth failures name the judge.auth key, not judge.endpoint: {message}" + ); + } +} diff --git a/crates/switchyard-filters/src/switchyard/steps.rs b/crates/switchyard-filters/src/switchyard/steps.rs new file mode 100644 index 0000000..e42a883 --- /dev/null +++ b/crates/switchyard-filters/src/switchyard/steps.rs @@ -0,0 +1,52 @@ +//! The decision-only step loop. +//! +//! Switchyard's `run_stream` yields *steps*; the host owns all I/O. For +//! Capability mode the order is: (1) the judge `CallLlm` +//! (`is_routed_call() == false`) — serve it; (2) `Step::Decision` with +//! `is_routed_call() == true` — the routing decision, emitted **before** any +//! answer call: read the tier tag and stop; (3) the answer `CallLlm` and +//! (4) `ReturnToAgent` — never reached. Dropping the [`StepStream`] is the +//! intended abandon mechanism: the algorithm task is aborted cleanly and an +//! unanswered answer call resolves to a benign, unobserved internal error. + +use futures::StreamExt as _; +use switchyard_libsy::{Step, StepStream}; + +use super::{error::RouteError, judge::JudgeTransport}; + +/// Drives the step stream to the routed decision and returns its tier tag. +/// +/// The judge call is served through `transport`; the answer call is never +/// served. Returning (on success or failure) drops the stream, which aborts +/// the algorithm task. +/// +/// # Errors +/// +/// Returns a [`RouteError`] when the judge callout fails, the run surfaces an +/// error step, or the stream ends without a routed decision. +pub(crate) async fn decide( + mut stream: StepStream, + judge_model: &str, + transport: &dyn JudgeTransport, +) -> Result { + while let Some(item) = stream.next().await { + let step = item.map_err(|err| RouteError::Run(err.to_string()))?; + match step { + Step::Decision(decision) if decision.is_routed_call() => { + return Ok(decision.selected_model().to_owned()); + }, + Step::Decision(_) => {}, + Step::CallLlm(call) => { + if call.get_decision().is_routed_call() { + // The answer call. Capability emits Step::Decision first, + // so this is defensive: learn the tier and stop without + // serving the answer. + return Ok(call.get_decision().selected_model().to_owned()); + } + super::judge::serve_judge(call, judge_model, transport).await?; + }, + Step::ReturnToAgent(_) => return Err(RouteError::NoDecision), + } + } + Err(RouteError::NoDecision) +} diff --git a/crates/switchyard-filters/src/switchyard/tests.rs b/crates/switchyard-filters/src/switchyard/tests.rs new file mode 100644 index 0000000..c23e1d6 --- /dev/null +++ b/crates/switchyard-filters/src/switchyard/tests.rs @@ -0,0 +1,697 @@ +//! Filter-level tests for `switchyard_route`. +//! +//! These drive the real `HttpFilter` hooks against hand-built +//! [`HttpFilterContext`]s (every field is public), with the judge served by a +//! scripted loopback HTTP stub through a real [`SubRequestClient`] — so the +//! full decision path (translate → Switchyard run → judge callout → clamp → +//! rewrite → stash) runs hermetically inside `make test`. + +use std::{ + collections::HashMap, + io::{Read as _, Write as _}, + net::{TcpListener, TcpStream}, + sync::{Arc, Mutex}, + thread::JoinHandle, +}; + +use bytes::Bytes; +use praxis_core::{ + id::IdGenerator, + subrequest::{SubRequestClient, SubRequestConnector}, +}; +use praxis_filter::{ + BodyAccess, BodyMode, FilterAction, FilterRegistry, HttpFilter, HttpFilterContext, Request, RequestExtensions, +}; +use serde_json::{Value, json}; + +use super::{METADATA_CLUSTER, METADATA_ERROR, METADATA_TIER, SwitchyardRouteFilter}; + +// --------------------------------------------------------------------------- +// Judge stub +// --------------------------------------------------------------------------- + +/// A running loopback judge stub: endpoint URL, captured request bodies, and +/// the serving thread's handle. +struct JudgeStub { + /// Full URL of the stub's chat-completions endpoint. + endpoint: String, + /// Raw HTTP requests captured in arrival order. + captured: Arc>>, + /// The serving thread; joined on drop-by-test-end via [`JudgeStub::join`]. + handle: JoinHandle<()>, +} + +impl JudgeStub { + /// Serves the scripted `(status, body)` responses, one per connection. + fn spawn(script: Vec<(u16, String)>) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let captured = Arc::new(Mutex::new(Vec::new())); + let captured_in_thread = Arc::clone(&captured); + let handle = std::thread::spawn(move || { + for (status, body) in script { + let (mut stream, _) = listener.accept().unwrap(); + let request = read_http_request(&mut stream); + captured_in_thread.lock().unwrap().push(request); + write_http_response(&mut stream, status, &body); + } + }); + Self { + endpoint: format!("http://127.0.0.1:{port}/v1/chat/completions"), + captured, + handle, + } + } + + /// Waits for the whole script to have been served. + fn join(self) -> Vec { + self.handle.join().unwrap(); + Arc::try_unwrap(self.captured).unwrap().into_inner().unwrap() + } +} + +/// Reads one HTTP/1.1 request (headers plus `Content-Length` body). +fn read_http_request(stream: &mut TcpStream) -> String { + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 4_096]; + loop { + let count = stream.read(&mut chunk).unwrap(); + if count == 0 { + break; + } + buffer.extend_from_slice(&chunk[..count]); + if let Some(text) = complete_request(&buffer) { + return text; + } + } + String::from_utf8_lossy(&buffer).into_owned() +} + +/// Returns the request text once the `Content-Length` body has fully arrived. +fn complete_request(buffer: &[u8]) -> Option { + let text = String::from_utf8_lossy(buffer); + let (head, rest) = text.split_once("\r\n\r\n")?; + let content_length: usize = head + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length:") + .map(str::to_owned) + })? + .trim() + .parse() + .ok()?; + (rest.len() >= content_length).then(|| text.into_owned()) +} + +/// Writes one HTTP/1.1 response and closes the connection. +fn write_http_response(stream: &mut TcpStream, status: u16, body: &str) { + let length = body.len(); + let response = format!( + "HTTP/1.1 {status} OK\r\nContent-Type: application/json\r\nContent-Length: {length}\r\nConnection: close\r\n\r\n{body}" + ); + stream.write_all(response.as_bytes()).unwrap(); +} + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +/// An OpenAI chat-completion reply whose content is a Capability verdict. +fn verdict_response(rule: &str, boundary: &str, p_solve: f64) -> String { + let verdict = json!({ + "crux": "test crux", + "primary_rule": rule, + "capability_boundary": boundary, + "p_solve": p_solve, + }) + .to_string(); + json!({ + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 1, + "model": "judge-model", + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": verdict}, + "finish_reason": "stop", + }], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + }) + .to_string() +} + +/// A verdict that classifies the task as easy (routes `weak`). +fn weak_verdict() -> (u16, String) { + (200, verdict_response("SUP-1", "supported", 0.95)) +} + +/// A verdict that classifies the task as hard (routes `strong`). +fn strong_verdict() -> (u16, String) { + (200, verdict_response("LIM-1", "unsupported", 0.05)) +} + +/// Filter YAML pointing the judge at `endpoint`. +fn filter_yaml(endpoint: &str, failure_mode: &str) -> String { + format!( + " +judge: + endpoint: {endpoint} + model: judge-model + timeout_ms: 5000 +targets: + weak: + cluster: cluster-weak + model: model-weak + strong: + cluster: cluster-strong + model: model-strong +on_failure: {failure_mode} +" + ) +} + +/// Filter YAML like [`filter_yaml`], with the escalation ratchet enabled so a +/// strong-floored session skips the judge entirely. +fn ratchet_yaml(endpoint: &str) -> String { + format!( + "{}session_floor:\n escalation_ratchet: true\n", + filter_yaml(endpoint, "open") + ) +} + +/// Builds the filter from YAML text. +fn build_filter(yaml: &str) -> Box { + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + SwitchyardRouteFilter::from_config(&value).unwrap() +} + +/// A minimal OpenAI chat request body naming the client's current model. +fn openai_body() -> Bytes { + Bytes::from( + json!({ + "model": "agent-default", + "messages": [{"role": "user", "content": "hello"}], + }) + .to_string(), + ) +} + +/// Owned parts a context borrows from. +struct CtxParts { + /// The immutable request view. + request: Request, + /// The context's id generator. + id_generator: IdGenerator, +} + +impl CtxParts { + /// Builds parts for a POST to `path` with the given extra headers. + fn new(path: &str, headers: &[(&str, &str)]) -> Self { + let mut header_map = http::HeaderMap::new(); + for (name, value) in headers { + header_map.insert( + http::header::HeaderName::from_bytes(name.as_bytes()).unwrap(), + http::HeaderValue::from_str(value).unwrap(), + ); + } + Self { + request: Request { + headers: header_map, + method: http::Method::POST, + uri: path.parse().unwrap(), + }, + id_generator: IdGenerator::with_seed(7), + } + } +} + +/// Builds a filter context over `parts`, mirroring praxis-filter's own +/// in-crate test constructor field for field. +fn make_ctx<'ctx>(parts: &'ctx CtxParts, client: Option<&'ctx SubRequestClient>) -> HttpFilterContext<'ctx> { + HttpFilterContext { + buffered_request_body: None, + body_done_indices: Vec::new(), + branch_iterations: HashMap::new(), + client_addr: None, + cluster: None, + current_filter_id: None, + downstream_tls: false, + extensions: RequestExtensions::default(), + executed_filter_indices: Vec::new(), + extra_request_headers: Vec::new(), + request_headers_to_remove: Vec::new(), + request_headers_to_set: Vec::new(), + filter_metadata: HashMap::new(), + pre_read_mutations: Vec::new(), + structured_metadata: HashMap::new(), + filter_results: HashMap::new(), + filter_state: HashMap::new(), + health_registry: None, + id_generator: &parts.id_generator, + kv_stores: None, + metrics_route: None, + peer_identity: None, + subrequest_client: client, + request: &parts.request, + request_body_bytes: 0, + request_body_mode: BodyMode::Stream, + request_start: std::time::Instant::now(), + response_body_bytes: 0, + response_body_mode: BodyMode::Stream, + response_header: None, + response_headers_modified: false, + rewritten_path: None, + selected_endpoint_index: None, + time_source: &praxis_core::time::SystemTimeSource, + upstream: None, + } +} + +/// A real loopback-capable sub-request client. +fn subrequest_client() -> SubRequestClient { + SubRequestClient::new(SubRequestConnector::new(4, None)) +} + +/// Runs one buffered body pass and returns `(action, body, metadata)`. +async fn run_body_phase( + filter: &dyn HttpFilter, + client: &SubRequestClient, + path: &str, + headers: &[(&str, &str)], + body_bytes: Bytes, +) -> (FilterAction, Option, HashMap) { + let parts = CtxParts::new(path, headers); + let mut ctx = make_ctx(&parts, Some(client)); + let mut body = Some(body_bytes); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + (action, body, ctx.filter_metadata) +} + +/// The `model` field of a JSON body. +fn body_model(body: Option<&Bytes>) -> String { + let value: Value = serde_json::from_slice(body.unwrap()).unwrap(); + value["model"].as_str().unwrap().to_owned() +} + +// --------------------------------------------------------------------------- +// Registration and trait surface +// --------------------------------------------------------------------------- + +#[test] +fn switchyard_route_is_registered() { + let mut registry = FilterRegistry::with_builtins(); + crate::register_filters(&mut registry); + let names = registry.available_filters(); + assert!( + names.contains(&"switchyard_route"), + "expected switchyard_route to be registered, got: {names:?}" + ); +} + +#[test] +fn trait_surface_matches_the_pipeline_contract() { + let filter = build_filter(&filter_yaml("http://127.0.0.1:1/v1/chat/completions", "open")); + assert_eq!(filter.name(), "switchyard_route", "name matches registration"); + assert!( + filter.selects_cluster(), + "must satisfy pipeline cluster-selector validation" + ); + assert!(filter.needs_request_context(), "body phase reads path and headers"); + let clusters = filter.selected_clusters(); + assert!( + clusters.contains(&"cluster-weak".to_owned()) && clusters.contains(&"cluster-strong".to_owned()), + "both candidate clusters must be declared, got: {clusters:?}" + ); + assert_eq!( + filter.request_body_access(), + BodyAccess::ReadWrite, + "the body is mutated" + ); + assert!( + matches!( + filter.request_body_mode(), + BodyMode::StreamBuffer { + max_bytes: Some(1_048_576) + } + ), + "default 1 MiB StreamBuffer" + ); +} + +#[test] +fn shared_cluster_is_declared_once() { + let yaml = filter_yaml("http://127.0.0.1:1/v1/chat/completions", "open").replace("cluster-strong", "cluster-weak"); + let filter = build_filter(&yaml); + assert_eq!( + filter.selected_clusters(), + vec!["cluster-weak".to_owned()], + "deduplicated" + ); +} + +// --------------------------------------------------------------------------- +// Phase 2: on_request +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn on_request_applies_the_stashed_cluster() { + let filter = build_filter(&filter_yaml("http://127.0.0.1:1/v1/chat/completions", "open")); + let parts = CtxParts::new("/v1/chat/completions", &[]); + let mut ctx = make_ctx(&parts, None); + ctx.set_metadata(METADATA_CLUSTER, "cluster-strong"); + let action = filter.on_request(&mut ctx).await.unwrap(); + assert!(matches!(action, FilterAction::Continue), "stash application continues"); + assert_eq!(ctx.cluster_name(), Some("cluster-strong"), "stashed cluster applied"); +} + +#[tokio::test] +async fn on_request_preserves_an_earlier_cluster_choice() { + let filter = build_filter(&filter_yaml("http://127.0.0.1:1/v1/chat/completions", "open")); + let parts = CtxParts::new("/v1/chat/completions", &[]); + let mut ctx = make_ctx(&parts, None); + ctx.cluster = Some(Arc::from("preset-cluster")); + ctx.set_metadata(METADATA_CLUSTER, "cluster-strong"); + let action = filter.on_request(&mut ctx).await.unwrap(); + assert!(matches!(action, FilterAction::Continue), "preservation continues"); + assert_eq!(ctx.cluster_name(), Some("preset-cluster"), "earlier choice preserved"); +} + +#[tokio::test] +async fn on_request_without_a_stash_sets_nothing() { + let filter = build_filter(&filter_yaml("http://127.0.0.1:1/v1/chat/completions", "open")); + let parts = CtxParts::new("/v1/chat/completions", &[]); + let mut ctx = make_ctx(&parts, None); + let action = filter.on_request(&mut ctx).await.unwrap(); + assert!(matches!(action, FilterAction::Continue), "no stash still continues"); + assert_eq!(ctx.cluster_name(), None, "no decision, no cluster"); +} + +// --------------------------------------------------------------------------- +// Failure paths (no network needed) +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn early_chunks_pass_straight_through() { + let filter = build_filter(&filter_yaml("http://127.0.0.1:1/v1/chat/completions", "open")); + let parts = CtxParts::new("/v1/chat/completions", &[]); + let mut ctx = make_ctx(&parts, None); + let original = openai_body(); + let mut body = Some(original.clone()); + let action = filter.on_request_body(&mut ctx, &mut body, false).await.unwrap(); + assert!(matches!(action, FilterAction::Continue), "non-final chunks continue"); + assert_eq!(body.as_ref().unwrap(), &original, "body untouched before end of stream"); + assert!(ctx.filter_metadata.is_empty(), "no metadata before end of stream"); +} + +#[tokio::test] +async fn fail_open_passes_through_unmodified_without_a_client() { + let filter = build_filter(&filter_yaml("http://127.0.0.1:1/v1/chat/completions", "open")); + let parts = CtxParts::new("/v1/chat/completions", &[]); + let mut ctx = make_ctx(&parts, None); + let original = openai_body(); + let mut body = Some(original.clone()); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + assert!(matches!(action, FilterAction::Continue), "fail-open continues"); + assert_eq!( + body.as_ref().unwrap(), + &original, + "the client's model is never clobbered" + ); + assert!(ctx.get_metadata(METADATA_CLUSTER).is_none(), "no cluster on failure"); + assert!( + ctx.get_metadata(METADATA_ERROR).unwrap().contains("subrequest client"), + "the reason is recorded" + ); +} + +#[tokio::test] +async fn fail_closed_rejects_with_503() { + let filter = build_filter(&filter_yaml("http://127.0.0.1:1/v1/chat/completions", "closed")); + let parts = CtxParts::new("/v1/chat/completions", &[]); + let mut ctx = make_ctx(&parts, None); + let mut body = Some(openai_body()); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected Reject, got {action:?}"); + }; + assert_eq!(rejection.status, 503, "closed mode rejects with 503"); +} + +#[tokio::test] +async fn fail_open_passes_through_non_json_bodies() { + let filter = build_filter(&filter_yaml("http://127.0.0.1:1/v1/chat/completions", "open")); + let parts = CtxParts::new("/v1/chat/completions", &[]); + let mut ctx = make_ctx(&parts, None); + let original = Bytes::from_static(b"not json at all"); + let mut body = Some(original.clone()); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + assert!(matches!(action, FilterAction::Continue), "fail-open continues"); + assert_eq!(body.as_ref().unwrap(), &original, "unparsable body forwarded as-is"); + assert!(ctx.get_metadata(METADATA_ERROR).is_some(), "the reason is recorded"); +} + +#[tokio::test] +async fn fail_open_passes_through_unknown_formats() { + let filter = build_filter(&filter_yaml("http://127.0.0.1:1/v1/chat/completions", "open")); + let parts = CtxParts::new("/unknown/endpoint", &[]); + let mut ctx = make_ctx(&parts, None); + let original = Bytes::from(json!({"model": "agent-default", "input": "hi"}).to_string()); + let mut body = Some(original.clone()); + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + assert!(matches!(action, FilterAction::Continue), "fail-open continues"); + assert_eq!(body.as_ref().unwrap(), &original, "unroutable format forwarded as-is"); + assert!( + ctx.get_metadata(METADATA_ERROR).unwrap().contains("wire format"), + "the reason is recorded" + ); +} + +// --------------------------------------------------------------------------- +// End-to-end decisions through the loopback judge +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn easy_requests_route_to_the_weak_tier() { + let stub = JudgeStub::spawn(vec![weak_verdict()]); + let filter = build_filter(&filter_yaml(&stub.endpoint, "open")); + let client = subrequest_client(); + let (action, body, metadata) = + run_body_phase(filter.as_ref(), &client, "/v1/chat/completions", &[], openai_body()).await; + assert!(matches!(action, FilterAction::Continue), "successful routing continues"); + assert_eq!( + body_model(body.as_ref()), + "model-weak", + "body model rewritten to the weak tier" + ); + assert_eq!(metadata.get(METADATA_CLUSTER).map(String::as_str), Some("cluster-weak")); + assert_eq!(metadata.get(METADATA_TIER).map(String::as_str), Some("weak")); + let captured = stub.join(); + assert!( + captured[0].contains("\"model\":\"judge-model\""), + "the judge sees the configured judge model: {}", + captured[0] + ); + assert!( + captured[0].contains("json_schema"), + "the classifier contract's response_format reaches the wire" + ); +} + +#[tokio::test] +async fn hard_requests_route_to_the_strong_tier() { + let stub = JudgeStub::spawn(vec![strong_verdict()]); + let filter = build_filter(&filter_yaml(&stub.endpoint, "open")); + let client = subrequest_client(); + let (action, body, metadata) = + run_body_phase(filter.as_ref(), &client, "/v1/chat/completions", &[], openai_body()).await; + assert!(matches!(action, FilterAction::Continue), "successful routing continues"); + assert_eq!( + body_model(body.as_ref()), + "model-strong", + "body model rewritten to the strong tier" + ); + assert_eq!( + metadata.get(METADATA_CLUSTER).map(String::as_str), + Some("cluster-strong") + ); + stub.join(); +} + +#[tokio::test] +async fn anthropic_requests_route_and_preserve_provider_fields() { + let stub = JudgeStub::spawn(vec![weak_verdict()]); + let filter = build_filter(&filter_yaml(&stub.endpoint, "open")); + let client = subrequest_client(); + let anthropic = Bytes::from( + json!({ + "model": "agent-default", + "max_tokens": 256, + "system": "be terse", + "messages": [{"role": "user", "content": "hello"}], + }) + .to_string(), + ); + let (_, body, metadata) = run_body_phase(filter.as_ref(), &client, "/v1/messages", &[], anthropic).await; + assert_eq!( + body_model(body.as_ref()), + "model-weak", + "Anthropic body model rewritten" + ); + let value: Value = serde_json::from_slice(body.as_ref().unwrap()).unwrap(); + assert_eq!(value["max_tokens"], 256, "provider-specific fields preserved"); + assert_eq!(value["system"], "be terse", "provider-specific fields preserved"); + assert_eq!(metadata.get(METADATA_CLUSTER).map(String::as_str), Some("cluster-weak")); + stub.join(); +} + +// --------------------------------------------------------------------------- +// The no-downgrade guarantee +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn a_strong_session_never_downgrades() { + let stub = JudgeStub::spawn(vec![strong_verdict(), weak_verdict()]); + let filter = build_filter(&filter_yaml(&stub.endpoint, "open")); + let client = subrequest_client(); + let session = &[("x-switchyard-session-id", "session-ratchet")]; + + let (_, first_body, _) = + run_body_phase(filter.as_ref(), &client, "/v1/chat/completions", session, openai_body()).await; + assert_eq!(body_model(first_body.as_ref()), "model-strong", "turn 1 reaches strong"); + + let (_, second_body, metadata) = + run_body_phase(filter.as_ref(), &client, "/v1/chat/completions", session, openai_body()).await; + assert_eq!( + body_model(second_body.as_ref()), + "model-strong", + "turn 2 must hold strong even when the judge says weak" + ); + assert_eq!(metadata.get(METADATA_TIER).map(String::as_str), Some("strong")); + stub.join(); +} + +#[tokio::test] +async fn the_escalation_ratchet_skips_the_judge_once_strong() { + // Only ONE verdict is scripted, yet TWO turns run. The stub serves exactly + // as many connections as scripted, so a second judge call would hang the + // turn waiting for a response that never comes. That it completes — and that + // exactly one request is captured — proves the ratcheted turn skipped the + // judge entirely, not merely that its verdict was discarded. + let stub = JudgeStub::spawn(vec![strong_verdict()]); + let filter = build_filter(&ratchet_yaml(&stub.endpoint)); + let client = subrequest_client(); + let session = &[("x-switchyard-session-id", "session-ratchet-skip")]; + + let (_, first_body, _) = + run_body_phase(filter.as_ref(), &client, "/v1/chat/completions", session, openai_body()).await; + assert_eq!(body_model(first_body.as_ref()), "model-strong", "turn 1 reaches strong"); + + let (_, second_body, metadata) = + run_body_phase(filter.as_ref(), &client, "/v1/chat/completions", session, openai_body()).await; + assert_eq!( + body_model(second_body.as_ref()), + "model-strong", + "turn 2 stays strong with no judge call" + ); + assert_eq!(metadata.get(METADATA_TIER).map(String::as_str), Some("strong")); + + let captured = stub.join(); + assert_eq!( + captured.len(), + 1, + "the judge was called exactly once, not on the ratcheted turn" + ); +} + +#[tokio::test] +async fn without_the_ratchet_a_strong_session_still_judges_every_turn() { + // The default (ratchet off) is Switchyard vanilla: the judge runs every + // turn even on a strong-floored session. Two verdicts are scripted and both + // must be consumed; the floor clamps the weak second verdict up to strong. + let stub = JudgeStub::spawn(vec![strong_verdict(), weak_verdict()]); + let filter = build_filter(&filter_yaml(&stub.endpoint, "open")); + let client = subrequest_client(); + let session = &[("x-switchyard-session-id", "session-no-ratchet")]; + + for _ in 0..2 { + let (_action, _body, _metadata) = + run_body_phase(filter.as_ref(), &client, "/v1/chat/completions", session, openai_body()).await; + } + + let captured = stub.join(); + assert_eq!(captured.len(), 2, "vanilla behaviour judges both turns"); +} + +#[tokio::test] +async fn a_judge_failure_never_forces_a_tier() { + let stub = JudgeStub::spawn(vec![strong_verdict(), (500, "oops".to_owned())]); + let filter = build_filter(&filter_yaml(&stub.endpoint, "open")); + let client = subrequest_client(); + let session = &[("x-switchyard-session-id", "session-failure")]; + + let (_, first_body, _) = + run_body_phase(filter.as_ref(), &client, "/v1/chat/completions", session, openai_body()).await; + assert_eq!(body_model(first_body.as_ref()), "model-strong", "turn 1 reaches strong"); + + let original = openai_body(); + let (action, second_body, metadata) = run_body_phase( + filter.as_ref(), + &client, + "/v1/chat/completions", + session, + original.clone(), + ) + .await; + assert!(matches!(action, FilterAction::Continue), "fail-open continues"); + assert_eq!( + second_body.as_ref().unwrap(), + &original, + "a judge failure leaves the client's model untouched" + ); + assert!(!metadata.contains_key(METADATA_CLUSTER), "no cluster on failure"); + assert!( + metadata.get(METADATA_ERROR).unwrap().contains("judge"), + "the judge failure is recorded" + ); + stub.join(); +} + +#[tokio::test] +async fn the_final_turn_evicts_the_floor() { + let stub = JudgeStub::spawn(vec![strong_verdict(), weak_verdict(), weak_verdict()]); + let filter = build_filter(&filter_yaml(&stub.endpoint, "open")); + let client = subrequest_client(); + let session = &[("x-switchyard-session-id", "session-final")]; + let final_turn = &[ + ("x-switchyard-session-id", "session-final"), + ("x-switchyard-session-final", "true"), + ]; + + let (_, first_body, _) = + run_body_phase(filter.as_ref(), &client, "/v1/chat/completions", session, openai_body()).await; + assert_eq!(body_model(first_body.as_ref()), "model-strong", "turn 1 reaches strong"); + + let (_, final_body, _) = run_body_phase( + filter.as_ref(), + &client, + "/v1/chat/completions", + final_turn, + openai_body(), + ) + .await; + assert_eq!( + body_model(final_body.as_ref()), + "model-strong", + "the floor still holds on the final turn" + ); + + let (_, next_body, _) = + run_body_phase(filter.as_ref(), &client, "/v1/chat/completions", session, openai_body()).await; + assert_eq!( + body_model(next_body.as_ref()), + "model-weak", + "a fresh session after eviction follows the judge again" + ); + stub.join(); +} diff --git a/crates/switchyard-filters/src/switchyard/translate.rs b/crates/switchyard-filters/src/switchyard/translate.rs new file mode 100644 index 0000000..6561f0b --- /dev/null +++ b/crates/switchyard-filters/src/switchyard/translate.rs @@ -0,0 +1,203 @@ +//! Wire-format detection, judge-side decoding, and the model rewrite. +//! +//! Translation is used **only to feed the judge**: the inbound body is decoded +//! into the Switchyard IR so the classifier sees a normalized request +//! regardless of source format. The forwarded request is never round-tripped +//! through the IR — both supported formats carry `model` at the JSON top +//! level, so the rewrite mutates the original parsed body in place (mirroring +//! the stock `ModelRewriteFilter`), preserving every provider-specific field. + +use serde_json::Value; +use switchyard_protocol::{LlmRequest, WireFormat}; + +use super::error::RouteError; + +/// Detects the request's wire format, primarily from the request path. +/// +/// Path suffixes are authoritative (`/chat/completions` ⇒ OpenAI chat, +/// `/messages` ⇒ Anthropic messages); otherwise the body shape decides. +/// +/// # Errors +/// +/// Returns [`RouteError::UnknownFormat`] (or a body-shape error) when neither +/// signal identifies a supported format; the caller takes the failure path. +pub(crate) fn detect_format(path: &str, body: &Value) -> Result { + if path.ends_with("/chat/completions") { + return Ok(WireFormat::OpenAiChat); + } + if path.ends_with("/messages") { + return Ok(WireFormat::AnthropicMessages); + } + detect_format_from_shape(body) +} + +/// Body-shape fallback for non-canonical paths. +/// +/// Anthropic messages bodies always carry a top-level `max_tokens` (and often +/// `system`); OpenAI chat bodies usually carry neither. An OpenAI chat request +/// using the legacy `max_tokens` off the canonical path would be misread — +/// the path check above is the primary, precise signal. +fn detect_format_from_shape(body: &Value) -> Result { + let Some(object) = body.as_object() else { + return Err(RouteError::Body("is not a JSON object")); + }; + if !object.contains_key("messages") { + return Err(RouteError::UnknownFormat); + } + if object.contains_key("system") || object.contains_key("max_tokens") { + return Ok(WireFormat::AnthropicMessages); + } + Ok(WireFormat::OpenAiChat) +} + +/// Decodes the inbound body into the Switchyard IR for the judge. +/// +/// # Errors +/// +/// Returns [`RouteError::Translation`] when the codec rejects the body. +pub(crate) fn decode_for_judge(format: WireFormat, body: &Value) -> Result { + switchyard_translation::decode_request(format, body).map_err(|err| RouteError::Translation(err.to_string())) +} + +/// Rewrites the top-level `model` field in place and re-serializes. +/// +/// # Errors +/// +/// Returns [`RouteError::Body`] when the body is not a JSON object, or +/// [`RouteError::Serialize`] when re-serialization fails. +pub(crate) fn rewrite_model(body: &mut Value, model: &str) -> Result, RouteError> { + let Some(object) = body.as_object_mut() else { + return Err(RouteError::Body("is not a JSON object")); + }; + object.insert("model".to_owned(), Value::String(model.to_owned())); + serde_json::to_vec(body).map_err(|err| RouteError::Serialize(err.to_string())) +} + +#[cfg(test)] +#[expect(clippy::allow_attributes, reason = "blanket test-module suppressions")] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + reason = "unwrap/panic are acceptable in tests" +)] +mod tests { + use serde_json::{Value, json}; + use switchyard_protocol::WireFormat; + + use super::{decode_for_judge, detect_format, rewrite_model}; + + /// A minimal OpenAI chat-completions body. + fn openai_body() -> Value { + json!({ + "model": "agent-default", + "messages": [{"role": "user", "content": "hello"}], + }) + } + + /// A minimal Anthropic messages body. + fn anthropic_body() -> Value { + json!({ + "model": "agent-default", + "max_tokens": 128, + "system": "be terse", + "messages": [{"role": "user", "content": "hello"}], + }) + } + + #[test] + fn path_detection_is_authoritative() { + let chat_format = detect_format("/v1/chat/completions", &anthropic_body()).unwrap(); + assert_eq!(chat_format, WireFormat::OpenAiChat, "path beats body shape"); + let messages_format = detect_format("/v1/messages", &openai_body()).unwrap(); + assert_eq!( + messages_format, + WireFormat::AnthropicMessages, + "messages path maps to Anthropic" + ); + } + + #[test] + fn shape_fallback_detects_both_formats() { + let openai_format = detect_format("/proxy", &openai_body()).unwrap(); + assert_eq!( + openai_format, + WireFormat::OpenAiChat, + "plain messages body is OpenAI chat" + ); + let anthropic_format = detect_format("/proxy", &anthropic_body()).unwrap(); + assert_eq!( + anthropic_format, + WireFormat::AnthropicMessages, + "system+max_tokens body is Anthropic" + ); + } + + #[test] + fn undetectable_bodies_are_errors() { + let formatless_error = detect_format("/proxy", &json!({"no": "messages"})).unwrap_err(); + assert!( + formatless_error.to_string().contains("unrecognized"), + "message-less body has no format: {formatless_error}" + ); + let shapeless_error = detect_format("/proxy", &json!(["not", "an", "object"])).unwrap_err(); + assert!( + shapeless_error.to_string().contains("object"), + "non-object body is an error: {shapeless_error}" + ); + } + + #[test] + fn openai_bodies_decode_for_the_judge() { + let request = decode_for_judge(WireFormat::OpenAiChat, &openai_body()).unwrap(); + assert_eq!(request.model.as_deref(), Some("agent-default"), "model survives decode"); + assert_eq!(request.messages.len(), 1, "message survives decode"); + } + + #[test] + fn anthropic_bodies_decode_for_the_judge() { + let request = decode_for_judge(WireFormat::AnthropicMessages, &anthropic_body()).unwrap(); + assert_eq!(request.model.as_deref(), Some("agent-default"), "model survives decode"); + assert_eq!(request.messages.len(), 1, "message survives decode"); + } + + #[test] + fn decode_is_lenient_about_malformed_fields() { + // Switchyard's default TranslationPolicy is permissive: a malformed + // field is preserved/diagnosed rather than fatal, so a shapeless body + // decodes to an empty-message request (the judge still runs; failure + // ownership stays with the host). + let request = decode_for_judge(WireFormat::OpenAiChat, &json!({"messages": "nope"})).unwrap(); + assert!(request.messages.is_empty(), "junk messages decode to none"); + } + + #[test] + fn non_object_bodies_fail_translation() { + let error = decode_for_judge(WireFormat::OpenAiChat, &json!(["not", "an", "object"])).unwrap_err(); + assert!( + error.to_string().contains("translation"), + "codec rejection surfaces as a translation error: {error}" + ); + } + + #[test] + fn rewrite_replaces_only_the_model() { + let mut body = anthropic_body(); + let bytes = rewrite_model(&mut body, "claude-strong").unwrap(); + let round_trip: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(round_trip["model"], "claude-strong", "model is rewritten"); + assert_eq!(round_trip["max_tokens"], 128, "other fields are preserved"); + assert_eq!(round_trip["system"], "be terse", "provider-specific fields survive"); + } + + #[test] + fn rewrite_rejects_non_objects() { + let mut body = json!("just a string"); + let error = rewrite_model(&mut body, "claude-strong").unwrap_err(); + assert!( + error.to_string().contains("object"), + "non-object rewrite errors: {error}" + ); + } +} diff --git a/crates/switchyard-server/Cargo.toml b/crates/switchyard-server/Cargo.toml new file mode 100644 index 0000000..8abe856 --- /dev/null +++ b/crates/switchyard-server/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "switchyard-server" +description = "Experimental Praxis AI gateway server composing the stock praxis-ai server with this workspace's filters" +repository = "https://github.com/praxis-proxy/experimental" +homepage = "https://github.com/praxis-proxy/experimental" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish = false + +[lints] +workspace = true + +# cargo-machete's source scan can't see these usages: +# - switchyard-filters is consumed by the discovery build script (its presence +# as a direct dep, carrying the praxis-filters marker, is the point). +# - praxis-ai-proxy is used as `praxis_ai::...` (lib name differs from package). +# - praxis-filter is referenced only in build-script-generated code. +[package.metadata.cargo-machete] +ignored = ["switchyard-filters", "praxis-ai-proxy", "praxis-filter"] + +[[bin]] +name = "switchyard-server" +path = "src/main.rs" + +[dependencies] +# Direct dependency on the filters crate is what makes it discoverable: the +# build script scans this crate's direct runtime deps for the +# `[package.metadata.praxis-filters]` marker. +switchyard-filters = { path = "../switchyard-filters" } + +# Praxis core (crates.io) — for constructing the SubRequestClient that +# `build_full_registry` requires. Keep the 0.5.x line in lockstep with the +# git-pinned praxis-ai-proxy crate below so `FilterRegistry` unifies. +praxis-filter = { version = "0.5.2", package = "praxis-proxy-filter" } +praxis-core = { version = "0.5.2", package = "praxis-proxy-core" } + +# The stock praxis-ai server. Pinned to an exact commit because praxis-ai crates +# are `publish = false` (git dependency only) and move fast; bump deliberately. +praxis-ai-proxy = { git = "https://github.com/praxis-proxy/ai", rev = "2d07c828e64e4f1f0bd80297478a6d19648c0cf3" } + +[build-dependencies] +cargo_metadata = "0.23.1" +praxis-ai-build-support = { git = "https://github.com/praxis-proxy/ai", rev = "2d07c828e64e4f1f0bd80297478a6d19648c0cf3" } diff --git a/crates/switchyard-server/build.rs b/crates/switchyard-server/build.rs new file mode 100644 index 0000000..cd5c082 --- /dev/null +++ b/crates/switchyard-server/build.rs @@ -0,0 +1,33 @@ +//! Build script: discover external filter crates and generate their +//! registration code. +//! +//! Mirrors praxis-ai's `server/build.rs` (via `praxis-ai-build-support`): scan +//! this crate's direct runtime dependencies for the +//! `[package.metadata.praxis-filters]` marker and emit +//! `fn register_external_filters(&mut FilterRegistry)` into `$OUT_DIR`, which +//! `src/main.rs` includes. + +use std::path::Path; + +/// Discovers external filter crates and writes the generated registration code. +/// +/// # Errors +/// +/// Returns an error if `cargo metadata` fails, `OUT_DIR` is unset, or the +/// generated file cannot be written. +fn main() -> Result<(), Box> { + let mut command = cargo_metadata::MetadataCommand::new(); + command.manifest_path(concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml")); + let metadata = command.exec()?; + + let crates = build_support::discover_external_filter_crate_names(&metadata); + let code = build_support::generate_registration_code(&crates); + + let out_dir = std::env::var("OUT_DIR")?; + let dest = Path::new(&out_dir).join("external_filters.rs"); + std::fs::write(&dest, code)?; + + println!("cargo:rerun-if-changed=Cargo.toml"); + println!("cargo:rerun-if-changed=build.rs"); + Ok(()) +} diff --git a/crates/switchyard-server/src/main.rs b/crates/switchyard-server/src/main.rs new file mode 100644 index 0000000..1215f30 --- /dev/null +++ b/crates/switchyard-server/src/main.rs @@ -0,0 +1,52 @@ +//! Thin server binary that composes the stock praxis-ai server with the +//! experimental filters discovered from this workspace. +//! +//! The build script (`build.rs`) generates `register_external_filters`, included +//! below. `main` follows praxis-ai's own bin flow: resolve and load config, +//! initialise tracing, build the full filter registry (built-ins + praxis-ai +//! filters + this workspace's discovered filters), then hand it to +//! [`praxis_ai::run_server_with_registry`]. + +use praxis_core::{ + config::Config, + subrequest::{SubRequestClient, SubRequestConnector}, +}; + +// Provides: fn register_external_filters(registry: &mut praxis_filter::FilterRegistry) +include!(concat!(env!("OUT_DIR"), "/external_filters.rs")); + +/// Builds a [`SubRequestClient`] from runtime config, mirroring praxis-ai's +/// `create_subrequest_client` so callout behaviour matches the stock server. +fn create_subrequest_client(config: &Config) -> SubRequestClient { + let pool_size = config + .runtime + .subrequest_pool_size + .unwrap_or(praxis_core::config::DEFAULT_SUBREQUEST_POOL_SIZE); + let connector = SubRequestConnector::new(pool_size, config.runtime.subrequest_max_connections); + let response_ceiling = config.body_limits.max_response_bytes.unwrap_or(usize::MAX); + SubRequestClient::with_max_response_bytes(connector, response_ceiling) +} + +/// Loads config, initialises tracing, composes the registry, and runs the server. +/// +/// # Errors +/// +/// Returns an error if config loading or tracing initialisation fails. On +/// success it does not return: [`praxis_ai::run_server_with_registry`] blocks +/// for the lifetime of the process. +fn main() -> Result<(), Box> { + // This thin POC bin does not parse CLI args; it uses the default config + // location. Mirror praxis-ai's own bin: pass the same explicit value (here + // `None`) to both helpers rather than feeding the resolved path back in. + let config_path = praxis_ai::resolve_config_path(None); + let config = praxis_ai::load_config(None)?; + + // Hold the tracing guard for the lifetime of the process. + let _tracing_guard = praxis_ai::init_tracing(&config)?; + + let subrequest_client = create_subrequest_client(&config); + let mut registry = praxis_ai::build_full_registry(&subrequest_client); + register_external_filters(&mut registry); + + praxis_ai::run_server_with_registry(config, registry, config_path); +} diff --git a/deny.toml b/deny.toml index e79174a..8e5d7ff 100644 --- a/deny.toml +++ b/deny.toml @@ -1,7 +1,13 @@ [advisories] unmaintained = "all" maximum-db-staleness = "P30D" -ignore = [] +ignore = [ + # Unmaintained transitive deps pinned by the praxis 0.5.2 / pingora-fork + # (quixotic-plecostomus 0.8.2) tree; not fixable from this workspace. + # Revisit on every praxis/pingora bump. + { id = "RUSTSEC-2024-0388", reason = "derivative: unmaintained, via pingora-fork proxy crates" }, + { id = "RUSTSEC-2025-0134", reason = "rustls-pemfile: unmaintained, via pingora-fork rustls stack" }, +] [licenses] allow = [ @@ -14,6 +20,9 @@ allow = [ "CDLA-Permissive-2.0", "ISC", "MIT", + # MIT No Attribution (OSI approved): borrow-or-share, via + # jsonschema <- switchyard-libsy. + "MIT-0", "Unicode-3.0", "Unicode-DFS-2016", "Unlicense", @@ -30,4 +39,6 @@ highlight = "all" unknown-registry = "deny" unknown-git = "deny" allow-registry = ["https://github.com/rust-lang/crates.io-index"] -allow-git = [] +# praxis-ai crates are publish = false and only consumable as git dependencies; +# switchyard-server pins them to an exact rev. +allow-git = ["https://github.com/praxis-proxy/ai"] diff --git a/docs/identity-metadata.md b/docs/identity-metadata.md new file mode 100644 index 0000000..661a019 --- /dev/null +++ b/docs/identity-metadata.md @@ -0,0 +1,94 @@ +# Identity metadata contract + +> **Status: proposed.** The key names below are a proposal and need maintainer +> sign-off before consumers build on them. Some downstream designs +> (praxis-proxy/ai#698, praxis-proxy/ai#130) are still open, so treat this as a +> living contract until those land. + +This document defines the `filter_metadata` keys and value formats that +identity-*producing* filters write and that identity-*consuming* filters read +inside a single Praxis request pipeline. It exists so that Track B's +`api_key_auth` and the metering/budget consumers of the Standalone AI Gateway MVP +epic (praxis-proxy/ai#758) can be built against a stable, agreed surface. + +## 1. Purpose and carrier + +Identity facts travel *inside* the filter pipeline via `ctx.filter_metadata`, a +flat `HashMap` written with `ctx.set_metadata(key, value)`. The +context enforces limits (from praxis `filter/src/context.rs`): + +- key ≤ 64 bytes, +- value ≤ 256 bytes, +- ≤ 128 entries total. + +Over-limit writes are **silently dropped with a `tracing::warn!`** — producers +must validate or truncate deliberately and must never rely on the silent drop. + +Request **headers are the ingress representation only**. A producer that reads +identity from an inbound header MUST strip that header before the request is +forwarded upstream, so client-supplied identity headers can never be spoofed +through the proxy (the capture-and-strip semantics of praxis-proxy/ai#698). + +Metadata is **not** a routing-match surface: the core `router` filter matches on +headers, not on `filter_metadata`. This contract is for observability, +inter-filter contracts, and consumer branching only. + +## 2. Keys + +All identity keys are dot-namespaced under `identity.`: + +| Key | Required | Meaning | +| ----------------------- | ------------------------ | ------------------------------------------------------------------------------- | +| `identity.user` | yes, if any producer ran | Stable principal identifier (username or key ID). | +| `identity.group` | optional | Group or team the principal belongs to. | +| `identity.subscription` | optional | Plan / tier (metering in praxis-proxy/ai#577 expects user/group/subscription). | +| `identity.method` | recommended | How identity was established: `api_key` \| `header` \| `jwt`. | +| `identity.attr.` | optional | Reserved namespace for pass-through attributes (e.g. captured `x-tenant-*`). | + +## 3. Value format + +- UTF-8, ≤ 256 bytes, no control characters. +- The producer normalizes: trim surrounding whitespace; **case-preserving**. + Consumers MUST NOT case-fold when comparing. +- Producers validate or truncate before writing; do not rely on the context's + silent over-limit drop. + +## 4. Producers + +- **`api_key_auth`** (Track B, experimental): resolves a key to identity from + inline/file/env config, following core `basic_auth`. Sets `identity.user` + (key ID or configured principal), optionally `identity.group` / + `identity.subscription`, and `identity.method = api_key`. +- **Future:** an identity-header guard (praxis-proxy/ai#698) mapping e.g. + `x-tenant-username` → `identity.user` (`identity.method = header`); tenant-ID + extraction (praxis-proxy/ai#130) mapping `X-Tenant-ID` → `identity.user`, with + an optional JWT-claims source (`identity.method = jwt`). + +Rule: **exactly one producer should be active per pipeline.** A later producer +MUST NOT overwrite an existing `identity.user`. + +## 5. Consumers + +- External metering (praxis-proxy/ai#577): balance check keyed by `identity.user` + (and group/subscription). +- `token_ceiling` (Track A): per-key/per-user budget. +- Per praxis-proxy/ai#130: rate limiting, routing decisions, logging, metrics. + +Consumers decide their own policy when identity is **absent**. Metering and +budget filters SHOULD be configurable between reject-vs-anonymous. + +## 6. Non-goals + +- Authorization semantics (what a principal may do) — this is identity only. +- Key formats / credential validation rules — producer-specific. +- **Session** identity. Session is a separate axis with its own keys + (`x-switchyard-session-id` is a *session*, not a *principal*); see the + `switchyard_route` docs. Do not conflate the two. + +## 7. Cross-references + +- Parent epic: praxis-proxy/ai#758 (Track B `api_key_auth` is the first consumer). +- praxis-proxy/ai#698 (identity-header capture-and-strip). +- praxis-proxy/ai#577 (external metering). +- praxis-proxy/ai#130 (tenant-ID extraction; rate limiting / routing / logging). +- `switchyard_route` docs (session key axis, deliberately separate). diff --git a/docs/switchyard-route.md b/docs/switchyard-route.md new file mode 100644 index 0000000..e2b0bec --- /dev/null +++ b/docs/switchyard-route.md @@ -0,0 +1,238 @@ +# `switchyard_route`: Capability-mode Mixture-of-Models routing + +> **Status: POC** (praxis-proxy/experimental#2, Track A item 2 of +> praxis-proxy/ai#758, discussion praxis-proxy/praxis#976). Built against +> NVIDIA NeMo Switchyard `=0.2.0` (pre-alpha; git tag `v0.2.0`, commit +> `1fc9ab887d1c663b0048ae24d5f473d15ed8daaa`). Any version bump is a +> deliberate change: re-run `make audit` and re-verify the API notes in the +> module docs. + +`switchyard_route` embeds Switchyard's `LlmTaskClassifier` (Capability mode) +in the Praxis request path as a **decision-only** router. One cheap judge +callout classifies each request (easy vs hard); Switchyard names an abstract +tier tag (`weak` / `strong`); the filter resolves that tag through its own +config table into a real `(cluster, model)` pair, rewriting the request +body's `model` field *and* selecting the Praxis cluster. The answer is served +once, by the real upstream — the filter never serves it. + +Switchyard only ever sees the abstract tags. Repointing `strong` from one +provider to another is a one-line config change; Switchyard is untouched. + +## How it works + +With `BodyMode::StreamBuffer`, the buffered-body hook runs *before* +`on_request`: + +1. `on_request_body` (end of stream): parse the JSON body, detect the wire + format (OpenAI chat completions or Anthropic messages, by path first and + body shape as fallback), decode it into the Switchyard IR, and drive + `run_stream` until the routed `Step::Decision`. The judge `CallLlm` step + is served through the server-shared `SubRequestClient` against the + configured judge endpoint; the stream is dropped at the decision, before + any answer call. The chosen tier's model is written into the body in + place (provider-specific fields preserved — no IR round-trip), and the + chosen cluster is stashed in filter metadata. +2. `on_request`: applies the stashed cluster to `ctx.cluster` (preserving a + cluster an earlier filter already chose). + +Observability metadata: `switchyard_route.cluster`, `.tier`, `.model` on +success; `switchyard_route.error` on the failure path. + +## Configuration + +```yaml +filter_chains: + - name: routing + filters: + - filter: switchyard_route + judge: # required — the classifier callout + endpoint: "http://judge.internal:8000/v1/chat/completions" + model: qwen3-judge # model id sent to the judge + auth: # optional — omit for a keyless judge + value_env: OPENAI_API_KEY # env var holding the secret (required) + header: authorization # default: authorization + scheme: Bearer # default: Bearer ("" sends raw value) + timeout_ms: 2000 # DNS + HTTP deadline (default 2000) + max_response_bytes: 65536 # judge reply cap (default 64 KiB) + threshold: 0.5 # classifier base threshold, [0,1] + targets: # required — exactly weak + strong + weak: + cluster: local-vllm # -> ctx.cluster + model: qwen2.5-7b # -> body["model"] + strong: + cluster: openai-frontier + model: gpt-4o + session_floor: # host-owned no-downgrade ratchet + enabled: true # default true + ttl_secs: 3600 # inactivity eviction (default 1 h) + exclude_below: true # also bar weak inside Switchyard + escalation_ratchet: false # skip the judge once floored strong (default false) + on_failure: open # open | closed (default open) + max_body_bytes: 1048576 # StreamBuffer cap (default 1 MiB) + session_header: x-switchyard-session-id + - filter: load_balancer + clusters: + - name: local-vllm + endpoints: ["10.0.1.1:8000"] + - name: openai-frontier + endpoints: ["10.0.2.1:8443"] +``` + +Notes: + +- The failure knob is **`on_failure`**, not `failure_mode`: `failure_mode` + is a structural key of Praxis's pipeline `FilterEntry` wrapper and is + stripped before the filter sees its config. +- There is deliberately **no `default_tier`**: on failure the filter never + forces a tier (see below). +- Bodies over `max_body_bytes` are rejected with 413 by the `StreamBuffer` + machinery itself, before the filter runs. +- **Judge credentials never live in config.** `judge.auth.value_env` names an + environment variable; the value is read once at startup (a missing or empty + variable fails filter construction) and sent as ` ` in the + configured header, marked sensitive so it is redacted from logs. Omit + `auth` entirely for a keyless judge (local vLLM/Ollama). + +## The no-downgrade guarantee + +**Requirement:** once a session has routed to `strong`, a later turn must +never be silently downgraded to `weak`. + +Switchyard v0.2.0 cannot provide this. Its Capability classifier is +per-request/stateless; `session_affinity: true` is a *first-decision-wins* +latch (it can pin a session to `weak` and block a needed upgrade); its +session state is in-process, TTL-bound, and not seedable from outside. The +filter therefore owns the guarantee, in two layers: + +1. **Don't overwrite on failure.** On *any* failure (bad body, unknown + format, judge callout error, missing subrequest client, decision-less + stream) the request passes through with the client's own `model` + untouched and no cluster set. The filter can never *cause* a downgrade by + clobbering a good model. This is stateless and survives everything. +2. **Session floor.** An in-process `session → tier` map (keyed by + `session_header`, TTL-evicted, dropped on + `x-switchyard-session-final: true`). Every decision is clamped to + `max(floor, decision)` — the ratchet only moves up. With + `exclude_below: true` the below-floor tier is additionally excluded + inside Switchyard for that turn. + +### The judge tax, and the escalation ratchet + +The Capability classifier calls the judge on **every** turn — that is +Switchyard's default, and this filter's. The judge is itself an LLM call, so +routing only saves money when the judge is cheap relative to the +weak↔strong gap, amortized over your traffic. A large frontier judge on +every turn can cost more than it saves, especially on the easy turns that +route `weak`. + +One slice of that cost is pure waste: once a session is floored at `strong`, +the clamp forces `strong` regardless of what the judge says, so re-judging it +spends a call to re-derive a foregone answer. `escalation_ratchet: true` +skips the judge entirely on any turn whose floor is already `strong` and +routes `strong` directly. It is **off by default** (matching Switchyard +vanilla); turn it on to stop the wasted spend, at the cost of the +(already-decided) per-turn verdict in the logs. + +This is deliberately the filter-side equivalent of Switchyard's own +escalation latch, `AffinityRouter::with_latch_only(["strong"])` — a +*directional* affinity that retains only the strong tier and never the weak +one. We express it against the filter's floor store (keyed by +`session_header`) rather than adopting `AffinityRouter`, which keys on +request metadata and lives inside the classifier cascade the `run_stream` +decision-only design bypasses. Same behaviour, no duplicated session state. + +Note it does **not** help the costs it can't: the first `strong` turn and +every `weak` turn still judge. If most traffic is one-shot or always-easy, +prefer a cheaper judge or a non-LLM pre-filter over this flag. + +**Honesty note for operators:** the floor cache has the same loss profile as +Switchyard's own state — a process restart, config reload, failover, or +replica hop wipes it. In those cases layer 1 degrades the behaviour to +"route as the request already asked", never "downgrade below what the client +asked". A durable floor store (Redis/KV behind the `SessionFloorStore` seam +in `floor.rs`) is the planned follow-up that makes the strict ratchet hold +across process boundaries. + +Judge failures inside Switchyard would default the decision to the capable +tier; the filter deliberately does not rely on that — a judge transport +error takes the host's failure path (pass through unmodified) so the outcome +is owned here, not by library defaulting. + +## Failure topology: what `open` really does + +`on_failure: open` means the filter forwards the request unmodified — it +does not select a cluster. What happens next depends on the chain: + +- If `switchyard_route` is the **only** cluster selector before + `load_balancer` (the demo topology below), the load balancer fails the + request: `no cluster set in context` → HTTP 500. Praxis rejects chains + with a second cluster selector, so there is no in-chain fallback route. +- `on_failure: closed` rejects deliberately with **503** and a clear body — + for this topology it is the clearer choice if you prefer explicit errors. + +In other words: `open` guarantees the *optimizer can never rewrite your +request wrongly*; it cannot conjure a route where none exists. Deployments +that need judge-outage survivability should front a default route at a +different layer. + +## Local demo (verified, real judge) + +Everything lives in `hack/switchyard-demo/` (see its README): the judge is +a **real** OpenAI-compatible model of your choosing; only the two upstream +"clusters" are stubbed (`upstreams.py`, loopback echo servers on +`:18092`/`:18093`) so the chosen cluster and the rewritten `model` field +are plainly visible. `run-demo.sh` renders `praxis.yaml` from the template, +starts everything (gateway on `127.0.0.1:18080`), and runs four turns: + +```console +$ cd hack/switchyard-demo +$ JUDGE_ENDPOINT=http://127.0.0.1:11434/v1/chat/completions \ + JUDGE_MODEL=llama3.2:3b \ + ./run-demo.sh +``` + +Transcript (recorded 2026-08-18 against this revision, judge = +`llama3.2:3b` served by a local Ollama — a real LLM verdict, not a script): + +```console +--- turn 1: easy question, session demo-A (expect weak) --- +{"served_by": "weak-upstream", "model_received": "qwen-mini"} +--- turn 2: hard question, session demo-A (expect strong) --- +{"served_by": "strong-upstream", "model_received": "qwen-max"} +--- turn 3: easy question again, session demo-A (the floor must HOLD strong) --- +{"served_by": "strong-upstream", "model_received": "qwen-max"} +--- turn 4: easy question, fresh session demo-B (isolated; expect weak) --- +{"served_by": "weak-upstream", "model_received": "qwen-mini"} +``` + +The client sent `"model": "agent-default"` on every turn; what each +upstream *received* is the tier model the filter wrote. Turns 1/2/4 are the +judge's real classification; turn 3 is the host-side floor holding `strong`. + +This transcript predates the escalation ratchet, so turn 3 there reached the +judge and had its `weak` verdict *clamped* up. The demo config now sets +`session_floor.escalation_ratchet: true`, so on a fresh run turn 3 skips the +judge entirely (`grep 'ratchet held' server.log` — three judge calls, not +four) and still holds `strong`. Same routing outcome, one fewer judge call. + +Judge-model realities observed while recording: a thinking model +(`qwen3:8b`) blew a 20 s judge deadline on the hard prompt — the filter +failed open exactly as designed (`routing unavailable: sub-request deadline +exceeded`, body untouched). Pick a small, fast judge; the demo template +uses a generous 60 s deadline for local models. + +With the judge stopped (upstreams still running), a request logs +`switchyard_route: routing unavailable ... Connection refused` at the +filter, passes through unmodified, and the load balancer returns 500 (`no +cluster set in context`) — the topology consequence described above. + +## Scope and follow-ups + +- **Capability mode only.** Passthrough/Random make no LLM decision + (Praxis already load-balances); Escalation cannot be decision-only (its + verdict needs the efficient model's actual answer). Documented, not built. +- **Two tiers** (`weak` < `strong`); N-way buckets would be Switchyard's + `Custom` path — follow-up territory. +- **Durable session-floor store** (Redis/KV adapter behind + `SessionFloorStore`) — follow-up issue; required for the strict ratchet + across restarts/replicas. diff --git a/hack/switchyard-demo/README.md b/hack/switchyard-demo/README.md new file mode 100644 index 0000000..f6ea227 --- /dev/null +++ b/hack/switchyard-demo/README.md @@ -0,0 +1,73 @@ +# switchyard_route local demo + +A self-contained demonstration of the `switchyard_route` filter (see +`docs/switchyard-route.md`) with a **real** judge model and stubbed +upstreams: + +- The **judge is real**: point `JUDGE_ENDPOINT` / `JUDGE_MODEL` at any + OpenAI-compatible chat-completions server (Ollama, vLLM, LM Studio, …). + The Capability classifier sends its packaged system prompt, your + messages, and a `json_schema` response format, and reads the model's + verdict. +- The **upstreams are stubbed** (`upstreams.py`): two loopback echo servers + standing in for the weak/strong clusters, so the chosen cluster and the + rewritten `model` field are plainly visible without needing two real + model deployments. + +## Run + +Keyless local judge (Ollama / vLLM / LM Studio): + +```console +$ JUDGE_ENDPOINT=http://127.0.0.1:11434/v1/chat/completions \ + JUDGE_MODEL=qwen3:8b \ + ./run-demo.sh +``` + +Hosted judge that needs a bearer token (OpenAI, Together, Fireworks, …): + +```console +$ JUDGE_ENDPOINT=https://api.openai.com/v1/chat/completions \ + JUDGE_MODEL=gpt-4o-mini \ + OPENAI_API_KEY=sk-... \ + JUDGE_KEY_ENV=OPENAI_API_KEY \ + ./run-demo.sh +``` + +`JUDGE_KEY_ENV` names the environment variable holding your token. The +filter reads that variable at startup and sends it as +`authorization: Bearer `; the secret never lands in `praxis.yaml`. +Leave `JUDGE_KEY_ENV` unset for a keyless judge and the auth block is +dropped from the rendered config. + +The script renders `praxis.yaml` from `praxis.yaml.template`, starts the +upstreams and the composed `switchyard-server` (gateway on +`127.0.0.1:18080`), and runs four turns: an easy question (expect +`weak-cluster` / `qwen-mini`), a hard question (expect `strong-cluster` / +`qwen-max`), the same easy question again in the same session (the +no-downgrade floor must hold `strong`), and an easy question in a fresh +session (isolated; back to `weak`). + +The demo config sets `session_floor.escalation_ratchet: true`, so turn 3 +holds `strong` **without calling the judge at all** — the floor already +forces `strong`, so re-judging would only pay to re-derive it. Confirm the +skip with `grep 'ratchet held' server.log`; you'll see three judge calls, +not four. The flag is off by default (Switchyard judges every turn); see +"The judge tax, and the escalation ratchet" in `docs/switchyard-route.md`. + +Filter logs land in `server.log`; the runner sets +`RUST_LOG=info,switchyard_filters=debug` so each successful routing +decision (`switchyard_route: routed tier=… cluster=… model=…`) is visible +there, alongside any fail-open reasons. `grep switchyard_route server.log`. + +Note: turns 1 and 4 depend on the judge model's actual verdict — a weak +judge model may classify conservatively. Turn 3 demonstrates the floor +regardless, as long as turn 2 reached `strong`. + +## Fail-open behaviour + +Stop the judge (or point `JUDGE_ENDPOINT` at a dead port) and re-run a +turn: the filter logs `switchyard_route: routing unavailable`, forwards the +body unmodified, and selects no cluster — in this chain the load balancer +then returns 500 (`no cluster set in context`). See "Failure topology" in +`docs/switchyard-route.md` for why, and when to prefer `on_failure: closed`. diff --git a/hack/switchyard-demo/praxis.yaml.template b/hack/switchyard-demo/praxis.yaml.template new file mode 100644 index 0000000..6bec168 --- /dev/null +++ b/hack/switchyard-demo/praxis.yaml.template @@ -0,0 +1,62 @@ +# switchyard_route demo topology (rendered by run-demo.sh; see the sibling +# README.md and docs/switchyard-route.md). +# +# gateway 127.0.0.1:18080 +# └─ switchyard_route +# ├─ judge callout → __JUDGE_ENDPOINT__ (a REAL OpenAI-compatible model) +# ├─ weak-cluster → 127.0.0.1:18092 (local echo upstream, model qwen-mini) +# └─ strong-cluster → 127.0.0.1:18093 (local echo upstream, model qwen-max) +# +# The judge is real: the Capability classifier POSTs an OpenAI chat-completions +# request (packaged classifier system prompt + your messages + a json_schema +# response_format) and reads the model's verdict. The two upstreams are local +# echo servers so the rewritten `model` field and the chosen cluster are plainly +# visible in the demo transcript. + +listeners: + - name: switchyard-gateway + address: "127.0.0.1:18080" + filter_chains: + - routing + +filter_chains: + - name: routing + filters: + - filter: switchyard_route + judge: + endpoint: "__JUDGE_ENDPOINT__" + model: __JUDGE_MODEL__ + # Credential for a hosted judge. The secret stays in the environment; + # only the variable *name* is written here. run-demo.sh drops this + # block entirely when JUDGE_KEY_ENV is unset (keyless local judges + # like vLLM/Ollama). +__JUDGE_AUTH_BLOCK__ + # Generous: real local judges (especially thinking models) can be + # slow; production judges should be small and fast (~2000 ms). + timeout_ms: 60000 + threshold: 0.5 + targets: + weak: + cluster: weak-cluster + model: qwen-mini + strong: + cluster: strong-cluster + model: qwen-max + session_floor: + enabled: true + ttl_secs: 3600 + exclude_below: true + # Once a session is floored strong, skip the judge entirely: its + # verdict is already foregone. Turn 3 of the demo shows this — the + # floor holds strong with NO judge call (grep the server log for + # "escalation ratchet held"). Off by default in the filter. + escalation_ratchet: true + on_failure: open + - filter: load_balancer + clusters: + - name: weak-cluster + endpoints: + - "127.0.0.1:18092" + - name: strong-cluster + endpoints: + - "127.0.0.1:18093" diff --git a/hack/switchyard-demo/run-demo.sh b/hack/switchyard-demo/run-demo.sh new file mode 100755 index 0000000..2f53c6b --- /dev/null +++ b/hack/switchyard-demo/run-demo.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Runs the switchyard_route demo against a REAL OpenAI-compatible judge. +# +# Keyless local judge (Ollama / vLLM / LM Studio): +# +# JUDGE_ENDPOINT=http://127.0.0.1:11434/v1/chat/completions \ +# JUDGE_MODEL=qwen3:8b \ +# ./run-demo.sh +# +# Hosted judge that needs a bearer token (OpenAI, Together, Fireworks, …): +# +# JUDGE_ENDPOINT=https://api.openai.com/v1/chat/completions \ +# JUDGE_MODEL=gpt-4o-mini \ +# OPENAI_API_KEY=sk-... \ +# JUDGE_KEY_ENV=OPENAI_API_KEY \ +# ./run-demo.sh +# +# JUDGE_KEY_ENV names the environment variable holding the token; the filter +# reads that variable at startup, so the secret never touches praxis.yaml. Leave +# it unset for a keyless judge and the auth block is dropped entirely. +# +# Renders praxis.yaml from praxis.yaml.template, starts the two local echo +# upstreams (upstreams.py) and the composed switchyard-server, then runs a +# four-turn transcript demonstrating: easy->weak, hard->strong, the session +# floor holding strong on a later easy turn, and session isolation. +# +# See the sibling README.md and docs/switchyard-route.md. + +cd "$(dirname "$0")" + +: "${JUDGE_ENDPOINT:?set JUDGE_ENDPOINT to an OpenAI-compatible chat-completions URL}" +: "${JUDGE_MODEL:?set JUDGE_MODEL to the judge model id}" + +GATEWAY=http://127.0.0.1:18080/v1/chat/completions +SERVER_BIN=../../target/debug/switchyard-server + +if [[ ! -x "$SERVER_BIN" ]]; then + echo "building switchyard-server..." >&2 + (cd ../.. && cargo build -p switchyard-server) +fi + +# Build the judge auth block only when JUDGE_KEY_ENV is set. When it is, verify +# the named variable actually holds a value now, so a typo fails here with a +# clear message instead of later as a fail-open judge callout. +if [[ -n "${JUDGE_KEY_ENV:-}" ]]; then + if [[ -z "${!JUDGE_KEY_ENV:-}" ]]; then + echo "JUDGE_KEY_ENV=${JUDGE_KEY_ENV} but that variable is unset or empty" >&2 + exit 1 + fi + AUTH_BLOCK=" auth:"$'\n'" value_env: ${JUDGE_KEY_ENV}" + # The server reads the credential from its own environment, so export it in + # case the caller passed it as a plain (unexported) shell variable. + export "${JUDGE_KEY_ENV?}" + echo "judge auth: sending the value of \$${JUDGE_KEY_ENV} as 'authorization: Bearer ...'" >&2 +else + AUTH_BLOCK="" + echo "judge auth: none (keyless judge)" >&2 +fi + +# Render the template. The auth-block substitution reads from a file to keep the +# secret's *name* (never its value) out of the sed program text, and to carry +# the block's newline cleanly. +printf '%s\n' "$AUTH_BLOCK" > .auth-block.yaml +sed -e "s|__JUDGE_ENDPOINT__|${JUDGE_ENDPOINT}|" \ + -e "s|__JUDGE_MODEL__|${JUDGE_MODEL}|" \ + -e "/__JUDGE_AUTH_BLOCK__/{ + r .auth-block.yaml + d + }" \ + praxis.yaml.template > praxis.yaml +rm -f .auth-block.yaml + +python3 upstreams.py & +UPSTREAMS_PID=$! +# Raise the filter to debug so successful routing decisions (logged at debug!) +# are visible in server.log, not just the warn-level fail-open path. Honour a +# caller-supplied RUST_LOG if they want something else. +RUST_LOG="${RUST_LOG:-info,switchyard_filters=debug}" "$SERVER_BIN" > server.log 2>&1 & +SERVER_PID=$! +cleanup() { + kill "$SERVER_PID" "$UPSTREAMS_PID" 2>/dev/null || true +} +trap cleanup EXIT +sleep 2 + +ask() { # ask + curl -s -m 120 -X POST "$GATEWAY" \ + -H 'content-type: application/json' \ + -H "x-switchyard-session-id: $1" \ + -d "{\"model\":\"agent-default\",\"messages\":[{\"role\":\"user\",\"content\":\"$2\"}]}" + echo +} + +echo "--- turn 1: easy question, session demo-A (expect weak) ---" +ask demo-A 'What is 2+2?' +echo "--- turn 2: hard question, session demo-A (expect strong) ---" +ask demo-A 'Prove that every finitely generated group acting freely on a tree is free, and generalize to graphs of groups.' +echo "--- turn 3: easy question again, session demo-A (floor HOLDS strong; judge SKIPPED) ---" +ask demo-A 'What is 3+3?' +echo "--- turn 4: easy question, fresh session demo-B (isolated; expect weak) ---" +ask demo-B 'What is 4+4?' +echo +echo "filter decisions and fail-open reasons: grep switchyard_route server.log" +echo "turn 3 skipped the judge (escalation ratchet): grep 'ratchet held' server.log" diff --git a/hack/switchyard-demo/upstreams.py b/hack/switchyard-demo/upstreams.py new file mode 100644 index 0000000..ffd36d1 --- /dev/null +++ b/hack/switchyard-demo/upstreams.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Local echo "clusters" for the switchyard_route demo (stdlib only). + +The judge in this demo is a *real* OpenAI-compatible endpoint (see +run-demo.sh); only the two upstream "model" servers are stubbed, so the +routing decision and the rewritten `model` field are plainly visible in the +response without needing real GPUs. + +Runs two loopback HTTP servers: + +- 18092: the weak (efficient) upstream. Echoes the model it received. +- 18093: the strong (capable) upstream. Echoes the model it received. + +Each responds to any POST with a JSON object naming which tier served the +request and the `model` field it saw (i.e. the value switchyard_route +rewrote into the body). Usage: python3 upstreams.py (Ctrl-C to stop) +""" + +import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +def upstream(name: str): + """An upstream handler that echoes which tier served the request.""" + + class Upstream(BaseHTTPRequestHandler): + def do_POST(self): # noqa: N802 (http.server API) + length = int(self.headers.get("Content-Length", "0")) + body = json.loads(self.rfile.read(length) or b"{}") + payload = json.dumps( + { + "served_by": name, + "model_received": body.get("model"), + } + ).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, format, *args): # noqa: A002 (matches base signature) + print(f"[{name}] {format % args}", flush=True) + + return Upstream + + +def serve(port: int, handler) -> ThreadingHTTPServer: + server = ThreadingHTTPServer(("127.0.0.1", port), handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + print(f"listening on 127.0.0.1:{port}", flush=True) + return server + + +if __name__ == "__main__": + servers = [ + serve(18092, upstream("weak-upstream")), + serve(18093, upstream("strong-upstream")), + ] + try: + threading.Event().wait() + except KeyboardInterrupt: + for running in servers: + running.shutdown() diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 61d8532..a925d26 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.96.0" +channel = "1.96.1" components = ["clippy", "rust-analyzer"]