diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..fe2ed2a3fa --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.git +target +**/target +evidence diff --git a/Cargo.lock b/Cargo.lock index 6fb50b27a0..4a5aea6ff8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,12 +117,38 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "antlr4rust" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093d520274bfff7278d776f7ea12981a0a0a6f96db90964658e0f38fc6e9a6a6" +dependencies = [ + "better_any", + "bit-set", + "byteorder", + "lazy_static", + "murmur3", + "once_cell", + "parking_lot", + "typed-arena", + "uuid", +] + [[package]] name = "anyhow" version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "ar_archive_writer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" +dependencies = [ + "object", +] + [[package]] name = "arc-swap" version = "1.9.2" @@ -132,12 +158,33 @@ dependencies = [ "rustversion", ] +[[package]] +name = "arcstr" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d" + +[[package]] +name = "arrayvec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" + [[package]] name = "arrayvec" version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "ascii-canvas" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891" +dependencies = [ + "term", +] + [[package]] name = "asn1-rs" version = "0.6.2" @@ -215,6 +262,17 @@ dependencies = [ "serde_json", ] +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + [[package]] name = "async-trait" version = "0.1.92" @@ -254,6 +312,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] @@ -316,6 +375,15 @@ dependencies = [ "tower-service", ] +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", +] + [[package]] name = "base64" version = "0.22.1" @@ -328,6 +396,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "better_any" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4372b9543397a4b86050cc5e7ee36953edf4bac9518e8a774c2da694977fb6e4" + [[package]] name = "bit-set" version = "0.8.0" @@ -352,6 +426,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.1" @@ -385,6 +465,16 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "borsh" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f" +dependencies = [ + "bytes", + "cfg_aliases", +] + [[package]] name = "brotli" version = "3.5.0" @@ -406,6 +496,15 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bstr" version = "1.13.1" @@ -470,9 +569,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.3" +version = "1.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" dependencies = [ "find-msvc-tools", "jobserver", @@ -480,6 +579,86 @@ dependencies = [ "shlex", ] +[[package]] +name = "cedar-policy" +version = "4.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f73547a0114dff845fcb8d4877548a665fb8ff949a30cb0963444d6c30ffd962" +dependencies = [ + "cedar-policy-core", + "cedar-policy-formatter", + "itertools 0.15.0", + "linked-hash-map", + "linked_hash_set", + "miette", + "ref-cast", + "semver", + "serde", + "serde_json", + "serde_with", + "smol_str", + "thiserror 2.0.20", +] + +[[package]] +name = "cedar-policy-core" +version = "4.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9d9ec16707c7b60aa73b922dc255ec0b97b6892e8e58e45188b15d874ca3590" +dependencies = [ + "chrono", + "educe", + "either", + "itertools 0.15.0", + "lalrpop", + "lalrpop-util", + "linked-hash-map", + "linked_hash_set", + "miette", + "nonempty", + "ref-cast", + "regex", + "rustc-literal-escaper", + "serde", + "serde_json", + "serde_with", + "smol_str", + "stacker", + "thiserror 2.0.20", + "unicode-security", +] + +[[package]] +name = "cedar-policy-formatter" +version = "4.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2483b2fa74f74b1b3945bec59feb7a3fc0ea56b6fb35564ef300884dd8415d4b" +dependencies = [ + "cedar-policy-core", + "itertools 0.15.0", + "logos", + "miette", + "pretty", + "regex", + "smol_str", +] + +[[package]] +name = "cel" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47a40f338a8c3505921000b609279775792c07cc21f97a3011578c0c5e1738ae" +dependencies = [ + "antlr4rust", + "chrono", + "lazy_static", + "nom", + "pastey", + "regex", + "serde", + "thiserror 1.0.69", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -510,11 +689,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf", +] + [[package]] name = "clap" version = "4.6.6" @@ -570,6 +761,15 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.20", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -583,7 +783,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" dependencies = [ "bytes", + "futures-core", "memchr", + "pin-project-lite", + "tokio", + "tokio-util", ] [[package]] @@ -756,14 +960,37 @@ dependencies = [ "petgraph", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + [[package]] name = "darling" version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.24.1", + "darling_macro 0.24.1", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", ] [[package]] @@ -779,13 +1006,24 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.119", +] + [[package]] name = "darling_macro" version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ - "darling_core", + "darling_core 0.24.1", "quote", "syn 3.0.3", ] @@ -829,18 +1067,79 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" dependencies = [ - "deadpool-runtime", + "deadpool-runtime 0.1.4", "lazy_static", "num_cpus", "tokio", ] +[[package]] +name = "deadpool" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883466cb8db62725aee5f4a6011e8a5d42912b42632df32aad57fc91127c6e04" +dependencies = [ + "deadpool-runtime 0.3.1", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-redis" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bafa30c49dafe086d10116074e422ad7fc1c3cf554697e744a3ab112599ebd09" +dependencies = [ + "deadpool 0.13.0", + "redis", +] + [[package]] name = "deadpool-runtime" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "deadpool-runtime" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2657f61fb1dd8bf37a8d51093cc7cee4e77125b22f7753f49b289f831bec2bae" +dependencies = [ + "tokio", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + [[package]] name = "der-parser" version = "9.0.0" @@ -850,7 +1149,7 @@ dependencies = [ "asn1-rs 0.6.2", "displaydoc", "nom", - "num-bigint", + "num-bigint 0.4.8", "num-traits", "rusticata-macros", ] @@ -864,7 +1163,7 @@ dependencies = [ "asn1-rs 0.7.2", "displaydoc", "nom", - "num-bigint", + "num-bigint 0.4.8", "num-traits", "rusticata-macros", ] @@ -874,6 +1173,9 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] [[package]] name = "derivative" @@ -938,6 +1240,18 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "educe" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e451fac8dd8dece16234604bf1efce6e90fddd8ab6ad4d66eec0eca5160959dd" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "either" version = "1.18.0" @@ -947,6 +1261,47 @@ dependencies = [ "serde", ] +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "ena" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" +dependencies = [ + "log", +] + +[[package]] +name = "enum-ordinalize" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -983,6 +1338,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "evmap" version = "11.0.0" @@ -1042,7 +1407,7 @@ checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" dependencies = [ "futures-core", "futures-sink", - "spin", + "spin 0.9.9", ] [[package]] @@ -1256,6 +1621,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "globset" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" +dependencies = [ + "aho-corasick", + "bstr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "h2" version = "0.4.18" @@ -1455,16 +1832,30 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] -name = "hyper-util" -version = "0.1.20" +name = "hyper-timeout" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" dependencies = [ - "base64 0.22.1", - "bytes", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "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", @@ -1574,9 +1965,9 @@ checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.3.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -1622,6 +2013,7 @@ checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", "hashbrown 0.12.3", + "serde", ] [[package]] @@ -1642,7 +2034,7 @@ version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" dependencies = [ - "bitflags", + "bitflags 2.13.1", "inotify-sys", "libc", ] @@ -1668,12 +2060,83 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.22.4" @@ -1744,6 +2207,33 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "getrandom 0.2.17", + "js-sys", + "pem", + "serde", + "serde_json", + "signature", + "simple_asn1", + "zeroize", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + [[package]] name = "kqueue" version = "1.2.1" @@ -1760,10 +2250,42 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags", + "bitflags 2.13.1", "libc", ] +[[package]] +name = "lalrpop" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba4ebbd48ce411c1d10fb35185f5a51a7bfa3d8b24b4e330d30c9e3a34129501" +dependencies = [ + "ascii-canvas", + "bit-set", + "ena", + "itertools 0.14.0", + "lalrpop-util", + "petgraph", + "pico-args", + "regex", + "regex-syntax", + "sha3", + "string_cache", + "term", + "unicode-xid", + "walkdir", +] + +[[package]] +name = "lalrpop-util" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5baa5e9ff84f1aefd264e6869907646538a52147a755d494517a8007fb48733" +dependencies = [ + "regex-automata", + "rustversion", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1814,6 +2336,24 @@ dependencies = [ "libc", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +dependencies = [ + "serde", +] + +[[package]] +name = "linked_hash_set" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "984fb35d06508d1e69fc91050cceba9c0b748f983e6739fa2c7a9237154c52c8" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1841,6 +2381,38 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "logos" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2c55a318a87600ea870ff8c2012148b44bf18b74fad48d0f835c38c7d07c5f" +dependencies = [ + "logos-derive", +] + +[[package]] +name = "logos-codegen" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58b3ffaa284e1350d017a57d04ada118c4583cf260c8fb01e0fe28a2e9cf8970" +dependencies = [ + "fnv", + "proc-macro2", + "quote", + "regex-automata", + "regex-syntax", + "syn 2.0.119", +] + +[[package]] +name = "logos-derive" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d3a9855747c17eaf4383823f135220716ab49bea5fbea7dd42cc9a92f8aa31" +dependencies = [ + "logos-codegen", +] + [[package]] name = "loom" version = "0.7.2" @@ -1863,6 +2435,12 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "lru" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" + [[package]] name = "lru-slab" version = "0.1.2" @@ -1953,6 +2531,29 @@ dependencies = [ "sketches-ddsketch", ] +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "serde", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "mime" version = "0.3.17" @@ -1987,13 +2588,37 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "msvc_spectre_libs" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29e871a9861f3664f18b7e04e9301d4edd55090c2dadb4b1c602e26ab32b1f5b" +dependencies = [ + "cc", +] + +[[package]] +name = "murmur3" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a198f9589efc03f544388dfc4a19fe8af4323662b62f598b8dcfdac62c14771c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "nix" version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", @@ -2016,13 +2641,22 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nonempty" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" +dependencies = [ + "serde", +] + [[package]] name = "notify" version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags", + "bitflags 2.13.1", "fsevent-sys", "inotify", "kqueue", @@ -2040,7 +2674,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags", + "bitflags 2.13.1", ] [[package]] @@ -2062,6 +2696,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2096,6 +2740,15 @@ dependencies = [ "libc", ] +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "memchr", +] + [[package]] name = "oid-registry" version = "0.7.1" @@ -2138,11 +2791,72 @@ 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 = "opentelemetry-otlp" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9966929966d17620d7c316c643ba62631826e10021409357772d5eea84f62c35" +dependencies = [ + "http", + "opentelemetry", + "opentelemetry-proto", + "opentelemetry_sdk", + "prost", + "thiserror 2.0.20", + "tokio", + "tonic", + "tonic-types", +] + +[[package]] +name = "opentelemetry-proto" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" +dependencies = [ + "opentelemetry", + "opentelemetry_sdk", + "prost", + "tonic", + "tonic-prost", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b59f80e1ac4d5ff7a2db8fb6c80badb7f0f3f858211fba08dd9aaec750894f9" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "portable-atomic", + "rand 0.9.5", + "thiserror 2.0.20", + "tokio", + "tokio-stream", +] + [[package]] name = "ordered-float" -version = "5.3.0" +version = "5.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" dependencies = [ "num-traits", ] @@ -2232,6 +2946,59 @@ dependencies = [ "indexmap 2.14.0", ] +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2250,6 +3017,27 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -2289,9 +3077,9 @@ dependencies = [ "praxis-proxy-core", "praxis-proxy-filter", "quixotic-plecostomus-core", - "reqwest", + "reqwest 0.13.4", "rmcp", - "schemars", + "schemars 1.2.2", "secrecy", "serde", "serde_json", @@ -2333,12 +3121,15 @@ dependencies = [ "praxis-ai-apis", "praxis-proxy-core", "praxis-proxy-filter", - "reqwest", + "rand 0.10.2", + "redis", + "reqwest 0.13.4", "serde", "serde_json", "serde_json_canonicalizer", "sha2 0.11.0", "tempfile", + "thiserror 2.0.20", "tokio", "tokio-util", "tracing", @@ -2371,6 +3162,222 @@ dependencies = [ "yaml_serde", ] +[[package]] +name = "praxis-policy" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f2aee539307927ff2bf99720d3c998c0e9a4ebf27abbe1ac862d6817ec92af2" +dependencies = [ + "praxis-policy-apl-cmf", + "praxis-policy-apl-core", + "praxis-policy-apl-runtime", + "praxis-policy-core", + "praxis-policy-pdp-cedar-direct", + "praxis-policy-pdp-cel", + "praxis-policy-pdp-opa", + "praxis-policy-plugin-delegator-oauth", + "praxis-policy-plugin-elicitation-ciba", + "praxis-policy-plugin-identity-jwt", + "praxis-policy-session-valkey", +] + +[[package]] +name = "praxis-policy-apl-cmf" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "278ebd1a349e2201705f46c0f63d5558b578508600c84fa958889979b184dda9" +dependencies = [ + "praxis-policy-apl-core", + "praxis-policy-core", + "serde_json", +] + +[[package]] +name = "praxis-policy-apl-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56cbea9f50f318f58790f373d5ce2323f3e6d823390117f399924a5503e13181" +dependencies = [ + "async-trait", + "futures", + "praxis-policy-orchestration", + "regex", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.20", +] + +[[package]] +name = "praxis-policy-apl-runtime" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e881265d0e1655ab764664d126d90ef9887da4c03c65037b4be1549a618dab75" +dependencies = [ + "async-trait", + "chrono", + "praxis-policy-apl-cmf", + "praxis-policy-apl-core", + "praxis-policy-core", + "serde_json", + "serde_yaml", + "sha2 0.10.9", + "thiserror 2.0.20", + "tokio", + "tracing", +] + +[[package]] +name = "praxis-policy-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b9b82c7558797be9bcf048c7d359dded98ee7e9adb87391b8b98c19d7daff8" +dependencies = [ + "arc-swap", + "async-trait", + "chrono", + "hashbrown 0.17.1", + "praxis-policy-orchestration", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.20", + "tokio", + "tokio-util", + "tracing", + "uuid", + "wildmatch", + "zeroize", +] + +[[package]] +name = "praxis-policy-orchestration" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb58ca9610a3244f2c9eaaea11c29e83ee60d5cd545e6e5e7c087b987185b86f" +dependencies = [ + "futures", + "tokio", +] + +[[package]] +name = "praxis-policy-pdp-cedar-direct" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d199493cefa1ecbf5712682416d89dc3c1602ea11e17421eec6d55dc76b41474" +dependencies = [ + "async-trait", + "cedar-policy", + "praxis-policy-apl-core", + "serde_json", + "serde_yaml", + "stacker", + "thiserror 2.0.20", +] + +[[package]] +name = "praxis-policy-pdp-cel" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2139bfe25a50486b89d0e8e7de27ce868b7991c6c9d0f1576998f99d01ae201" +dependencies = [ + "async-trait", + "cel", + "praxis-policy-apl-core", + "serde_yaml", + "thiserror 2.0.20", + "tracing", +] + +[[package]] +name = "praxis-policy-pdp-opa" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3637f3957e9ca313ad81a35b093be2fd02c53dba96f294e4d793cf3eed36da6f" +dependencies = [ + "async-trait", + "praxis-policy-apl-core", + "regorus", + "serde_json", + "serde_yaml", + "thiserror 2.0.20", + "tokio", + "tracing", +] + +[[package]] +name = "praxis-policy-plugin-delegator-oauth" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ecf96fbc6946f672f4bb8fbd1c6fd29a44a238e6af31d93c76ec07bf9cc6b7c" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chrono", + "praxis-policy-core", + "reqwest 0.12.28", + "serde", + "serde_json", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "praxis-policy-plugin-elicitation-ciba" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1dd821594ad409500ad633e9ea7ad4bc8c37374f6705d28580ec11e0d83374" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chrono", + "praxis-policy-core", + "reqwest 0.12.28", + "serde", + "serde_json", + "tokio", + "zeroize", +] + +[[package]] +name = "praxis-policy-plugin-identity-jwt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a319ad5e8934a00b657c87556c014e39ce4d28bfbf5e515d4c765f02e80d27f" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chrono", + "futures", + "jsonwebtoken", + "praxis-policy-core", + "reqwest 0.12.28", + "serde", + "serde_json", + "tokio", + "tracing", +] + +[[package]] +name = "praxis-policy-session-valkey" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4882ddf222b74ab023f0ce3b6fddf81fdee26a5c1c2920ec8f52df18fb40f62" +dependencies = [ + "async-trait", + "deadpool-redis", + "praxis-policy-apl-runtime", + "redis", + "serde", + "serde_yaml", + "sha2 0.10.9", + "thiserror 2.0.20", + "tokio", + "tracing", + "url", +] + [[package]] name = "praxis-proxy" version = "0.5.3" @@ -2402,6 +3409,9 @@ dependencies = [ "dashmap 6.2.1", "http", "metrics", + "opentelemetry", + "opentelemetry-otlp", + "opentelemetry_sdk", "praxis-proxy-tls", "quixotic-plecostomus-core", "quixotic-plecostomus-http", @@ -2410,7 +3420,9 @@ dependencies = [ "serde", "thiserror 2.0.20", "tokio", + "tonic", "tracing", + "tracing-opentelemetry", "tracing-subscriber", "yaml_serde", ] @@ -2422,11 +3434,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9be5f77647237cae66bd8c28a9dac8a7dbf72c6c8f9d769f1987fede0bd16dd4" dependencies = [ "async-trait", + "base64 0.23.1", "bytes", "dashmap 6.2.1", "http", "metrics", "percent-encoding", + "praxis-policy", "praxis-proxy-core", "praxis-proxy-tls", "quixotic-plecostomus-core", @@ -2435,7 +3449,9 @@ dependencies = [ "secrecy", "serde", "serde_json", + "sha2 0.11.0", "smallvec", + "subtle", "thiserror 2.0.20", "tokio", "tracing", @@ -2509,7 +3525,7 @@ dependencies = [ "quixotic-plecostomus-core", "rand 0.10.2", "rcgen", - "reqwest", + "reqwest 0.13.4", "rustls", "rustls-pemfile", "serde", @@ -2559,6 +3575,23 @@ dependencies = [ "yaml_serde", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "pretty" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d22152487193190344590e4f30e219cf3fe140d9e7a3fdb683d82aa2c5f4156" +dependencies = [ + "arrayvec 0.5.2", + "typed-arena", + "unicode-width 0.2.2", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -2581,6 +3614,48 @@ dependencies = [ "yansi", ] +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + +[[package]] +name = "psm" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "quanta" version = "0.12.6" @@ -2670,7 +3745,7 @@ dependencies = [ "httpdate", "indexmap 1.9.3", "log", - "lru", + "lru 0.16.4", "once_cell", "parking_lot", "quixotic-plecostomus-core", @@ -2781,7 +3856,7 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec55d3bcbd56e52b3424bc62739a237d43c14df8ed04aaf294ae187b60da348a" dependencies = [ - "arrayvec", + "arrayvec 0.7.8", "hashbrown 0.17.1", "parking_lot", "rand 0.8.7", @@ -2797,7 +3872,7 @@ dependencies = [ "dashmap 5.5.3", "futures", "log", - "lru", + "lru 0.16.4", "parking_lot", "quixotic-plecostomus-timeout", "tokio", @@ -3000,7 +4075,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags", + "bitflags 2.13.1", ] [[package]] @@ -3017,13 +4092,44 @@ dependencies = [ "yasna", ] +[[package]] +name = "redis" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e37a4ca5c6ca42aa3e6df2fd32b987a65d32a4c2159a6f3fe0fd1df306a2658f" +dependencies = [ + "arc-swap", + "arcstr", + "async-lock", + "backon", + "bytes", + "cfg-if", + "combine", + "futures-channel", + "futures-util", + "itoa", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-native-certs 0.8.4", + "ryu", + "sha1_smol", + "socket2", + "tokio", + "tokio-rustls", + "tokio-util", + "url", + "webpki-roots", + "xxhash-rust", +] + [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.1", ] [[package]] @@ -3059,22 +4165,91 @@ dependencies = [ ] [[package]] -name = "regex-automata" -version = "0.4.18" +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 = "regorus" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cc4dc91481b1d4001ba7f2e81f7faf674142e0ac36d37d79e5f02764d06571e" +dependencies = [ + "anyhow", + "chrono", + "chrono-tz", + "data-encoding", + "globset", + "indexmap 2.14.0", + "lazy_static", + "lru 0.18.2", + "msvc_spectre_libs", + "num-bigint 0.5.1", + "num-traits", + "parking_lot", + "postcard", + "rand 0.10.2", + "regex", + "semver", + "serde", + "serde_json", + "serde_yaml", + "spin 0.12.3", + "thiserror 2.0.20", + "url", + "uuid", +] + +[[package]] +name = "reqwest" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", ] -[[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" @@ -3126,7 +4301,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -3148,9 +4323,9 @@ dependencies = [ "pastey", "pin-project-lite", "rand 0.10.2", - "reqwest", + "reqwest 0.13.4", "rmcp-macros", - "schemars", + "schemars 1.2.2", "serde", "serde_json", "sse-stream", @@ -3169,7 +4344,7 @@ version = "3.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a85d45508e9b4ba024fe996c2638799635d75b6dd0ba8f32ccf08f8026f0c780" dependencies = [ - "darling", + "darling 0.24.1", "proc-macro2", "quote", "serde_json", @@ -3201,7 +4376,7 @@ version = "1.42.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" dependencies = [ - "arrayvec", + "arrayvec 0.7.8", "num-traits", ] @@ -3211,6 +4386,12 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "rustc-literal-escaper" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe6f213fb658c8fb95baabd5420393438cf5a98d707f5dd701d9197c705f71e" + [[package]] name = "rustc_version" version = "0.4.1" @@ -3235,7 +4416,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", @@ -3331,14 +4512,14 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.14" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -3377,6 +4558,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "schemars" version = "1.2.2" @@ -3431,7 +4624,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags", + "bitflags 2.13.1", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -3444,7 +4637,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -3543,6 +4736,51 @@ dependencies = [ "serde_json", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" @@ -3578,6 +4816,12 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -3600,6 +4844,16 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -3625,6 +4879,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -3647,6 +4910,24 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint 0.4.8", + "num-traits", + "thiserror 2.0.20", + "time", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "sketches-ddsketch" version = "0.3.1" @@ -3668,6 +4949,16 @@ dependencies = [ "serde", ] +[[package]] +name = "smol_str" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" +dependencies = [ + "borsh", + "serde_core", +] + [[package]] name = "socket2" version = "0.6.5" @@ -3687,6 +4978,12 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spin" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0134f9043ed38b087ac4f7d4af44c79e2c9e5094421fe3164f435ce585953b10" + [[package]] name = "sqlx" version = "0.9.0" @@ -3780,7 +5077,7 @@ checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", "base64 0.22.1", - "bitflags", + "bitflags 2.13.1", "byteorder", "crc", "dotenvy", @@ -3850,12 +5147,37 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + [[package]] name = "static_assertions" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -3967,6 +5289,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "term" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -4195,11 +5526,63 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", "libc", "pin-project-lite", "tokio", ] +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-types" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab1b02061f83d519bba3caa167f88f261ef05720ab8ebc954ade70de3348e8" +dependencies = [ + "prost", + "prost-types", + "tonic", +] + [[package]] name = "tower" version = "0.5.3" @@ -4208,11 +5591,15 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", + "indexmap 2.14.0", "pin-project-lite", + "slab", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -4221,7 +5608,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags", + "bitflags 2.13.1", "bytes", "futures-util", "http", @@ -4289,6 +5676,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" @@ -4342,6 +5745,12 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + [[package]] name = "typenum" version = "1.20.1" @@ -4381,12 +5790,52 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-security" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e4ddba1535dd35ed8b61c52166b7155d7f4e4b8847cec6f48e71dc66d8b5e50" +dependencies = [ + "unicode-normalization", + "unicode-script", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[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.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -4448,6 +5897,8 @@ checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" dependencies = [ "getrandom 0.4.3", "js-sys", + "rand 0.10.2", + "serde_core", "wasm-bindgen", ] @@ -4615,6 +6066,12 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" +[[package]] +name = "wildmatch" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29333c3ea1ba8b17211763463ff24ee84e41c78224c16b001cd907e663a38c68" + [[package]] name = "winapi" version = "0.3.9" @@ -4878,7 +6335,7 @@ checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" dependencies = [ "assert-json-diff", "base64 0.22.1", - "deadpool", + "deadpool 0.12.3", "futures", "http", "http-body-util", @@ -4950,7 +6407,7 @@ dependencies = [ "praxis-proxy-core", "praxis-test-utils", "quote", - "reqwest", + "reqwest 0.13.4", "serde", "serde_json", "sha2 0.11.0", @@ -4962,6 +6419,12 @@ dependencies = [ "yaml_serde", ] +[[package]] +name = "xxhash-rust" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" + [[package]] name = "yaml_serde" version = "0.10.7" @@ -5062,6 +6525,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "serde", + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -5088,9 +6563,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 2f2ac932fe..e5d7b20027 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ clap = { version = "4.6.6", features = ["derive"] } dashmap = "6.2.1" futures = "0.3.34" h2 = "0.4.18" +redis = { version = "1.5.0", default-features = false, features = ["connection-manager", "script", "tokio-rustls-comp", "tls-rustls-webpki-roots"] } http = "1.5.0" http-body-util = "0.1.5" hyper = { version = "1.11.0", features = ["http1", "server"] } @@ -284,4 +285,3 @@ bare_urls = "deny" invalid_html_tags = "deny" missing_crate_level_docs = "deny" private_doc_tests = "allow" - diff --git a/Containerfile b/Containerfile index 95a1057cf1..bd2c198f95 100644 --- a/Containerfile +++ b/Containerfile @@ -49,9 +49,15 @@ RUN mkdir -p apis/src filters/src server/src \ && echo '//! stub' > server/src/lib.rs \ && printf '//! stub\nfn main() {}\n' > server/src/main.rs +ARG ENABLE_OPENTELEMETRY=false + RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/src/target \ - cargo build --release -p praxis-ai-proxy + if [ "$ENABLE_OPENTELEMETRY" = "true" ]; then \ + cargo build --release -p praxis-ai-proxy --features opentelemetry; \ + else \ + cargo build --release -p praxis-ai-proxy; \ + fi # ------------------------------------------------------------------------------ # Cache Tricks @@ -74,7 +80,11 @@ RUN find apis/src filters/src server/src \ RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/src/target \ - cargo build --release -p praxis-ai-proxy \ + if [ "$ENABLE_OPENTELEMETRY" = "true" ]; then \ + cargo build --release -p praxis-ai-proxy --features opentelemetry; \ + else \ + cargo build --release -p praxis-ai-proxy; \ + fi \ && cp target/release/praxis-ai /usr/local/bin/praxis-ai # ------------------------------------------------------------------------------ diff --git a/apis/src/lib.rs b/apis/src/lib.rs index 851fdcd43d..efad7f2f22 100644 --- a/apis/src/lib.rs +++ b/apis/src/lib.rs @@ -95,6 +95,7 @@ pub(crate) mod test_utils { response_body_mode: praxis_filter::BodyMode::Stream, response_header: None, response_headers_modified: false, + subrequest_response_mode: Default::default(), subrequest_client: None, subrequest_response_mode: praxis_filter::SubRequestResponseMode::Buffered, #[cfg(feature = "praxis-main")] diff --git a/docs/filters/intelligent_route.md b/docs/filters/intelligent_route.md index 41bb402b8a..4ae527cda7 100644 --- a/docs/filters/intelligent_route.md +++ b/docs/filters/intelligent_route.md @@ -13,13 +13,13 @@ This filter is registered by the AI proxy (not Praxis core) because it encodes A **Behavior:** - If `ctx.cluster` is already set by an earlier filter, the selection is preserved and no metadata is written. - If no routing source is present, the filter returns `Continue` without routing. - If the model header or MCP tool name is blank, oversized, or invalid, the filter rejects with 400. - If a matching candidate is found, `ctx.cluster` is set and bounded route-decision metadata is written. - If no matching candidate is found, the filter rejects with 404. -**Selection:** the first matching candidate in the configured or overlay-rendered order wins after admission filtering. Praxis AI does not recompute source geography, load, or score. `admission_state=none` is never eligible. `admission_state=existing_only` is only eligible through an already-bound session affinity entry. +**Selection:** session affinity is resolved first. New requests use the overlay's selection mode within the first viable producer-defined group. Missing group or policy metadata uses deterministic first-admitted ordering. Praxis AI does not recompute source geography, load, or score. `admission_state=none` is never eligible. `existing_only` is eligible only through an already-bound session affinity entry. -**Metadata:** on successful selection, bounded in-process filter metadata is written under the `intelligent_route.` namespace (`kind`, `name`, `site`, `cluster`, `local_site`, `stable_id`, `admission_state`, and optionally `rank`, `selection_tier`). When session affinity is enabled, `session.bound`, `session.reused`, and `session.failover` keys are also written. When the selected cluster is present in `provider_hop_clusters`, client-supplied `x-ai-routing-candidate`, `x-ai-routing-request-id`, and `x-ai-routing-revision` values are removed and replaced with the selected stable ID, a generated provider-hop request ID, and the serving overlay revision (envelope mode only). These AI-owned, non-reserved headers are sent only to an mTLS-authenticated provider gateway; the provider must run `peer_identity_trust` before consuming them. No credential reference or value is forwarded. No request-time database, control-plane, or metrics lookups are performed. +**Metadata:** on successful selection, bounded in-process filter metadata is written under the `intelligent_route.` namespace (`kind`, `name`, `site`, `cluster`, `local_site`, `stable_id`, `admission_state`, and optionally `rank`, `selection_group`, `selection_mode`, and `selection_tier`). When session affinity is enabled, `session.bound`, `session.reused`, and `session.failover` keys are also written. When the selected cluster is present in `provider_hop_clusters`, client-supplied `x-ai-routing-candidate`, `x-ai-routing-request-id`, and `x-ai-routing-revision` values are removed and replaced with the selected stable ID, a generated provider-hop request ID, and the serving overlay revision (envelope mode only). These AI-owned, non-reserved headers are sent only to an mTLS-authenticated provider gateway; the provider must run `peer_identity_trust` before consuming them. No credential reference or value is forwarded. No request-time database, control-plane, or metrics lookups are performed. **MCP lookup:** if `mcp.method` filter metadata is set to `tools/call` and `mcp.name` is present, `mcp_tool` candidates are matched. Other MCP methods (`initialize`, `notifications/*`, etc.) skip routing. -**Hot reload:** when `reload.enabled` is `true` (the default in overlay mode), the filter watches the overlay file's parent directory for filesystem events. On change, the file is re-read, SHA-256 hashed (skipped if identical), parsed, validated, and atomically swapped in via `ArcSwap`. In-flight requests continue using their previously loaded snapshot. Unreadable or invalid files retain the previous snapshot. Kubernetes `ConfigMap` projected volumes use atomic symlink replacement (`..data`), which the watcher detects as a Create/Modify event on the parent directory. The overlay `ConfigMap` **must not** use `subPath` volume mounts — `subPath` bypasses the `..data` symlink mechanism and the watcher will not detect updates. +**Hot reload:** when `reload.enabled` is `true` (the default in overlay mode), the filter watches the overlay file's parent directory for filesystem events. On change, the file is re-read, SHA-256 hashed, parsed, validated, and atomically swapped in when its semantic revision changes via `ArcSwap`. In-flight requests continue using their previously loaded snapshot. Unreadable or invalid files retain the previous snapshot. Kubernetes `ConfigMap` projected volumes use atomic symlink replacement (`..data`), which the watcher detects as a Create/Modify event on the parent directory. The overlay `ConfigMap` **must not** use `subPath` volume mounts — `subPath` bypasses the `..data` symlink mechanism and the watcher will not detect updates. **Envelope contract:** The configuration producer publishes `routing-overlay.json` as a versioned, content-addressed envelope alongside the legacy `routing-config.json` payload. The AI-owned v1 shape is: diff --git a/docs/filters/reference.md b/docs/filters/reference.md index 32452bc2f3..b85a256a20 100644 --- a/docs/filters/reference.md +++ b/docs/filters/reference.md @@ -86,6 +86,12 @@ see the [Praxis core filter reference][core-ref]. |--------|-------------| | [`time_to_first_token`](time_to_first_token.md) | Measures time-to-first-token for streaming AI responses. | +### Token Rate Limit + +| Filter | Description | +|--------|-------------| +| [`token_rate_limit`](token_rate_limit.md) | Token quota admission with local or shared sliding-window accounting. | + ### Token Usage | Filter | Description | diff --git a/docs/filters/token_rate_limit.md b/docs/filters/token_rate_limit.md new file mode 100644 index 0000000000..bfbe95197b --- /dev/null +++ b/docs/filters/token_rate_limit.md @@ -0,0 +1,48 @@ + + + +# `token_rate_limit` + +Token quota admission with local or shared sliding-window accounting. + +## Configuration Notes + +Place this filter after authentication and before `intelligent_route`. `token_count` must publish `token.total` before the terminal response-body callback so successful reservations reconcile actual usage. Failures and missing usage are charged conservatively at the fixed estimate. + +The memory backend is process-local. Valkey shares one rule-scoped quota across gateway processes, uses a reconnecting connection manager and cached Lua scripts, and fails closed when unavailable. Use `rediss://` with Rustls trust roots when credentials must be protected in transit. + +The configured key and active-reservation limits are per rule for both backends. The final principal/model key is validated before allocation. Windows are represented with bounded one-second buckets; a window may use at most 4,096 buckets. + +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `key` | KeyConfig | yes | | +| `key.principal` | PrincipalKeyConfig | yes | | +| `key.principal.source` | `metadata` | yes | | +| `key.principal.name` | string | yes | | +| `key.principal.onMissing` | `reject` | yes | | +| `key.model` | ModelKeyConfig | yes | | +| `key.model.source` | `header` | yes | | +| `key.model.name` | string | yes | | +| `key.model.onMissing` | `reject` | yes | | +| `key.model.allowedModels` | string[] | yes | | +| `reservationTimeout` | string | yes | | +| `limits` | LimitsConfig | yes | | +| `limits.maxKeys` | integer | yes | | +| `limits.maxKeyLength` | integer | yes | | +| `limits.maxActiveReservations` | integer | yes | | +| `rules` | RuleConfig[] | yes | | +| `rules[].name` | string | yes | | +| `rules[].match` | RuleMatchConfig | no | | +| `rules[].match.metadata` | object | yes | | +| `rules[].estimation` | EstimationConfig | yes | | +| `rules[].estimation.strategy` | `fixed` | yes | | +| `rules[].estimation.tokens` | integer | yes | | +| `rules[].token_budgets` | BudgetConfig[] | yes | | +| `rules[].token_budgets[].window` | string | yes | | +| `rules[].token_budgets[].capacity` | integer | yes | | +| `backend` | BackendConfig | no | | +| `backend.kind` | `memory` \| `valkey` | no | | +| `backend.url` | string | no | | +| `backend.namespace` | string | no | | diff --git a/filters/Cargo.toml b/filters/Cargo.toml index a2b463e405..b806c5a1fb 100644 --- a/filters/Cargo.toml +++ b/filters/Cargo.toml @@ -34,13 +34,16 @@ metrics = { workspace = true } notify = { workspace = true } praxis-ai-apis = { workspace = true } praxis-core = { workspace = true } -praxis-filter = { workspace = true } +praxis-filter = { workspace = true, features = ["basic-auth-filter"] } +rand = { workspace = true } reqwest.workspace = true +redis.workspace = true serde = { workspace = true } serde_json = { workspace = true } serde_json_canonicalizer = { workspace = true } serde_yaml = { workspace = true } sha2 = { workspace = true } +thiserror = { workspace = true } tokio = { workspace = true, features = ["rt", "sync", "time"] } tokio-util = { workspace = true } tracing = { workspace = true } diff --git a/filters/src/lib.rs b/filters/src/lib.rs index 3ead3f55f6..9e9fccea79 100644 --- a/filters/src/lib.rs +++ b/filters/src/lib.rs @@ -17,6 +17,7 @@ pub mod prompt_enrich; mod register; pub mod routing; mod time_to_first_token; +mod token_rate_limit; mod token_usage; pub use agentic::{a2a::A2aFilter, mcp::McpFilter}; @@ -26,6 +27,7 @@ pub use prompt_enrich::PromptEnrichFilter; pub use register::{build_ai_registry, register_ai_filters}; pub use routing::{CredentialInjectFilter, IntelligentRouteFilter, ProviderRouteFilter}; pub use time_to_first_token::TimeToFirstTokenFilter; +pub use token_rate_limit::TokenRateLimitFilter; pub use token_usage::{TokenCountFilter, TokenUsageHeadersFilter}; // ----------------------------------------------------------------------------- diff --git a/filters/src/register.rs b/filters/src/register.rs index 836b4326ac..4dba797b1e 100644 --- a/filters/src/register.rs +++ b/filters/src/register.rs @@ -8,7 +8,8 @@ use praxis_filter::FilterRegistry; use crate::{ A2aFilter, AiGuardrailsFilter, CredentialInjectFilter, IntelligentRouteFilter, McpFilter, ModelToHeaderFilter, - PromptEnrichFilter, ProviderRouteFilter, TimeToFirstTokenFilter, TokenCountFilter, TokenUsageHeadersFilter, + PromptEnrichFilter, ProviderRouteFilter, TimeToFirstTokenFilter, TokenCountFilter, TokenRateLimitFilter, + TokenUsageHeadersFilter, }; /// Register all in-tree AI HTTP filters into `registry`. @@ -85,6 +86,10 @@ fn register_general_ai_filters(registry: &mut FilterRegistry) { @register registry, http "token_count" => TokenCountFilter::from_config ); + praxis_filter::register_filters!( + @register registry, + http "token_rate_limit" => TokenRateLimitFilter::from_config + ); praxis_filter::register_filters!( @register registry, http "token_usage_headers" => TokenUsageHeadersFilter::from_config diff --git a/filters/src/routing/credential_inject.rs b/filters/src/routing/credential_inject.rs index 4569a1154e..9438d84fd2 100644 --- a/filters/src/routing/credential_inject.rs +++ b/filters/src/routing/credential_inject.rs @@ -442,9 +442,7 @@ mod tests { use super::*; - // ------------------------------------------------------------------------- - // Config Validation - // ------------------------------------------------------------------------- + // ---- Config validation ---- #[test] fn empty_credentials_rejected() { @@ -519,9 +517,7 @@ mod tests { assert_eq!(default_strategy(), STRATEGY_BEARER_TOKEN); } - // ------------------------------------------------------------------------- - // No-Op When No Credential Is Selected - // ------------------------------------------------------------------------- + // ---- No-op when no selected credential ---- #[tokio::test] async fn no_selected_credential_is_noop() { @@ -540,9 +536,7 @@ mod tests { ); } - // ------------------------------------------------------------------------- - // Bearer Token Injection - // ------------------------------------------------------------------------- + // ---- Bearer token injection ---- #[tokio::test] async fn bearer_token_with_configured_value_injects_authorization() { @@ -585,9 +579,7 @@ mod tests { ); } - // ------------------------------------------------------------------------- - // File Source - // ------------------------------------------------------------------------- + // ---- File source ---- #[tokio::test] async fn file_source_reads_token_and_injects_authorization() { @@ -695,9 +687,7 @@ mod tests { } } - // ------------------------------------------------------------------------- - // Fail Closed - // ------------------------------------------------------------------------- + // ---- Fail closed ---- #[tokio::test] async fn missing_configured_token_fails_closed_503() { @@ -736,9 +726,7 @@ mod tests { ); } - // ------------------------------------------------------------------------- - // Security: Token Not In Metadata - // ------------------------------------------------------------------------- + // ---- Security: token not in metadata ---- #[tokio::test] async fn token_not_in_filter_metadata_after_injection() { @@ -758,9 +746,7 @@ mod tests { } } - // ------------------------------------------------------------------------- - // Multi-Credential Selection - // ------------------------------------------------------------------------- + // ---- Multi-credential selection ---- #[tokio::test] async fn multiple_credentials_selects_matching_entry_only() { diff --git a/filters/src/routing/descriptor.rs b/filters/src/routing/descriptor.rs index 938aa6de38..d244b99346 100644 --- a/filters/src/routing/descriptor.rs +++ b/filters/src/routing/descriptor.rs @@ -175,7 +175,8 @@ fn default_fresh() -> bool { /// /// Created by [`validate_candidates`] from raw config entries. /// All string fields are bounded and non-blank. Overlay-specific -/// fields (`admission_state`, `rank`, `selection_tier`, `stable_id`) +/// fields (`admission_state`, `rank`, `selection_group`, `selection_tier`, +/// `stable_id`) /// are populated by [`enrich_from_overlay`] after validation. /// /// [`enrich_from_overlay`]: super::overlay::enrich_from_overlay @@ -210,6 +211,9 @@ pub(crate) struct RouteCandidate { /// Producer-assigned rank within the overlay (lower is better). pub rank: Option, + /// Producer-assigned priority group (lower is preferred). + pub selection_group: Option, + /// Producer-assigned locality tier (e.g. `"same_region"`). pub selection_tier: Option>, @@ -298,6 +302,7 @@ pub(crate) fn validate_candidates(raw: Vec) -> Result, + /// State belongs to this snapshot and therefore resets only on a real + /// semantic snapshot replacement. + pub(crate) next: AtomicUsize, +} + +/// Precomputed capability lookup used by the request path. +pub(crate) type GroupIndex = HashMap, Vec>>; + +/// Validate selection-group invariants and build the request-time index. +/// +/// Metadata is validated independently for every `(kind, name)` capability: +/// grouped and ungrouped candidates cannot be mixed, numbering starts at zero +/// and is contiguous, and all members of a group share an admission state. +#[expect( + clippy::too_many_lines, + reason = "validation and index construction must remain atomic" +)] +pub(crate) fn build(candidates: &[RouteCandidate]) -> Result { + let mut index: GroupIndex = HashMap::new(); + let mut mode: HashMap<(CapabilityKind, Arc), bool> = HashMap::new(); + + for (candidate_index, candidate) in candidates.iter().enumerate() { + let key = (candidate.kind, Arc::clone(&candidate.name)); + let grouped = candidate.selection_group.is_some(); + if let Some(previous) = mode.insert(key, grouped) + && previous != grouped + { + return Err(format!( + "routing: candidate {candidate_index}: capability mixes grouped and ungrouped candidates" + ) + .into()); + } + + let Some(number) = candidate.selection_group else { + continue; + }; + let groups = index + .entry(candidate.kind) + .or_default() + .entry(Arc::clone(&candidate.name)) + .or_default(); + + match groups.last_mut() { + None if number != 0 => { + return Err(format!("routing: candidate {candidate_index}: selection_group must start at 0").into()); + }, + Some(group) if group.number == number => { + if group.admission_state != candidate.admission_state { + return Err(format!( + "routing: candidate {candidate_index}: selection_group {number} mixes admission states" + ) + .into()); + } + group.candidate_indexes.push(candidate_index); + continue; + }, + Some(group) if number != group.number.saturating_add(1) => { + return Err(format!( + "routing: candidate {candidate_index}: selection_group must be contiguous and monotonic" + ) + .into()); + }, + _ => {}, + } + + groups.push(SelectionGroup { + number, + admission_state: candidate.admission_state, + candidate_indexes: vec![candidate_index], + next: AtomicUsize::new(0), + }); + } + + Ok(index) +} + +#[cfg(test)] +#[expect(clippy::unwrap_used, reason = "test assertions")] +mod tests { + use std::sync::Arc; + + use super::{ + super::descriptor::{AdmissionState, CapabilityKind, RouteCandidate}, + build, + }; + + fn candidate(group: Option, admission_state: AdmissionState) -> RouteCandidate { + RouteCandidate { + admission_state, + cluster: Arc::from("cluster"), + credential: None, + fresh: true, + kind: CapabilityKind::InferenceModel, + name: Arc::from("model"), + rank: None, + selection_group: group, + selection_tier: None, + site: Arc::from("site"), + stable_id: Arc::from("stable"), + } + } + + #[test] + fn group_numbering_starts_at_zero() { + let error = build(&[candidate(Some(1), AdmissionState::NewAndExisting)]).unwrap_err(); + assert!(error.to_string().contains("must start at 0")); + } + + #[test] + fn group_members_have_uniform_admission() { + let error = build(&[ + candidate(Some(0), AdmissionState::NewAndExisting), + candidate(Some(0), AdmissionState::ExistingOnly), + ]) + .unwrap_err(); + assert!(error.to_string().contains("mixes admission states")); + } + + #[test] + fn group_numbers_must_be_contiguous_per_capability() { + let error = build(&[ + candidate(Some(0), AdmissionState::NewAndExisting), + candidate(Some(2), AdmissionState::NewAndExisting), + ]) + .unwrap_err(); + assert!(error.to_string().contains("contiguous and monotonic")); + } + + #[test] + #[expect(clippy::indexing_slicing, reason = "test fixture assertions use known keys")] + fn interleaved_capabilities_keep_independent_group_indexes() { + let mut other = candidate(Some(0), AdmissionState::NewAndExisting); + other.name = Arc::from("other-model"); + let candidates = vec![ + candidate(Some(0), AdmissionState::NewAndExisting), + other, + candidate(Some(0), AdmissionState::NewAndExisting), + ]; + let index = build(&candidates).unwrap(); + + let by_name = index.get(&CapabilityKind::InferenceModel).unwrap(); + assert_eq!(by_name.get("model").unwrap().len(), 1); + assert_eq!(by_name.get("other-model").unwrap().len(), 1); + assert_eq!(by_name.get("model").unwrap()[0].candidate_indexes, vec![0, 2]); + } +} diff --git a/filters/src/routing/intelligent_route.rs b/filters/src/routing/intelligent_route.rs index 4652a57560..19bb3d4bfb 100644 --- a/filters/src/routing/intelligent_route.rs +++ b/filters/src/routing/intelligent_route.rs @@ -14,8 +14,10 @@ //! MCP metadata takes precedence over the model header to prevent a //! client-supplied model name from hijacking MCP routing. //! -//! Candidate selection is deterministic: the first matching candidate in -//! the configured or overlay-rendered order wins after admission filtering. +//! Candidate selection preserves deterministic ordering when no selection +//! policy is configured. A versioned overlay may define priority groups and a +//! local deterministic, random, or round-robin mode within the first viable +//! group. //! The filter does not recompute source geography, load, or scoring. //! //! No request-time metrics or control-plane lookups are performed. @@ -38,10 +40,12 @@ use super::{ descriptor::{self, AdmissionState, CandidateConfig, CapabilityKind, RouteCandidate}, metadata::{ OVERLAY_REVISION_HEADER, PROVIDER_HOP_REQUEST_ID_HEADER, ROUTE_ADMISSION_STATE, ROUTE_CLUSTER, ROUTE_KIND, - ROUTE_LOCAL_SITE, ROUTE_NAME, ROUTE_PROVIDER_HOP_REQUEST_ID, ROUTE_RANK, ROUTE_SELECTION_TIER, ROUTE_SITE, - ROUTE_STABLE_ID, SELECTED_CANDIDATE_HEADER, set_credential_metadata, + ROUTE_LOCAL_SITE, ROUTE_NAME, ROUTE_PROVIDER_HOP_REQUEST_ID, ROUTE_RANK, ROUTE_SELECTION_GROUP, + ROUTE_SELECTION_MODE, ROUTE_SELECTION_TIER, ROUTE_SITE, ROUTE_STABLE_ID, SELECTED_CANDIDATE_HEADER, + set_credential_metadata, }, - overlay::{self, ExpectedOverlayScope, OverlayReloadHandle, RouteSnapshot}, + overlay::{self, ExpectedOverlayScope, OverlayReloadHandle, PickerPolicy, RouteSnapshot}, + picker, }; // ----------------------------------------------------------------------------- @@ -290,16 +294,18 @@ enum AffinityOutcome<'a> { /// - If a matching candidate is found, `ctx.cluster` is set and bounded route-decision metadata is written. /// - If no matching candidate is found, the filter rejects with 404. /// -/// **Selection:** the first matching candidate in the configured or -/// overlay-rendered order wins after admission filtering. Praxis AI does not -/// recompute source geography, load, or score. `admission_state=none` is never -/// eligible. `admission_state=existing_only` is only eligible through an -/// already-bound session affinity entry. +/// **Selection:** session affinity is resolved first. New requests use the +/// overlay's selection mode within the first viable producer-defined group. +/// Missing group or policy metadata uses deterministic first-admitted ordering. +/// Praxis AI does not recompute source geography, load, or score. +/// `admission_state=none` is never eligible. `existing_only` is eligible only +/// through an already-bound session affinity entry. /// /// **Metadata:** on successful selection, bounded in-process filter /// metadata is written under the `intelligent_route.` namespace (`kind`, `name`, /// `site`, `cluster`, `local_site`, `stable_id`, `admission_state`, and -/// optionally `rank`, `selection_tier`). When session affinity is enabled, +/// optionally `rank`, `selection_group`, `selection_mode`, and +/// `selection_tier`). When session affinity is enabled, /// `session.bound`, `session.reused`, and `session.failover` keys are also /// written. When the selected cluster is present in `provider_hop_clusters`, /// client-supplied `x-ai-routing-candidate`, @@ -318,8 +324,8 @@ enum AffinityOutcome<'a> { /// /// **Hot reload:** when `reload.enabled` is `true` (the default in overlay /// mode), the filter watches the overlay file's parent directory for -/// filesystem events. On change, the file is re-read, SHA-256 hashed -/// (skipped if identical), parsed, validated, and atomically swapped in +/// filesystem events. On change, the file is re-read, SHA-256 hashed, parsed, +/// validated, and atomically swapped in when its semantic revision changes /// via `ArcSwap`. In-flight requests continue using their previously /// loaded snapshot. Unreadable or invalid files retain the previous /// snapshot. Kubernetes `ConfigMap` projected volumes use atomic symlink @@ -490,10 +496,13 @@ impl IntelligentRouteFilter { c, &self.provider_hop_clusters, snap.semantic_revision.as_ref(), + snap.selection_mode, ); } let failover = matches!(outcome, AffinityOutcome::Failover); - let Some(c) = select_admitted(&snap.candidates, kind, name) else { + let Some((c, selection_group)) = + picker::select_candidate(&snap.candidates, &snap.group_index, kind, name, snap.selection_mode) + else { tracing::debug!(kind = kind.as_str(), name = %name, "intelligent_route: no candidate"); return Ok(FilterAction::Reject(Rejection::status(404))); }; @@ -503,6 +512,8 @@ impl IntelligentRouteFilter { c, &self.provider_hop_clusters, snap.semantic_revision.as_ref(), + selection_group, + snap.selection_mode, )?; if let Some(aff) = &self.session_affinity { record_session(aff, ctx, &c.stable_id, session_key.as_deref(), failover); @@ -655,18 +666,24 @@ impl HttpFilter for IntelligentRouteFilter { } /// Apply a reused (session-affinity-bound) candidate. +#[expect( + clippy::too_many_arguments, + reason = "route application requires immutable snapshot context" +)] fn apply_reused( ctx: &mut HttpFilterContext<'_>, local_site: &Arc, candidate: &RouteCandidate, provider_hop_clusters: &BTreeSet, semantic_revision: Option<&Arc>, + mode: PickerPolicy, ) -> Result { ctx.cluster = Some(Arc::clone(&candidate.cluster)); record_route_decision(ctx, local_site, candidate); write_provider_context(ctx, candidate, provider_hop_clusters, semantic_revision)?; #[cfg(feature = "opentelemetry")] crate::opentelemetry::record_routing_selection(candidate, local_site, semantic_revision); + record_selection_metadata(ctx, candidate.selection_group, mode); ctx.set_metadata("intelligent_route.session.bound", "true"); ctx.set_metadata("intelligent_route.session.reused", "true"); ctx.set_metadata("intelligent_route.session.failover", "false"); @@ -674,18 +691,25 @@ fn apply_reused( } /// Set cluster and record route decision metadata. +#[expect( + clippy::too_many_arguments, + reason = "route application requires immutable snapshot context" +)] fn apply_route( ctx: &mut HttpFilterContext<'_>, local_site: &Arc, candidate: &RouteCandidate, provider_hop_clusters: &BTreeSet, semantic_revision: Option<&Arc>, + selection_group: Option, + mode: PickerPolicy, ) -> Result<(), FilterError> { ctx.cluster = Some(Arc::clone(&candidate.cluster)); record_route_decision(ctx, local_site, candidate); write_provider_context(ctx, candidate, provider_hop_clusters, semantic_revision)?; #[cfg(feature = "opentelemetry")] crate::opentelemetry::record_routing_selection(candidate, local_site, semantic_revision); + record_selection_metadata(ctx, selection_group, mode); Ok(()) } @@ -902,39 +926,15 @@ fn evict_expired(affinity: &SessionAffinity) { } // ----------------------------------------------------------------------------- -// Candidate Selection +// Selection Evidence // ----------------------------------------------------------------------------- -/// Select the first candidate admitted for a new request. -/// -/// Always excludes [`Excluded`] candidates. When `is_new_session` -/// is `true`, also excludes [`ExistingOnly`] candidates. -/// -/// Praxis AI intentionally preserves the configured or overlay-rendered -/// candidate order. The source owns geography, load-aware ordering, and rank. -/// -/// [`Excluded`]: AdmissionState::Excluded -/// [`ExistingOnly`]: AdmissionState::ExistingOnly -fn select_admitted<'a>( - candidates: &'a [RouteCandidate], - kind: CapabilityKind, - name: &str, -) -> Option<&'a RouteCandidate> { - for c in candidates { - if c.kind != kind || &*c.name != name { - continue; - } - if !is_admitted_for_new_request(c.admission_state) { - continue; - } - return Some(c); +/// Record bounded request metadata without changing forwarding headers. +fn record_selection_metadata(ctx: &mut HttpFilterContext<'_>, group: Option, mode: PickerPolicy) { + if let Some(group) = group { + ctx.set_metadata(ROUTE_SELECTION_GROUP, group.to_string()); + ctx.set_metadata(ROUTE_SELECTION_MODE, mode.as_str()); } - None -} - -/// Whether a candidate passes admission filtering. -fn is_admitted_for_new_request(state: AdmissionState) -> bool { - state == AdmissionState::NewAndExisting } // ----------------------------------------------------------------------------- @@ -2764,6 +2764,7 @@ mod tests { kind: CapabilityKind::InferenceModel, name: Arc::from("llama"), rank: None, + selection_group: None, selection_tier: None, site: Arc::from("s"), stable_id: descriptor::default_stable_id(CapabilityKind::InferenceModel, "llama", "s", cluster), diff --git a/filters/src/routing/metadata.rs b/filters/src/routing/metadata.rs index 9ce437e482..502b76c721 100644 --- a/filters/src/routing/metadata.rs +++ b/filters/src/routing/metadata.rs @@ -45,6 +45,10 @@ pub(crate) const ROUTE_LOCAL_SITE: &str = "intelligent_route.local_site"; pub(crate) const ROUTE_NAME: &str = "intelligent_route.name"; /// Selection rank within the candidate tier. pub(crate) const ROUTE_RANK: &str = "intelligent_route.rank"; +/// Producer-defined group used for this selection. +pub(crate) const ROUTE_SELECTION_GROUP: &str = "intelligent_route.selection_group"; +/// Request-time selection mode applied within the selected group. +pub(crate) const ROUTE_SELECTION_MODE: &str = "intelligent_route.selection_mode"; /// Selection tier (primary, fallback, etc.). pub(crate) const ROUTE_SELECTION_TIER: &str = "intelligent_route.selection_tier"; /// Remote site name of the selected candidate. diff --git a/filters/src/routing/mod.rs b/filters/src/routing/mod.rs index 549947142a..7900383dd7 100644 --- a/filters/src/routing/mod.rs +++ b/filters/src/routing/mod.rs @@ -10,9 +10,11 @@ mod credential_inject; pub(crate) mod descriptor; +pub(crate) mod group_index; mod intelligent_route; pub(crate) mod metadata; pub(crate) mod overlay; +pub(crate) mod picker; mod provider_route; pub use credential_inject::CredentialInjectFilter; diff --git a/filters/src/routing/overlay.rs b/filters/src/routing/overlay.rs index a8a75886ff..2b22f15ea6 100644 --- a/filters/src/routing/overlay.rs +++ b/filters/src/routing/overlay.rs @@ -32,6 +32,7 @@ use tokio_util::sync::CancellationToken; use super::{ descriptor::{self, AdmissionState, CandidateConfig, CapabilityKind, RouteCandidate}, + group_index::{self, GroupIndex}, metadata::{CandidateCredential, CredentialRef}, }; @@ -219,6 +220,42 @@ pub(crate) struct OverlayDocument { /// Network name. Used for scope validation in envelope mode. #[serde(default)] pub(super) network: Option, + + /// Optional request-time selection policy. + #[serde(default)] + pub(crate) selection_policy: Option, +} + +/// Request-time selection algorithm used within the first viable group. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) enum PickerPolicy { + /// Select the first admitted candidate. + #[default] + Deterministic, + /// Rotate equally through candidates in the active group. + RoundRobin, + /// Select a candidate uniformly at random from the active group. + Random, +} + +impl PickerPolicy { + /// Return the bounded metadata representation. + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Deterministic => "deterministic", + Self::RoundRobin => "round_robin", + Self::Random => "random", + } + } +} + +/// Wire wrapper kept extensible independently from routing and scoring policy. +#[derive(Clone, Copy, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct SelectionPolicy { + /// Selection mode applied locally by `intelligent_route`. + pub(crate) mode: PickerPolicy, } /// A single routing candidate from the overlay. @@ -254,6 +291,10 @@ pub(crate) struct OverlayCandidate { #[serde(default)] pub(crate) rank: Option, + /// Producer-defined priority group. Lower groups are attempted first. + #[serde(default)] + pub(crate) selection_group: Option, + /// Producer-assigned locality tier (e.g. `"same_region"`). #[serde(default)] pub(crate) selection_tier: Option, @@ -319,6 +360,12 @@ pub(crate) struct RouteSnapshot { /// Validated route candidates. pub(crate) candidates: Vec, + /// Validated lookup and snapshot-scoped selection state. + pub(crate) group_index: GroupIndex, + + /// Effective request-time selection mode. + pub(crate) selection_mode: PickerPolicy, + /// Whether this snapshot was loaded from an envelope or legacy payload. pub(crate) contract_format: ContractFormat, @@ -468,10 +515,17 @@ impl RouteSnapshot { descriptor::validate_local_site(&envelope.overlay.local_site)?; let candidates = overlay_to_candidates(&envelope.overlay)?; + let group_index = group_index::build(&candidates)?; let generated_at = envelope.overlay.generated_at.map(|s| Arc::from(s.as_str())); + let selection_mode = envelope + .overlay + .selection_policy + .map_or(PickerPolicy::Deterministic, |policy| policy.mode); Ok(Self { candidates, + group_index, + selection_mode, contract_format: ContractFormat::Envelope, content_hash, generated_at, @@ -488,10 +542,16 @@ impl RouteSnapshot { descriptor::validate_local_site(&doc.local_site)?; let candidates = overlay_to_candidates(&doc)?; + let group_index = group_index::build(&candidates)?; let generated_at = doc.generated_at.map(|s| Arc::from(s.as_str())); + let selection_mode = doc + .selection_policy + .map_or(PickerPolicy::Deterministic, |policy| policy.mode); Ok(Self { candidates, + group_index, + selection_mode, contract_format: ContractFormat::Legacy, content_hash, generated_at, @@ -507,6 +567,8 @@ impl RouteSnapshot { pub(crate) fn from_static(candidates: Vec, local_site: Arc) -> Self { Self { candidates, + group_index: GroupIndex::new(), + selection_mode: PickerPolicy::Deterministic, contract_format: ContractFormat::Legacy, content_hash: [0; 32], generated_at: None, @@ -614,6 +676,9 @@ fn compute_semantic_digest(overlay_value: &serde_json::Value) -> Result Result, F /// Apply producer-supplied metadata to validated candidates. /// /// Zips the validated candidate list with the original overlay entries -/// and sets `admission_state`, `rank`, `selection_tier`, and `stable_id`. +/// and sets `admission_state`, `rank`, `selection_group`, `selection_tier`, +/// and `stable_id`. /// Called after [`validate_candidates`] so `deny_unknown_fields` on /// [`CandidateConfig`] is never bypassed. /// @@ -706,6 +772,7 @@ pub(super) fn enrich_from_overlay( .map_err(|e| FilterError::from(format!("routing: candidate {i}: {e}")))?; } c.rank = oc.rank; + c.selection_group = oc.selection_group; if let Some(t) = &oc.selection_tier { if t.trim().is_empty() || t.len() > 128 { return Err(format!("routing: candidate {i}: selection_tier must be 1-128 non-blank bytes").into()); @@ -942,7 +1009,7 @@ fn handle_overlay_reload( return; }; - if is_unchanged(&content, snapshot) { + if is_unchanged(&content, snapshot, expected_scope) { return; } @@ -990,12 +1057,26 @@ pub(crate) fn read_overlay_bounded(path: &Path) -> Result, std::io::Erro Ok(buf) } -/// Check whether the content hash matches the current snapshot. -fn is_unchanged(content: &[u8], snapshot: &ArcSwap) -> bool { +/// Avoid replacing request-time state for a timestamp-only rewrite. +/// +/// The candidate snapshot is fully parsed and scope-validated before its +/// semantic revision is trusted. This preserves round-robin counters without +/// allowing malformed content to bypass normal reload validation. +fn is_unchanged( + content: &[u8], + snapshot: &ArcSwap, + expected_scope: Option<&ExpectedOverlayScope>, +) -> bool { let new_hash: [u8; 32] = Sha256::digest(content).into(); - let unchanged = new_hash == snapshot.load().content_hash; + let current = snapshot.load(); + let unchanged = new_hash == current.content_hash + || (current.semantic_revision.is_some() + && RouteSnapshot::from_overlay_with_scope(content, expected_scope) + .ok() + .and_then(|candidate| candidate.semantic_revision) + == current.semantic_revision); if unchanged { - tracing::debug!("intelligent_route: overlay content unchanged (hash match)"); + tracing::debug!("intelligent_route: overlay snapshot is semantically unchanged"); } unchanged } @@ -1145,6 +1226,53 @@ mod tests { assert!(doc.candidates[0].credential.is_none()); } + #[test] + fn missing_selection_policy_uses_deterministic_mode() { + let json = r#"{ + "local_site": "site-a", + "candidates": [{ + "kind": "inference_model", + "name": "llama-3", + "site": "site-a", + "cluster": "local-inference" + }] + }"#; + let snapshot = RouteSnapshot::from_overlay(json.as_bytes()).unwrap(); + assert_eq!(snapshot.selection_mode, PickerPolicy::Deterministic); + assert!(snapshot.group_index.is_empty()); + } + + #[test] + fn round_robin_policy_and_group_survive_overlay_load() { + let json = r#"{ + "local_site": "site-a", + "selection_policy": {"mode": "roundRobin"}, + "candidates": [{ + "kind": "inference_model", + "name": "llama-3", + "site": "site-a", + "cluster": "local-inference", + "selection_group": 0 + }] + }"#; + let snapshot = RouteSnapshot::from_overlay(json.as_bytes()).unwrap(); + assert_eq!(snapshot.selection_mode, PickerPolicy::RoundRobin); + assert_eq!(snapshot.candidates[0].selection_group, Some(0)); + } + + #[test] + fn malformed_group_metadata_fails_closed() { + let mixed = r#"{ + "local_site": "site-a", + "candidates": [ + {"kind":"inference_model","name":"m","site":"a","cluster":"a","selection_group":0}, + {"kind":"inference_model","name":"m","site":"b","cluster":"b"} + ] + }"#; + let error = RouteSnapshot::from_overlay(mixed.as_bytes()).unwrap_err(); + assert!(error.to_string().contains("mixes grouped and ungrouped")); + } + #[test] fn parse_overlay_with_credential_camel_case() { let json = r#"{ @@ -2173,6 +2301,53 @@ mod tests { ); } + #[test] + fn semantic_equivalent_rewrite_does_not_replace_picker_state() { + let valid = include_bytes!("../../../tests/fixtures/overlay-contract/v1/valid-multi-candidate.json"); + let timestamp_only = + include_bytes!("../../../tests/fixtures/overlay-contract/v1/timestamp-change-same-revision.json"); + let snapshot = ArcSwap::from_pointee(RouteSnapshot::from_overlay(valid).unwrap()); + + assert!(is_unchanged(timestamp_only, &snapshot, None)); + } + + fn select_round_robin_cluster(snapshot: &RouteSnapshot) -> String { + crate::routing::picker::select_candidate( + &snapshot.candidates, + &snapshot.group_index, + CapabilityKind::InferenceModel, + "m", + snapshot.selection_mode, + ) + .unwrap() + .0 + .cluster + .to_string() + } + + #[test] + fn new_snapshot_has_independent_picker_state() { + let json_v1 = r#"{ + "local_site":"site-a", + "selection_policy":{"mode":"roundRobin"}, + "candidates":[ + {"kind":"inference_model","name":"m","site":"a","cluster":"a","selection_group":0}, + {"kind":"inference_model","name":"m","site":"b","cluster":"b","selection_group":0} + ] + }"#; + let json_v2 = json_v1.replace("\"cluster\":\"a\"", "\"cluster\":\"c\""); + let first = RouteSnapshot::from_overlay(json_v1.as_bytes()).unwrap(); + let second = RouteSnapshot::from_overlay(json_v2.as_bytes()).unwrap(); + + let first_selection = select_round_robin_cluster(&first); + let second_selection = select_round_robin_cluster(&first); + let fresh_selection = select_round_robin_cluster(&second); + + assert_eq!(first_selection, "a"); + assert_eq!(second_selection, "b"); + assert_eq!(fresh_selection, "c"); + } + #[test] fn overlay_fixture_unknown_additive_field() { let fixture = include_bytes!("../../../tests/fixtures/overlay-contract/v1/unknown-additive-field.json"); diff --git a/filters/src/routing/picker.rs b/filters/src/routing/picker.rs new file mode 100644 index 0000000000..496b249950 --- /dev/null +++ b/filters/src/routing/picker.rs @@ -0,0 +1,311 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Request-time local selection inside a producer-defined priority group. + +use std::sync::atomic::{AtomicUsize, Ordering}; + +use rand::RngExt as _; + +use super::{ + descriptor::{AdmissionState, CapabilityKind, RouteCandidate}, + group_index::{GroupIndex, SelectionGroup}, + overlay::PickerPolicy, +}; + +/// Select a candidate from the lowest viable producer-defined group. +pub(crate) fn select_candidate<'a>( + candidates: &'a [RouteCandidate], + groups: &GroupIndex, + kind: CapabilityKind, + name: &str, + policy: PickerPolicy, +) -> Option<(&'a RouteCandidate, Option)> { + if let Some(capability_groups) = groups.get(&kind).and_then(|by_name| by_name.get(name)) { + for group in capability_groups { + if let Some(candidate) = select_from_group(candidates, group, policy) { + return Some((candidate, Some(group.number))); + } + } + return None; + } + select_legacy(candidates, kind, name).map(|candidate| (candidate, None)) +} + +/// Preserve the exact ordered behavior for overlays without group metadata. +fn select_legacy<'a>(candidates: &'a [RouteCandidate], kind: CapabilityKind, name: &str) -> Option<&'a RouteCandidate> { + candidates.iter().find(|candidate| { + candidate.kind == kind + && &*candidate.name == name + && candidate.admission_state == AdmissionState::NewAndExisting + }) +} + +/// Select one member of a prevalidated, uniformly admitted group. +fn select_from_group<'a>( + candidates: &'a [RouteCandidate], + group: &SelectionGroup, + policy: PickerPolicy, +) -> Option<&'a RouteCandidate> { + if group.admission_state != AdmissionState::NewAndExisting { + return None; + } + let ordinal = choose_ordinal(policy, group.candidate_indexes.len(), &group.next); + select_from_group_at(candidates, group, ordinal) +} + +/// Select a specific member of a prevalidated group. +/// +/// This small ordinal seam keeps policy tests deterministic without changing +/// production randomness or making the request path injectable at runtime. +fn select_from_group_at<'a>( + candidates: &'a [RouteCandidate], + group: &SelectionGroup, + ordinal: usize, +) -> Option<&'a RouteCandidate> { + group + .candidate_indexes + .get(ordinal) + .and_then(|&index| candidates.get(index)) +} + +/// Resolve a policy to an index inside a non-empty selection group. +fn choose_ordinal(policy: PickerPolicy, len: usize, counter: &AtomicUsize) -> usize { + match policy { + PickerPolicy::Deterministic => 0, + PickerPolicy::RoundRobin => counter.fetch_add(1, Ordering::Relaxed) % len, + PickerPolicy::Random => rand::rng().random_range(0..len), + } +} + +#[cfg(test)] +#[expect(clippy::unwrap_used, reason = "test assertions")] +mod tests { + use std::sync::{Arc, atomic::AtomicUsize}; + + use super::{ + super::{ + descriptor::{AdmissionState, CapabilityKind, RouteCandidate}, + group_index, + overlay::PickerPolicy, + }, + choose_ordinal, select_candidate, select_from_group_at, + }; + + fn candidate(cluster: &str, group: Option, admission: AdmissionState) -> RouteCandidate { + RouteCandidate { + admission_state: admission, + cluster: Arc::from(cluster), + credential: None, + fresh: true, + kind: CapabilityKind::InferenceModel, + name: Arc::from("model"), + rank: None, + selection_group: group, + selection_tier: None, + site: Arc::from("site"), + stable_id: Arc::from(cluster), + } + } + + #[test] + fn round_robin_distributes_only_inside_best_group() { + let candidates = vec![ + candidate("a", Some(0), AdmissionState::NewAndExisting), + candidate("b", Some(0), AdmissionState::NewAndExisting), + candidate("fallback", Some(1), AdmissionState::NewAndExisting), + ]; + let groups = group_index::build(&candidates).unwrap(); + let selected: Vec<_> = (0..4) + .map(|_| { + select_candidate( + &candidates, + &groups, + CapabilityKind::InferenceModel, + "model", + PickerPolicy::RoundRobin, + ) + .map(|(candidate, _)| candidate.cluster.to_string()) + .unwrap_or_default() + }) + .collect(); + assert_eq!(selected, ["a", "b", "a", "b"]); + } + + #[test] + fn unavailable_best_group_falls_through() { + let candidates = vec![ + candidate("draining", Some(0), AdmissionState::ExistingOnly), + candidate("fallback", Some(1), AdmissionState::NewAndExisting), + ]; + let groups = group_index::build(&candidates).unwrap(); + let selected = select_candidate( + &candidates, + &groups, + CapabilityKind::InferenceModel, + "model", + PickerPolicy::RoundRobin, + ); + assert_eq!(selected.map(|(candidate, _)| &*candidate.cluster), Some("fallback")); + } + + #[test] + fn ungrouped_overlay_preserves_first_candidate_selection() { + let candidates = vec![ + candidate("a", None, AdmissionState::NewAndExisting), + candidate("b", None, AdmissionState::NewAndExisting), + ]; + let groups = group_index::build(&candidates).unwrap(); + let selected = select_candidate( + &candidates, + &groups, + CapabilityKind::InferenceModel, + "model", + PickerPolicy::RoundRobin, + ); + assert_eq!(selected.map(|(candidate, _)| &*candidate.cluster), Some("a")); + } + + #[test] + fn deterministic_always_selects_first_candidate_in_best_group() { + let candidates = vec![ + candidate("a", Some(0), AdmissionState::NewAndExisting), + candidate("b", Some(0), AdmissionState::NewAndExisting), + ]; + let groups = group_index::build(&candidates).unwrap(); + + for _ in 0..8 { + let selected = select_candidate( + &candidates, + &groups, + CapabilityKind::InferenceModel, + "model", + PickerPolicy::Deterministic, + ); + assert_eq!(selected.map(|(candidate, _)| &*candidate.cluster), Some("a")); + } + } + + #[test] + fn random_stays_inside_the_best_group() { + let candidates = vec![ + candidate("a", Some(0), AdmissionState::NewAndExisting), + candidate("b", Some(0), AdmissionState::NewAndExisting), + candidate("fallback", Some(1), AdmissionState::NewAndExisting), + ]; + let groups = group_index::build(&candidates).unwrap(); + + let best_group = groups + .get(&CapabilityKind::InferenceModel) + .unwrap() + .get("model") + .unwrap() + .first() + .unwrap(); + let selected_a = select_from_group_at(&candidates, best_group, 0).unwrap(); + let selected_b = select_from_group_at(&candidates, best_group, 1).unwrap(); + + assert_eq!(&*selected_a.cluster, "a"); + assert_eq!(&*selected_b.cluster, "b"); + assert_eq!(best_group.number, 0); + } + + #[test] + fn round_robin_counter_wraps_without_leaving_group() { + let counter = AtomicUsize::new(usize::MAX); + assert_eq!(choose_ordinal(PickerPolicy::RoundRobin, 2, &counter), usize::MAX % 2); + assert_eq!(choose_ordinal(PickerPolicy::RoundRobin, 2, &counter), 0); + } + + #[test] + #[expect(clippy::too_many_lines, reason = "independent counter assertions")] + fn counters_are_isolated_per_capability() { + let mut other = candidate("other-a", Some(0), AdmissionState::NewAndExisting); + other.name = Arc::from("other-model"); + let candidates = vec![ + candidate("model-a", Some(0), AdmissionState::NewAndExisting), + candidate("model-b", Some(0), AdmissionState::NewAndExisting), + other, + ]; + let groups = group_index::build(&candidates).unwrap(); + + let first_model = select_candidate( + &candidates, + &groups, + CapabilityKind::InferenceModel, + "model", + PickerPolicy::RoundRobin, + ) + .unwrap(); + let first_other = select_candidate( + &candidates, + &groups, + CapabilityKind::InferenceModel, + "other-model", + PickerPolicy::RoundRobin, + ) + .unwrap(); + let second_model = select_candidate( + &candidates, + &groups, + CapabilityKind::InferenceModel, + "model", + PickerPolicy::RoundRobin, + ) + .unwrap(); + + assert_eq!(&*first_model.0.cluster, "model-a"); + assert_eq!(&*first_other.0.cluster, "other-a"); + assert_eq!(&*second_model.0.cluster, "model-b"); + } + + #[test] + #[expect(clippy::too_many_lines, reason = "concurrent selection assertion")] + fn concurrent_round_robin_selection_is_safe_and_complete() { + let candidates = Arc::new(vec![ + candidate("a", Some(0), AdmissionState::NewAndExisting), + candidate("b", Some(0), AdmissionState::NewAndExisting), + ]); + let groups = Arc::new(group_index::build(&candidates).unwrap()); + + let counts = std::thread::scope(|scope| { + let handles: Vec<_> = (0..8) + .map(|_| { + let candidates = Arc::clone(&candidates); + let groups = Arc::clone(&groups); + scope.spawn(move || { + let mut counts = [0_usize; 2]; + for _ in 0..128 { + let selected = select_candidate( + &candidates, + &groups, + CapabilityKind::InferenceModel, + "model", + PickerPolicy::RoundRobin, + ) + .unwrap(); + let cluster = selected.0.cluster.as_ref(); + assert!(matches!(cluster, "a" | "b")); + if cluster == "a" { + counts[0] += 1; + } else { + counts[1] += 1; + } + } + counts + }) + }) + .collect(); + handles + .into_iter() + .map(|handle| handle.join().unwrap()) + .fold([0_usize; 2], |mut total, counts| { + total[0] += counts[0]; + total[1] += counts[1]; + total + }) + }); + + assert_eq!(counts, [512, 512]); + } +} diff --git a/filters/src/routing/provider_route.rs b/filters/src/routing/provider_route.rs index 6a4ef4f9bf..7c88163289 100644 --- a/filters/src/routing/provider_route.rs +++ b/filters/src/routing/provider_route.rs @@ -385,9 +385,7 @@ mod tests { test_utils, }; - // ------------------------------------------------------------------------- - // Header Stripping - // ------------------------------------------------------------------------- + // ---- Header stripping ---- #[tokio::test] async fn strips_routing_protocol_headers() { @@ -561,9 +559,7 @@ mod tests { assert!(ctx.request_headers_to_set.is_empty()); } - // ------------------------------------------------------------------------- - // Exact Candidate Matching - // ------------------------------------------------------------------------- + // ---- Exact candidate matching ---- #[tokio::test] async fn known_candidate_selects_cluster() { @@ -611,9 +607,7 @@ mod tests { ); } - // ------------------------------------------------------------------------- - // Missing Required Headers - // ------------------------------------------------------------------------- + // ---- Missing required headers ---- #[tokio::test] async fn missing_candidate_header_denied_403() { @@ -669,9 +663,7 @@ mod tests { ); } - // ------------------------------------------------------------------------- - // Oversized Or Empty Values Fail Closed - // ------------------------------------------------------------------------- + // ---- Oversized/empty values fail closed ---- #[tokio::test] async fn empty_candidate_id_denied() { @@ -698,9 +690,7 @@ mod tests { ); } - // ------------------------------------------------------------------------- - // Metadata Output - // ------------------------------------------------------------------------- + // ---- Metadata output ---- #[tokio::test] async fn sets_provider_route_metadata() { @@ -715,9 +705,7 @@ mod tests { assert!(ctx.get_metadata(PROVIDER_ROUTE_REQUEST_ID).is_some()); } - // ------------------------------------------------------------------------- - // Provider Attribution - // ------------------------------------------------------------------------- + // ---- Provider attribution ---- #[tokio::test] async fn sets_provider_attribution_request_header() { @@ -774,9 +762,7 @@ mod tests { assert!(resp.headers.get(PROVIDER_ATTRIBUTION_RESPONSE_HEADER).is_none()); } - // ------------------------------------------------------------------------- - // Credential Metadata - // ------------------------------------------------------------------------- + // ---- Credential metadata ---- #[tokio::test] async fn no_credential_clears_metadata() { @@ -840,9 +826,7 @@ mod tests { ); } - // ------------------------------------------------------------------------- - // Config Validation - // ------------------------------------------------------------------------- + // ---- Config validation ---- #[test] fn empty_routes_rejected() { diff --git a/filters/src/token_rate_limit/backend.rs b/filters/src/token_rate_limit/backend.rs new file mode 100644 index 0000000000..5dfd5cb244 --- /dev/null +++ b/filters/src/token_rate_limit/backend.rs @@ -0,0 +1,507 @@ +//! Pluggable token-rate-limit state backends. + +use std::{ + sync::{Arc, LazyLock, Mutex, OnceLock}, + time::Duration, +}; + +use async_trait::async_trait; +use redis::aio::ConnectionManager; +use sha2::{Digest as _, Sha256}; +use tokio::sync::mpsc; + +use super::ledger::{Budget, Decision, Ledger, Settlement}; + +#[derive(Debug, Clone)] +pub(crate) struct ReserveRequest { + pub(crate) key: String, + pub(crate) estimate: u64, + pub(crate) now_ms: u64, +} + +#[derive(Debug, Clone)] +pub(crate) struct ReconcileRequest { + pub(crate) key: String, + pub(crate) reservation_id: u64, + pub(crate) actual: Option, + pub(crate) estimate: u64, + pub(crate) now_ms: u64, +} + +#[derive(Debug, Clone)] +pub(crate) enum BackendReserve { + Admitted { reservation_id: u64, estimate: u64 }, + Denied { retry_after_ms: u64 }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BackendSettlement { + Applied { actual: u64, refund: u64, overage: u64 }, + Noop, +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum BackendError { + #[error("shared quota backend unavailable: {0}")] + Unavailable(String), + #[error("shared quota backend returned an invalid response")] + InvalidResponse, +} + +#[async_trait] +pub(crate) trait TokenRateLimitStateBackend: Send + Sync { + async fn reserve(&self, request: ReserveRequest) -> Result; + + async fn reconcile(&self, request: ReconcileRequest) -> Result; + + fn enqueue_reconcile(&self, request: ReconcileRequest) -> Result<(), BackendError>; + + fn limit(&self) -> u64; + + fn local_state(&self) -> Option<(&Ledger, u64)> { + None + } +} + +pub(crate) struct InMemoryTokenRateLimitBackend { + ledger: Arc, +} + +impl InMemoryTokenRateLimitBackend { + pub(crate) fn new(ledger: Ledger) -> Self { + Self { + ledger: Arc::new(ledger), + } + } +} + +#[async_trait] +impl TokenRateLimitStateBackend for InMemoryTokenRateLimitBackend { + async fn reserve(&self, request: ReserveRequest) -> Result { + Ok( + match self.ledger.reserve(&request.key, request.estimate, request.now_ms) { + Decision::Admitted(reservation) => BackendReserve::Admitted { + reservation_id: reservation.id, + estimate: reservation.estimate, + }, + Decision::Denied { retry_after_ms, .. } => BackendReserve::Denied { retry_after_ms }, + }, + ) + } + + async fn reconcile(&self, request: ReconcileRequest) -> Result { + Ok( + match self + .ledger + .reconcile(request.reservation_id, request.actual, request.now_ms) + { + Settlement::Applied { + actual, + refund, + overage, + } => BackendSettlement::Applied { + actual, + refund, + overage, + }, + Settlement::Noop => BackendSettlement::Noop, + }, + ) + } + + fn enqueue_reconcile(&self, request: ReconcileRequest) -> Result<(), BackendError> { + let _ = self + .ledger + .reconcile(request.reservation_id, request.actual, request.now_ms); + Ok(()) + } + + fn limit(&self) -> u64 { + self.ledger.limit() + } + + fn local_state(&self) -> Option<(&Ledger, u64)> { + Some((&self.ledger, 0)) + } +} + +const RESERVE_SCRIPT: &str = " +local now = redis.call('TIME') +local now_ms = tonumber(now[1]) * 1000 + math.floor(tonumber(now[2]) / 1000) +local timeout_ms = tonumber(ARGV[1]) +local max_keys = tonumber(ARGV[2]) +local max_active = tonumber(ARGV[3]) +local estimate = tonumber(ARGV[4]) +local budget_count = tonumber(ARGV[5]) +local settled = KEYS[2] +local active = KEYS[3] +local settled_index = KEYS[8] +local active_tokens_key = KEYS[9] +local bucket_ms = tonumber(ARGV[6 + (budget_count * 2)]) +local max_buckets = tonumber(ARGV[7 + (budget_count * 2)]) + +local active_total = tonumber(redis.call('GET', KEYS[5]) or '0') +local expired_global = redis.call('ZRANGE', KEYS[7], '-inf', now_ms, 'BYSCORE') +for i = 1, #expired_global do + local member = expired_global[i] + local split = string.find(member, '|') + if split then + local physical = string.sub(member, 1, split - 1) + local reservation = string.sub(member, split + 1) + local active_key = physical .. ':active' + local value = redis.call('HGET', active_key, reservation) + if value then + local value_split = string.find(value, '|') + local amount = tonumber(string.sub(value, 1, value_split - 1)) + local reserved_at = tonumber(string.sub(value, value_split + 1)) + local bucket = math.floor(reserved_at / bucket_ms) * bucket_ms + redis.call('HINCRBY', physical .. ':settled', bucket, amount) + redis.call('ZADD', physical .. ':settled-index', bucket, bucket) + redis.call('HDEL', active_key, reservation) + redis.call('DECRBY', physical .. ':active-tokens', amount) + active_total = math.max(0, active_total - 1) + end + end + redis.call('ZREM', KEYS[7], member) +end +redis.call('SET', KEYS[5], active_total) + +local max_window = 0 +for i = 1, budget_count do + local window = tonumber(ARGV[5 + (i * 2) - 1]) + if window > max_window then max_window = window end +end +local expired_buckets = redis.call('ZRANGEBYSCORE', settled_index, '-inf', now_ms - max_window) +for i = 1, #expired_buckets do + redis.call('HDEL', settled, expired_buckets[i]) + redis.call('ZREM', settled_index, expired_buckets[i]) +end +if redis.call('ZCARD', settled_index) > max_buckets then + return {0, max_window} +end + +redis.call('SET', KEYS[5], active_total) + +redis.call('ZREMRANGEBYSCORE', KEYS[4], '-inf', now_ms) +local key_exists = redis.call('ZSCORE', KEYS[4], KEYS[1]) ~= false +if not key_exists and redis.call('ZCARD', KEYS[4]) >= max_keys then + return {0, max_window} +end +if active_total >= max_active then + return {0, max_window} +end + +for i = 1, budget_count do + local window = tonumber(ARGV[5 + (i * 2) - 1]) + local capacity = tonumber(ARGV[5 + (i * 2)]) + local settled_sum = 0 + local buckets = redis.call('ZRANGEBYSCORE', settled_index, now_ms - window, '+inf') + for j = 1, #buckets do + settled_sum = settled_sum + tonumber(redis.call('HGET', settled, buckets[j]) or '0') + end + local active_sum = tonumber(redis.call('GET', active_tokens_key) or '0') + if settled_sum + active_sum + estimate > capacity then + return {0, max_window} + end +end + +local id = redis.call('INCR', KEYS[6]) +redis.call('HSET', active, id, estimate .. '|' .. now_ms) +redis.call('INCR', KEYS[5]) +redis.call('INCRBY', active_tokens_key, estimate) +redis.call('ZADD', KEYS[7], now_ms + timeout_ms, KEYS[1] .. '|' .. id) +local ttl = math.max(max_window + timeout_ms, 1000) +redis.call('ZADD', KEYS[4], now_ms + ttl, KEYS[1]) +redis.call('PEXPIRE', settled, ttl) +redis.call('PEXPIRE', active, ttl) +redis.call('PEXPIRE', settled_index, ttl) +redis.call('PEXPIRE', active_tokens_key, ttl) +redis.call('PEXPIRE', KEYS[1], ttl) +return {1, id, estimate} +"; + +const RECONCILE_SCRIPT: &str = " +local value = redis.call('HGET', KEYS[3], ARGV[1]) +if not value then return {0} end +local sep = string.find(value, '|') +local estimate = tonumber(string.sub(value, 1, sep - 1)) +local reserved_at = tonumber(string.sub(value, sep + 1)) +local actual = tonumber(ARGV[2]) +redis.call('HDEL', KEYS[3], ARGV[1]) +redis.call('DECRBY', KEYS[9], estimate) +local active_total = math.max(0, tonumber(redis.call('GET', KEYS[5]) or '0') - 1) +redis.call('SET', KEYS[5], active_total) +redis.call('ZREM', KEYS[7], KEYS[1] .. '|' .. ARGV[1]) +local bucket_ms = tonumber(ARGV[3]) +local bucket = math.floor(reserved_at / bucket_ms) * bucket_ms +redis.call('HINCRBY', KEYS[2], bucket, actual) +redis.call('ZADD', KEYS[8], bucket, bucket) +return {1, actual, math.max(0, estimate - actual), math.max(0, actual - estimate)} +"; + +static RESERVE_LUA: LazyLock = LazyLock::new(|| redis::Script::new(RESERVE_SCRIPT)); +static RECONCILE_LUA: LazyLock = LazyLock::new(|| redis::Script::new(RECONCILE_SCRIPT)); + +pub(crate) struct ValkeyTokenRateLimitBackend { + client: redis::Client, + connection_manager: tokio::sync::OnceCell, + namespace: String, + rule: String, + budgets: Vec, + reservation_timeout_ms: u64, + max_keys: usize, + max_active_reservations: usize, + limit: u64, + reconcile_tx: mpsc::Sender, + reconcile_rx: Mutex>>, + worker_started: OnceLock<()>, +} + +pub(crate) struct ValkeyBackendConfig { + pub(crate) url: String, + pub(crate) namespace: String, + pub(crate) rule: String, + pub(crate) budgets: Vec, + pub(crate) reservation_timeout_ms: u64, + pub(crate) max_keys: usize, + pub(crate) max_active_reservations: usize, +} + +impl ValkeyTokenRateLimitBackend { + pub(crate) fn new(config: ValkeyBackendConfig) -> Result { + let client = redis::Client::open(config.url).map_err(|e| BackendError::Unavailable(e.to_string()))?; + let limit = config.budgets.iter().map(|budget| budget.capacity).min().unwrap_or(0); + let (reconcile_tx, reconcile_rx) = mpsc::channel(1024); + let worker_backend = Self { + client, + connection_manager: tokio::sync::OnceCell::const_new(), + namespace: config.namespace, + rule: config.rule, + budgets: config.budgets, + reservation_timeout_ms: config.reservation_timeout_ms, + max_keys: config.max_keys, + max_active_reservations: config.max_active_reservations, + limit, + reconcile_tx: reconcile_tx.clone(), + reconcile_rx: Mutex::new(Some(reconcile_rx)), + worker_started: OnceLock::new(), + }; + Ok(worker_backend) + } + + fn clone_without_sender(&self) -> Self { + let (tx, _rx) = mpsc::channel(1); + Self { + client: self.client.clone(), + connection_manager: tokio::sync::OnceCell::const_new(), + namespace: self.namespace.clone(), + rule: self.rule.clone(), + budgets: self.budgets.clone(), + reservation_timeout_ms: self.reservation_timeout_ms, + max_keys: self.max_keys, + max_active_reservations: self.max_active_reservations, + limit: self.limit, + reconcile_tx: tx, + reconcile_rx: Mutex::new(None), + worker_started: OnceLock::new(), + } + } + + fn start_worker(&self) -> Result<(), BackendError> { + let runtime = tokio::runtime::Handle::try_current() + .map_err(|_error| BackendError::Unavailable("Valkey reconciliation requires a Tokio runtime".into()))?; + self.worker_started.get_or_init(|| { + let Some(mut receiver) = self.reconcile_rx.lock().ok().and_then(|mut guard| guard.take()) else { + return; + }; + let worker = self.clone_without_sender(); + runtime.spawn(async move { + while let Some(request) = receiver.recv().await { + let mut attempts = 0; + loop { + match worker.reconcile(request.clone()).await { + Ok(_) => { + metrics::counter!("praxis_ai_token_rate_limit_backend_reconciliation_total", "backend" => "valkey", "result" => "completed").increment(1); + break; + }, + Err(error) if attempts < 2 => { + attempts += 1; + tracing::warn!(attempts, %error, "token-rate-limit reconciliation retry"); + tokio::time::sleep(Duration::from_millis(25 * attempts)).await; + }, + Err(error) => { + metrics::counter!("praxis_ai_token_rate_limit_backend_errors_total", "backend" => "valkey", "operation" => "reconcile").increment(1); + tracing::error!(%error, "token-rate-limit reconciliation abandoned after retries"); + break; + }, + } + } + } + }); + }); + Ok(()) + } + + fn key_parts(&self, key: &str) -> [String; 9] { + let mut rule_digest = Sha256::new(); + rule_digest.update(self.namespace.as_bytes()); + rule_digest.update([0]); + rule_digest.update(self.rule.as_bytes()); + let rule_hash = rule_digest + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let rule_prefix = format!("{}:v1:rule:{}", self.namespace, rule_hash); + let mut digest = Sha256::new(); + digest.update(self.namespace.as_bytes()); + digest.update([0]); + digest.update(self.rule.as_bytes()); + digest.update([0]); + digest.update(key.as_bytes()); + let hash = digest + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let prefix = format!("{rule_prefix}:key:{hash}"); + [ + prefix.clone(), + format!("{prefix}:settled"), + format!("{prefix}:active"), + format!("{rule_prefix}:keys"), + format!("{rule_prefix}:active-count"), + format!("{rule_prefix}:reservation-seq"), + format!("{rule_prefix}:active-index"), + format!("{rule_prefix}:settled-index"), + format!("{rule_prefix}:active-tokens"), + ] + } + + async fn connection(&self) -> Result { + let manager = self + .connection_manager + .get_or_try_init(|| async { + tokio::time::timeout(Duration::from_millis(500), self.client.get_connection_manager()) + .await + .map_err(|_error| BackendError::Unavailable("Valkey connection timed out".into()))? + .map_err(|e| BackendError::Unavailable(e.to_string())) + }) + .await?; + Ok(manager.clone()) + } +} + +#[async_trait] +impl TokenRateLimitStateBackend for ValkeyTokenRateLimitBackend { + async fn reserve(&self, request: ReserveRequest) -> Result { + let keys = self.key_parts(&request.key); + let mut args: Vec = vec![ + self.reservation_timeout_ms.to_string(), + self.max_keys.to_string(), + self.max_active_reservations.to_string(), + request.estimate.to_string(), + self.budgets.len().to_string(), + ]; + for budget in &self.budgets { + args.push(budget.window_ms.to_string()); + args.push(budget.capacity.to_string()); + } + args.push("1000".into()); + args.push("4096".into()); + let mut invocation = RESERVE_LUA.key(&keys[0]); + for key in keys.iter().skip(1) { + invocation.key(key); + } + for arg in args { + invocation.arg(arg); + } + let mut connection = self.connection().await?; + let response: Vec = + tokio::time::timeout(Duration::from_millis(500), invocation.invoke_async(&mut connection)) + .await + .map_err(|_error| BackendError::Unavailable("Valkey reservation timed out".into()))? + .map_err(|e| BackendError::Unavailable(e.to_string()))?; + match response.as_slice() { + [1, id, estimate] => Ok(BackendReserve::Admitted { + reservation_id: u64::try_from(*id).map_err(|_error| BackendError::InvalidResponse)?, + estimate: u64::try_from(*estimate).map_err(|_error| BackendError::InvalidResponse)?, + }), + [0, retry_after] => Ok(BackendReserve::Denied { + retry_after_ms: u64::try_from(*retry_after).map_err(|_error| BackendError::InvalidResponse)?, + }), + _ => Err(BackendError::InvalidResponse), + } + } + + async fn reconcile(&self, request: ReconcileRequest) -> Result { + let keys = self.key_parts(&request.key); + let actual = request.actual.unwrap_or(request.estimate); + let mut invocation = RECONCILE_LUA.key(&keys[0]); + for key in keys.iter().skip(1) { + invocation.key(key); + } + invocation.arg(request.reservation_id).arg(actual).arg(1000_u64); + let mut connection = self.connection().await?; + let response: Vec = + tokio::time::timeout(Duration::from_millis(500), invocation.invoke_async(&mut connection)) + .await + .map_err(|_error| BackendError::Unavailable("Valkey reconciliation timed out".into()))? + .map_err(|e| BackendError::Unavailable(e.to_string()))?; + match response.as_slice() { + [0] => Ok(BackendSettlement::Noop), + [1, actual, refund, overage] => Ok(BackendSettlement::Applied { + actual: u64::try_from(*actual).map_err(|_error| BackendError::InvalidResponse)?, + refund: u64::try_from(*refund).map_err(|_error| BackendError::InvalidResponse)?, + overage: u64::try_from(*overage).map_err(|_error| BackendError::InvalidResponse)?, + }), + _ => Err(BackendError::InvalidResponse), + } + } + + fn enqueue_reconcile(&self, request: ReconcileRequest) -> Result<(), BackendError> { + self.start_worker()?; + self.reconcile_tx + .try_send(request) + .map_err(|error| BackendError::Unavailable(format!("reconciliation queue is full or stopped: {error}"))) + } + + fn limit(&self) -> u64 { + self.limit + } +} + +#[cfg(test)] +mod tests { + use super::{super::ledger::Budget, *}; + + #[test] + #[expect(clippy::unwrap_used, reason = "the test supplies a valid local Redis URL")] + fn key_parts_are_scoped_and_hash_quota_keys() { + let backend = ValkeyTokenRateLimitBackend::new(ValkeyBackendConfig { + url: "redis://127.0.0.1:6379".into(), + namespace: "praxis-test".into(), + rule: "default".into(), + budgets: vec![ + Budget { + window_ms: 60_000, + capacity: 100, + }, + Budget { + window_ms: 3_600_000, + capacity: 1_000, + }, + ], + reservation_timeout_ms: 120_000, + max_keys: 100, + max_active_reservations: 100, + }) + .unwrap(); + let parts = backend.key_parts("alice/model-a"); + + assert_eq!(parts.len(), 9); + assert_eq!(backend.limit(), 100); + assert!(parts.iter().all(|part| part.starts_with("praxis-test:"))); + assert!(parts.iter().all(|part| !part.contains("alice/model-a"))); + } +} diff --git a/filters/src/token_rate_limit/ledger.rs b/filters/src/token_rate_limit/ledger.rs new file mode 100644 index 0000000000..d7b5b5697c --- /dev/null +++ b/filters/src/token_rate_limit/ledger.rs @@ -0,0 +1,630 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Exact local sliding-window reservation ledger. + +#![allow( + missing_docs, + clippy::missing_docs_in_private_items, + clippy::too_many_lines, + reason = "private ledger implementation is covered by its public filter contract and focused tests" +)] + +use std::{ + collections::{HashMap, VecDeque}, + sync::{ + Arc, Mutex, + atomic::{AtomicU64, AtomicUsize, Ordering}, + }, +}; + +use dashmap::DashMap; + +/// A positive rolling-window budget. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct Budget { + /// Window length in milliseconds. + pub(super) window_ms: u64, + /// Maximum settled plus active tokens in the window. + pub(super) capacity: u64, +} + +/// Bounds and timing for a ledger. +#[derive(Clone, Debug)] +pub(super) struct LedgerConfig { + /// All budgets in one atomic reservation rule. + pub(super) budgets: Vec, + /// Time after which an ambiguous reservation is charged at its estimate. + pub(super) reservation_timeout_ms: u64, + /// Maximum logical keys retained by the ledger. + pub(super) max_keys: usize, + /// Maximum key length retained by the ledger. + pub(super) max_key_length: usize, + /// Maximum active reservations retained by the ledger. + pub(super) max_active_reservations: usize, +} + +impl LedgerConfig { + /// Validate configuration before constructing a ledger. + pub(super) fn validate(&self) -> Result<(), String> { + if self.budgets.is_empty() { + return Err("at least one budget is required".into()); + } + if self.budgets.iter().any(|b| b.window_ms == 0 || b.capacity == 0) { + return Err("budget window and capacity must be positive".into()); + } + if self.budgets.windows(2).any(|w| { + w.first() + .zip(w.get(1)) + .is_some_and(|(left, right)| left.window_ms == right.window_ms) + }) { + return Err("budget windows must be unique".into()); + } + if self.reservation_timeout_ms == 0 { + return Err("reservation timeout must be positive".into()); + } + if self.max_keys == 0 || self.max_key_length == 0 || self.max_active_reservations == 0 { + return Err("ledger bounds must be positive".into()); + } + Ok(()) + } +} + +/// A reservation admitted atomically across every configured budget. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct Reservation { + /// Opaque identifier used for idempotent reconciliation. + pub(super) id: u64, + /// Estimated token cost reserved at admission. + pub(super) estimate: u64, + /// Monotonic timestamp at admission, in milliseconds. + pub(super) created_at_ms: u64, +} + +/// Result of attempting admission. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum Decision { + /// Request may proceed with this reservation. + Admitted(Reservation), + /// Request must be rejected before routing. + Denied { + /// Conservative delay before another admission attempt. + retry_after_ms: u64, + /// Bounded reason used for operational counters. + reason: DenialReason, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum DenialReason { + InvalidKey, + KeyCapacity, + WindowCapacity, + ReservationCapacity, +} + +/// Result of reconciling a reservation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum Settlement { + /// Actual usage was applied exactly once. + Applied { + /// Actual tokens charged to the rolling ledger. + actual: u64, + /// Estimate returned to the ledger. + refund: u64, + /// Usage above the estimate. + overage: u64, + }, + /// The reservation was already reconciled or conservatively expired. + Noop, +} + +#[derive(Debug)] +struct Usage { + at_ms: u64, + tokens: u64, +} + +#[derive(Debug)] +struct ActiveReservation { + estimate: u64, + created_at_ms: u64, +} + +#[derive(Debug, Default)] +struct KeyState { + settled: VecDeque, + active: HashMap, +} + +impl KeyState { + fn reap(&mut self, now_ms: u64, config: &LedgerConfig) -> Vec { + let expired: Vec = self + .active + .iter() + .filter_map(|(id, reservation)| { + (now_ms.saturating_sub(reservation.created_at_ms) >= config.reservation_timeout_ms).then_some(*id) + }) + .collect(); + + for id in &expired { + if let Some(reservation) = self.active.remove(id) { + // An ambiguous request is never free traffic. Charge the + // estimate at admission time so the normal window expiry + // rules still apply. + self.settled.push_back(Usage { + at_ms: reservation.created_at_ms, + tokens: reservation.estimate, + }); + } + } + + let max_window = config.budgets.iter().map(|b| b.window_ms).max().unwrap_or(0); + while self + .settled + .front() + .is_some_and(|entry| now_ms.saturating_sub(entry.at_ms) >= max_window) + { + self.settled.pop_front(); + } + expired + } + + fn usage_in_window(&self, now_ms: u64, window_ms: u64) -> u64 { + let settled = self + .settled + .iter() + .filter(|entry| now_ms.saturating_sub(entry.at_ms) < window_ms) + .fold(0_u64, |sum, entry| sum.saturating_add(entry.tokens)); + let active = self + .active + .values() + .fold(0_u64, |sum, reservation| sum.saturating_add(reservation.estimate)); + settled.saturating_add(active) + } + + fn retry_after_ms(&self, now_ms: u64, config: &LedgerConfig) -> u64 { + config + .budgets + .iter() + .flat_map(|budget| { + self.settled.iter().filter_map(move |entry| { + let expiry = entry.at_ms.saturating_add(budget.window_ms); + (expiry > now_ms).then_some(expiry - now_ms) + }) + }) + .max() + .unwrap_or(config.reservation_timeout_ms) + .max(config.reservation_timeout_ms) + } + + fn is_empty(&self) -> bool { + self.active.is_empty() && self.settled.is_empty() + } +} + +/// Thread-safe exact local ledger with independent locks per key. +pub(super) struct Ledger { + config: LedgerConfig, + keys: DashMap>>, + reservations: DashMap, + next_id: AtomicU64, + key_count: AtomicUsize, + active_reservations: AtomicUsize, +} + +impl Ledger { + /// Construct a validated empty ledger. + pub(super) fn new(config: LedgerConfig) -> Result { + config.validate()?; + Ok(Self { + config, + keys: DashMap::new(), + reservations: DashMap::new(), + next_id: AtomicU64::new(1), + key_count: AtomicUsize::new(0), + active_reservations: AtomicUsize::new(0), + }) + } + + /// Return the smallest configured capacity for bounded quota headers. + pub(super) fn limit(&self) -> u64 { + self.config + .budgets + .iter() + .map(|budget| budget.capacity) + .min() + .unwrap_or(0) + } + + /// Current number of active reservations. + pub(super) fn active_count(&self) -> usize { + self.active_reservations.load(Ordering::Relaxed) + } + + /// Current number of retained logical keys. + pub(super) fn key_count(&self) -> usize { + self.key_count.load(Ordering::Relaxed) + } + + /// Reserve an estimate atomically across all configured windows. + pub(super) fn reserve(&self, key: &str, estimate: u64, now_ms: u64) -> Decision { + if key.is_empty() || key.len() > self.config.max_key_length || estimate == 0 { + return Decision::Denied { + retry_after_ms: 0, + reason: DenialReason::InvalidKey, + }; + } + + let state = match self.keys.entry(key.to_owned()) { + dashmap::mapref::entry::Entry::Occupied(entry) => Arc::clone(entry.get()), + dashmap::mapref::entry::Entry::Vacant(entry) => { + if self + .key_count + .fetch_update(Ordering::AcqRel, Ordering::Relaxed, |count| { + (count < self.config.max_keys).then_some(count + 1) + }) + .is_err() + { + return Decision::Denied { + retry_after_ms: 0, + reason: DenialReason::KeyCapacity, + }; + } + let state = Arc::new(Mutex::new(KeyState::default())); + entry.insert(Arc::clone(&state)); + state + }, + }; + let mut state = match state.lock() { + Ok(state) => state, + Err(poisoned) => poisoned.into_inner(), + }; + let expired = state.reap(now_ms, &self.config); + for id in &expired { + self.reservations.remove(id); + } + self.active_reservations.fetch_sub(expired.len(), Ordering::Relaxed); + + if self + .config + .budgets + .iter() + .any(|budget| state.usage_in_window(now_ms, budget.window_ms).saturating_add(estimate) > budget.capacity) + { + return Decision::Denied { + retry_after_ms: state.retry_after_ms(now_ms, &self.config), + reason: DenialReason::WindowCapacity, + }; + } + if self + .active_reservations + .fetch_update(Ordering::AcqRel, Ordering::Relaxed, |active| { + (active < self.config.max_active_reservations).then_some(active + 1) + }) + .is_err() + { + return Decision::Denied { + retry_after_ms: self.config.reservation_timeout_ms, + reason: DenialReason::ReservationCapacity, + }; + } + + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + state.active.insert( + id, + ActiveReservation { + estimate, + created_at_ms: now_ms, + }, + ); + self.reservations.insert(id, key.to_owned()); + drop(state); + Decision::Admitted(Reservation { + id, + estimate, + created_at_ms: now_ms, + }) + } + + /// Reconcile actual usage. Repeated calls for one ID are no-ops. + pub(super) fn reconcile(&self, id: u64, actual: Option, now_ms: u64) -> Settlement { + let Some((_, key)) = self.reservations.remove(&id) else { + return Settlement::Noop; + }; + let Some(state) = self.keys.get(&key).map(|entry| Arc::clone(entry.value())) else { + return Settlement::Noop; + }; + let mut state = match state.lock() { + Ok(state) => state, + Err(poisoned) => poisoned.into_inner(), + }; + let Some(reservation) = state.active.remove(&id) else { + return Settlement::Noop; + }; + self.active_reservations.fetch_sub(1, Ordering::Relaxed); + let actual = actual.unwrap_or(reservation.estimate); + state.settled.push_back(Usage { + at_ms: reservation.created_at_ms, + tokens: actual, + }); + let refund = reservation.estimate.saturating_sub(actual); + let overage = actual.saturating_sub(reservation.estimate); + let expired = state.reap(now_ms, &self.config); + for expired_id in expired { + self.reservations.remove(&expired_id); + self.active_reservations.fetch_sub(1, Ordering::Relaxed); + } + drop(state); + Settlement::Applied { + actual, + refund, + overage, + } + } + + /// Conservatively expire a bounded number of keys and reclaim idle state. + pub(super) fn cleanup(&self, now_ms: u64, max_keys_to_scan: usize) -> usize { + let mut orphaned = 0; + let keys: Vec = self + .keys + .iter() + .take(max_keys_to_scan) + .map(|entry| entry.key().clone()) + .collect(); + for key in keys { + let Some(entry) = self.keys.get_mut(&key) else { + continue; + }; + let state_arc = Arc::clone(entry.value()); + let mut state = match state_arc.lock() { + Ok(state) => state, + Err(poisoned) => poisoned.into_inner(), + }; + let expired = state.reap(now_ms, &self.config); + orphaned += expired.len(); + for id in &expired { + self.reservations.remove(id); + } + self.active_reservations.fetch_sub(expired.len(), Ordering::Relaxed); + let empty = state.is_empty(); + drop(state); + drop(entry); + if empty + && self + .keys + .remove_if(&key, |_, candidate| { + Arc::ptr_eq(candidate, &state_arc) + && match candidate.lock() { + Ok(state) => state.is_empty(), + Err(poisoned) => poisoned.into_inner().is_empty(), + } + }) + .is_some() + { + self.key_count.fetch_sub(1, Ordering::Relaxed); + } + } + orphaned + } +} + +#[cfg(test)] +#[expect(clippy::allow_attributes, reason = "blanket test suppressions")] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::panic, + clippy::manual_let_else, + clippy::match_wildcard_for_single_variants, + reason = "ledger tests intentionally fail fast on impossible fixture states" +)] +mod tests { + use std::sync::{Arc, Barrier}; + + use super::*; + + fn ledger(budgets: &[(u64, u64)]) -> Ledger { + Ledger::new(LedgerConfig { + budgets: budgets + .iter() + .map(|&(window_ms, capacity)| Budget { window_ms, capacity }) + .collect(), + reservation_timeout_ms: 100, + max_keys: 8, + max_key_length: 256, + max_active_reservations: 32, + }) + .unwrap() + } + + #[test] + fn admits_and_denies_one_window() { + let l = ledger(&[(60_000, 10)]); + assert!(matches!(l.reserve("a", 10, 0), Decision::Admitted(_))); + assert!(matches!(l.reserve("a", 1, 0), Decision::Denied { .. })); + } + + #[test] + fn concurrent_same_key_admission_cannot_oversubscribe() { + let ledger = Arc::new(ledger(&[(1_000, 100)])); + let barrier = Arc::new(Barrier::new(16)); + let handles = (0..16) + .map(|_| { + let ledger = Arc::clone(&ledger); + let barrier = Arc::clone(&barrier); + std::thread::spawn(move || { + barrier.wait(); + matches!(ledger.reserve("same", 10, 0), Decision::Admitted(_)) + }) + }) + .collect::>(); + let admitted = handles + .into_iter() + .filter_map(|handle| handle.join().ok()) + .filter(|ok| *ok) + .count(); + assert_eq!(admitted, 10, "exactly the capacity should be admitted"); + } + + #[test] + fn concurrent_different_keys_respect_global_reservation_bound() { + let ledger = Arc::new( + Ledger::new(LedgerConfig { + budgets: vec![Budget { + window_ms: 1_000, + capacity: 1_000, + }], + reservation_timeout_ms: 100, + max_keys: 32, + max_key_length: 256, + max_active_reservations: 4, + }) + .unwrap(), + ); + let barrier = Arc::new(Barrier::new(16)); + let handles = (0..16) + .map(|index| { + let ledger = Arc::clone(&ledger); + let barrier = Arc::clone(&barrier); + std::thread::spawn(move || { + barrier.wait(); + matches!(ledger.reserve(&format!("key-{index}"), 1, 0), Decision::Admitted(_)) + }) + }) + .collect::>(); + let admitted = handles + .into_iter() + .filter_map(|handle| handle.join().ok()) + .filter(|ok| *ok) + .count(); + assert_eq!(admitted, 4, "the global reservation bound must be atomic"); + assert_eq!(ledger.active_count(), 4); + } + + #[test] + fn exact_boundary_expires_usage() { + let l = ledger(&[(100, 10)]); + let r = match l.reserve("a", 10, 0) { + Decision::Admitted(r) => r, + _ => panic!(), + }; + assert!(matches!(l.reconcile(r.id, Some(10), 0), Settlement::Applied { .. })); + assert!(matches!(l.reserve("a", 10, 100), Decision::Admitted(_))); + } + + #[test] + fn multiple_windows_are_atomic() { + let l = ledger(&[(100, 10), (1_000, 15)]); + let r = match l.reserve("a", 10, 0) { + Decision::Admitted(r) => r, + _ => panic!(), + }; + l.reconcile(r.id, Some(10), 0); + assert!(matches!(l.reserve("a", 6, 0), Decision::Denied { .. })); + assert!(matches!(l.reserve("b", 6, 0), Decision::Admitted(_))); + } + + #[test] + fn active_reservations_count_and_orphans_are_charged() { + let l = ledger(&[(1_000, 10)]); + assert!(matches!(l.reserve("a", 10, 0), Decision::Admitted(_))); + assert_eq!(l.active_count(), 1); + assert!(matches!(l.reserve("a", 1, 0), Decision::Denied { .. })); + l.cleanup(100, 8); + assert_eq!(l.active_count(), 0); + assert!(matches!(l.reserve("a", 1, 100), Decision::Denied { .. })); + } + + #[test] + fn idle_settled_keys_are_reclaimed_atomically() { + let l = ledger(&[(100, 10)]); + let r = match l.reserve("a", 10, 0) { + Decision::Admitted(r) => r, + _ => panic!(), + }; + assert!(matches!(l.reconcile(r.id, Some(10), 0), Settlement::Applied { .. })); + assert_eq!(l.key_count(), 1); + l.cleanup(100, 8); + assert_eq!(l.key_count(), 0); + } + + #[test] + fn refund_exact_and_overage_are_recorded() { + let l = ledger(&[(1_000, 100)]); + let r = match l.reserve("a", 50, 0) { + Decision::Admitted(r) => r, + _ => panic!(), + }; + assert_eq!( + l.reconcile(r.id, Some(20), 0), + Settlement::Applied { + actual: 20, + refund: 30, + overage: 0 + } + ); + let r = match l.reserve("a", 50, 0) { + Decision::Admitted(r) => r, + _ => panic!(), + }; + assert_eq!( + l.reconcile(r.id, Some(70), 0), + Settlement::Applied { + actual: 70, + refund: 0, + overage: 20 + } + ); + } + + #[test] + fn duplicate_reconciliation_is_noop() { + let l = ledger(&[(1_000, 100)]); + let r = match l.reserve("a", 5, 0) { + Decision::Admitted(r) => r, + _ => panic!(), + }; + assert!(matches!( + l.reconcile(r.id, None, 0), + Settlement::Applied { actual: 5, .. } + )); + assert_eq!(l.reconcile(r.id, Some(99), 0), Settlement::Noop); + } + + #[test] + fn keys_are_independent_and_bounded() { + let l = ledger(&[(1_000, 10)]); + assert!(matches!(l.reserve("a", 10, 0), Decision::Admitted(_))); + assert!(matches!(l.reserve("b", 10, 0), Decision::Admitted(_))); + assert!(matches!(l.reserve("", 1, 0), Decision::Denied { .. })); + } + + #[test] + fn invalid_config_is_rejected() { + assert!( + Ledger::new(LedgerConfig { + budgets: vec![], + reservation_timeout_ms: 1, + max_keys: 1, + max_key_length: 1, + max_active_reservations: 1 + }) + .is_err() + ); + assert!( + Ledger::new(LedgerConfig { + budgets: vec![Budget { + window_ms: 0, + capacity: 1 + }], + reservation_timeout_ms: 1, + max_keys: 1, + max_key_length: 1, + max_active_reservations: 1 + }) + .is_err() + ); + } +} diff --git a/filters/src/token_rate_limit/mod.rs b/filters/src/token_rate_limit/mod.rs new file mode 100644 index 0000000000..845f968538 --- /dev/null +++ b/filters/src/token_rate_limit/mod.rs @@ -0,0 +1,704 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Token quota admission with local or shared sliding-window accounting. +//! +//! The minimum POC uses trusted principal metadata plus a bounded, configured +//! model allowlist from the `X-Model` request header as the local quota key. +//! +//! ```yaml +//! key: +//! principal: +//! source: metadata +//! name: identity.user_id +//! onMissing: reject +//! model: +//! source: header +//! name: x-model +//! onMissing: reject +//! allowedModels: [model-a, model-b] +//! reservationTimeout: 2m +//! limits: +//! maxKeys: 10000 +//! maxKeyLength: 256 +//! maxActiveReservations: 50000 +//! rules: +//! - name: default +//! estimation: { strategy: fixed, tokens: 500 } +//! token_budgets: +//! - { window: 1m, capacity: 3000 } +//! backend: +//! kind: valkey +//! url: ${TOKEN_RATE_LIMIT_VALKEY_URL} +//! namespace: praxis:token-rate-limit +//! ``` +//! +//! Put this filter after authentication and before `intelligent_route`, with +//! `token_count` before it in the response-body lifecycle. Admission reserves +//! the fixed estimate atomically across all configured windows. Successful +//! responses remain reserved until the terminal body callback publishes +//! `token.total`; failures and missing usage are charged conservatively at the +//! estimate. Reconciliation is idempotent. +//! +//! The memory backend is process-local. The Valkey backend shares the same +//! rule-scoped quota across gateway processes, uses a reconnecting connection +//! manager, cached Lua scripts, one-second accounting buckets, and fails +//! closed when Valkey is unavailable. Valkey URLs may use `rediss://`; deploy +//! the matching Rustls trust roots and verify the server certificate. A +//! private network or password alone does not encrypt credentials in transit. +//! +//! `maxKeys`, `maxKeyLength`, and `maxActiveReservations` are per configured +//! rule for both backends. The final principal-plus-model key is validated +//! before state allocation. Window sizes are bounded to 4,096 one-second +//! buckets so the shared script has explicit, bounded work and cardinality. + +#![allow( + missing_docs, + clippy::missing_docs_in_private_items, + clippy::too_many_lines, + clippy::multiple_inherent_impl, + reason = "private configuration schema is covered by the public filter contract and focused tests" +)] + +#[cfg(test)] +#[expect(clippy::allow_attributes, reason = "blanket test suppressions")] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, reason = "tests")] +mod tests; + +mod backend; +mod ledger; + +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, + time::Instant, +}; + +use async_trait::async_trait; +use http::header::HeaderName; +use metrics::{counter, gauge}; +use praxis_filter::{ + BodyAccess, FilterAction, FilterError, HttpFilter, HttpFilterContext, Rejection, parse_filter_config, +}; +use serde::Deserialize; + +use self::{ + backend::{ + BackendReserve, BackendSettlement, InMemoryTokenRateLimitBackend, ReconcileRequest, ReserveRequest, + TokenRateLimitStateBackend, ValkeyBackendConfig, ValkeyTokenRateLimitBackend, + }, + ledger::{Budget, Ledger, LedgerConfig}, +}; + +const META_RESERVATION_ID: &str = "token_rate_limit.reservation_id"; +const META_ACTIVE: &str = "token_rate_limit.active"; +const META_KEY: &str = "token_rate_limit.key"; +const META_TOKEN_TOTAL: &str = "token.total"; +const MAX_RULES: usize = 32; +const MAX_BUDGETS: usize = 8; +const MAX_DURATION_MS: u64 = 7 * 24 * 60 * 60 * 1000; +const BUCKET_MS: u64 = 1_000; +const MAX_BUCKETS_PER_WINDOW: u64 = 4_096; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct TokenRateLimitConfig { + key: KeyConfig, + #[serde(rename = "reservationTimeout", alias = "reservation_timeout")] + reservation_timeout: String, + limits: LimitsConfig, + rules: Vec, + #[serde(default)] + backend: BackendConfig, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct BackendConfig { + #[serde(default)] + kind: BackendKind, + #[serde(default)] + url: Option, + #[serde(default)] + namespace: Option, +} + +#[derive(Debug, Default, Clone, Copy, Deserialize)] +#[serde(rename_all = "lowercase")] +enum BackendKind { + #[default] + Memory, + Valkey, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct KeyConfig { + principal: PrincipalKeyConfig, + model: ModelKeyConfig, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PrincipalKeyConfig { + source: KeySource, + name: String, + #[serde(rename = "onMissing", alias = "on_missing")] + on_missing: MissingKeyAction, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ModelKeyConfig { + source: ModelKeySource, + name: String, + #[serde(rename = "onMissing", alias = "on_missing")] + on_missing: MissingKeyAction, + #[serde(rename = "allowedModels", alias = "allowed_models")] + allowed_models: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +enum KeySource { + Metadata, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +enum ModelKeySource { + Header, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +enum MissingKeyAction { + Reject, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct EstimationConfig { + strategy: EstimationStrategy, + tokens: u64, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +enum EstimationStrategy { + Fixed, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct LimitsConfig { + #[serde(rename = "maxKeys", alias = "max_keys")] + max_keys: usize, + #[serde(rename = "maxKeyLength", alias = "max_key_length")] + max_key_length: usize, + #[serde(rename = "maxActiveReservations", alias = "max_active_reservations")] + max_active_reservations: usize, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RuleConfig { + name: String, + #[serde(rename = "match")] + rule_match: Option, + estimation: EstimationConfig, + #[serde(rename = "token_budgets", alias = "budgets")] + token_budgets: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RuleMatchConfig { + metadata: HashMap, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct BudgetConfig { + window: String, + capacity: u64, +} + +struct RuleRuntime { + match_value: Option, + backend: Arc, + estimate: u64, +} + +/// Token quota admission with local or shared sliding-window accounting. +/// +/// Place this filter after authentication and before `intelligent_route`. +/// `token_count` must publish `token.total` before the terminal response-body +/// callback so successful reservations reconcile actual usage. Failures and +/// missing usage are charged conservatively at the fixed estimate. +/// +/// The memory backend is process-local. Valkey shares one rule-scoped quota +/// across gateway processes, uses a reconnecting connection manager and +/// cached Lua scripts, and fails closed when unavailable. Use `rediss://` with +/// Rustls trust roots when credentials must be protected in transit. +/// +/// The configured key and active-reservation limits are per rule for both +/// backends. The final principal/model key is validated before allocation. +/// Windows are represented with bounded one-second buckets; a window may use +/// at most 4,096 buckets. +pub struct TokenRateLimitFilter { + principal_key_name: String, + model_header: HeaderName, + allowed_models: HashSet, + max_key_length: usize, + rules: Vec, + epoch: Instant, +} + +impl TokenRateLimitFilter { + fn from_config_inner(config: &serde_yaml::Value) -> Result { + let cfg: TokenRateLimitConfig = parse_filter_config("token_rate_limit", config)?; + if cfg.key.principal.name.is_empty() || cfg.key.principal.name.len() > 64 { + return Err("token_rate_limit: key.principal.name must be 1-64 bytes".into()); + } + if !matches!(cfg.key.principal.source, KeySource::Metadata) + || !matches!(cfg.key.principal.on_missing, MissingKeyAction::Reject) + || !matches!(cfg.key.model.source, ModelKeySource::Header) + || !matches!(cfg.key.model.on_missing, MissingKeyAction::Reject) + { + return Err("token_rate_limit: only metadata/header with reject keying is supported".into()); + } + let model_header = HeaderName::try_from(cfg.key.model.name.as_str()) + .map_err(|error| format!("token_rate_limit: invalid key.model.name: {error}"))?; + let allowed_model_count = cfg.key.model.allowed_models.len(); + if allowed_model_count == 0 || allowed_model_count > MAX_RULES { + return Err("token_rate_limit: key.model.allowedModels must contain 1-32 models".into()); + } + let allowed_models = cfg.key.model.allowed_models.into_iter().collect::>(); + if allowed_models.len() != allowed_model_count + || allowed_models.iter().any(|model| model.is_empty() || model.len() > 256) + { + return Err("token_rate_limit: key.model.allowedModels must contain unique 1-256 byte models".into()); + } + if cfg.rules.is_empty() || cfg.rules.len() > MAX_RULES { + return Err(format!("token_rate_limit: rules must contain 1-{MAX_RULES} entries").into()); + } + if cfg.limits.max_key_length == 0 || cfg.limits.max_key_length > 256 { + return Err("token_rate_limit: limits.max_key_length must be 1-256".into()); + } + let mut names = HashSet::new(); + let mut match_values = HashSet::new(); + let timeout_ms = parse_duration_ms(&cfg.reservation_timeout)?; + let mut rules = Vec::with_capacity(cfg.rules.len()); + let rule_count = cfg.rules.len(); + for (rule_index, rule) in cfg.rules.into_iter().enumerate() { + if rule.name.is_empty() || !names.insert(rule.name.clone()) { + return Err("token_rate_limit: rule names must be non-empty and unique".into()); + } + if !matches!(rule.estimation.strategy, EstimationStrategy::Fixed) || rule.estimation.tokens == 0 { + return Err(format!( + "token_rate_limit: rule '{}' must use fixed positive estimation", + rule.name + ) + .into()); + } + if rule.token_budgets.is_empty() || rule.token_budgets.len() > MAX_BUDGETS { + return Err(format!("token_rate_limit: each rule must contain 1-{MAX_BUDGETS} budgets").into()); + } + let match_value = match rule.rule_match { + None => { + if rule_index + 1 != rule_count { + return Err("token_rate_limit: the default rule must be last".into()); + } + None + }, + Some(rule_match) => { + if rule_match.metadata.len() != 1 { + return Err("token_rate_limit: each rule match must contain exactly one metadata key".into()); + } + let Some(value) = rule_match.metadata.get(&cfg.key.principal.name) else { + return Err("token_rate_limit: rule match must use the configured principal metadata".into()); + }; + if value.is_empty() + || value.len() > cfg.limits.max_key_length + || !match_values.insert(value.clone()) + { + return Err("token_rate_limit: rule match values must be non-empty and unique".into()); + } + Some(value.clone()) + }, + }; + let budgets = rule + .token_budgets + .iter() + .map(parse_budget) + .collect::, _>>()?; + let backend_budgets = budgets.clone(); + let ledger = Ledger::new(LedgerConfig { + budgets, + reservation_timeout_ms: timeout_ms, + max_keys: cfg.limits.max_keys, + max_key_length: cfg.limits.max_key_length, + max_active_reservations: cfg.limits.max_active_reservations, + }) + .map_err(|e| format!("token_rate_limit: {e}"))?; + let backend: Arc = match cfg.backend.kind { + BackendKind::Memory => Arc::new(InMemoryTokenRateLimitBackend::new(ledger)), + BackendKind::Valkey => { + let url = cfg + .backend + .url + .as_deref() + .ok_or("token_rate_limit: backend.url is required for Valkey")?; + let url = expand_backend_url(url)?; + let namespace = cfg.backend.namespace.as_deref().unwrap_or("praxis:trl"); + Arc::new(ValkeyTokenRateLimitBackend::new(ValkeyBackendConfig { + url, + namespace: namespace.to_owned(), + rule: rule.name.clone(), + budgets: backend_budgets, + reservation_timeout_ms: timeout_ms, + max_keys: cfg.limits.max_keys, + max_active_reservations: cfg.limits.max_active_reservations, + })?) + }, + }; + rules.push(RuleRuntime { + match_value, + backend, + estimate: rule.estimation.tokens, + }); + } + Ok(Self { + principal_key_name: cfg.key.principal.name, + model_header, + allowed_models, + max_key_length: cfg.limits.max_key_length, + rules, + epoch: Instant::now(), + }) + } + + fn now_ms(&self) -> u64 { + u64::try_from(self.epoch.elapsed().as_millis().min(u128::from(u64::MAX))).unwrap_or(u64::MAX) + } + + fn matching_rule(&self, key: &str) -> Option { + self.rules + .iter() + .position(|rule| rule.match_value.as_deref().is_some_and(|expected| expected == key)) + .or_else(|| self.rules.iter().position(|rule| rule.match_value.is_none())) + } + + async fn reconcile(&self, ctx: &mut HttpFilterContext<'_>, actual: Option) { + let Some((rule_index, id)) = ctx + .get_metadata(META_RESERVATION_ID) + .and_then(|value| value.split_once(':')) + .and_then(|(rule, reservation)| Some((rule.parse::().ok()?, reservation.parse::().ok()?))) + else { + return; + }; + let Some(key) = ctx.get_metadata(META_KEY).map(str::to_owned) else { + return; + }; + if let Some(rule) = self.rules.get(rule_index) { + match rule + .backend + .reconcile(ReconcileRequest { + key, + reservation_id: id, + actual, + estimate: rule.estimate, + now_ms: self.now_ms(), + }) + .await + { + Ok(BackendSettlement::Applied { + actual, + refund, + overage, + }) => { + counter!("praxis_ai_token_rate_limit_reservations_total", "result" => "reconciled").increment(1); + counter!("praxis_ai_token_rate_limit_tokens_total", "kind" => "actual").increment(actual); + counter!("praxis_ai_token_rate_limit_tokens_total", "kind" => "refunded").increment(refund); + counter!("praxis_ai_token_rate_limit_tokens_total", "kind" => "overage").increment(overage); + }, + Ok(BackendSettlement::Noop) => {}, + Err(error) => { + counter!("praxis_ai_token_rate_limit_backend_errors_total", "backend" => "valkey", "operation" => "reconcile").increment(1); + tracing::error!(%error, "token-rate-limit response reconciliation failed"); + }, + } + } + ctx.filter_metadata.remove(META_RESERVATION_ID); + ctx.filter_metadata.remove(META_ACTIVE); + ctx.filter_metadata.remove(META_KEY); + self.record_state_metrics(); + } + + fn record_state_metrics(&self) { + let local = self + .rules + .iter() + .filter_map(|rule| rule.backend.local_state().map(|(ledger, _)| ledger)) + .collect::>(); + let active = local.iter().map(|ledger| ledger.active_count()).sum::(); + let keys = local.iter().map(|ledger| ledger.key_count()).sum::(); + #[expect( + clippy::cast_precision_loss, + reason = "metrics gauges use f64 and configured bounds keep values practical" + )] + gauge!("praxis_ai_token_rate_limit_active_reservations").set(active as f64); + #[expect( + clippy::cast_precision_loss, + reason = "metrics gauges use f64 and configured bounds keep values practical" + )] + gauge!("praxis_ai_token_rate_limit_active_keys").set(keys as f64); + } + + /// Construct the filter from YAML configuration. + pub(crate) fn from_config(config: &serde_yaml::Value) -> Result, FilterError> { + Ok(Box::new(Self::from_config_inner(config)?)) + } +} + +fn expand_backend_url(url: &str) -> Result { + let Some(start) = url.find("${") else { + return Ok(url.to_owned()); + }; + let Some(name) = url.strip_prefix("${").and_then(|value| value.strip_suffix('}')) else { + return Err("token_rate_limit: backend.url supports one complete ${ENV_VAR} reference".into()); + }; + if start != 0 || name.contains("${") { + return Err("token_rate_limit: backend.url supports one complete ${ENV_VAR} reference".into()); + } + if name.is_empty() + || !name + .bytes() + .enumerate() + .all(|(index, byte)| byte == b'_' || byte.is_ascii_uppercase() || (index > 0 && byte.is_ascii_digit())) + { + return Err("token_rate_limit: backend.url contains an invalid environment variable reference".into()); + } + std::env::var(name).map_err(|_error| "token_rate_limit: backend.url environment variable is not set".into()) +} + +impl std::fmt::Debug for TokenRateLimitFilter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TokenRateLimitFilter") + .field("principal_key_name", &self.principal_key_name) + .field("model_header", &self.model_header) + .field("rules", &self.rules.len()) + .finish_non_exhaustive() + } +} + +#[async_trait] +impl HttpFilter for TokenRateLimitFilter { + fn name(&self) -> &'static str { + "token_rate_limit" + } + + fn response_body_access(&self) -> BodyAccess { + BodyAccess::ReadOnly + } + + async fn on_request(&self, ctx: &mut HttpFilterContext<'_>) -> Result { + let now_ms = self.now_ms(); + for rule in &self.rules { + let Some((ledger, _)) = rule.backend.local_state() else { + continue; + }; + let orphaned = ledger.cleanup(now_ms, 1); + counter!("praxis_ai_token_rate_limit_cleanup_total").increment(1); + if orphaned > 0 { + counter!("praxis_ai_token_rate_limit_reservations_total", "result" => "orphaned") + .increment(orphaned as u64); + } + } + self.record_state_metrics(); + let Some(principal) = ctx.get_metadata(&self.principal_key_name).map(str::to_owned) else { + counter!("praxis_ai_token_rate_limit_requests_total", "decision" => "denied", "reason" => "missing_identity").increment(1); + return Ok(FilterAction::Reject(Rejection::status(401))); + }; + let Some(model) = ctx + .request + .headers + .get(&self.model_header) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty() && value.len() <= 256) + else { + counter!("praxis_ai_token_rate_limit_requests_total", "decision" => "denied", "reason" => "missing_model") + .increment(1); + return Ok(FilterAction::Reject(Rejection::status(400))); + }; + if !self.allowed_models.contains(model) { + counter!("praxis_ai_token_rate_limit_requests_total", "decision" => "denied", "reason" => "unknown_model") + .increment(1); + return Ok(FilterAction::Reject(Rejection::status(404))); + } + let key = format!("{}:{}:{}", principal.len(), principal, model); + if key.len() > self.max_key_length { + counter!("praxis_ai_token_rate_limit_requests_total", "decision" => "denied", "reason" => "key_too_long") + .increment(1); + return Ok(FilterAction::Reject(Rejection::status(400))); + } + let Some(rule_index) = self.matching_rule(&principal) else { + counter!("praxis_ai_token_rate_limit_requests_total", "decision" => "denied", "reason" => "no_rule") + .increment(1); + return Ok(FilterAction::Reject(Rejection::status(403))); + }; + let rule = &self.rules[rule_index]; + match rule + .backend + .reserve(ReserveRequest { + key: key.clone(), + estimate: rule.estimate, + now_ms, + }) + .await + { + Ok(BackendReserve::Admitted { + reservation_id, + estimate, + }) => { + counter!("praxis_ai_token_rate_limit_requests_total", "decision" => "admitted", "reason" => "reserved") + .increment(1); + counter!("praxis_ai_token_rate_limit_reservations_total", "result" => "created").increment(1); + counter!("praxis_ai_token_rate_limit_tokens_total", "kind" => "estimated").increment(estimate); + ctx.set_metadata(META_RESERVATION_ID, format!("{rule_index}:{reservation_id}")); + ctx.set_metadata(META_ACTIVE, "true"); + ctx.set_metadata(META_KEY, &key); + if ctx.get_metadata(META_RESERVATION_ID).is_none() { + drop(rule.backend.enqueue_reconcile(ReconcileRequest { + key: key.clone(), + reservation_id, + actual: None, + estimate: rule.estimate, + now_ms, + })); + counter!("praxis_ai_token_rate_limit_requests_total", "decision" => "denied", "reason" => "metadata_capacity") + .increment(1); + return Ok(FilterAction::Reject(Rejection::status(500))); + } + self.record_state_metrics(); + Ok(FilterAction::Continue) + }, + Ok(BackendReserve::Denied { retry_after_ms }) => { + counter!("praxis_ai_token_rate_limit_requests_total", "decision" => "denied", "reason" => "capacity") + .increment(1); + let seconds = retry_after_ms.saturating_add(999) / 1000; + Ok(FilterAction::Reject( + Rejection::status(429) + .with_header("Retry-After", seconds.max(1).to_string()) + .with_header("X-RateLimit-Limit", rule.backend.limit().to_string()) + .with_header("X-RateLimit-Remaining", "0") + .with_header("X-RateLimit-Reset", seconds.max(1).to_string()), + )) + }, + Err(error) => { + counter!("praxis_ai_token_rate_limit_backend_errors_total", "backend" => "valkey", "operation" => "reserve").increment(1); + tracing::error!(%error, "token-rate-limit admission backend failed"); + Ok(FilterAction::Reject(Rejection::status(503))) + }, + } + } + + async fn on_response(&self, ctx: &mut HttpFilterContext<'_>) -> Result { + let success = ctx + .response_header + .as_ref() + .is_some_and(|response| response.status.is_success()); + if !success { + // Successful responses remain reserved until the terminal body + // hook, where token_count has published actual usage. Failures + // have no reliable body usage and are charged conservatively now. + self.reconcile(ctx, None).await; + } + Ok(FilterAction::Continue) + } + + fn on_response_body( + &self, + ctx: &mut HttpFilterContext<'_>, + _body: &mut Option, + end_of_stream: bool, + ) -> Result { + if end_of_stream { + let actual = ctx + .get_metadata(META_TOKEN_TOTAL) + .and_then(|value| value.parse::().ok()); + let Some((rule_index, id)) = ctx + .get_metadata(META_RESERVATION_ID) + .and_then(|value| value.split_once(':')) + .and_then(|(rule, reservation)| Some((rule.parse::().ok()?, reservation.parse::().ok()?))) + else { + return Ok(FilterAction::Continue); + }; + let Some(rule) = self.rules.get(rule_index) else { + return Ok(FilterAction::Continue); + }; + if let Some((ledger, _)) = rule.backend.local_state() { + let _ = ledger.reconcile(id, actual, self.now_ms()); + ctx.filter_metadata.remove(META_RESERVATION_ID); + ctx.filter_metadata.remove(META_ACTIVE); + ctx.filter_metadata.remove(META_KEY); + } else if let Some(key) = ctx.get_metadata(META_KEY).map(str::to_owned) { + rule.backend + .enqueue_reconcile(ReconcileRequest { + key, + reservation_id: id, + actual, + estimate: rule.estimate, + now_ms: self.now_ms(), + }) + .map_err(|error| -> FilterError { error.into() })?; + ctx.filter_metadata.remove(META_RESERVATION_ID); + ctx.filter_metadata.remove(META_ACTIVE); + ctx.filter_metadata.remove(META_KEY); + } + } + Ok(FilterAction::Continue) + } +} + +fn parse_budget(config: &BudgetConfig) -> Result { + if config.capacity == 0 { + return Err("token_rate_limit: budget capacity must be positive".into()); + } + let window_ms = parse_duration_ms(&config.window)?; + if window_ms.div_ceil(BUCKET_MS) > MAX_BUCKETS_PER_WINDOW { + return Err(format!( + "token_rate_limit: budget window cannot exceed {MAX_BUCKETS_PER_WINDOW} one-second buckets" + ) + .into()); + } + Ok(Budget { + window_ms, + capacity: config.capacity, + }) +} + +fn parse_duration_ms(value: &str) -> Result { + let value = value.trim(); + let (number, multiplier) = if let Some(value) = value.strip_suffix("ms") { + (value, 1_u64) + } else if let Some(value) = value.strip_suffix('s') { + (value, 1_000_u64) + } else if let Some(value) = value.strip_suffix('m') { + (value, 60_000_u64) + } else if let Some(value) = value.strip_suffix('h') { + (value, 3_600_000_u64) + } else { + return Err(format!("invalid duration '{value}'").into()); + }; + let amount = number + .parse::() + .map_err(|error| format!("invalid duration '{value}': {error}"))?; + let millis = amount.checked_mul(multiplier).ok_or("duration overflow")?; + if millis == 0 || millis > MAX_DURATION_MS { + return Err("duration must be positive and bounded".into()); + } + Ok(millis) +} diff --git a/filters/src/token_rate_limit/tests.rs b/filters/src/token_rate_limit/tests.rs new file mode 100644 index 0000000000..a667269de4 --- /dev/null +++ b/filters/src/token_rate_limit/tests.rs @@ -0,0 +1,386 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +#![allow(clippy::indexing_slicing, reason = "test fixtures are structurally defined")] + +use super::*; + +#[tokio::test] +async fn valkey_worker_reconciles_body_usage_and_preserves_idempotency() { + let Ok(url) = std::env::var("TOKEN_RATE_LIMIT_VALKEY_URL") else { + return; + }; + let mut value = config(); + value["backend"] = serde_yaml::from_str(&format!( + "kind: valkey\nurl: {url}\nnamespace: praxis-test-{}\n", + std::process::id() + )) + .unwrap(); + set_capacity(&mut value, 14); + let filter = TokenRateLimitFilter::from_config_inner(&value).unwrap(); + + let request = request_with_model("model-a"); + let mut first = crate::test_utils::make_filter_context(&request); + first.set_metadata("identity.user_id", "valkey-alice"); + assert!(matches!( + filter.on_request(&mut first).await.unwrap(), + FilterAction::Continue + )); + let reservation_id = first + .get_metadata(META_RESERVATION_ID) + .and_then(|value| value.split_once(':')) + .and_then(|(_, id)| id.parse::().ok()) + .unwrap(); + let quota_key = first.get_metadata(META_KEY).unwrap().to_owned(); + first.set_metadata(META_TOKEN_TOTAL, "4"); + let mut body = None; + assert!(matches!( + filter.on_response_body(&mut first, &mut body, true).unwrap(), + FilterAction::Continue + )); + + let mut admitted_after_reconcile = false; + for _ in 0..20 { + let mut next = crate::test_utils::make_filter_context(&request); + next.set_metadata("identity.user_id", "valkey-alice"); + if matches!(filter.on_request(&mut next).await.unwrap(), FilterAction::Continue) { + admitted_after_reconcile = true; + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + assert!(admitted_after_reconcile, "worker settlement did not become visible"); + assert_eq!( + filter.rules[0] + .backend + .reconcile(ReconcileRequest { + key: quota_key, + reservation_id, + actual: Some(4), + estimate: 10, + now_ms: filter.now_ms(), + }) + .await + .unwrap(), + BackendSettlement::Noop + ); +} + +#[tokio::test] +async fn valkey_is_shared_across_independent_filter_instances() { + let Ok(url) = std::env::var("TOKEN_RATE_LIMIT_VALKEY_URL") else { + return; + }; + let namespace = format!("praxis-test-{}-shared", std::process::id()); + let mut value = config(); + value["backend"] = serde_yaml::from_str(&format!("kind: valkey\nurl: {url}\nnamespace: {namespace}\n")).unwrap(); + set_capacity(&mut value, 10); + let first = TokenRateLimitFilter::from_config_inner(&value).unwrap(); + let second = TokenRateLimitFilter::from_config_inner(&value).unwrap(); + + let request = request_with_model("model-a"); + let mut first_ctx = crate::test_utils::make_filter_context(&request); + first_ctx.set_metadata("identity.user_id", "shared-alice"); + let mut second_ctx = crate::test_utils::make_filter_context(&request); + second_ctx.set_metadata("identity.user_id", "shared-alice"); + let (first_result, second_result) = + tokio::join!(first.on_request(&mut first_ctx), second.on_request(&mut second_ctx)); + let admitted = [first_result.unwrap(), second_result.unwrap()] + .into_iter() + .filter(|result| matches!(result, FilterAction::Continue)) + .count(); + assert_eq!(admitted, 1, "shared Valkey quota admitted concurrent requests twice"); +} + +#[tokio::test] +async fn valkey_failure_fails_closed_before_route_metadata() { + let mut value = config(); + value["backend"] = + serde_yaml::from_str("kind: valkey\nurl: redis://127.0.0.1:6399\nnamespace: praxis-test-unavailable\n") + .unwrap(); + let filter = TokenRateLimitFilter::from_config_inner(&value).unwrap(); + let request = request_with_model("model-a"); + let mut ctx = crate::test_utils::make_filter_context(&request); + ctx.set_metadata("identity.user_id", "offline-alice"); + let action = filter.on_request(&mut ctx).await.unwrap(); + assert!(matches!(action, FilterAction::Reject(rejection) if rejection.status == 503)); + assert!(ctx.cluster.is_none()); +} + +#[tokio::test] +async fn valkey_global_active_bound_and_expiry_are_atomic() { + let Ok(url) = std::env::var("TOKEN_RATE_LIMIT_VALKEY_URL") else { + return; + }; + let namespace = format!("praxis-test-{}-active-bound", std::process::id()); + let mut value = config(); + value["backend"] = serde_yaml::from_str(&format!("kind: valkey\nurl: {url}\nnamespace: {namespace}\n")).unwrap(); + value["reservationTimeout"] = serde_yaml::Value::String("1s".into()); + value["limits"]["max_active_reservations"] = serde_yaml::Value::Number(1.into()); + let first = TokenRateLimitFilter::from_config_inner(&value).unwrap(); + let second = TokenRateLimitFilter::from_config_inner(&value).unwrap(); + + let request = request_with_model("model-a"); + let mut first_ctx = crate::test_utils::make_filter_context(&request); + first_ctx.set_metadata("identity.user_id", "active-one"); + let mut second_ctx = crate::test_utils::make_filter_context(&request); + second_ctx.set_metadata("identity.user_id", "active-two"); + let (first_result, second_result) = + tokio::join!(first.on_request(&mut first_ctx), second.on_request(&mut second_ctx)); + let admitted = [first_result.unwrap(), second_result.unwrap()] + .into_iter() + .filter(|result| matches!(result, FilterAction::Continue)) + .count(); + assert_eq!(admitted, 1); + + tokio::time::sleep(std::time::Duration::from_millis(1_100)).await; + let mut after_expiry = crate::test_utils::make_filter_context(&request); + after_expiry.set_metadata("identity.user_id", "active-three"); + assert!(matches!( + first.on_request(&mut after_expiry).await.unwrap(), + FilterAction::Continue + )); +} + +#[tokio::test] +async fn valkey_missing_usage_is_charged_at_estimate() { + let Ok(url) = std::env::var("TOKEN_RATE_LIMIT_VALKEY_URL") else { + return; + }; + let mut value = config(); + value["backend"] = serde_yaml::from_str(&format!( + "kind: valkey\nurl: {url}\nnamespace: praxis-test-{}-missing\n", + std::process::id() + )) + .unwrap(); + set_capacity(&mut value, 10); + let filter = TokenRateLimitFilter::from_config_inner(&value).unwrap(); + let request = request_with_model("model-a"); + let mut first = crate::test_utils::make_filter_context(&request); + first.set_metadata("identity.user_id", "missing-usage"); + assert!(matches!( + filter.on_request(&mut first).await.unwrap(), + FilterAction::Continue + )); + let mut body = None; + assert!(matches!( + filter.on_response_body(&mut first, &mut body, true).unwrap(), + FilterAction::Continue + )); + + let mut denied = false; + for _ in 0..20 { + let mut next = crate::test_utils::make_filter_context(&request); + next.set_metadata("identity.user_id", "missing-usage"); + if matches!(filter.on_request(&mut next).await.unwrap(), FilterAction::Reject(rejection) if rejection.status == 429) + { + denied = true; + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + assert!(denied, "missing usage was not charged conservatively"); +} + +#[allow(clippy::indexing_slicing, reason = "test fixtures are structurally defined above")] +fn set_capacity(value: &mut serde_yaml::Value, capacity: u64) { + value["rules"][0]["token_budgets"][0]["capacity"] = serde_yaml::Value::Number(capacity.into()); +} + +fn config() -> serde_yaml::Value { + serde_yaml::from_str( + "key:\n principal:\n source: metadata\n name: identity.user_id\n onMissing: reject\n model:\n source: header\n name: x-model\n onMissing: reject\n allowedModels: [model-a, model-b]\nreservationTimeout: 2m\nlimits:\n max_keys: 10\n max_key_length: 256\n max_active_reservations: 10\nrules:\n - name: tenant-default\n estimation:\n strategy: fixed\n tokens: 10\n token_budgets:\n - window: 1m\n capacity: 100\n", + ) + .unwrap() +} + +fn request_with_model(model: &'static str) -> praxis_filter::Request { + let mut request = crate::test_utils::make_request(http::Method::POST, "/v1/chat/completions"); + request.headers.insert("x-model", model.parse().unwrap()); + request +} + +#[test] +fn parses_fixed_configuration() { + TokenRateLimitFilter::from_config_inner(&config()).unwrap(); +} + +#[test] +fn valkey_configuration_requires_a_url() { + let mut value = config(); + value["backend"] = serde_yaml::from_str("kind: valkey\n").unwrap(); + + let error = TokenRateLimitFilter::from_config_inner(&value).unwrap_err(); + assert!(error.to_string().contains("backend.url is required for Valkey")); +} + +#[test] +fn valkey_configuration_rejects_an_invalid_url() { + let mut value = config(); + value["backend"] = serde_yaml::from_str("kind: valkey\nurl: not-a-valkey-url\n").unwrap(); + + let error = TokenRateLimitFilter::from_config_inner(&value).unwrap_err(); + assert!(error.to_string().contains("shared quota backend unavailable")); +} + +#[test] +fn declares_response_body_access_for_reconciliation() { + let filter = TokenRateLimitFilter::from_config_inner(&config()).unwrap(); + assert_eq!(filter.response_body_access(), BodyAccess::ReadOnly); +} + +#[test] +fn rejects_unknown_configuration_fields() { + let mut value = config(); + value.as_mapping_mut().expect("fixture must be a mapping").insert( + serde_yaml::Value::String("unexpected".into()), + serde_yaml::Value::String("reject".into()), + ); + assert!(TokenRateLimitFilter::from_config_inner(&value).is_err()); +} + +#[test] +fn rejects_missing_identity_or_model() { + let filter = TokenRateLimitFilter::from_config_inner(&config()).unwrap(); + let request = request_with_model("model-a"); + let mut ctx = crate::test_utils::make_filter_context(&request); + let action = futures::executor::block_on(filter.on_request(&mut ctx)).unwrap(); + assert!(matches!(action, FilterAction::Reject(rejection) if rejection.status == 401)); + + let request = crate::test_utils::make_request(http::Method::POST, "/v1/chat/completions"); + let mut ctx = crate::test_utils::make_filter_context(&request); + ctx.set_metadata("identity.user_id", "alice"); + let action = futures::executor::block_on(filter.on_request(&mut ctx)).unwrap(); + assert!(matches!(action, FilterAction::Reject(rejection) if rejection.status == 400)); + + let request = request_with_model("invented-model"); + let mut ctx = crate::test_utils::make_filter_context(&request); + ctx.set_metadata("identity.user_id", "alice"); + let action = futures::executor::block_on(filter.on_request(&mut ctx)).unwrap(); + assert!(matches!(action, FilterAction::Reject(rejection) if rejection.status == 404)); +} + +#[test] +fn rejects_composite_key_before_backend_allocation() { + let mut value = config(); + value["limits"]["max_key_length"] = serde_yaml::Value::Number(8.into()); + let filter = TokenRateLimitFilter::from_config_inner(&value).unwrap(); + let request = request_with_model("model-a"); + let mut ctx = crate::test_utils::make_filter_context(&request); + ctx.set_metadata("identity.user_id", "alice"); + let action = futures::executor::block_on(filter.on_request(&mut ctx)).unwrap(); + assert!(matches!(action, FilterAction::Reject(rejection) if rejection.status == 400)); + assert!(ctx.get_metadata(META_RESERVATION_ID).is_none()); +} + +#[test] +#[allow(clippy::indexing_slicing, reason = "test fixture has a known rules sequence")] +fn rejects_default_rule_before_specific_rule() { + let mut value = config(); + let rules = value["rules"].as_sequence_mut().unwrap(); + rules.push(serde_yaml::from_str( + "name: alice\nmatch:\n metadata:\n identity.user_id: alice\nestimation:\n strategy: fixed\n tokens: 10\ntoken_budgets:\n - window: 1m\n capacity: 10\n", + ) + .unwrap()); + assert!(TokenRateLimitFilter::from_config_inner(&value).is_err()); +} + +#[test] +fn principal_and_model_buckets_are_independent() { + let value: serde_yaml::Value = serde_yaml::from_str( + "key:\n principal:\n source: metadata\n name: identity.user_id\n onMissing: reject\n model:\n source: header\n name: x-model\n onMissing: reject\n allowedModels: [model-a, model-b]\nreservationTimeout: 2m\nlimits:\n max_keys: 10\n max_key_length: 256\n max_active_reservations: 10\nrules:\n - name: alice\n match:\n metadata:\n identity.user_id: alice\n estimation:\n strategy: fixed\n tokens: 10\n token_budgets:\n - window: 1m\n capacity: 10\n - name: bob\n match:\n metadata:\n identity.user_id: bob\n estimation:\n strategy: fixed\n tokens: 10\n token_budgets:\n - window: 1m\n capacity: 10\n", + ) + .unwrap(); + let filter = TokenRateLimitFilter::from_config_inner(&value).unwrap(); + + for (user, model) in [("alice", "model-a"), ("alice", "model-b"), ("bob", "model-a")] { + let request = request_with_model(model); + let mut ctx = crate::test_utils::make_filter_context(&request); + ctx.set_metadata("identity.user_id", user); + let action = futures::executor::block_on(filter.on_request(&mut ctx)).unwrap(); + assert!(matches!(action, FilterAction::Continue)); + } + + let request = request_with_model("model-a"); + let mut ctx = crate::test_utils::make_filter_context(&request); + ctx.set_metadata("identity.user_id", "alice"); + let action = futures::executor::block_on(filter.on_request(&mut ctx)).unwrap(); + assert!(matches!(action, FilterAction::Reject(rejection) if rejection.status == 429)); +} + +#[tokio::test] +async fn admitted_request_reconciles_actual_usage_and_allows_refund() { + let mut value = config(); + set_capacity(&mut value, 14); + let filter = TokenRateLimitFilter::from_config_inner(&value).unwrap(); + + let request = request_with_model("model-a"); + let mut ctx = crate::test_utils::make_filter_context(&request); + ctx.set_metadata("identity.user_id", "alice"); + assert!(matches!( + filter.on_request(&mut ctx).await.unwrap(), + FilterAction::Continue + )); + ctx.set_metadata(META_TOKEN_TOTAL, "4"); + let mut response = crate::test_utils::make_response(); + ctx.response_header = Some(&mut response); + assert!(matches!( + filter.on_response(&mut ctx).await.unwrap(), + FilterAction::Continue + )); + ctx.response_header = None; + + let request = request_with_model("model-a"); + let mut before_body = crate::test_utils::make_filter_context(&request); + before_body.set_metadata("identity.user_id", "alice"); + assert!(matches!( + filter.on_request(&mut before_body).await.unwrap(), + FilterAction::Reject(rejection) if rejection.status == 429 + )); + + let mut body = None; + assert!(matches!( + filter.on_response_body(&mut ctx, &mut body, true).unwrap(), + FilterAction::Continue + )); + + let request = request_with_model("model-a"); + let mut ctx = crate::test_utils::make_filter_context(&request); + ctx.set_metadata("identity.user_id", "alice"); + assert!(matches!( + filter.on_request(&mut ctx).await.unwrap(), + FilterAction::Continue + )); +} + +#[tokio::test] +async fn exhausted_request_is_429_before_any_route_metadata_is_set() { + let mut value = config(); + set_capacity(&mut value, 10); + let filter = TokenRateLimitFilter::from_config_inner(&value).unwrap(); + let request = request_with_model("model-a"); + let mut first = crate::test_utils::make_filter_context(&request); + first.set_metadata("identity.user_id", "alice"); + assert!(matches!( + filter.on_request(&mut first).await.unwrap(), + FilterAction::Continue + )); + + let mut second = crate::test_utils::make_filter_context(&request); + second.set_metadata("identity.user_id", "alice"); + let action = filter.on_request(&mut second).await.unwrap(); + let FilterAction::Reject(rejection) = action else { + panic!("expected 429") + }; + assert_eq!(rejection.status, 429); + assert!(rejection.headers.iter().any(|(name, _)| name == "Retry-After")); + assert!(rejection.headers.iter().any(|(name, _)| name == "X-RateLimit-Limit")); + assert!( + rejection + .headers + .iter() + .any(|(name, _)| name == "X-RateLimit-Remaining") + ); + assert!(rejection.headers.iter().any(|(name, _)| name == "X-RateLimit-Reset")); + assert!(second.cluster.is_none()); +} diff --git a/server/Cargo.toml b/server/Cargo.toml index 3591849391..d07f9e012e 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -22,7 +22,7 @@ name = "praxis-ai" path = "src/main.rs" [features] -opentelemetry = ["praxis-ai-filters/opentelemetry"] +opentelemetry = ["praxis-ai-filters/opentelemetry", "praxis-core/otel"] praxis-main = ["praxis-ai-filters/praxis-main", "praxis-ai-apis/praxis-main"] [lints] @@ -39,7 +39,7 @@ praxis-protocol = { workspace = true } praxis-tls = { workspace = true } serde = { workspace = true } serde_yaml = { workspace = true } -tokio = { workspace = true, features = ["rt", "sync", "time", "fs"] } +tokio = { workspace = true, features = ["rt", "rt-multi-thread", "sync", "time", "fs"] } tokio-util = { workspace = true } tracing = { workspace = true } diff --git a/server/src/pipelines.rs b/server/src/pipelines.rs index 60c520d143..bf217afead 100644 --- a/server/src/pipelines.rs +++ b/server/src/pipelines.rs @@ -55,6 +55,7 @@ pub fn resolve_pipelines( configure_pipeline(&mut pipeline, config, health_registry, kv_stores, subrequest_client)?; validate_provider_boundary(listener, &entries, &chains)?; + validate_token_rate_limit_order(listener, &entries)?; validate_pipeline(&pipeline, &entries, &listener.name, &config.insecure_options)?; pipelines.insert(listener.name.clone(), Arc::new(pipeline)); @@ -63,6 +64,35 @@ pub fn resolve_pipelines( Ok(ListenerPipelines::new(pipelines)) } +/// Keep quota admission ahead of routing and token usage extraction. +fn validate_token_rate_limit_order( + listener: &Listener, + entries: &[FilterEntry], +) -> Result<(), Box> { + let Some(limiter_index) = entries.iter().position(|entry| entry.filter_type == "token_rate_limit") else { + return Ok(()); + }; + if entries + .iter() + .position(|entry| entry.filter_type == "intelligent_route") + .is_some_and(|route_index| route_index < limiter_index) + { + return Err(format!( + "listener '{}': token_rate_limit must precede intelligent_route", + listener.name + ) + .into()); + } + if entries + .iter() + .position(|entry| entry.filter_type == "token_count") + .is_some_and(|token_count_index| token_count_index < limiter_index) + { + return Err(format!("listener '{}': token_count must follow token_rate_limit", listener.name).into()); + } + Ok(()) +} + /// Apply body limits, health registry, KV stores, and insecure options to a /// pipeline. fn configure_pipeline( diff --git a/tests/integration/Cargo.toml b/tests/integration/Cargo.toml index f1e3b3c4bc..25feb913b5 100644 --- a/tests/integration/Cargo.toml +++ b/tests/integration/Cargo.toml @@ -8,7 +8,7 @@ readme = "../../README.md" publish = false [features] -cpex-policy-engine = [] +cpex-policy-engine = ["praxis-filter/policy-engine"] no-mac-cert-rotation-tests = [] # We use this to disable testing cert rotation on macOS praxis-main = ["praxis-test-utils/praxis-main"]