From bcff8afd13b10fd2852baef9ba0f447091052ba4 Mon Sep 17 00:00:00 2001 From: usize Date: Tue, 18 Aug 2026 16:07:23 -0700 Subject: [PATCH 1/9] feat: scaffold switchyard filter and server crates Port the switchyard-poc scaffold (4447055) onto the conventioned main, restructured to the template's crates/ layout (praxis-proxy/experimental#1, Track A of praxis-proxy/ai#758): - crates/switchyard-filters: [package.metadata.praxis-filters] auto-discovery marker, export_filters! registration, and a no-op experimental_placeholder filter with unit tests proving registration (replaced by switchyard_route in a follow-up commit, experimental#2). - crates/switchyard-server: discovery build.rs via praxis-ai-build-support, composing the git-pinned praxis-ai-proxy server (build_full_registry + run_server_with_registry) with discovered filters. - Toolchain/MSRV: bump 1.96 -> 1.96.1 (rust-toolchain, workspace rust-version, clippy.toml msrv) because the Switchyard 0.2.0 crates pin MSRV 1.96.1. Needs a maintainer decision on the template side. - deny.toml: re-add [sources] allow-git for praxis-proxy/ai (publish = false crates, git-pinned by switchyard-server). - docs/identity-metadata.md: the proposed identity metadata contract for the upcoming api_key_auth producer and the metering/budget consumers. Validated: build, test, clippy -D warnings, nightly fmt, rustdoc -D warnings, cargo machete. Assisted by Fable 5 Signed-off-by: usize --- Cargo.lock | 4702 +++++++++++++++++- Cargo.toml | 5 +- clippy.toml | 2 +- crates/switchyard-filters/Cargo.toml | 27 + crates/switchyard-filters/src/lib.rs | 18 + crates/switchyard-filters/src/placeholder.rs | 75 + crates/switchyard-server/Cargo.toml | 45 + crates/switchyard-server/build.rs | 33 + crates/switchyard-server/src/main.rs | 52 + deny.toml | 4 +- docs/identity-metadata.md | 94 + rust-toolchain.toml | 2 +- 12 files changed, 4854 insertions(+), 205 deletions(-) create mode 100644 crates/switchyard-filters/Cargo.toml create mode 100644 crates/switchyard-filters/src/lib.rs create mode 100644 crates/switchyard-filters/src/placeholder.rs create mode 100644 crates/switchyard-server/Cargo.toml create mode 100644 crates/switchyard-server/build.rs create mode 100644 crates/switchyard-server/src/main.rs create mode 100644 docs/identity-metadata.md diff --git a/Cargo.lock b/Cargo.lock index 9909104..b639dca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,379 +3,4681 @@ 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", + "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "arrayvec" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] -name = "ppv-lite86" -version = "0.2.21" +name = "asn1-rs" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" dependencies = [ - "zerocopy", + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", ] [[package]] -name = "proc-macro2" -version = "1.0.107" +name = "asn1-rs-derive" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" dependencies = [ - "unicode-ident", + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", ] [[package]] -name = "proptest" -version = "1.11.0" +name = "asn1-rs-impl" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ - "bit-set", - "bit-vec", - "bitflags", - "num-traits", - "rand", - "rand_chacha", - "rand_xorshift", - "regex-syntax", - "rusty-fork", - "tempfile", - "unarray", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "quick-error" -version = "1.2.3" +name = "async-trait" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] [[package]] -name = "quote" -version = "1.0.47" +name = "atoi" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" dependencies = [ - "proc-macro2", + "num-traits", ] [[package]] -name = "r-efi" -version = "5.3.0" +name = "atomic-waker" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] -name = "r-efi" -version = "6.0.0" +name = "autocfg" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] -name = "rand" -version = "0.9.5" +name = "aws-lc-rs" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" dependencies = [ - "rand_chacha", - "rand_core", + "aws-lc-sys", + "zeroize", ] [[package]] -name = "rand_chacha" -version = "0.9.0" +name = "aws-lc-sys" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" dependencies = [ - "ppv-lite86", - "rand_core", + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", ] [[package]] -name = "rand_core" -version = "0.9.5" +name = "base64" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] -name = "rand_xorshift" -version = "0.4.0" +name = "base64" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "rand_core", + "bit-vec", ] [[package]] -name = "regex-syntax" -version = "0.8.11" +name = "bit-vec" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] -name = "rustix" -version = "1.1.4" +name = "bitflags" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", + "digest 0.10.7", ] [[package]] -name = "rusty-fork" -version = "0.3.1" +name = "block-buffer" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "fnv", - "quick-error", - "tempfile", - "wait-timeout", + "generic-array", ] [[package]] -name = "syn" -version = "2.0.119" +name = "block-buffer" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "hybrid-array", ] [[package]] -name = "tempfile" -version = "3.27.0" +name = "brotli" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +checksum = "d640d25bc63c50fb1f0b545ffd80207d2e10a4c965530809b40ba3386825c391" dependencies = [ - "fastrand", - "getrandom 0.4.3", - "once_cell", - "rustix", - "windows-sys", + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", ] [[package]] -name = "tracing" -version = "0.1.44" +name = "brotli-decompressor" +version = "2.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +checksum = "4e2e4afe60d7dd600fdd3de8d0f08c2b7ec039712e3b6137ff98b7004e82de4f" dependencies = [ - "pin-project-lite", - "tracing-attributes", - "tracing-core", + "alloc-no-stdlib", + "alloc-stdlib", ] [[package]] -name = "tracing-attributes" -version = "0.1.31" +name = "bstr" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" dependencies = [ - "proc-macro2", - "quote", - "syn", + "memchr", + "regex-automata", + "serde_core", ] [[package]] -name = "tracing-core" -version = "0.1.36" +name = "bumpalo" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +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 = [ - "once_cell", + "serde_core", ] [[package]] -name = "unarray" -version = "0.1.4" +name = "cargo-platform" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba" +dependencies = [ + "serde", + "serde_core", +] [[package]] -name = "unicode-ident" -version = "1.0.24" +name = "cargo_metadata" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.20", +] [[package]] -name = "wait-timeout" -version = "0.2.1" +name = "cc" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" dependencies = [ + "find-msvc-tools", + "jobserver", "libc", + "shlex", ] [[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" +name = "cfg-if" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "windows-link" -version = "0.2.1" +name = "cfg_aliases" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] -name = "windows-sys" -version = "0.61.2" +name = "chacha20" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ - "windows-link", + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", ] [[package]] -name = "wit-bindgen" -version = "0.57.1" +name = "chrono" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] [[package]] -name = "zerocopy" -version = "0.8.56" +name = "clap" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ - "zerocopy-derive", + "clap_builder", + "clap_derive", ] [[package]] -name = "zerocopy-derive" -version = "0.8.56" +name = "clap_builder" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ - "proc-macro2", + "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 = "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 = "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 = "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 = "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", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[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 = "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 = "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-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-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-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 = "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 = "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", + "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", + "strum_macros", + "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 = "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", +] + +[[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 = "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", + "praxis-proxy-filter", + "yaml_serde", +] + +[[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 = "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", + "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", + "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-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-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 = "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 = "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/clippy.toml b/clippy.toml index 75013e5..b3c0362 100644 --- a/clippy.toml +++ b/clippy.toml @@ -11,7 +11,7 @@ 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"] disallowed-methods = [ diff --git a/crates/switchyard-filters/Cargo.toml b/crates/switchyard-filters/Cargo.toml new file mode 100644 index 0000000..474319d --- /dev/null +++ b/crates/switchyard-filters/Cargo.toml @@ -0,0 +1,27 @@ +[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" +serde_yaml = { package = "yaml_serde", version = "0.10.4" } + +# 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" } diff --git a/crates/switchyard-filters/src/lib.rs b/crates/switchyard-filters/src/lib.rs new file mode 100644 index 0000000..ef566ae --- /dev/null +++ b/crates/switchyard-filters/src/lib.rs @@ -0,0 +1,18 @@ +//! Experimental Praxis AI filters. +//! +//! 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. +//! +//! For now this crate ships a single no-op placeholder filter +//! (`experimental_placeholder`), so that end-to-end discovery and registration +//! are provable before the real `switchyard_route` filter lands (tracked in +//! praxis-proxy/experimental#2). + +mod placeholder; + +praxis_filter::export_filters! { + http "experimental_placeholder" => placeholder::PlaceholderFilter::from_config, +} diff --git a/crates/switchyard-filters/src/placeholder.rs b/crates/switchyard-filters/src/placeholder.rs new file mode 100644 index 0000000..b3ea976 --- /dev/null +++ b/crates/switchyard-filters/src/placeholder.rs @@ -0,0 +1,75 @@ +//! A no-op placeholder filter. +//! +//! This exists solely to prove that praxis-ai's build-time filter discovery and +//! the [`praxis_filter::export_filters!`] registration path work end to end for +//! this crate. It performs no request or response processing and will be +//! replaced by the real `switchyard_route` filter (praxis-proxy/experimental#2). + +use async_trait::async_trait; +use praxis_filter::{FilterAction, FilterError, HttpFilter, HttpFilterContext}; + +/// A filter that does nothing and lets every request continue unchanged. +#[derive(Debug)] +pub(crate) struct PlaceholderFilter; + +impl PlaceholderFilter { + /// Builds a [`PlaceholderFilter`] from filter configuration. + /// + /// The configuration is ignored; this filter has no options. + /// + /// # Errors + /// + /// Never returns an error. The `Result` return type is required by the + /// filter factory signature that [`praxis_filter::export_filters!`] expects. + #[expect( + clippy::unnecessary_wraps, + reason = "the export_filters! factory signature requires Result even though this never fails" + )] + pub(crate) fn from_config(_config: &serde_yaml::Value) -> Result, FilterError> { + Ok(Box::new(Self)) + } +} + +#[async_trait] +impl HttpFilter for PlaceholderFilter { + fn name(&self) -> &'static str { + "experimental_placeholder" + } + + async fn on_request(&self, _ctx: &mut HttpFilterContext<'_>) -> Result { + Ok(FilterAction::Continue) + } +} + +#[cfg(test)] +#[expect(clippy::allow_attributes, reason = "blanket test-module suppressions")] +#[allow(clippy::unwrap_used, clippy::panic, reason = "unwrap/panic are acceptable in tests")] +mod tests { + use praxis_filter::FilterRegistry; + + use super::PlaceholderFilter; + + /// The filter's advertised name matches the name it is registered under. + #[test] + fn name_matches_registration() { + let filter = PlaceholderFilter; + assert_eq!( + praxis_filter::HttpFilter::name(&filter), + "experimental_placeholder", + "advertised name must match the export_filters! registration" + ); + } + + /// The macro-generated `register_filters` makes the placeholder discoverable + /// in a `FilterRegistry`, mirroring what praxis-ai's discovery does. + #[test] + fn placeholder_is_registered() { + let mut registry = FilterRegistry::with_builtins(); + crate::register_filters(&mut registry); + let names = registry.available_filters(); + assert!( + names.contains(&"experimental_placeholder"), + "expected experimental_placeholder to be registered, got: {names:?}" + ); + } +} 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..bfaf1b2 100644 --- a/deny.toml +++ b/deny.toml @@ -30,4 +30,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..02b6b63 --- /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/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"] From 015fbc77935d89712abd4217e9964cc2c662f955 Mon Sep 17 00:00:00 2001 From: usize Date: Tue, 18 Aug 2026 16:39:54 -0700 Subject: [PATCH 2/9] feat: add the switchyard_route filter (Capability-mode MoM routing) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement praxis-proxy/experimental#2 (Track A item 2 of praxis-proxy/ai#758, discussion praxis#976): a decision-only Mixture-of-Models router embedding NVIDIA NeMo Switchyard's LlmTaskClassifier (Capability mode, =0.2.0, git tag v0.2.0 / commit 1fc9ab88) in the Praxis request path. Design (see crates/switchyard-filters/src/switchyard.rs module docs): - Two-phase pipeline: with BodyMode::StreamBuffer the buffered body hook runs first — parse body, detect wire format (OpenAI chat + Anthropic messages), decode to the Switchyard IR for the judge, drive run_stream to the routed Step::Decision, rewrite body model in place; the chosen cluster is stashed in filter metadata and applied to ctx.cluster in on_request. - Decision-only: the filter serves ONLY the judge CallLlm (via the server-shared SubRequestClient against an explicit judge endpoint) and drops the stream at the routed decision, before any answer call. - Host-owned no-downgrade guarantee: (1) every failure path passes the request through UNMODIFIED (never forces a tier); (2) an in-process session floor (DashMap + TTL, evicted on x-switchyard-session-final) clamps every decision to max(floor, decision) and optionally excludes the below-floor tier inside Switchyard. Switchyard itself cannot provide this: its session affinity is a first-decision-wins latch and its state is neither durable nor seedable. A durable floor store is a follow-up. - The tag->(cluster, model) table lives only in filter config; Switchyard sees abstract weak/strong/judge tags. Notable deltas discovered against the plan: - The pipeline's FilterEntry wrapper reserves the failure_mode key (parse_filter_config strips it), so the filter's knob is on_failure. - libsy's run_stream driver always offloads every model call to the step stream, so no RoutedLlmClient is attached to any target; the step loop serves the judge directly. - The algorithm is built once at from_config time (its session-state sweeper task must not be respawned per request). Also: allow MIT-0 (borrow-or-share via jsonschema <- switchyard-libsy) and ignore two unmaintained advisories pinned by the pingora-fork tree (RUSTSEC-2024-0388 derivative, RUSTSEC-2025-0134 rustls-pemfile) in deny.toml; add OpenAI/NeMo to clippy doc-valid-idents. Tests (45, hermetic in make test): config parse/validation, wire-format detection + IR decode + model rewrite for both formats, session-floor ratchet/TTL/eviction, judge endpoint parsing, and filter-level tests driving the real hooks against hand-built HttpFilterContexts with a scripted loopback judge stub over a real SubRequestClient — including: easy->weak, hard->strong, a strong session never downgrades when the judge later says weak, a judge failure leaves the body untouched, and session-final eviction. Validated: build, test, clippy -D warnings (workspace wall), nightly fmt, rustdoc -D warnings, cargo machete, cargo deny check, cargo audit, coverage 94.3% lines / 93.6% regions (gates: 90/80), single praxis-proxy-filter 0.5.2 in the tree. Assisted by Fable 5 Signed-off-by: usize --- Cargo.lock | 387 ++++++++++- clippy.toml | 2 +- crates/switchyard-filters/Cargo.toml | 21 + crates/switchyard-filters/src/lib.rs | 17 +- crates/switchyard-filters/src/placeholder.rs | 75 --- crates/switchyard-filters/src/switchyard.rs | 344 ++++++++++ .../src/switchyard/algorithm.rs | 54 ++ .../src/switchyard/config.rs | 481 +++++++++++++ .../src/switchyard/error.rs | 43 ++ .../src/switchyard/floor.rs | 209 ++++++ .../src/switchyard/judge.rs | 69 ++ .../switchyard-filters/src/switchyard/peer.rs | 237 +++++++ .../src/switchyard/steps.rs | 52 ++ .../src/switchyard/tests.rs | 636 ++++++++++++++++++ .../src/switchyard/translate.rs | 203 ++++++ deny.toml | 11 +- 16 files changed, 2750 insertions(+), 91 deletions(-) delete mode 100644 crates/switchyard-filters/src/placeholder.rs create mode 100644 crates/switchyard-filters/src/switchyard.rs create mode 100644 crates/switchyard-filters/src/switchyard/algorithm.rs create mode 100644 crates/switchyard-filters/src/switchyard/config.rs create mode 100644 crates/switchyard-filters/src/switchyard/error.rs create mode 100644 crates/switchyard-filters/src/switchyard/floor.rs create mode 100644 crates/switchyard-filters/src/switchyard/judge.rs create mode 100644 crates/switchyard-filters/src/switchyard/peer.rs create mode 100644 crates/switchyard-filters/src/switchyard/steps.rs create mode 100644 crates/switchyard-filters/src/switchyard/tests.rs create mode 100644 crates/switchyard-filters/src/switchyard/translate.rs diff --git a/Cargo.lock b/Cargo.lock index b639dca..1885d53 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,6 +18,7 @@ dependencies = [ "const-random", "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -177,6 +178,28 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "async-trait" version = "0.1.92" @@ -292,6 +315,12 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "brotli" version = "3.5.0" @@ -330,6 +359,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "byteorder" version = "1.5.0" @@ -782,6 +817,15 @@ 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" @@ -848,6 +892,17 @@ dependencies = [ "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" @@ -877,6 +932,17 @@ dependencies = [ "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" @@ -921,6 +987,16 @@ 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" @@ -1080,9 +1156,11 @@ 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]] @@ -1579,6 +1657,68 @@ dependencies = [ "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" @@ -1780,6 +1920,12 @@ dependencies = [ "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" @@ -1873,6 +2019,20 @@ 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" @@ -1883,6 +2043,21 @@ dependencies = [ "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" @@ -1898,6 +2073,27 @@ 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" @@ -1950,6 +2146,19 @@ 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" @@ -1974,6 +2183,12 @@ dependencies = [ "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" @@ -2395,7 +2610,7 @@ dependencies = [ "rmp", "rmp-serde", "serde", - "strum", + "strum 0.26.3", "tokio", ] @@ -2442,8 +2657,8 @@ dependencies = [ "serde_yaml", "sfv", "socket2", - "strum", - "strum_macros", + "strum 0.26.3", + "strum_macros 0.26.4", "tokio", "tokio-stream", "tokio-test", @@ -2732,6 +2947,43 @@ 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" @@ -3506,7 +3758,16 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros", + "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]] @@ -3522,6 +3783,18 @@ dependencies = [ "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" @@ -3533,10 +3806,61 @@ 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" @@ -3549,6 +3873,20 @@ dependencies = [ "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" @@ -3673,7 +4011,7 @@ dependencies = [ "anyhow", "base64 0.22.1", "bstr", - "fancy-regex", + "fancy-regex 0.17.0", "lazy_static", "regex", "rustc-hash", @@ -3772,6 +4110,7 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", @@ -3925,6 +4264,22 @@ dependencies = [ "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" @@ -3986,6 +4341,12 @@ 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" @@ -4066,6 +4427,16 @@ dependencies = [ "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" @@ -4084,6 +4455,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/clippy.toml b/clippy.toml index b3c0362..38b2948 100644 --- a/clippy.toml +++ b/clippy.toml @@ -13,7 +13,7 @@ stack-size-threshold = 16384 avoid-breaking-exported-api = false 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 index 474319d..cd51456 100644 --- a/crates/switchyard-filters/Cargo.toml +++ b/crates/switchyard-filters/Cargo.toml @@ -19,9 +19,30 @@ 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" diff --git a/crates/switchyard-filters/src/lib.rs b/crates/switchyard-filters/src/lib.rs index ef566ae..ce3515c 100644 --- a/crates/switchyard-filters/src/lib.rs +++ b/crates/switchyard-filters/src/lib.rs @@ -1,18 +1,17 @@ -//! Experimental Praxis AI filters. +//! 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 +//! `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. //! -//! For now this crate ships a single no-op placeholder filter -//! (`experimental_placeholder`), so that end-to-end discovery and registration -//! are provable before the real `switchyard_route` filter lands (tracked in -//! praxis-proxy/experimental#2). +//! 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 placeholder; +mod switchyard; praxis_filter::export_filters! { - http "experimental_placeholder" => placeholder::PlaceholderFilter::from_config, + http "switchyard_route" => switchyard::SwitchyardRouteFilter::from_config, } diff --git a/crates/switchyard-filters/src/placeholder.rs b/crates/switchyard-filters/src/placeholder.rs deleted file mode 100644 index b3ea976..0000000 --- a/crates/switchyard-filters/src/placeholder.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! A no-op placeholder filter. -//! -//! This exists solely to prove that praxis-ai's build-time filter discovery and -//! the [`praxis_filter::export_filters!`] registration path work end to end for -//! this crate. It performs no request or response processing and will be -//! replaced by the real `switchyard_route` filter (praxis-proxy/experimental#2). - -use async_trait::async_trait; -use praxis_filter::{FilterAction, FilterError, HttpFilter, HttpFilterContext}; - -/// A filter that does nothing and lets every request continue unchanged. -#[derive(Debug)] -pub(crate) struct PlaceholderFilter; - -impl PlaceholderFilter { - /// Builds a [`PlaceholderFilter`] from filter configuration. - /// - /// The configuration is ignored; this filter has no options. - /// - /// # Errors - /// - /// Never returns an error. The `Result` return type is required by the - /// filter factory signature that [`praxis_filter::export_filters!`] expects. - #[expect( - clippy::unnecessary_wraps, - reason = "the export_filters! factory signature requires Result even though this never fails" - )] - pub(crate) fn from_config(_config: &serde_yaml::Value) -> Result, FilterError> { - Ok(Box::new(Self)) - } -} - -#[async_trait] -impl HttpFilter for PlaceholderFilter { - fn name(&self) -> &'static str { - "experimental_placeholder" - } - - async fn on_request(&self, _ctx: &mut HttpFilterContext<'_>) -> Result { - Ok(FilterAction::Continue) - } -} - -#[cfg(test)] -#[expect(clippy::allow_attributes, reason = "blanket test-module suppressions")] -#[allow(clippy::unwrap_used, clippy::panic, reason = "unwrap/panic are acceptable in tests")] -mod tests { - use praxis_filter::FilterRegistry; - - use super::PlaceholderFilter; - - /// The filter's advertised name matches the name it is registered under. - #[test] - fn name_matches_registration() { - let filter = PlaceholderFilter; - assert_eq!( - praxis_filter::HttpFilter::name(&filter), - "experimental_placeholder", - "advertised name must match the export_filters! registration" - ); - } - - /// The macro-generated `register_filters` makes the placeholder discoverable - /// in a `FilterRegistry`, mirroring what praxis-ai's discovery does. - #[test] - fn placeholder_is_registered() { - let mut registry = FilterRegistry::with_builtins(); - crate::register_filters(&mut registry); - let names = registry.available_filters(); - assert!( - names.contains(&"experimental_placeholder"), - "expected experimental_placeholder to be registered, got: {names:?}" - ); - } -} diff --git a/crates/switchyard-filters/src/switchyard.rs b/crates/switchyard-filters/src/switchyard.rs new file mode 100644 index 0000000..0f2024b --- /dev/null +++ b/crates/switchyard-filters/src/switchyard.rs @@ -0,0 +1,344 @@ +//! 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 +//! 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::parse(&parsed.judge.endpoint)?; + 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 tag = self.decide(ctx, format, &value, floor).await?; + let tier = Tier::from_tag(&tag).ok_or(RouteError::UnknownTier(tag))?; + let chosen = 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(()) + } + + /// 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..2497bc6 --- /dev/null +++ b/crates/switchyard-filters/src/switchyard/algorithm.rs @@ -0,0 +1,54 @@ +//! 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 (Switchyard's affinity is a +/// first-decision-wins latch, not a ratchet; the session floor in `floor.rs` +/// owns the no-downgrade guarantee instead). +/// +/// # 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..fa1982b --- /dev/null +++ b/crates/switchyard-filters/src/switchyard/config.rs @@ -0,0 +1,481 @@ +//! 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 (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, + /// 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. +#[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, +} + +impl Default for SessionFloorConfig { + fn default() -> Self { + Self { + enabled: true, + ttl_secs: default_floor_ttl_secs(), + exclude_below: true, + } + } +} + +/// 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 [`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")); + } + 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_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 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}"); + } + + #[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..a573510 --- /dev/null +++ b/crates/switchyard-filters/src/switchyard/peer.rs @@ -0,0 +1,237 @@ +//! 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::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, +} + +impl JudgeEndpoint { + /// Parses an absolute http(s) URL into its connection components. + /// + /// # Errors + /// + /// Returns a [`FilterError`] when the URL is relative, uses a scheme other + /// than http/https, or carries a malformed authority or path. + pub(crate) fn parse(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, + }) + } +} + +/// 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() +} + +/// 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"), + ); + 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; + + #[test] + fn https_endpoints_parse_with_tls_and_default_port() { + let endpoint = JudgeEndpoint::parse("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"); + } + + #[test] + fn http_endpoints_parse_with_explicit_port() { + let endpoint = JudgeEndpoint::parse("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("ftp://judge/v1").is_err(), + "non-http scheme rejected" + ); + assert!( + JudgeEndpoint::parse("/v1/chat/completions").is_err(), + "relative URL rejected" + ); + assert!(JudgeEndpoint::parse("http://").is_err(), "missing host rejected"); + } +} 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..7d7a2c4 --- /dev/null +++ b/crates/switchyard-filters/src/switchyard/tests.rs @@ -0,0 +1,636 @@ +//! 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} +" + ) +} + +/// 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, "unparseable 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 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/deny.toml b/deny.toml index bfaf1b2..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", From d9937de0324e333c653939204b6a1e479639424c Mon Sep 17 00:00:00 2001 From: usize Date: Tue, 18 Aug 2026 16:47:08 -0700 Subject: [PATCH 3/9] docs: add the switchyard_route guide and a local demo recipe - docs/switchyard-route.md: design summary, full config reference (incl. the on_failure-vs-failure_mode structural-key note), the no-downgrade guarantee and its honest loss profile, the open-mode failure topology (pass-through cannot conjure a route: an LB-terminated chain returns 500 on judge outage; closed gives a deliberate 503), and a verified local demo transcript. - hack/switchyard-demo/: praxis.yaml (gateway :18080, judge :18091, weak :18092 / strong :18093 clusters) and stubs.py (stdlib-only judge + echo upstreams). Verified live: easy->weak with model rewrite, hard->strong, the session floor holding strong on a later easy turn, session isolation, and fail-open pass-through with the judge down. - switchyard-filters: declare test tokio runtime features as dev-dependencies. Assisted by Fable 5 Signed-off-by: usize --- crates/switchyard-filters/Cargo.toml | 4 + docs/switchyard-route.md | 193 ++++++++++++++++++++++ hack/switchyard-demo/praxis.yaml.template | 50 ++++++ hack/switchyard-demo/upstreams.py | 65 ++++++++ 4 files changed, 312 insertions(+) create mode 100644 docs/switchyard-route.md create mode 100644 hack/switchyard-demo/praxis.yaml.template create mode 100644 hack/switchyard-demo/upstreams.py diff --git a/crates/switchyard-filters/Cargo.toml b/crates/switchyard-filters/Cargo.toml index cd51456..6ce4931 100644 --- a/crates/switchyard-filters/Cargo.toml +++ b/crates/switchyard-filters/Cargo.toml @@ -46,3 +46,7 @@ pingora-core = { version = "0.8.2", package = "quixotic-plecostomus-core", featu 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/docs/switchyard-route.md b/docs/switchyard-route.md new file mode 100644 index 0000000..937fb1c --- /dev/null +++ b/docs/switchyard-route.md @@ -0,0 +1,193 @@ +# `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 + 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 + 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. + +## 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. + +**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) + +Everything lives in `hack/switchyard-demo/`: `praxis.yaml` (gateway on +`127.0.0.1:18080`, judge callout to `:18091`, weak/strong clusters on +`:18092`/`:18093`) and `stubs.py` (a stdlib-only judge + two echo +upstreams; the judge routes to `strong` when the newest user message +contains the word "hard"). + +```console +$ cargo build -p switchyard-server +$ cd hack/switchyard-demo +$ python3 stubs.py & +$ ../../target/debug/switchyard-server & # reads ./praxis.yaml +``` + +Transcript (recorded 2026-08-18 against this revision): + +```console +$ curl -s -X POST http://127.0.0.1:18080/v1/chat/completions \ + -H 'content-type: application/json' \ + -H 'x-switchyard-session-id: demo-A' \ + -d '{"model":"agent-default","messages":[{"role":"user","content":"what is 2+2?"}]}' +{"served_by": "weak-upstream", "model_received": "qwen-mini"} + +$ # hard question, same session -> strong +$ curl -s ... -H 'x-switchyard-session-id: demo-A' \ + -d '{"model":"agent-default","messages":[{"role":"user","content":"prove a hard novel theorem in algebraic topology"}]}' +{"served_by": "strong-upstream", "model_received": "qwen-max"} + +$ # easy question again, same session -> the floor HOLDS strong +$ curl -s ... -H 'x-switchyard-session-id: demo-A' \ + -d '{"model":"agent-default","messages":[{"role":"user","content":"what is 3+3?"}]}' +{"served_by": "strong-upstream", "model_received": "qwen-max"} + +$ # fresh session is isolated -> weak again +$ curl -s ... -H 'x-switchyard-session-id: demo-B' \ + -d '{"model":"agent-default","messages":[{"role":"user","content":"what is 4+4?"}]}' +{"served_by": "weak-upstream", "model_received": "qwen-mini"} +``` + +With the judge stopped (upstreams still running), the same 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/praxis.yaml.template b/hack/switchyard-demo/praxis.yaml.template new file mode 100644 index 0000000..c7cc66a --- /dev/null +++ b/hack/switchyard-demo/praxis.yaml.template @@ -0,0 +1,50 @@ +# 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__ + timeout_ms: 20000 + 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 + 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/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() From e2a90e02675514155bbe25dfe2d5bb3048550954 Mon Sep 17 00:00:00 2001 From: usize Date: Tue, 18 Aug 2026 16:51:35 -0700 Subject: [PATCH 4/9] docs(demo): run the switchyard demo against a real judge model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the scripted judge with a real OpenAI-compatible judge endpoint: praxis.yaml.template is rendered by run-demo.sh (JUDGE_ENDPOINT / JUDGE_MODEL), upstreams.py stubs only the two tier clusters, and the guide carries a transcript recorded against a local Ollama llama3.2:3b judge — including the no-downgrade floor overriding a real weak verdict, and a thinking model (qwen3:8b) tripping the judge deadline into fail-open. Assisted by Fable 5 Signed-off-by: usize --- .gitignore | 4 ++ docs/switchyard-route.md | 55 ++++++++++---------- hack/switchyard-demo/README.md | 45 +++++++++++++++++ hack/switchyard-demo/praxis.yaml.template | 4 +- hack/switchyard-demo/run-demo.sh | 61 +++++++++++++++++++++++ 5 files changed, 141 insertions(+), 28 deletions(-) create mode 100644 hack/switchyard-demo/README.md create mode 100755 hack/switchyard-demo/run-demo.sh diff --git a/.gitignore b/.gitignore index ad67955..4bd6621 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,7 @@ 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 diff --git a/docs/switchyard-route.md b/docs/switchyard-route.md index 937fb1c..017dcf9 100644 --- a/docs/switchyard-route.md +++ b/docs/switchyard-route.md @@ -136,47 +136,48 @@ 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) +## Local demo (verified, real judge) -Everything lives in `hack/switchyard-demo/`: `praxis.yaml` (gateway on -`127.0.0.1:18080`, judge callout to `:18091`, weak/strong clusters on -`:18092`/`:18093`) and `stubs.py` (a stdlib-only judge + two echo -upstreams; the judge routes to `strong` when the newest user message -contains the word "hard"). +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 -$ cargo build -p switchyard-server $ cd hack/switchyard-demo -$ python3 stubs.py & -$ ../../target/debug/switchyard-server & # reads ./praxis.yaml +$ 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): +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 -$ curl -s -X POST http://127.0.0.1:18080/v1/chat/completions \ - -H 'content-type: application/json' \ - -H 'x-switchyard-session-id: demo-A' \ - -d '{"model":"agent-default","messages":[{"role":"user","content":"what is 2+2?"}]}' +--- turn 1: easy question, session demo-A (expect weak) --- {"served_by": "weak-upstream", "model_received": "qwen-mini"} - -$ # hard question, same session -> strong -$ curl -s ... -H 'x-switchyard-session-id: demo-A' \ - -d '{"model":"agent-default","messages":[{"role":"user","content":"prove a hard novel theorem in algebraic topology"}]}' +--- turn 2: hard question, session demo-A (expect strong) --- {"served_by": "strong-upstream", "model_received": "qwen-max"} - -$ # easy question again, same session -> the floor HOLDS strong -$ curl -s ... -H 'x-switchyard-session-id: demo-A' \ - -d '{"model":"agent-default","messages":[{"role":"user","content":"what is 3+3?"}]}' +--- turn 3: easy question again, session demo-A (the floor must HOLD strong) --- {"served_by": "strong-upstream", "model_received": "qwen-max"} - -$ # fresh session is isolated -> weak again -$ curl -s ... -H 'x-switchyard-session-id: demo-B' \ - -d '{"model":"agent-default","messages":[{"role":"user","content":"what is 4+4?"}]}' +--- turn 4: easy question, fresh session demo-B (isolated; expect weak) --- {"served_by": "weak-upstream", "model_received": "qwen-mini"} ``` -With the judge stopped (upstreams still running), the same request logs +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 overriding a +judge that would have said `weak`. + +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. diff --git a/hack/switchyard-demo/README.md b/hack/switchyard-demo/README.md new file mode 100644 index 0000000..796366e --- /dev/null +++ b/hack/switchyard-demo/README.md @@ -0,0 +1,45 @@ +# 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 + +```console +$ JUDGE_ENDPOINT=http://127.0.0.1:11434/v1/chat/completions \ + JUDGE_MODEL=qwen3:8b \ + ./run-demo.sh +``` + +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-upstream` / `qwen-mini`), a hard question (expect `strong-upstream` / +`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`). + +Filter logs (decisions and fail-open reasons) land in `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 index c7cc66a..fbf398d 100644 --- a/hack/switchyard-demo/praxis.yaml.template +++ b/hack/switchyard-demo/praxis.yaml.template @@ -26,7 +26,9 @@ filter_chains: judge: endpoint: "__JUDGE_ENDPOINT__" model: __JUDGE_MODEL__ - timeout_ms: 20000 + # 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: diff --git a/hack/switchyard-demo/run-demo.sh b/hack/switchyard-demo/run-demo.sh new file mode 100755 index 0000000..c9a00aa --- /dev/null +++ b/hack/switchyard-demo/run-demo.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Runs the switchyard_route demo against a REAL OpenAI-compatible judge. +# +# JUDGE_ENDPOINT=http://127.0.0.1:11434/v1/chat/completions \ +# JUDGE_MODEL=qwen3:8b \ +# ./run-demo.sh +# +# 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 + +sed -e "s|__JUDGE_ENDPOINT__|${JUDGE_ENDPOINT}|" \ + -e "s|__JUDGE_MODEL__|${JUDGE_MODEL}|" \ + praxis.yaml.template > praxis.yaml + +python3 upstreams.py & +UPSTREAMS_PID=$! +"$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 (the floor must HOLD strong) ---" +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 errors: grep switchyard server.log" From 77c8165ce10ea975335cd9af870ee3a9b7b10eaa Mon Sep 17 00:00:00 2001 From: usize Date: Tue, 18 Aug 2026 17:28:58 -0700 Subject: [PATCH 5/9] feat(switchyard): authenticate the judge callout via an env-var credential MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hosted OpenAI-compatible judge (OpenAI, Together, Fireworks, …) needs a bearer token, which the callout previously could not send. Add an optional `judge.auth` block: judge: auth: value_env: OPENAI_API_KEY # env var holding the secret (required) header: authorization # default scheme: Bearer # default; "" sends the raw value The secret never appears in YAML — config names the environment variable and the value is resolved once at filter construction, failing fast if it is unset or empty. The resolved header value is marked sensitive so it is redacted from tracing, and injected into the judge sub-request. Omit the block entirely for a keyless judge (local vLLM/Ollama). The demo runner gains a hosted-judge path: set JUDGE_KEY_ENV to the variable name and run-demo.sh renders the auth block (dropping it when unset) and exports the credential to the server; it also raises RUST_LOG so successful routing decisions are visible in server.log. Assisted by Opus 4.8 Signed-off-by: usize --- .gitignore | 1 + crates/switchyard-filters/src/switchyard.rs | 9 +- .../src/switchyard/config.rs | 91 +++++++++++++ .../switchyard-filters/src/switchyard/peer.rs | 127 ++++++++++++++++-- docs/switchyard-route.md | 9 ++ hack/switchyard-demo/README.md | 23 +++- hack/switchyard-demo/praxis.yaml.template | 5 + hack/switchyard-demo/run-demo.sh | 48 ++++++- 8 files changed, 299 insertions(+), 14 deletions(-) diff --git a/.gitignore b/.gitignore index 4bd6621..892bcf6 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ target # switchyard-demo rendered/runtime artifacts hack/switchyard-demo/praxis.yaml hack/switchyard-demo/server.log +hack/switchyard-demo/.auth-block.yaml diff --git a/crates/switchyard-filters/src/switchyard.rs b/crates/switchyard-filters/src/switchyard.rs index 0f2024b..dbbb07f 100644 --- a/crates/switchyard-filters/src/switchyard.rs +++ b/crates/switchyard-filters/src/switchyard.rs @@ -39,6 +39,13 @@ //! 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: @@ -132,7 +139,7 @@ impl SwitchyardRouteFilter { 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::parse(&parsed.judge.endpoint)?; + let judge_endpoint = JudgeEndpoint::from_config(&parsed.judge)?; let floor = parsed .session_floor .enabled diff --git a/crates/switchyard-filters/src/switchyard/config.rs b/crates/switchyard-filters/src/switchyard/config.rs index fa1982b..caedb05 100644 --- a/crates/switchyard-filters/src/switchyard/config.rs +++ b/crates/switchyard-filters/src/switchyard/config.rs @@ -48,6 +48,27 @@ impl Tier { } } +/// 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)] @@ -56,6 +77,9 @@ pub(crate) struct JudgeConfig { 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, @@ -176,6 +200,16 @@ 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 @@ -249,6 +283,26 @@ fn validate_judge(judge: &JudgeConfig) -> Result<(), FilterError> { 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(()) } @@ -456,6 +510,43 @@ targets: 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"); diff --git a/crates/switchyard-filters/src/switchyard/peer.rs b/crates/switchyard-filters/src/switchyard/peer.rs index a573510..190849a 100644 --- a/crates/switchyard-filters/src/switchyard/peer.rs +++ b/crates/switchyard-filters/src/switchyard/peer.rs @@ -14,7 +14,11 @@ use pingora_core::upstreams::peer::HttpPeer; use praxis_core::subrequest::{SubRequest, SubRequestClient, SubRequestError, SubResponse}; use praxis_filter::FilterError; -use super::{config::JudgeConfig, error::RouteError, judge::JudgeTransport}; +use super::{ + config::{JudgeAuthConfig, JudgeConfig}, + error::RouteError, + judge::JudgeTransport, +}; /// A judge endpoint parsed once at configuration time. #[derive(Debug, Clone)] @@ -31,16 +35,27 @@ pub(crate) struct JudgeEndpoint { 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 an absolute http(s) URL into its connection components. + /// 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, or carries a malformed authority or path. - pub(crate) fn parse(endpoint: &str) -> Result { + /// 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}")))?; @@ -67,10 +82,50 @@ impl JudgeEndpoint { 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() { @@ -86,6 +141,11 @@ 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`]. @@ -119,6 +179,9 @@ impl<'req> SubRequestJudge<'req> { 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(), @@ -201,20 +264,32 @@ fn check_status(response: SubResponse) -> Result { reason = "unwrap/panic are acceptable in tests" )] mod tests { - use super::JudgeEndpoint; + 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("https://judge.internal/v1/chat/completions").unwrap(); + 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("http://127.0.0.1:8000/v1/chat/completions?tag=x").unwrap(); + 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"); @@ -225,13 +300,45 @@ mod tests { #[test] fn bad_endpoints_are_rejected() { assert!( - JudgeEndpoint::parse("ftp://judge/v1").is_err(), + JudgeEndpoint::parse_url("ftp://judge/v1").is_err(), "non-http scheme rejected" ); assert!( - JudgeEndpoint::parse("/v1/chat/completions").is_err(), + JudgeEndpoint::parse_url("/v1/chat/completions").is_err(), "relative URL rejected" ); - assert!(JudgeEndpoint::parse("http://").is_err(), "missing host 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/docs/switchyard-route.md b/docs/switchyard-route.md index 017dcf9..192d26a 100644 --- a/docs/switchyard-route.md +++ b/docs/switchyard-route.md @@ -48,6 +48,10 @@ filter_chains: 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] @@ -82,6 +86,11 @@ Notes: 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 diff --git a/hack/switchyard-demo/README.md b/hack/switchyard-demo/README.md index 796366e..99e6555 100644 --- a/hack/switchyard-demo/README.md +++ b/hack/switchyard-demo/README.md @@ -16,12 +16,30 @@ upstreams: ## 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 @@ -30,7 +48,10 @@ upstreams and the composed `switchyard-server` (gateway on no-downgrade floor must hold `strong`), and an easy question in a fresh session (isolated; back to `weak`). -Filter logs (decisions and fail-open reasons) land in `server.log`. +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 diff --git a/hack/switchyard-demo/praxis.yaml.template b/hack/switchyard-demo/praxis.yaml.template index fbf398d..d3caade 100644 --- a/hack/switchyard-demo/praxis.yaml.template +++ b/hack/switchyard-demo/praxis.yaml.template @@ -26,6 +26,11 @@ filter_chains: 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 diff --git a/hack/switchyard-demo/run-demo.sh b/hack/switchyard-demo/run-demo.sh index c9a00aa..b8070dc 100755 --- a/hack/switchyard-demo/run-demo.sh +++ b/hack/switchyard-demo/run-demo.sh @@ -3,10 +3,24 @@ 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 @@ -27,13 +41,43 @@ if [[ ! -x "$SERVER_BIN" ]]; then (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=$! -"$SERVER_BIN" > server.log 2>&1 & +# 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 @@ -58,4 +102,4 @@ 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 errors: grep switchyard server.log" +echo "filter decisions and fail-open reasons: grep switchyard_route server.log" From da332b0c934ae2141b1bd11a82c1be7185ccdcd4 Mon Sep 17 00:00:00 2001 From: usize Date: Wed, 19 Aug 2026 10:37:45 -0700 Subject: [PATCH 6/9] feat(switchyard): skip the judge on an already-strong session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Capability classifier judges every turn — that is Switchyard's default and, until now, this filter's. But once a session is floored at the top tier, the no-downgrade clamp forces `strong` regardless of the verdict, so re-judging spends an LLM call to re-derive a foregone answer. Add an opt-in `session_floor.escalation_ratchet` (default false, matching Switchyard vanilla). When enabled, `route()` short-circuits above the judge callout on any turn whose floor is already `strong`: it routes `strong` directly and never dials the judge. This is the filter-side equivalent of Switchyard's own directional escalation latch (`AffinityRouter::with_latch_only(["strong"])`), expressed against the floor store the run_stream decision-only design already owns rather than adopting `AffinityRouter` (which keys on request metadata inside the classifier cascade we bypass). Tests use the scripted judge stub's connection count as ground truth: with the ratchet on, two strong turns capture exactly one judge request; off, both turns judge. Also correct the algorithm.rs comment that claimed Switchyard affinity cannot ratchet — it can, via with_latch_only. The demo enables the flag so turn 3 visibly holds strong with no judge call (grep 'ratchet held' server.log — three judge calls, not four). Assisted by Opus 4.8 Signed-off-by: usize --- crates/switchyard-filters/src/switchyard.rs | 20 +++++- .../src/switchyard/algorithm.rs | 14 ++++- .../src/switchyard/config.rs | 40 ++++++++++++ .../src/switchyard/tests.rs | 61 +++++++++++++++++++ docs/switchyard-route.md | 39 +++++++++++- hack/switchyard-demo/README.md | 9 ++- hack/switchyard-demo/praxis.yaml.template | 5 ++ hack/switchyard-demo/run-demo.sh | 3 +- 8 files changed, 181 insertions(+), 10 deletions(-) diff --git a/crates/switchyard-filters/src/switchyard.rs b/crates/switchyard-filters/src/switchyard.rs index dbbb07f..d59949f 100644 --- a/crates/switchyard-filters/src/switchyard.rs +++ b/crates/switchyard-filters/src/switchyard.rs @@ -159,9 +159,17 @@ impl SwitchyardRouteFilter { let session = self.session_id(ctx); let now = Instant::now(); let floor = self.session_floor(session.as_deref(), now); - let tag = self.decide(ctx, format, &value, floor).await?; - let tier = Tier::from_tag(&tag).ok_or(RouteError::UnknownTier(tag))?; - let chosen = floor.map_or(tier, |held| held.max(tier)); + 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) } @@ -214,6 +222,12 @@ impl SwitchyardRouteFilter { 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())?; diff --git a/crates/switchyard-filters/src/switchyard/algorithm.rs b/crates/switchyard-filters/src/switchyard/algorithm.rs index 2497bc6..e06d092 100644 --- a/crates/switchyard-filters/src/switchyard/algorithm.rs +++ b/crates/switchyard-filters/src/switchyard/algorithm.rs @@ -17,9 +17,17 @@ pub(crate) const JUDGE_TAG: &str = "judge"; /// 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 (Switchyard's affinity is a -/// first-decision-wins latch, not a ratchet; the session floor in `floor.rs` -/// owns the no-downgrade guarantee instead). +/// `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 /// diff --git a/crates/switchyard-filters/src/switchyard/config.rs b/crates/switchyard-filters/src/switchyard/config.rs index caedb05..da4b27c 100644 --- a/crates/switchyard-filters/src/switchyard/config.rs +++ b/crates/switchyard-filters/src/switchyard/config.rs @@ -128,6 +128,14 @@ impl TargetsConfig { } /// 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 { @@ -141,6 +149,19 @@ pub(crate) struct SessionFloorConfig { /// `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 { @@ -149,6 +170,7 @@ impl Default for SessionFloorConfig { enabled: true, ttl_secs: default_floor_ttl_secs(), exclude_below: true, + escalation_ratchet: false, } } } @@ -419,6 +441,10 @@ targets: 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"); } @@ -455,6 +481,20 @@ session_header: x-my-session 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 = " diff --git a/crates/switchyard-filters/src/switchyard/tests.rs b/crates/switchyard-filters/src/switchyard/tests.rs index 7d7a2c4..0f06551 100644 --- a/crates/switchyard-filters/src/switchyard/tests.rs +++ b/crates/switchyard-filters/src/switchyard/tests.rs @@ -171,6 +171,15 @@ 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(); @@ -562,6 +571,58 @@ async fn a_strong_session_never_downgrades() { 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())]); diff --git a/docs/switchyard-route.md b/docs/switchyard-route.md index 192d26a..e2b0bec 100644 --- a/docs/switchyard-route.md +++ b/docs/switchyard-route.md @@ -66,6 +66,7 @@ filter_chains: 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 @@ -115,6 +116,35 @@ filter therefore owns the guarantee, in two layers: `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 @@ -177,8 +207,13 @@ Transcript (recorded 2026-08-18 against this revision, judge = 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 overriding a -judge that would have said `weak`. +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 diff --git a/hack/switchyard-demo/README.md b/hack/switchyard-demo/README.md index 99e6555..f6ea227 100644 --- a/hack/switchyard-demo/README.md +++ b/hack/switchyard-demo/README.md @@ -43,11 +43,18 @@ 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-upstream` / `qwen-mini`), a hard question (expect `strong-upstream` / +`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 diff --git a/hack/switchyard-demo/praxis.yaml.template b/hack/switchyard-demo/praxis.yaml.template index d3caade..6bec168 100644 --- a/hack/switchyard-demo/praxis.yaml.template +++ b/hack/switchyard-demo/praxis.yaml.template @@ -46,6 +46,11 @@ __JUDGE_AUTH_BLOCK__ 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: diff --git a/hack/switchyard-demo/run-demo.sh b/hack/switchyard-demo/run-demo.sh index b8070dc..2f53c6b 100755 --- a/hack/switchyard-demo/run-demo.sh +++ b/hack/switchyard-demo/run-demo.sh @@ -97,9 +97,10 @@ 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 (the floor must HOLD strong) ---" +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" From 4b5fc5bbcddd2c6341437959ece67867837b634a Mon Sep 17 00:00:00 2001 From: usize Date: Wed, 19 Aug 2026 11:54:48 -0700 Subject: [PATCH 7/9] fix(switchyard): stub new crate manifests in Containerfile; fix typo The Containerfile's dependency-cache stage copies each workspace member's manifest and stubs its source before the warmup build. It only listed experimental-probe, so once the workspace gained the switchyard-filters and switchyard-server members, cargo could not load the workspace manifest inside the builder ("failed to read crates/switchyard-filters/Cargo.toml") and the Container job failed. Copy and stub both new crate manifests (lib.rs for the filters lib, main.rs for the server bin). The image still ships only the probe binary; the switchyard sources stay stubbed in the builder. Also fix a typo flagged by meta-lint: unparseable -> unparsable. Verified: `docker build -f Containerfile .` succeeds. Assisted by Opus 4.8 Signed-off-by: usize --- Containerfile | 10 +++++++++- crates/switchyard-filters/src/switchyard/tests.rs | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) 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/crates/switchyard-filters/src/switchyard/tests.rs b/crates/switchyard-filters/src/switchyard/tests.rs index 0f06551..c23e1d6 100644 --- a/crates/switchyard-filters/src/switchyard/tests.rs +++ b/crates/switchyard-filters/src/switchyard/tests.rs @@ -447,7 +447,7 @@ async fn fail_open_passes_through_non_json_bodies() { 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, "unparseable body forwarded as-is"); + assert_eq!(body.as_ref().unwrap(), &original, "unparsable body forwarded as-is"); assert!(ctx.get_metadata(METADATA_ERROR).is_some(), "the reason is recorded"); } From 5a69b1c0896c6e6b35a2271dc11a3e40815e6f02 Mon Sep 17 00:00:00 2001 From: usize Date: Wed, 19 Aug 2026 11:57:39 -0700 Subject: [PATCH 8/9] style(clippy): taplo-format doc-valid-idents array Adding OpenAI/NeMo pushed the doc-valid-idents array past taplo's column width, so it must be expanded to one entry per line. Content is unchanged; this only satisfies `taplo fmt --check` in meta-lint. Assisted by Opus 4.8 Signed-off-by: usize --- clippy.toml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/clippy.toml b/clippy.toml index 38b2948..f50d014 100644 --- a/clippy.toml +++ b/clippy.toml @@ -13,7 +13,18 @@ stack-size-threshold = 16384 avoid-breaking-exported-api = false 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", "OpenAI", "NeMo"] +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" }, From b5cd850beebf59ef2c65ef7b33d9f9d42aa1cd11 Mon Sep 17 00:00:00 2001 From: usize Date: Wed, 19 Aug 2026 12:00:49 -0700 Subject: [PATCH 9/9] docs: align identity-metadata table for MD060 markdownlint-cli2 (meta-lint) enforces MD060 table-column-style "aligned": every row's pipes must line up with the header separator. The Required and Meaning columns had cells wider than the header, so the closing pipes drifted. Widen the header/separator to the widest cell and pad every row to match. Content unchanged. Verified: `markdownlint-cli2@0.23.2` over all tracked *.md is clean. Assisted by Opus 4.8 Signed-off-by: usize --- docs/identity-metadata.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/identity-metadata.md b/docs/identity-metadata.md index 02b6b63..661a019 100644 --- a/docs/identity-metadata.md +++ b/docs/identity-metadata.md @@ -37,13 +37,13 @@ inter-filter contracts, and consumer branching only. 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-*`). | +| 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