diff --git a/Cargo.lock b/Cargo.lock index c8e7775b..0c4a1df9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aead" version = "0.5.2" @@ -68,6 +74,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy 0.8.27", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -77,6 +96,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android-tzdata" version = "0.1.1" @@ -98,6 +123,12 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "arc-swap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" + [[package]] name = "argon2" version = "0.5.3" @@ -128,7 +159,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror", + "thiserror 1.0.63", "time", ] @@ -155,6 +186,55 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-openai" +version = "0.29.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c58fd812d4b7152e0f748254c03927f27126a5d83fccf265b2baddaaa1aeea41" +dependencies = [ + "async-openai-macros", + "backoff", + "base64 0.22.1", + "bytes", + "derive_builder", + "eventsource-stream", + "futures", + "rand 0.9.2", + "reqwest 0.12.23", + "reqwest-eventsource", + "secrecy", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", +] + +[[package]] +name = "async-openai-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0289cba6d5143bfe8251d57b4a8cac036adf158525a76533a7082ba65ec76398" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -177,6 +257,12 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -455,17 +541,17 @@ dependencies = [ "aws-smithy-types", "bytes", "fastrand", - "h2", + "h2 0.3.26", "http 0.2.12", "http-body 0.4.6", "http-body 1.0.1", "httparse", "hyper 0.14.30", - "hyper-rustls", + "hyper-rustls 0.24.2", "once_cell", "pin-project-lite", "pin-utils", - "rustls", + "rustls 0.21.12", "tokio", "tracing", ] @@ -612,10 +698,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1" dependencies = [ "futures-core", - "getrandom", + "getrandom 0.2.15", "instant", "pin-project-lite", - "rand", + "rand 0.8.5", "tokio", ] @@ -629,7 +715,7 @@ dependencies = [ "cc", "cfg-if", "libc", - "miniz_oxide", + "miniz_oxide 0.7.4", "object", "rustc-demangle", ] @@ -684,7 +770,7 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36915bbaca237c626689b5bd14d02f2ba7a5a359d30a2a08be697392e3718079" dependencies = [ - "thiserror", + "thiserror 1.0.63", ] [[package]] @@ -707,6 +793,15 @@ dependencies = [ "serde", ] +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bip39" version = "2.1.0" @@ -855,6 +950,31 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bon" +version = "3.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebeb9aaf9329dff6ceb65c689ca3db33dbf15f324909c60e4e5eef5701ce31b1" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77e9d642a7e3a318e37c2c9427b5a6a48aa1ad55dcd986f3034ab2239045a645" +dependencies = [ + "darling 0.21.3", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.106", +] + [[package]] name = "bstr" version = "1.12.0" @@ -908,6 +1028,10 @@ name = "cc" version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9e8aabfac534be767c909e0690571677d49f41bd8465ae876fe043d52ba5292" +dependencies = [ + "jobserver", + "libc", +] [[package]] name = "cfg-if" @@ -1009,6 +1133,37 @@ dependencies = [ "digest", ] +[[package]] +name = "cmsketch" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c840ee51da812c6cd621f9f7e07dfb00a49f91283a8e6380c78cba4f61aba" +dependencies = [ + "paste", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1025,6 +1180,17 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_affinity" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a034b3a7b624016c6e13f5df875747cc25f884156aad2abd12b6c46797971342" +dependencies = [ + "libc", + "num_cpus", + "winapi", +] + [[package]] name = "cpufeatures" version = "0.2.13" @@ -1034,6 +1200,34 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.20" @@ -1053,10 +1247,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "ctr" version = "0.9.2" @@ -1092,14 +1307,48 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "darling" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +dependencies = [ + "darling_core 0.14.4", + "darling_macro 0.14.4", +] + [[package]] name = "darling" version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.10", + "darling_macro 0.20.10", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling_core" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 1.0.109", ] [[package]] @@ -1112,17 +1361,53 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.11.1", + "syn 2.0.106", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", "syn 2.0.106", ] +[[package]] +name = "darling_macro" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +dependencies = [ + "darling_core 0.14.4", + "quote", + "syn 1.0.109", +] + [[package]] name = "darling_macro" version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ - "darling_core", + "darling_core 0.20.10", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", "quote", "syn 2.0.106", ] @@ -1178,6 +1463,37 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.10", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.106", +] + [[package]] name = "diesel" version = "2.2.2" @@ -1244,6 +1560,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.59.0", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1261,13 +1598,19 @@ version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dsl_auto_type" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5d9abe6314103864cc2d8901b7ae224e0ab1a103a0a416661b4097b0779b607" dependencies = [ - "darling", + "darling 0.20.10", "either", "heck 0.5.0", "proc-macro2", @@ -1276,27 +1619,84 @@ dependencies = [ ] [[package]] -name = "ecow" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54bfbb1708988623190a6c4dbedaeaf0f53c20c6395abd6a01feb327b3146f4b" +name = "dspy-rs" +version = "0.6.0" +source = "git+https://github.com/krypticmouse/DSRs?branch=main#47ca636086403344b91fc4a90108f99a27101ae6" dependencies = [ + "anyhow", + "async-openai", + "async-trait", + "bon", + "csv", + "dsrs_macros", + "foyer", + "futures", + "hf-hub", + "indexmap", + "kdam", + "rand 0.8.5", + "rayon", + "regex", + "reqwest 0.12.23", + "rstest", + "schemars", + "secrecy", "serde", + "serde_json", + "tempfile", + "tokio", + "validator", ] [[package]] -name = "either" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" - -[[package]] -name = "encoding_rs" -version = "0.8.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59" +name = "dsrs_macros" +version = "0.6.0" +source = "git+https://github.com/krypticmouse/DSRs?branch=main#47ca636086403344b91fc4a90108f99a27101ae6" dependencies = [ - "cfg-if", + "anyhow", + "indexmap", + "proc-macro2", + "quote", + "schemars", + "serde", + "serde_json", + "syn 2.0.106", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecow" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54bfbb1708988623190a6c4dbedaeaf0f53c20c6395abd6a01feb327b3146f4b" +dependencies = [ + "serde", +] + +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59" +dependencies = [ + "cfg-if", ] [[package]] @@ -1315,6 +1715,38 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "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 = "eventsource-stream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" +dependencies = [ + "futures-core", + "nom", + "pin-project-lite", +] + [[package]] name = "fancy-regex" version = "0.12.0" @@ -1325,6 +1757,16 @@ dependencies = [ "regex", ] +[[package]] +name = "fastant" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bf7fa928ce0c4a43bd6e7d1235318fc32ac3a3dea06a2208c44e729449471a" +dependencies = [ + "small_ctor", + "web-time", +] + [[package]] name = "fastrand" version = "2.1.1" @@ -1337,12 +1779,40 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "flate2" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc5a4e564e38c699f2880d3fda590bedc2e69f3f84cd48b457bd892ce61d0aa9" +dependencies = [ + "crc32fast", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "nanorand", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foreign-types" version = "0.3.2" @@ -1367,6 +1837,123 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "foyer" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa5d15035074ac205314ecc39ffb7697d59ba9deed2380fa12a7d54ddf35e9ba" +dependencies = [ + "equivalent", + "foyer-common", + "foyer-memory", + "foyer-storage", + "madsim-tokio", + "mixtrics", + "pin-project", + "serde", + "thiserror 2.0.17", + "tokio", + "tracing", +] + +[[package]] +name = "foyer-common" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "181bfdf387bd81442dd529e46b4cf632fd75076349d962b8a96aea24eddf5848" +dependencies = [ + "bincode", + "bytes", + "cfg-if", + "itertools", + "madsim-tokio", + "mixtrics", + "parking_lot", + "pin-project", + "serde", + "thiserror 2.0.17", + "tokio", + "twox-hash", +] + +[[package]] +name = "foyer-intrusive-collections" +version = "0.10.0-dev" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4fee46bea69e0596130e3210e65d3424e0ac1e6df3bde6636304bdf1ca4a3b" +dependencies = [ + "memoffset 0.9.1", +] + +[[package]] +name = "foyer-memory" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "757d608277911c2292b7563638b5b7f804904ff71a4e4757d97a94cd6a067e57" +dependencies = [ + "arc-swap", + "bitflags 2.6.0", + "cmsketch", + "equivalent", + "foyer-common", + "foyer-intrusive-collections", + "hashbrown 0.15.5", + "itertools", + "madsim-tokio", + "mixtrics", + "parking_lot", + "pin-project", + "serde", + "thiserror 2.0.17", + "tokio", + "tracing", +] + +[[package]] +name = "foyer-storage" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e1045dd1812baa313d8cb97b53f540bd8ed315f4585982f78ae7f6a1cdde4e2" +dependencies = [ + "allocator-api2", + "anyhow", + "bytes", + "core_affinity", + "equivalent", + "fastant", + "flume", + "foyer-common", + "foyer-memory", + "fs4", + "futures-core", + "futures-util", + "hashbrown 0.15.5", + "io-uring", + "itertools", + "libc", + "lz4", + "madsim-tokio", + "parking_lot", + "pin-project", + "rand 0.9.2", + "serde", + "thiserror 2.0.17", + "tokio", + "tracing", + "twox-hash", + "zstd", +] + +[[package]] +name = "fs4" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4" +dependencies = [ + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "futures" version = "0.3.31" @@ -1485,6 +2072,20 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + [[package]] name = "ghash" version = "0.5.1" @@ -1501,6 +2102,12 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "governor" version = "0.6.3" @@ -1516,7 +2123,7 @@ dependencies = [ "parking_lot", "portable-atomic", "quanta", - "rand", + "rand 0.8.5", "smallvec", "spinning_top", ] @@ -1540,6 +2147,25 @@ dependencies = [ "tracing", ] +[[package]] +name = "h2" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.1.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "1.8.3" @@ -1562,6 +2188,17 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.0" @@ -1586,6 +2223,12 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1613,6 +2256,30 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" +[[package]] +name = "hf-hub" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "629d8f3bbeda9d148036d6b0de0a3ab947abd08ce90626327fc3547a49d59d97" +dependencies = [ + "dirs", + "futures", + "http 1.1.0", + "indicatif", + "libc", + "log", + "native-tls", + "num_cpus", + "rand 0.9.2", + "reqwest 0.12.23", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", + "ureq", + "windows-sys 0.60.2", +] + [[package]] name = "hmac" version = "0.12.1" @@ -1700,7 +2367,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2", + "h2 0.3.26", "http 0.2.12", "http-body 0.4.6", "httparse", @@ -1724,6 +2391,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2 0.4.12", "http 1.1.0", "http-body 1.0.1", "httparse", @@ -1746,10 +2414,27 @@ dependencies = [ "http 0.2.12", "hyper 0.14.30", "log", - "rustls", - "rustls-native-certs", + "rustls 0.21.12", + "rustls-native-certs 0.6.3", + "tokio", + "tokio-rustls 0.24.1", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http 1.1.0", + "hyper 1.7.0", + "hyper-util", + "rustls 0.23.14", + "rustls-native-certs 0.8.0", + "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.0", + "tower-service", ] [[package]] @@ -1800,9 +2485,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2 0.6.0", + "system-configuration 0.6.1", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1993,6 +2680,19 @@ dependencies = [ "hashbrown 0.16.0", ] +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width", + "web-time", +] + [[package]] name = "inout" version = "0.1.3" @@ -2039,12 +2739,31 @@ dependencies = [ "serde", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.81" @@ -2082,7 +2801,7 @@ dependencies = [ "ciborium", "hmac", "lazy_static", - "rand_core", + "rand_core 0.6.4", "secp256k1", "serde", "serde_json", @@ -2092,6 +2811,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "kdam" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5740f66a8d86a086ebcacfb937070e8be6eb2f8fb45e4ae7fa428ca2a98a7b1f" +dependencies = [ + "terminal_size", + "windows-sys 0.59.0", +] + [[package]] name = "keccak" version = "0.1.5" @@ -2120,14 +2849,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" [[package]] -name = "linux-raw-sys" -version = "0.4.14" +name = "libredox" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +dependencies = [ + "bitflags 2.6.0", + "libc", +] [[package]] -name = "litemap" -version = "0.7.4" +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" @@ -2147,6 +2886,86 @@ version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lz4" +version = "1.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a20b523e860d03443e98350ceaac5e71c6ba89aea7d960769ec3ce37f4de5af4" +dependencies = [ + "lz4-sys", +] + +[[package]] +name = "lz4-sys" +version = "1.11.1+lz4-1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "madsim" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18351aac4194337d6ea9ffbd25b3d1540ecc0754142af1bff5ba7392d1f6f771" +dependencies = [ + "ahash", + "async-channel", + "async-stream", + "async-task", + "bincode", + "bytes", + "downcast-rs", + "errno", + "futures-util", + "lazy_static", + "libc", + "madsim-macros", + "naive-timer", + "panic-message", + "rand 0.8.5", + "rand_xoshiro", + "rustversion", + "serde", + "spin", + "tokio", + "tokio-util", + "toml", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "madsim-macros" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3d248e97b1a48826a12c3828d921e8548e714394bf17274dd0a93910dc946e1" +dependencies = [ + "darling 0.14.4", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "madsim-tokio" +version = "0.2.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d3eb2acc57c82d21d699119b859e2df70a91dbdb84734885a1e72be83bdecb5" +dependencies = [ + "madsim", + "spin", + "tokio", +] + [[package]] name = "matchers" version = "0.1.0" @@ -2203,6 +3022,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2218,18 +3047,38 @@ dependencies = [ "adler", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" dependencies = [ - "hermit-abi", + "hermit-abi 0.3.9", "libc", "wasi", "windows-sys 0.52.0", ] +[[package]] +name = "mixtrics" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c4c1f8a5250642cbedbb30bd21a84bb960a9cbfe8c8c30a910103513647326d" +dependencies = [ + "itertools", + "parking_lot", +] + [[package]] name = "multer" version = "3.1.0" @@ -2247,6 +3096,21 @@ dependencies = [ "version_check", ] +[[package]] +name = "naive-timer" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "034a0ad7deebf0c2abcf2435950a6666c3c15ea9d8fad0c0f48efa8a7f843fed" + +[[package]] +name = "nanorand" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" +dependencies = [ + "getrandom 0.2.15", +] + [[package]] name = "native-tls" version = "0.2.12" @@ -2356,6 +3220,22 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi 0.5.2", + "libc", +] + +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + [[package]] name = "oauth2" version = "4.4.2" @@ -2364,15 +3244,15 @@ checksum = "c38841cdd844847e3e7c8d29cef9dcfed8877f8f56f9071f77843ecf3baf937f" dependencies = [ "base64 0.13.1", "chrono", - "getrandom", + "getrandom 0.2.15", "http 0.2.12", - "rand", + "rand 0.8.5", "reqwest 0.11.27", "serde", "serde_json", "serde_path_to_error", "sha2", - "thiserror", + "thiserror 1.0.63", "url", ] @@ -2412,6 +3292,7 @@ version = "0.1.0" dependencies = [ "aes-gcm", "aes-siv", + "anyhow", "async-stream", "async-trait", "aws-config", @@ -2434,9 +3315,10 @@ dependencies = [ "diesel", "diesel-derive-enum", "dotenv", + "dspy-rs", "futures", "generic-array", - "getrandom", + "getrandom 0.2.15", "hex", "hmac", "hyper 0.14.30", @@ -2447,7 +3329,7 @@ dependencies = [ "oauth2", "once_cell", "password-auth", - "rand_core", + "rand_core 0.6.4", "rcgen", "regex", "reqwest 0.11.27", @@ -2459,7 +3341,7 @@ dependencies = [ "serde_json", "sha2", "subtle", - "thiserror", + "thiserror 1.0.63", "tiktoken-rs", "tokio", "tokio-stream", @@ -2519,6 +3401,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "outref" version = "0.5.1" @@ -2531,6 +3419,18 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "panic-message" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384e52fd8fbd4cbe3c317e8216260c21a0f9134de108cea8a4dd4e7e152c472d" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.3" @@ -2561,9 +3461,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a2a4764cc1f8d961d802af27193c6f4f0124bd0e76e8393cf818e18880f0524" dependencies = [ "argon2", - "getrandom", + "getrandom 0.2.15", "password-hash", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -2573,10 +3473,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" dependencies = [ "base64ct", - "rand_core", + "rand_core 0.6.4", "subtle", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pem" version = "3.0.4" @@ -2672,7 +3578,7 @@ version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" dependencies = [ - "zerocopy", + "zerocopy 0.7.35", ] [[package]] @@ -2684,6 +3590,25 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.106", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -2730,6 +3655,61 @@ dependencies = [ "winapi", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.1", + "rustls 0.23.14", + "socket2 0.5.7", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash 2.1.1", + "rustls 0.23.14", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.5.7", + "tracing", + "windows-sys 0.59.0", +] + [[package]] name = "quote" version = "1.0.41" @@ -2739,6 +3719,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r2d2" version = "0.8.10" @@ -2757,8 +3743,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", ] [[package]] @@ -2768,7 +3764,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", ] [[package]] @@ -2777,7 +3783,25 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.15", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xoshiro" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" +dependencies = [ + "rand_core 0.6.4", ] [[package]] @@ -2789,6 +3813,26 @@ dependencies = [ "bitflags 2.6.0", ] +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "rcgen" version = "0.13.1" @@ -2811,6 +3855,37 @@ dependencies = [ "bitflags 2.6.0", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.15", + "libredox", + "thiserror 2.0.17", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "regex" version = "1.11.3" @@ -2861,6 +3936,12 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + [[package]] name = "reqwest" version = "0.11.27" @@ -2872,11 +3953,11 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "h2", + "h2 0.3.26", "http 0.2.12", "http-body 0.4.6", "hyper 0.14.30", - "hyper-rustls", + "hyper-rustls 0.24.2", "hyper-tls 0.5.0", "ipnet", "js-sys", @@ -2886,22 +3967,22 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", - "rustls", - "rustls-pemfile", + "rustls 0.21.12", + "rustls-pemfile 1.0.4", "serde", "serde_json", "serde_urlencoded", "sync_wrapper 0.1.2", - "system-configuration", + "system-configuration 0.5.1", "tokio", "tokio-native-tls", - "tokio-rustls", + "tokio-rustls 0.24.1", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", + "webpki-roots 0.25.4", "winreg", ] @@ -2913,18 +3994,28 @@ checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", + "futures-channel", "futures-core", + "futures-util", + "h2 0.4.12", "http 1.1.0", "http-body 1.0.1", "http-body-util", "hyper 1.7.0", + "hyper-rustls 0.27.7", "hyper-tls 0.6.0", "hyper-util", "js-sys", "log", + "mime", + "mime_guess", "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls 0.23.14", + "rustls-native-certs 0.8.0", "rustls-pki-types", "serde", "serde_json", @@ -2932,15 +4023,34 @@ dependencies = [ "sync_wrapper 1.0.1", "tokio", "tokio-native-tls", + "tokio-rustls 0.26.0", + "tokio-util", "tower 0.5.2", "tower-http 0.6.6", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", ] +[[package]] +name = "reqwest-eventsource" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "632c55746dbb44275691640e7b40c907c16a2dc1a5842aa98aaec90da6ec6bde" +dependencies = [ + "eventsource-stream", + "futures-core", + "futures-timer", + "mime", + "nom", + "pin-project-lite", + "reqwest 0.12.23", + "thiserror 1.0.63", +] + [[package]] name = "resend-rs" version = "0.9.1" @@ -2950,10 +4060,10 @@ dependencies = [ "ecow", "governor", "maybe-async", - "rand", + "rand 0.8.5", "reqwest 0.12.23", "serde", - "thiserror", + "thiserror 1.0.63", ] [[package]] @@ -2964,13 +4074,43 @@ checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" dependencies = [ "cc", "cfg-if", - "getrandom", + "getrandom 0.2.15", "libc", "spin", "untrusted", "windows-sys 0.52.0", ] +[[package]] +name = "rstest" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fc39292f8613e913f7df8fa892b8944ceb47c247b78e1b1ae2f09e019be789d" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", + "rustc_version", +] + +[[package]] +name = "rstest_macros" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f168d99749d307be9de54d23fd226628d99768225ef08f6ffb52e0182a27746" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn 2.0.106", + "unicode-ident", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -2983,6 +4123,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -3003,15 +4149,15 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.34" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ "bitflags 2.6.0", "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3022,10 +4168,25 @@ checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" dependencies = [ "log", "ring", - "rustls-webpki", + "rustls-webpki 0.101.7", "sct", ] +[[package]] +name = "rustls" +version = "0.23.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "415d9944693cb90382053259f89fbb077ea730ad7273047ec63b19bc9b160ba8" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.102.8", + "subtle", + "zeroize", +] + [[package]] name = "rustls-native-certs" version = "0.6.3" @@ -3033,7 +4194,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" dependencies = [ "openssl-probe", - "rustls-pemfile", + "rustls-pemfile 1.0.4", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcaf18a4f2be7326cd874a5fa579fae794320a0f388d365dca7e480e55f83f8a" +dependencies = [ + "openssl-probe", + "rustls-pemfile 2.2.0", + "rustls-pki-types", "schannel", "security-framework", ] @@ -3047,11 +4221,23 @@ dependencies = [ "base64 0.21.7", ] +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e696e35370c65c9c541198af4543ccd580cf17fc25d8e05c5a242b202488c55" +dependencies = [ + "web-time", +] [[package]] name = "rustls-webpki" @@ -3063,6 +4249,17 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -3093,6 +4290,31 @@ dependencies = [ "parking_lot", ] +[[package]] +name = "schemars" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33d020396d1d138dc19f1165df7545479dcd58d93810dc5d646a16e55abefa80" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.106", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -3116,7 +4338,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e0cc0f1cf93f4969faf3ea1c7d8a9faed25918d96affa959720823dfe86d4f3" dependencies = [ "bitcoin_hashes 0.14.0", - "rand", + "rand 0.8.5", "secp256k1-sys", "serde", ] @@ -3130,6 +4352,16 @@ dependencies = [ "cc", ] +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "serde", + "zeroize", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -3208,12 +4440,24 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "serde_json" version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ + "indexmap", "itoa", "memchr", "ryu", @@ -3231,6 +4475,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3282,6 +4535,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + [[package]] name = "simple_asn1" version = "0.6.2" @@ -3290,7 +4549,7 @@ checksum = "adc4e5204eb1910f40f9cfa375f6f05b68c3abac4b6fd879c8ff5e7ae8a0a085" dependencies = [ "num-bigint", "num-traits", - "thiserror", + "thiserror 1.0.63", "time", ] @@ -3303,6 +4562,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "small_ctor" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88414a5ca1f85d82cc34471e975f0f74f6aa54c40f062efa42c0080e7f763f81" + [[package]] name = "smallvec" version = "1.13.2" @@ -3329,11 +4594,25 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + [[package]] name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] [[package]] name = "spinning_top" @@ -3348,7 +4627,13 @@ dependencies = [ name = "stable_deref_trait" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" [[package]] name = "strsim" @@ -3430,7 +4715,18 @@ checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" dependencies = [ "bitflags 1.3.2", "core-foundation", - "system-configuration-sys", + "system-configuration-sys 0.5.0", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.6.0", + "core-foundation", + "system-configuration-sys 0.6.0", ] [[package]] @@ -3443,26 +4739,55 @@ dependencies = [ "libc", ] +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tempfile" -version = "3.12.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04cbcdd0c794ebb0d4cf35e88edd2f7d2c4c3e9a5a6dab322839b321c6a87a64" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ - "cfg-if", "fastrand", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.59.0", ] +[[package]] +name = "terminal_size" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" +dependencies = [ + "rustix", + "windows-sys 0.60.2", +] + [[package]] name = "thiserror" version = "1.0.63" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.63", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", ] [[package]] @@ -3476,6 +4801,17 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "thread_local" version = "1.1.8" @@ -3498,7 +4834,7 @@ dependencies = [ "fancy-regex", "lazy_static", "parking_lot", - "rustc-hash", + "rustc-hash 1.1.0", ] [[package]] @@ -3604,7 +4940,18 @@ version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" dependencies = [ - "rustls", + "rustls 0.21.12", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" +dependencies = [ + "rustls 0.23.14", + "rustls-pki-types", "tokio", ] @@ -3632,6 +4979,57 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" + [[package]] name = "tower" version = "0.4.13" @@ -3777,12 +5175,27 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" +dependencies = [ + "rand 0.9.2", +] + [[package]] name = "typenum" version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + [[package]] name = "unicode-bidi" version = "0.3.15" @@ -3804,6 +5217,12 @@ dependencies = [ "tinyvec", ] +[[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" @@ -3826,6 +5245,26 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74fc6b57825be3373f7054754755f03ac3a8f5d70015ccad699ba2029956f4a" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "native-tls", + "once_cell", + "rustls 0.23.14", + "rustls-pki-types", + "serde", + "serde_json", + "socks", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.2" @@ -3862,7 +5301,7 @@ version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314" dependencies = [ - "getrandom", + "getrandom 0.2.15", "serde", ] @@ -3888,7 +5327,7 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" dependencies = [ - "darling", + "darling 0.20.10", "once_cell", "proc-macro-error2", "proc-macro2", @@ -3945,6 +5384,15 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.104" @@ -4017,6 +5465,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" version = "0.3.81" @@ -4027,12 +5488,40 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.25.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.3", +] + +[[package]] +name = "webpki-roots" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b130c0d2d49f8b6889abc456e795e82525204f27c42cf767cf0d7734e089b8" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" @@ -4064,6 +5553,47 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -4091,6 +5621,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -4115,13 +5654,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -4134,6 +5690,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -4146,6 +5708,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -4158,12 +5726,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -4176,6 +5756,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -4188,6 +5774,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -4200,6 +5792,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -4212,6 +5810,21 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.50.0" @@ -4222,6 +5835,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + [[package]] name = "write16" version = "1.0.0" @@ -4241,7 +5860,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ "curve25519-dalek", - "rand_core", + "rand_core 0.6.4", "serde", "zeroize", ] @@ -4259,7 +5878,7 @@ dependencies = [ "nom", "oid-registry", "rusticata-macros", - "thiserror", + "thiserror 1.0.63", "time", ] @@ -4309,7 +5928,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" dependencies = [ "byteorder", - "zerocopy-derive", + "zerocopy-derive 0.7.35", +] + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive 0.8.27", ] [[package]] @@ -4323,6 +5951,17 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "zerofrom" version = "0.1.5" @@ -4385,3 +6024,31 @@ dependencies = [ "quote", "syn 2.0.106", ] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index 714f347a..eb89640c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } tower-http = { version = "0.5.2", features = ["cors"] } thiserror = "1.0.63" async-trait = "0.1.81" +anyhow = "1.0" jsonwebtoken = "9.3.0" jwt-compact = { version = "0.9.0-beta.1", features = ["es256k"] } diesel = { version = "=2.2.2", features = [ @@ -75,3 +76,7 @@ lazy_static = "1.4.0" subtle = "2.6.1" tiktoken-rs = "0.5" once_cell = "1.19" + +# DSPy for structured prompting and optimization +# Using main branch since v0.6.0 tag doesn't exist yet +dspy-rs = { git = "https://github.com/krypticmouse/DSRs", branch = "main" } diff --git a/docs/nitro-deploy.md b/docs/nitro-deploy.md index f3b676f1..b331e758 100644 --- a/docs/nitro-deploy.md +++ b/docs/nitro-deploy.md @@ -830,6 +830,54 @@ A restart should not be needed but if you need to: sudo systemctl restart vsock-billing-proxy.service ``` +## Vsock Kagi Search proxy +Create a vsock proxy service so that enclave program can talk to the Kagi Search API: + +First configure the endpoint into its allowlist: + +```sh +sudo vim /etc/nitro_enclaves/vsock-proxy.yaml +``` + +Add this line: +``` +- {address: kagi.com, port: 443} +``` + +Now create a service that spins this up automatically: + +```sh +sudo vim /etc/systemd/system/vsock-kagi-proxy.service +``` + +``` +[Unit] +Description=Vsock Kagi Search Proxy Service +After=network.target + +[Service] +User=root +ExecStart=/usr/bin/vsock-proxy 8026 kagi.com 443 +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +Activate the service: + +```sh +sudo systemctl daemon-reload +sudo systemctl enable vsock-kagi-proxy.service +sudo systemctl start vsock-kagi-proxy.service +sudo systemctl status vsock-kagi-proxy.service +``` + +A restart should not be needed but if you need to: +```sh +sudo systemctl restart vsock-kagi-proxy.service +``` + ## Vsock Tinfoil proxies Create vsock proxy services so that tinfoil-proxy can talk to Tinfoil services: @@ -1319,6 +1367,26 @@ INSERT INTO enclave_secrets (key, value) VALUES ('billing_server_url', decode('your_base64_string', 'base64')); ``` +#### Kagi API Key + +After the DB is initialized, we need to store the Kagi Search API key encrypted to the enclave KMS key. + +```sh +echo -n "KAGI_API_KEY" | base64 -w 0 +``` + +Take that output and encrypt to the KMS key, from a machine that has encrypt access to the key: + +```sh +aws kms encrypt --key-id "KEY_ARN" --plaintext "BASE64_KEY" --query CiphertextBlob --output text +``` + +Take that encrypted base64 and insert it into the `enclave_secrets` table with key as `kagi_api_key` and value as the base64. + +```sql +INSERT INTO enclave_secrets (key, value) +VALUES ('kagi_api_key', decode('your_base64_string', 'base64')); +``` ## Secrets Manager diff --git a/entrypoint.sh b/entrypoint.sh index 105817a4..5ba12b58 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -284,6 +284,10 @@ echo "127.0.0.21 doc-upload.model.tinfoil.sh" >> /etc/hosts echo "127.0.0.22 inference.tinfoil.sh" >> /etc/hosts log "Added Tinfoil proxy domains to /etc/hosts" +# Add Kagi Search hostname to /etc/hosts +echo "127.0.0.23 kagi.com" >> /etc/hosts +log "Added Kagi Search domain to /etc/hosts" + touch /app/libnsm.so log "Created /app/libnsm.so" @@ -376,6 +380,10 @@ python3 /app/traffic_forwarder.py 127.0.0.21 443 3 8024 & log "Starting Tinfoil Inference traffic forwarder" python3 /app/traffic_forwarder.py 127.0.0.22 443 3 8025 & +# Start the traffic forwarder for Kagi Search in the background +log "Starting Kagi Search traffic forwarder" +python3 /app/traffic_forwarder.py 127.0.0.23 443 3 8026 & + # Wait for the forwarders to start log "Waiting for forwarders to start" sleep 5 @@ -539,6 +547,14 @@ else log "Tinfoil Inference connection failed" fi +# Test the connection to Kagi Search +log "Testing connection to Kagi Search:" +if timeout 5 bash -c ' Result; + fn get_tool_call_by_uuid(&self, uuid: Uuid, user_id: Uuid) -> Result; fn create_tool_output(&self, new_output: NewToolOutput) -> Result; // Context reconstruction @@ -2340,6 +2341,12 @@ impl DBConnection for PostgresConnection { new_call.insert(conn).map_err(DBError::from) } + fn get_tool_call_by_uuid(&self, uuid: Uuid, user_id: Uuid) -> Result { + debug!("Getting tool call by UUID: {} for user: {}", uuid, user_id); + let conn = &mut self.db.get().map_err(|_| DBError::ConnectionError)?; + ToolCall::get_by_uuid(conn, uuid, user_id).map_err(DBError::from) + } + fn create_tool_output(&self, new_output: NewToolOutput) -> Result { debug!("Creating new tool output"); let conn = &mut self.db.get().map_err(|_| DBError::ConnectionError)?; diff --git a/src/kagi.rs b/src/kagi.rs new file mode 100644 index 00000000..72878e89 --- /dev/null +++ b/src/kagi.rs @@ -0,0 +1,218 @@ +//! Minimal Kagi Search API client +//! +//! This module provides a lightweight client for the Kagi Search API. +//! Only includes what we actually use - no bloat from auto-generated code. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +const KAGI_API_BASE: &str = "https://kagi.com/api/v1"; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); +const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Debug, thiserror::Error)] +pub enum KagiError { + #[error("HTTP request failed: {0}")] + Request(#[from] reqwest::Error), + #[error("API error: {status} - {message}")] + Api { status: u16, message: String }, +} + +/// Kagi API client with reusable HTTP client and stored API key +#[derive(Clone)] +pub struct KagiClient { + client: reqwest::Client, + api_key: Arc, +} + +impl KagiClient { + /// Create a new Kagi client with the given API key + /// The API key will automatically be prefixed with "Bot " for authorization + pub fn new(api_key: String) -> Result { + let client = reqwest::Client::builder() + .timeout(REQUEST_TIMEOUT) + .connect_timeout(CONNECT_TIMEOUT) + .pool_max_idle_per_host(100) + .user_agent("OpenAPI-Generator/0.1.0/rust") + .build() + .map_err(KagiError::Request)?; + + // Automatically prefix with "Bot " if not already present + let formatted_key = if api_key.starts_with("Bot ") { + api_key + } else { + format!("Bot {}", api_key) + }; + + Ok(Self { + client, + api_key: Arc::new(formatted_key), + }) + } + + /// Execute a search query + pub async fn search(&self, request: SearchRequest) -> Result { + let url = format!("{}/search", KAGI_API_BASE); + + let response = self + .client + .post(&url) + .header("Authorization", self.api_key.as_str()) + .json(&request) + .send() + .await?; + + let status = response.status(); + + if !status.is_success() { + let error_text = response.text().await.unwrap_or_default(); + return Err(KagiError::Api { + status: status.as_u16(), + message: error_text, + }); + } + + let search_response = response.json::().await?; + Ok(search_response) + } +} + +impl std::fmt::Debug for KagiClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("KagiClient") + .field("api_key", &"[REDACTED]") + .finish() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchRequest { + pub query: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub workflow: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub lens_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub lens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout: Option, +} + +impl SearchRequest { + pub fn new(query: String) -> Self { + Self { + query, + workflow: None, + lens_id: None, + lens: None, + timeout: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Workflow { + Search, + Images, + Videos, + News, + Podcasts, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LensConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub include: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub exclude: Option>, +} + +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct SearchResponse { + #[serde(skip_serializing_if = "Option::is_none")] + pub meta: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct Meta { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub node: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ms: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct SearchData { + #[serde(skip_serializing_if = "Option::is_none")] + pub search: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub image: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub video: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub podcast: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub podcast_creator: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub news: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub adjacent_question: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub direct_answer: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub interesting_news: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub interesting_finds: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub infobox: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub package_tracking: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub public_records: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub weather: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub related_search: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub listicle: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub web_archive: Option>, +} + +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct SearchResult { + pub url: String, + pub title: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub snippet: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub time: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub image: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub props: Option>, +} + +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct SearchResultImage { + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub height: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub width: Option, +} diff --git a/src/main.rs b/src/main.rs index a1674510..2e526e24 100644 --- a/src/main.rs +++ b/src/main.rs @@ -75,6 +75,7 @@ mod db; mod email; mod encrypt; mod jwt; +mod kagi; mod kv; mod message_signing; mod migrations; @@ -103,6 +104,7 @@ const RESEND_API_KEY_NAME: &str = "resend_api_key"; const BILLING_API_KEY_NAME: &str = "billing_api_key"; const BILLING_SERVER_URL_NAME: &str = "billing_server_url"; +const KAGI_API_KEY_NAME: &str = "kagi_api_key"; #[derive(Debug, Clone, Deserialize, Serialize)] pub struct EnclaveRequest { @@ -409,6 +411,7 @@ pub struct AppState { billing_client: Option, apple_jwt_verifier: Arc, cancellation_broadcast: tokio::sync::broadcast::Sender, + kagi_client: Option>, } #[derive(Default)] @@ -432,6 +435,7 @@ pub struct AppStateBuilder { sqs_publisher: Option>, billing_api_key: Option, billing_server_url: Option, + kagi_api_key: Option, } impl AppStateBuilder { @@ -536,6 +540,11 @@ impl AppStateBuilder { self } + pub fn kagi_api_key(mut self, kagi_api_key: Option) -> Self { + self.kagi_api_key = kagi_api_key; + self + } + pub async fn build(self) -> Result { let app_mode = self .app_mode @@ -632,6 +641,27 @@ impl AppStateBuilder { let (cancellation_tx, _) = tokio::sync::broadcast::channel(1024); + // Initialize Kagi client if API key is provided + let kagi_client = if let Some(ref api_key) = self.kagi_api_key { + tracing::info!("Initializing Kagi client with connection pooling (max 100 idle connections, 10s timeout)"); + match crate::kagi::KagiClient::new(api_key.clone()) { + Ok(client) => { + tracing::debug!("Kagi client initialized successfully"); + Some(Arc::new(client)) + } + Err(e) => { + tracing::error!( + "Failed to initialize Kagi client: {:?}. Web search will be unavailable.", + e + ); + None + } + } + } else { + tracing::debug!("Kagi API key not configured, web search tool will be unavailable"); + None + }; + Ok(AppState { app_mode, db, @@ -647,6 +677,7 @@ impl AppStateBuilder { billing_client, apple_jwt_verifier, cancellation_broadcast: cancellation_tx, + kagi_client, }) } } @@ -2133,6 +2164,48 @@ async fn retrieve_billing_server_url( } } +async fn retrieve_kagi_api_key( + aws_credential_manager: Arc>>, + db: Arc, +) -> Result, Error> { + let creds = aws_credential_manager + .read() + .await + .clone() + .expect("non-local mode should have creds") + .get_credentials() + .await + .expect("non-local mode should have creds"); + + // check if the key already exists in the db + let existing_key = db.get_enclave_secret_by_key(KAGI_API_KEY_NAME)?; + + if let Some(ref encrypted_key) = existing_key { + // Convert the stored bytes back to base64 + let base64_encrypted_key = general_purpose::STANDARD.encode(&encrypted_key.value); + + debug!("trying to decrypt base64 encrypted Kagi API key"); + + // Decrypt the existing key + let decrypted_bytes = decrypt_with_kms( + &creds.region, + &creds.access_key_id, + &creds.secret_access_key, + &creds.token, + &base64_encrypted_key, + ) + .map_err(|e| Error::EncryptionError(e.to_string()))?; + + // Convert the decrypted bytes to a UTF-8 string + String::from_utf8(decrypted_bytes) + .map_err(|e| Error::EncryptionError(format!("Failed to decode UTF-8: {}", e))) + .map(Some) + } else { + tracing::info!("Kagi API key not found in the database"); + Ok(None) + } +} + #[tokio::main] async fn main() -> Result<(), Error> { // Add debug logs for entrypoints and exit points @@ -2395,6 +2468,13 @@ async fn main() -> Result<(), Error> { std::env::var("BILLING_SERVER_URL").ok() }; + let kagi_api_key = if app_mode != AppMode::Local { + // Get from database if in enclave mode + retrieve_kagi_api_key(aws_credential_manager.clone(), db.clone()).await? + } else { + std::env::var("KAGI_API_KEY").ok() + }; + let app_state = AppStateBuilder::default() .app_mode(app_mode.clone()) .db(db) @@ -2412,6 +2492,7 @@ async fn main() -> Result<(), Error> { .sqs_queue_maple_events_url(sqs_queue_maple_events_url) .billing_api_key(billing_api_key) .billing_server_url(billing_server_url) + .kagi_api_key(kagi_api_key) .build() .await?; tracing::info!("App state created, app_mode: {:?}", app_mode); diff --git a/src/models/responses.rs b/src/models/responses.rs index 841895cd..4fb06a0a 100644 --- a/src/models/responses.rs +++ b/src/models/responses.rs @@ -562,6 +562,23 @@ pub struct NewToolCall { pub status: String, } +impl ToolCall { + pub fn get_by_uuid( + conn: &mut PgConnection, + uuid: Uuid, + user_id: Uuid, + ) -> Result { + tool_calls::table + .filter(tool_calls::uuid.eq(uuid)) + .filter(tool_calls::user_id.eq(user_id)) + .first::(conn) + .map_err(|e| match e { + diesel::result::Error::NotFound => ResponsesError::ToolCallNotFound, + _ => ResponsesError::DatabaseError(e), + }) + } +} + impl NewToolCall { pub fn insert(&self, conn: &mut PgConnection) -> Result { diesel::insert_into(tool_calls::table) @@ -708,6 +725,8 @@ pub struct RawThreadMessage { pub tool_call_id: Option, #[diesel(sql_type = diesel::sql_types::Nullable)] pub finish_reason: Option, + #[diesel(sql_type = diesel::sql_types::Nullable)] + pub tool_name: Option, } impl RawThreadMessage { @@ -741,7 +760,8 @@ impl RawThreadMessage { r.model, um.prompt_tokens as token_count, NULL::uuid as tool_call_id, - NULL::text as finish_reason + NULL::text as finish_reason, + NULL::text as tool_name FROM user_messages um LEFT JOIN responses r ON um.response_id = r.id WHERE um.conversation_id = $1 @@ -759,7 +779,8 @@ impl RawThreadMessage { r.model, am.completion_tokens as token_count, NULL::uuid as tool_call_id, - am.finish_reason + am.finish_reason, + NULL::text as tool_name FROM assistant_messages am LEFT JOIN responses r ON am.response_id = r.id WHERE am.conversation_id = $1 @@ -777,7 +798,8 @@ impl RawThreadMessage { NULL::text as model, tc.argument_tokens as token_count, tc.uuid as tool_call_id, - NULL::text as finish_reason + NULL::text as finish_reason, + tc.name as tool_name FROM tool_calls tc WHERE tc.conversation_id = $1 @@ -794,7 +816,8 @@ impl RawThreadMessage { NULL::text as model, tto.output_tokens as token_count, tc.uuid as tool_call_id, - NULL::text as finish_reason + NULL::text as finish_reason, + tc.name as tool_name FROM tool_outputs tto JOIN tool_calls tc ON tto.tool_call_fk = tc.id WHERE tto.conversation_id = $1 @@ -832,7 +855,8 @@ impl RawThreadMessage { r.model, um.prompt_tokens as token_count, NULL::uuid as tool_call_id, - NULL::text as finish_reason + NULL::text as finish_reason, + NULL::text as tool_name FROM user_messages um LEFT JOIN responses r ON um.response_id = r.id WHERE um.conversation_id = $1 @@ -850,7 +874,8 @@ impl RawThreadMessage { r.model, am.completion_tokens as token_count, NULL::uuid as tool_call_id, - am.finish_reason + am.finish_reason, + NULL::text as tool_name FROM assistant_messages am LEFT JOIN responses r ON am.response_id = r.id WHERE am.conversation_id = $1 @@ -868,7 +893,8 @@ impl RawThreadMessage { NULL::text as model, tc.argument_tokens as token_count, tc.uuid as tool_call_id, - NULL::text as finish_reason + NULL::text as finish_reason, + tc.name as tool_name FROM tool_calls tc WHERE tc.conversation_id = $1 @@ -885,7 +911,8 @@ impl RawThreadMessage { NULL::text as model, tto.output_tokens as token_count, tc.uuid as tool_call_id, - NULL::text as finish_reason + NULL::text as finish_reason, + tc.name as tool_name FROM tool_outputs tto JOIN tool_calls tc ON tto.tool_call_fk = tc.id WHERE tto.conversation_id = $1 @@ -932,7 +959,8 @@ impl RawThreadMessage { r.model, um.prompt_tokens as token_count, NULL::uuid as tool_call_id, - NULL::text as finish_reason + NULL::text as finish_reason, + NULL::text as tool_name FROM user_messages um LEFT JOIN responses r ON um.response_id = r.id WHERE um.response_id = $1 @@ -950,7 +978,8 @@ impl RawThreadMessage { r.model, am.completion_tokens as token_count, NULL::uuid as tool_call_id, - am.finish_reason + am.finish_reason, + NULL::text as tool_name FROM assistant_messages am LEFT JOIN responses r ON am.response_id = r.id WHERE am.response_id = $1 @@ -968,7 +997,8 @@ impl RawThreadMessage { NULL::text as model, tc.argument_tokens as token_count, tc.uuid as tool_call_id, - NULL::text as finish_reason + NULL::text as finish_reason, + tc.name as tool_name FROM tool_calls tc WHERE tc.response_id = $1 @@ -985,7 +1015,8 @@ impl RawThreadMessage { NULL::text as model, tto.output_tokens as token_count, tc.uuid as tool_call_id, - NULL::text as finish_reason + NULL::text as finish_reason, + tc.name as tool_name FROM tool_outputs tto JOIN tool_calls tc ON tto.tool_call_fk = tc.id WHERE tto.response_id = $1 @@ -1047,7 +1078,8 @@ impl RawThreadMessage { r.model, um.prompt_tokens as token_count, NULL::uuid as tool_call_id, - NULL::text as finish_reason + NULL::text as finish_reason, + NULL::text as tool_name FROM user_messages um LEFT JOIN responses r ON um.response_id = r.id WHERE um.conversation_id = $1 @@ -1065,7 +1097,8 @@ impl RawThreadMessage { r.model, am.completion_tokens as token_count, NULL::uuid as tool_call_id, - am.finish_reason + am.finish_reason, + NULL::text as tool_name FROM assistant_messages am LEFT JOIN responses r ON am.response_id = r.id WHERE am.conversation_id = $1 @@ -1083,7 +1116,8 @@ impl RawThreadMessage { NULL::text as model, tc.argument_tokens as token_count, tc.uuid as tool_call_id, - NULL::text as finish_reason + NULL::text as finish_reason, + tc.name as tool_name FROM tool_calls tc WHERE tc.conversation_id = $1 @@ -1100,7 +1134,8 @@ impl RawThreadMessage { NULL::text as model, tto.output_tokens as token_count, tc.uuid as tool_call_id, - NULL::text as finish_reason + NULL::text as finish_reason, + tc.name as tool_name FROM tool_outputs tto JOIN tool_calls tc ON tto.tool_call_fk = tc.id WHERE tto.conversation_id = $1 diff --git a/src/web/responses/constants.rs b/src/web/responses/constants.rs index 28a8d6f4..576b2ad4 100644 --- a/src/web/responses/constants.rs +++ b/src/web/responses/constants.rs @@ -68,3 +68,7 @@ pub const DEFAULT_PAGINATION_ORDER: &str = "desc"; /// Tool call defaults pub const DEFAULT_TOOL_FUNCTION_NAME: &str = "function"; + +/// Tool-related event types +pub const EVENT_TOOL_CALL_CREATED: &str = "tool_call.created"; +pub const EVENT_TOOL_OUTPUT_CREATED: &str = "tool_output.created"; diff --git a/src/web/responses/context_builder.rs b/src/web/responses/context_builder.rs index c0ef5c7f..2e35ec98 100644 --- a/src/web/responses/context_builder.rs +++ b/src/web/responses/context_builder.rs @@ -84,31 +84,142 @@ pub fn build_prompt( // Decrypt and add the messages we fetched for r in raw { - // Skip messages with no content (in_progress assistant messages) - let content_enc = match &r.content_enc { - Some(enc) => enc, - None => continue, - }; - - let plain = decrypt_with_key(user_key, content_enc) - .map_err(|_| crate::ApiError::InternalServerError)?; - let content = String::from_utf8_lossy(&plain).into_owned(); - let role = match r.message_type.as_str() { - "user" => ROLE_USER, - "assistant" => ROLE_ASSISTANT, - "tool_output" => "tool", - _ => continue, // Skip tool_call itself - }; - let t = r - .token_count - .map(|v| v as usize) - .unwrap_or_else(|| count_tokens(&content)); - msgs.push(ChatMsg { - role, - content, - tool_call_id: r.tool_call_id, - tok: t, - }); + match r.message_type.as_str() { + "user" => { + // User messages have encrypted MessageContent + let content_enc = match &r.content_enc { + Some(enc) => enc, + None => continue, + }; + let plain = decrypt_with_key(user_key, content_enc) + .map_err(|_| crate::ApiError::InternalServerError)?; + let content = String::from_utf8_lossy(&plain).into_owned(); + let t = r + .token_count + .map(|v| v as usize) + .unwrap_or_else(|| count_tokens(&content)); + msgs.push(ChatMsg { + role: ROLE_USER, + content, + tool_call_id: None, + tok: t, + }); + } + "assistant" => { + // Skip in_progress assistant messages (no content yet) + let content_enc = match &r.content_enc { + Some(enc) => enc, + None => continue, + }; + let plain = decrypt_with_key(user_key, content_enc) + .map_err(|_| crate::ApiError::InternalServerError)?; + let content = String::from_utf8_lossy(&plain).into_owned(); + let t = r + .token_count + .map(|v| v as usize) + .unwrap_or_else(|| count_tokens(&content)); + msgs.push(ChatMsg { + role: ROLE_ASSISTANT, + content, + tool_call_id: None, + tok: t, + }); + } + "tool_call" => { + // Tool calls are stored with encrypted arguments + // We need to format these as assistant messages with tool_calls array + let content_enc = match &r.content_enc { + Some(enc) => enc, + None => continue, + }; + let plain = decrypt_with_key(user_key, content_enc) + .map_err(|_| crate::ApiError::InternalServerError)?; + let arguments_str = String::from_utf8_lossy(&plain).into_owned(); + + // Parse arguments as JSON - if malformed, use empty object but continue safely + let arguments: serde_json::Value = + serde_json::from_str(&arguments_str).unwrap_or_else(|e| { + error!("Failed to parse tool call arguments as JSON: {:?}. Using empty object.", e); + serde_json::json!({}) + }); + + // Get tool name from database + let tool_name = r.tool_name.as_deref().unwrap_or("function"); + + // Serialize arguments back to string for OpenAI format + // OpenAI expects arguments as a JSON string, not a JSON object + let arguments_string = serde_json::to_string(&arguments).unwrap_or_else(|e| { + error!( + "Failed to serialize tool arguments: {:?}. Using empty object string.", + e + ); + "{}".to_string() + }); + + // Format as assistant message with tool_calls + let tool_call_msg = serde_json::json!({ + "role": "assistant", + "tool_calls": [{ + "id": r.tool_call_id.unwrap_or_else(uuid::Uuid::new_v4).to_string(), + "type": "function", + "function": { + "name": tool_name, + "arguments": arguments_string + } + }] + }); + + // Serialize tool_call_msg for storage in ChatMsg + // This should never fail since we're serializing a well-formed JSON structure + let content = match serde_json::to_string(&tool_call_msg) { + Ok(s) => s, + Err(e) => { + error!( + "Failed to serialize tool_call message: {:?}. Skipping this tool call.", + e + ); + // If this fails, skip this message entirely rather than corrupting the conversation + continue; + } + }; + + let t = r + .token_count + .map(|v| v as usize) + .unwrap_or_else(|| count_tokens(&arguments_str)); + + msgs.push(ChatMsg { + role: ROLE_ASSISTANT, + content, + tool_call_id: None, + tok: t, + }); + } + "tool_output" => { + // Tool outputs have encrypted output content + let content_enc = match &r.content_enc { + Some(enc) => enc, + None => continue, + }; + let plain = decrypt_with_key(user_key, content_enc) + .map_err(|_| crate::ApiError::InternalServerError)?; + let content = String::from_utf8_lossy(&plain).into_owned(); + let t = r + .token_count + .map(|v| v as usize) + .unwrap_or_else(|| count_tokens(&content)); + msgs.push(ChatMsg { + role: "tool", + content, + tool_call_id: r.tool_call_id, + tok: t, + }); + } + _ => { + // Unknown message type, skip + continue; + } + } } // Insert truncation message if we truncated @@ -432,7 +543,28 @@ pub fn build_prompt_from_chat_messages( })? .to_string() }) + } else if m.role == ROLE_ASSISTANT { + // Check if this is a tool_call message (JSON with tool_calls field) or regular assistant message + if let Ok(parsed) = serde_json::from_str::(&m.content) { + if parsed.get("tool_calls").is_some() { + // This is a tool_call message - use the JSON directly + parsed + } else { + // Regular assistant message - plain string + json!({ + "role": ROLE_ASSISTANT, + "content": m.content + }) + } + } else { + // Not valid JSON, treat as regular assistant message + json!({ + "role": ROLE_ASSISTANT, + "content": m.content + }) + } } else { + // User messages // Deserialize stored MessageContent and convert to OpenAI format let content = if m.role == ROLE_USER { // User messages are stored as MessageContent - convert to OpenAI format @@ -443,7 +575,7 @@ pub fn build_prompt_from_chat_messages( })?; MessageContentConverter::to_openai_format(&mc) } else { - // Assistant messages are plain strings + // Fallback for any other role serde_json::Value::String(m.content.clone()) }; diff --git a/src/web/responses/conversions.rs b/src/web/responses/conversions.rs index 1f70973e..957968d0 100644 --- a/src/web/responses/conversions.rs +++ b/src/web/responses/conversions.rs @@ -192,7 +192,7 @@ pub enum ConversationItem { #[serde(skip_serializing_if = "Option::is_none")] created_at: Option, }, - #[serde(rename = "function_tool_call")] + #[serde(rename = "function_call")] FunctionToolCall { id: Uuid, call_id: Uuid, @@ -203,7 +203,7 @@ pub enum ConversationItem { #[serde(skip_serializing_if = "Option::is_none")] created_at: Option, }, - #[serde(rename = "function_tool_call_output")] + #[serde(rename = "function_call_output")] FunctionToolCallOutput { id: Uuid, call_id: Uuid, @@ -309,7 +309,10 @@ impl ConversationItemConverter { error!("tool_call_id missing for tool call"); ApiError::InternalServerError })?, - name: DEFAULT_TOOL_FUNCTION_NAME.to_string(), + name: msg.tool_name.clone().unwrap_or_else(|| { + error!("tool_name missing for tool call, using default"); + DEFAULT_TOOL_FUNCTION_NAME.to_string() + }), arguments: content, status: msg.status.clone(), created_at: Some(msg.created_at.timestamp()), diff --git a/src/web/responses/dspy_adapter.rs b/src/web/responses/dspy_adapter.rs new file mode 100644 index 00000000..7614ea98 --- /dev/null +++ b/src/web/responses/dspy_adapter.rs @@ -0,0 +1,233 @@ +//! DSPy adapter for OpenSecret's completions API +//! +//! This module provides a custom Adapter implementation that integrates DSRs (DSPy Rust) +//! with our existing completions API infrastructure, ensuring billing, routing, +//! and auth are handled correctly. + +use crate::{ + models::users::User, + web::openai::{get_chat_completion_response, BillingContext, CompletionChunk}, + ApiError, AppState, +}; +use async_trait::async_trait; +use axum::http::HeaderMap; +use dspy_rs::{adapter::Adapter, Chat, ChatAdapter, Example, LMResponse, LmUsage, Message, MetaSignature, Prediction, LM}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::sync::Arc; +use tracing::{debug, error, trace, warn}; + +/// Custom LM implementation that wraps our completions API +/// +/// This ensures all DSPy calls go through our centralized billing, +/// routing (primary/fallback), retry logic, and auth handling. +/// +/// IMPORTANT: This implements the v0.6.0 API where LM takes &self (not &mut self) +/// and is wrapped in Arc (not Arc>). +#[derive(Clone)] +pub struct OpenSecretLM { + state: Arc, + user: User, + billing_context: BillingContext, +} + +impl OpenSecretLM { + pub fn new(state: Arc, user: User, billing_context: BillingContext) -> Self { + Self { + state, + user, + billing_context, + } + } + + /// Call our completions API (non-streaming) + /// + /// Converts DSRs Chat format → our API → back to DSRs Message format. + /// Billing, routing, retries, and auth all happen inside get_chat_completion_response. + /// + /// NOTE: v0.6.0 signature - takes &self (not &mut), returns LMResponse (not tuple) + pub async fn call(&self, messages: Chat) -> Result { + debug!("OpenSecretLM: Starting DSPy LM call"); + + // 1. Convert DSRs Chat → JSON + let messages_json = messages.to_json(); + trace!("OpenSecretLM: Converted messages to JSON: {:?}", messages_json); + + // 2. Build request body + let body = json!({ + "model": self.billing_context.model_name, + "messages": messages_json, + "stream": false // Non-streaming for classification + }); + + debug!( + "OpenSecretLM: Calling completions API with model {}", + self.billing_context.model_name + ); + + // 3. Call OUR API (billing, routing, retries all handled!) + let completion = get_chat_completion_response( + &self.state, + &self.user, + body, + &HeaderMap::new(), + self.billing_context.clone(), + ) + .await?; + + debug!("OpenSecretLM: Received completion stream"); + + // 4. Extract response from CompletionChunk stream + let mut rx = completion.stream; + if let Some(CompletionChunk::FullResponse(response_json)) = rx.recv().await { + trace!("OpenSecretLM: Received full response: {:?}", response_json); + + // 5. Parse response content + let content = response_json + .get("choices") + .and_then(|c| c.get(0)) + .and_then(|c| c.get("message")) + .and_then(|m| m.get("content")) + .and_then(|c| c.as_str()) + .ok_or_else(|| { + error!("OpenSecretLM: Failed to extract content from response"); + ApiError::InternalServerError + })?; + + debug!( + "OpenSecretLM: Extracted content: {}", + content.chars().take(100).collect::() + ); + + // 6. Extract usage (DSPy uses u32 for token counts) + let usage = if let Some(usage_json) = response_json.get("usage") { + LmUsage { + prompt_tokens: usage_json + .get("prompt_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0) as u32, + completion_tokens: usage_json + .get("completion_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0) as u32, + total_tokens: usage_json + .get("total_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0) as u32, + reasoning_tokens: Some(0), + } + } else { + LmUsage::default() + }; + + trace!("OpenSecretLM: Extracted usage: {:?}", usage); + + // 7. Create output message + let output = Message::assistant(content); + + // 8. Build full chat history (input + output) + let mut full_chat = messages.clone(); + full_chat.push_message(output.clone()); + + // 9. Return v0.6.0 LMResponse struct + debug!("OpenSecretLM: Call completed successfully"); + Ok(LMResponse { + output, + usage, + chat: full_chat, + }) + } else { + error!("OpenSecretLM: Did not receive FullResponse chunk"); + Err(ApiError::InternalServerError) + } + } +} + +/// Custom Adapter that uses OpenSecret's completions API instead of DSRs's default LM +/// +/// This adapter implements the DSRs Adapter trait, allowing us to use DSRs's `Predict` +/// module and other patterns while maintaining our existing infrastructure for billing, +/// routing, and auth. +/// +/// The adapter delegates formatting and parsing to ChatAdapter but overrides the `call` +/// method to use our custom OpenSecretLM instead of the standard DSRs LM. +#[derive(Clone)] +pub struct OpenSecretAdapter { + state: Arc, + user: User, + billing_context: BillingContext, + /// We use ChatAdapter for standard formatting and parsing + chat_adapter: ChatAdapter, +} + +impl OpenSecretAdapter { + pub fn new(state: Arc, user: User, billing_context: BillingContext) -> Self { + Self { + state, + user, + billing_context, + chat_adapter: ChatAdapter::default(), + } + } +} + +#[async_trait] +impl Adapter for OpenSecretAdapter { + /// Format the signature and inputs into a Chat (reuses ChatAdapter's logic) + fn format(&self, signature: &dyn MetaSignature, inputs: Example) -> Chat { + self.chat_adapter.format(signature, inputs) + } + + /// Parse the LM response back into structured output (reuses ChatAdapter's logic) + fn parse_response( + &self, + signature: &dyn MetaSignature, + response: Message, + ) -> HashMap { + self.chat_adapter.parse_response(signature, response) + } + + /// Call the LM - THIS is where we use our custom OpenSecretLM + /// + /// Note: The `lm` parameter is ignored because DSRs passes Arc but we need + /// to use our custom OpenSecretLM that wraps our infrastructure. This is a + /// necessary bridge to make DSRs work with our existing systems. + async fn call( + &self, + _lm: Arc, // Ignored - we use our own LM + signature: &dyn MetaSignature, + inputs: Example, + ) -> anyhow::Result { + debug!( + "OpenSecretAdapter: Calling {} signature", + std::any::type_name_of_val(signature) + ); + + // Format inputs into Chat messages using standard ChatAdapter logic + let messages = self.format(signature, inputs); + + // Use our custom LM that integrates with our infrastructure + let our_lm = OpenSecretLM::new( + self.state.clone(), + self.user.clone(), + self.billing_context.clone(), + ); + + // Call our LM + let response = our_lm.call(messages).await.map_err(|e| { + error!("OpenSecretAdapter: LM call failed: {:?}", e); + anyhow::anyhow!("LM call failed: {:?}", e) + })?; + + // Parse the response using standard ChatAdapter logic + let output = self.parse_response(signature, response.output); + + debug!("OpenSecretAdapter: Successfully parsed response"); + + // Return prediction with usage stats + Ok(Prediction { + data: output, + lm_usage: response.usage, + }) + } +} diff --git a/src/web/responses/dspy_modules.rs b/src/web/responses/dspy_modules.rs new file mode 100644 index 00000000..b4810430 --- /dev/null +++ b/src/web/responses/dspy_modules.rs @@ -0,0 +1,187 @@ +//! DSPy modules for classification and query extraction +//! +//! This module provides DSPy-style wrappers around our signatures, following +//! the standard DSPy pattern of encapsulating Predict-like modules in domain-specific structs. +//! +//! These modules use our custom OpenSecretAdapter to integrate with our existing +//! infrastructure while maintaining the DSPy API style. + +use crate::{ + models::users::User, + web::{ + openai::BillingContext, + responses::{prompts, OpenSecretAdapter}, + }, + ApiError, AppState, +}; +use dspy_rs::{adapter::Adapter, core::lm::LM, example, MetaSignature}; +use std::sync::Arc; +use tracing::{debug, warn}; + +/// IntentClassifier - Classifies user intent as "web_search" or "chat" +/// +/// This follows the DSPy pattern of wrapping a signature with domain-specific logic. +/// Uses a fast, cheap model with temperature=0 for deterministic classification. +pub struct IntentClassifier { + signature: Box, + adapter: OpenSecretAdapter, + /// Dummy LM required by DSRs API but ignored by our adapter + dummy_lm: Arc, +} + +impl IntentClassifier { + /// Create a new intent classifier + /// + /// # Arguments + /// * `state` - Application state for API access + /// * `user` - User making the request + pub async fn new(state: Arc, user: User) -> Self { + let billing_context = BillingContext::new( + crate::web::openai_auth::AuthMethod::Jwt, + "llama-3.3-70b".to_string(), + ); + + let adapter = OpenSecretAdapter::new(state, user, billing_context); + let signature = Box::new(prompts::new_intent_classifier()); + + // Create a dummy LM - won't be used because our adapter ignores it + // We need a real LM instance to satisfy the API, but our adapter ignores it + let dummy_lm = Arc::new( + LM::builder() + .api_key("dummy_key".into()) + .build() + .await, + ); + + Self { + signature, + adapter, + dummy_lm, + } + } + + /// Classify a user message as "web_search" or "chat" + /// + /// # Arguments + /// * `message` - The user's message to classify + /// + /// # Returns + /// - "web_search" if the user needs current information, facts, or search + /// - "chat" if the user wants casual conversation or general discussion + pub async fn classify(&self, message: &str) -> Result { + debug!("IntentClassifier: Classifying message"); + + let input = example! { + "user_message": "input" => message, + }; + + // Call our adapter directly (similar to how Predict::forward works) + // The dummy_lm is ignored by our adapter + let result = self + .adapter + .call(self.dummy_lm.clone(), self.signature.as_ref(), input) + .await + .map_err(|e| { + warn!("IntentClassifier: Classification failed: {:?}", e); + ApiError::InternalServerError + })?; + + let intent = result + .get("intent", None) + .as_str() + .unwrap_or("chat") + .trim() + .to_lowercase(); + + debug!("IntentClassifier: Classified as '{}'", intent); + + // Normalize to expected values + let normalized = if intent.contains("web_search") || intent.contains("search") { + "web_search".to_string() + } else { + "chat".to_string() + }; + + Ok(normalized) + } +} + +/// QueryExtractor - Extracts clean search queries from natural language +/// +/// This follows the DSPy pattern of wrapping a signature with domain-specific logic. +/// Uses a fast, cheap model with temperature=0 for consistent extraction. +pub struct QueryExtractor { + signature: Box, + adapter: OpenSecretAdapter, + /// Dummy LM required by DSRs API but ignored by our adapter + dummy_lm: Arc, +} + +impl QueryExtractor { + /// Create a new query extractor + /// + /// # Arguments + /// * `state` - Application state for API access + /// * `user` - User making the request + pub async fn new(state: Arc, user: User) -> Self { + let billing_context = BillingContext::new( + crate::web::openai_auth::AuthMethod::Jwt, + "llama-3.3-70b".to_string(), + ); + + let adapter = OpenSecretAdapter::new(state, user, billing_context); + let signature = Box::new(prompts::new_query_extractor()); + + // Create a dummy LM - won't be used because our adapter ignores it + // We need a real LM instance to satisfy the API, but our adapter ignores it + let dummy_lm = Arc::new( + LM::builder() + .api_key("dummy_key".into()) + .build() + .await, + ); + + Self { + signature, + adapter, + dummy_lm, + } + } + + /// Extract a clean search query from a natural language question + /// + /// # Arguments + /// * `user_message` - The user's natural language question + /// + /// # Returns + /// A concise search query extracted from the message + pub async fn extract(&self, user_message: &str) -> Result { + debug!("QueryExtractor: Extracting query from message"); + + let input = example! { + "user_message": "input" => user_message, + }; + + // Call our adapter directly (similar to how Predict::forward works) + // The dummy_lm is ignored by our adapter + let result = self + .adapter + .call(self.dummy_lm.clone(), self.signature.as_ref(), input) + .await + .map_err(|e| { + warn!("QueryExtractor: Extraction failed: {:?}", e); + ApiError::InternalServerError + })?; + + let query = result + .get("search_query", None) + .as_str() + .unwrap_or(user_message) + .trim() + .to_string(); + + debug!("QueryExtractor: Extracted query: '{}'", query); + + Ok(query) + } +} diff --git a/src/web/responses/events.rs b/src/web/responses/events.rs index 6edffb05..88d398e7 100644 --- a/src/web/responses/events.rs +++ b/src/web/responses/events.rs @@ -11,13 +11,15 @@ use super::constants::{ EVENT_RESPONSE_COMPLETED, EVENT_RESPONSE_CONTENT_PART_ADDED, EVENT_RESPONSE_CONTENT_PART_DONE, EVENT_RESPONSE_CREATED, EVENT_RESPONSE_ERROR, EVENT_RESPONSE_IN_PROGRESS, EVENT_RESPONSE_OUTPUT_ITEM_ADDED, EVENT_RESPONSE_OUTPUT_ITEM_DONE, - EVENT_RESPONSE_OUTPUT_TEXT_DELTA, EVENT_RESPONSE_OUTPUT_TEXT_DONE, + EVENT_RESPONSE_OUTPUT_TEXT_DELTA, EVENT_RESPONSE_OUTPUT_TEXT_DONE, EVENT_TOOL_CALL_CREATED, + EVENT_TOOL_OUTPUT_CREATED, }; use super::handlers::{ encrypt_event, ResponseCancelledEvent, ResponseCompletedEvent, ResponseContentPartAddedEvent, ResponseContentPartDoneEvent, ResponseCreatedEvent, ResponseErrorEvent, ResponseInProgressEvent, ResponseOutputItemAddedEvent, ResponseOutputItemDoneEvent, - ResponseOutputTextDeltaEvent, ResponseOutputTextDoneEvent, + ResponseOutputTextDeltaEvent, ResponseOutputTextDoneEvent, ToolCallCreatedEvent, + ToolOutputCreatedEvent, }; /// Handles SSE event emission with automatic encryption and error handling @@ -137,6 +139,8 @@ pub enum ResponseEvent { Completed(ResponseCompletedEvent), Cancelled(ResponseCancelledEvent), Error(ResponseErrorEvent), + ToolCallCreated(ToolCallCreatedEvent), + ToolOutputCreated(ToolOutputCreatedEvent), } impl ResponseEvent { @@ -154,6 +158,8 @@ impl ResponseEvent { ResponseEvent::Completed(_) => EVENT_RESPONSE_COMPLETED, ResponseEvent::Cancelled(_) => EVENT_RESPONSE_CANCELLED, ResponseEvent::Error(_) => EVENT_RESPONSE_ERROR, + ResponseEvent::ToolCallCreated(_) => EVENT_TOOL_CALL_CREATED, + ResponseEvent::ToolOutputCreated(_) => EVENT_TOOL_OUTPUT_CREATED, } } @@ -175,6 +181,8 @@ impl ResponseEvent { emitter.emit_without_sequence(self.event_type(), e).await } ResponseEvent::Error(e) => emitter.emit_without_sequence(self.event_type(), e).await, + ResponseEvent::ToolCallCreated(e) => emitter.emit(self.event_type(), e).await, + ResponseEvent::ToolOutputCreated(e) => emitter.emit(self.event_type(), e).await, } } } diff --git a/src/web/responses/handlers.rs b/src/web/responses/handlers.rs index 3e10bb36..8634fb2c 100644 --- a/src/web/responses/handlers.rs +++ b/src/web/responses/handlers.rs @@ -12,9 +12,10 @@ use crate::{ encryption_middleware::{decrypt_request, encrypt_response, EncryptedResponse}, openai::get_chat_completion_response, responses::{ - build_prompt, build_usage, constants::*, error_mapping, storage_task, - ContentPartBuilder, DeletedObjectResponse, MessageContent, MessageContentConverter, - MessageContentPart, OutputItemBuilder, ResponseBuilder, ResponseEvent, SseEventEmitter, + build_prompt, build_usage, constants::*, error_mapping, prompts, storage_task, tools, + ContentPartBuilder, DeletedObjectResponse, IntentClassifier, MessageContent, + MessageContentConverter, MessageContentPart, OutputItemBuilder, QueryExtractor, + ResponseBuilder, ResponseEvent, SseEventEmitter, }, }, ApiError, AppState, @@ -582,6 +583,46 @@ pub fn router(state: Arc) -> Router { .with_state(state) } +/// SSE Event wrapper for tool_call.created +#[derive(Debug, Clone, Serialize)] +pub struct ToolCallCreatedEvent { + /// Event type (always "tool_call.created") + #[serde(rename = "type")] + pub event_type: &'static str, + + /// Sequence number for ordering + pub sequence_number: i32, + + /// Tool call ID + pub tool_call_id: Uuid, + + /// Tool name + pub name: String, + + /// Tool arguments (JSON value) + pub arguments: Value, +} + +/// SSE Event wrapper for tool_output.created +#[derive(Debug, Clone, Serialize)] +pub struct ToolOutputCreatedEvent { + /// Event type (always "tool_output.created") + #[serde(rename = "type")] + pub event_type: &'static str, + + /// Sequence number for ordering + pub sequence_number: i32, + + /// Tool output ID + pub tool_output_id: Uuid, + + /// Tool call ID this output belongs to + pub tool_call_id: Uuid, + + /// Tool output content + pub output: String, +} + /// Message types for the storage task #[derive(Debug, Clone)] pub enum StorageMessage { @@ -596,6 +637,19 @@ pub enum StorageMessage { }, Error(String), Cancelled, + /// Tool-related messages + ToolCall { + tool_call_id: Uuid, + name: String, + arguments: Value, + }, + ToolOutput { + tool_output_id: Uuid, + tool_call_id: Uuid, + output: String, + }, + /// Signal that assistant message is about to start streaming + AssistantMessageStarting, } /// Validated and prepared request data @@ -610,7 +664,7 @@ struct PreparedRequest { /// Context and conversation data after building prompt struct BuiltContext { conversation: crate::models::responses::Conversation, - prompt_messages: Vec, + prompt_messages: Arc>, total_prompt_tokens: usize, } @@ -782,30 +836,18 @@ async fn spawn_title_generation_task( }); } -/// Phase 1: Validate input and prepare encrypted content +/// Phase 1: Validate and normalize input /// -/// This phase performs all input validation and normalization without any side effects -/// (no database writes). It ensures the request is valid before proceeding. +/// Performs all input validation and normalization without any side effects. +/// Ensures the request is valid before proceeding. /// -/// # Validations -/// - Rejects guest users +/// Operations: +/// - Validates user is not guest /// - Gets user encryption key /// - Normalizes message content to Parts format -/// - Rejects unsupported features (file uploads) -/// - Counts tokens for billing check -/// - Encrypts content for storage +/// - Validates no unsupported features (file uploads) +/// - Counts tokens and encrypts content /// - Generates assistant message UUID -/// -/// # Arguments -/// * `state` - Application state -/// * `user` - Authenticated user -/// * `body` - Request body -/// -/// # Returns -/// PreparedRequest containing validated and encrypted data -/// -/// # Errors -/// Returns ApiError if validation fails or user is unauthorized async fn validate_and_normalize_input( state: &Arc, user: &User, @@ -902,35 +944,15 @@ async fn validate_and_normalize_input( }) } -/// Phase 2: Build conversation context and check billing -/// -/// This phase is read-only - it builds the conversation context from existing -/// messages and performs billing checks WITHOUT writing to the database. This -/// ensures we don't persist data if the user is over quota. -/// -/// # Operations -/// - Gets conversation from database -/// - Builds context from all existing messages -/// - Adds the NEW user message to context (not yet persisted) -/// - Checks billing quota (only for free users) -/// - Validates token limits +/// Phase 2: Build context and check billing /// -/// # Critical Design Note -/// The new user message is added to the context array but NOT yet persisted. -/// This allows accurate billing checks before committing to storage. +/// Read-only phase that builds conversation context and validates billing quota +/// before any database writes occur. /// -/// # Arguments -/// * `state` - Application state -/// * `user` - Authenticated user -/// * `body` - Request body -/// * `user_key` - User's encryption key -/// * `prepared` - Validated request data from Phase 1 -/// -/// # Returns -/// BuiltContext containing conversation, prompt messages, and token count -/// -/// # Errors -/// Returns ApiError if conversation not found, billing check fails, or user over quota +/// Operations: +/// - Fetches conversation and existing messages +/// - Builds prompt context with new user message (not yet persisted) +/// - Checks billing quota and token limits async fn build_context_and_check_billing( state: &Arc, user: &User, @@ -1013,42 +1035,25 @@ async fn build_context_and_check_billing( Ok(BuiltContext { conversation, - prompt_messages, + prompt_messages: Arc::new(prompt_messages), total_prompt_tokens, }) } -/// Phase 3: Persist request data to database -/// -/// This phase writes to the database ONLY after all validation and billing checks -/// have passed. This ensures atomic semantics - either everything is written or -/// nothing is written. +/// Phase 3: Persist request data /// -/// # Database Operations -/// - Creates Response record (job tracker) with status=in_progress -/// - Creates user message record linked to response -/// - Creates placeholder assistant message (status=in_progress, content=NULL) -/// - Encrypts metadata if provided -/// - Extracts internal_message_id from metadata if present +/// Writes to database after all validation and billing checks have passed. /// -/// # Design Notes -/// - Placeholder assistant message allows clients to see in-progress status -/// - Content is NULL until streaming completes -/// - Response ID is used to link all related records -/// - Metadata is encrypted before storage +/// Database operations: +/// - Creates Response record (status=in_progress) +/// - Creates user message /// -/// # Arguments -/// * `state` - Application state -/// * `user` - Authenticated user -/// * `body` - Request body -/// * `prepared` - Validated request data from Phase 1 -/// * `conversation` - Conversation from Phase 2 -/// -/// # Returns -/// PersistedData containing created records and decrypted metadata -/// -/// # Errors -/// Returns ApiError if database operations fail +/// Note: Assistant message is NOT created here - it's created later in Phase 6 (after tools). +/// Originally, the assistant placeholder was created here, but this caused timestamp +/// ordering issues: the assistant message would get created_at=T1 (early), then tools +/// would execute at T2/T3, making the assistant appear BEFORE its tools in queries +/// ordered by created_at. By creating the assistant message in Phase 6 (after tools), +/// we ensure the correct semantic order: user → tool_call → tool_output → assistant. async fn persist_request_data( state: &Arc, user: &User, @@ -1125,25 +1130,6 @@ async fn persist_request_data( .create_user_message(new_msg) .map_err(error_mapping::map_generic_db_error)?; - // Create placeholder assistant message with status='in_progress' and NULL content - let placeholder_assistant = NewAssistantMessage { - uuid: prepared.assistant_message_id, - conversation_id: conversation.id, - response_id: Some(response.id), - user_id: user.uuid, - content_enc: None, - completion_tokens: 0, - status: STATUS_IN_PROGRESS.to_string(), - finish_reason: None, - }; - state - .db - .create_assistant_message(placeholder_assistant) - .map_err(|e| { - error!("Error creating placeholder assistant message: {:?}", e); - ApiError::InternalServerError - })?; - info!( "Created response {} for user {} in conversation {}", response.uuid, user.uuid, conversation.uuid @@ -1162,43 +1148,242 @@ async fn persist_request_data( }) } -/// Phase 4: Setup streaming pipeline with channels and tasks +/// Helper function to check if tool_choice allows tool execution /// -/// This phase sets up the dual-stream architecture that allows simultaneous -/// streaming to the client and storage to the database. The streaming continues -/// independently even if the client disconnects. +/// Returns false if tool_choice is explicitly set to "none", true otherwise +fn is_tool_choice_allowed(tool_choice: &Option) -> bool { + tool_choice.as_deref() != Some("none") +} + +/// Helper function to check if web_search tool is enabled in the request /// -/// # Architecture -/// - Creates two channels: storage (critical) and client (best-effort) -/// - Spawns storage task to persist data as it arrives -/// - Spawns upstream processor to parse SSE from chat API -/// - Returns client channel for SSE event generation +/// Returns true if the tools array contains an object with type="web_search" +fn is_web_search_enabled(tools: &Option) -> bool { + if let Some(tools_value) = tools { + if let Some(tools_array) = tools_value.as_array() { + return tools_array.iter().any(|tool| { + tool.get("type") + .and_then(|t| t.as_str()) + .map(|s| s == "web_search") + .unwrap_or(false) + }); + } + } + false +} + +/// Phase 5: Classify intent and execute tools (optional) /// -/// # Key Design Principles -/// 1. **Dual streaming**: Client and storage streams operate independently -/// 2. **Storage priority**: Storage sends must succeed, client sends can fail -/// 3. **Independent lifecycle**: Streaming continues even if client disconnects -/// 4. **Cancellation support**: Listens for cancellation broadcast signals +/// Classifies user intent and executes tools if needed. Runs after dual streams +/// are created so tool events can be sent to both client and storage. /// -/// # Task Spawning -/// - Storage task: Accumulates content and persists on completion -/// - Upstream processor: Parses SSE frames and broadcasts to both channels +/// Flow: +/// 1. Classify intent: chat vs web_search +/// 2. If web_search: extract query and execute tool +/// 3. Send ToolCall event to streams +/// 4. Send ToolOutput event to streams (always, even on error) +/// 5. Send persistence command via dedicated channel and wait for acknowledgment /// -/// # Arguments -/// * `state` - Application state -/// * `user` - Authenticated user -/// * `body` - Request body -/// * `context` - Built context from Phase 2 -/// * `prepared` - Validated request data from Phase 1 -/// * `persisted` - Persisted records from Phase 3 -/// * `headers` - Request headers for upstream API call +/// Tool execution is best-effort and uses fast model (llama-3.3-70b). +async fn classify_and_execute_tools( + state: &Arc, + user: &User, + prepared: &PreparedRequest, + persisted: &PersistedData, + tx_client: &mpsc::Sender, + tx_storage: &mpsc::Sender, + rx_tool_ack: tokio::sync::oneshot::Receiver>, +) -> Result, ApiError> { + // Extract text from user message for classification + let user_text = + MessageContentConverter::extract_text_for_token_counting(&prepared.message_content); + + trace!( + "Classifying user intent for message: {}", + user_text.chars().take(100).collect::() + ); + debug!("Starting DSPy-based intent classification"); + + // Step 1: Classify intent using DSPy IntentClassifier module + let classifier = IntentClassifier::new(state.clone(), user.clone()).await; + + let intent = match classifier.classify(&user_text).await { + Ok(intent) => { + debug!("Classified intent: {}", intent); + intent + } + Err(e) => { + // Best effort - if classification fails, default to chat + warn!("DSPy classification failed (defaulting to chat): {:?}", e); + "chat".to_string() + } + }; + + // Step 2: If intent is web_search, execute tool + if intent == "web_search" { + debug!("User message classified as web_search, executing tool"); + + // Extract search query using DSPy QueryExtractor module + let extractor = QueryExtractor::new(state.clone(), user.clone()).await; + + let search_query = match extractor.extract(&user_text).await { + Ok(query) => { + trace!("Extracted search query: {}", query); + debug!("Search query extracted successfully"); + query + } + Err(e) => { + warn!("DSPy query extraction failed, using original message: {:?}", e); + user_text.clone() + } + }; + + // Generate UUIDs for tool_call and tool_output + let tool_call_id = Uuid::new_v4(); + let tool_output_id = Uuid::new_v4(); + + // Prepare tool arguments + let tool_arguments = json!({"query": search_query}); + + // Send tool_call event through both streams FIRST (before execution) + let tool_call_msg = StorageMessage::ToolCall { + tool_call_id, + name: "web_search".to_string(), + arguments: tool_arguments.clone(), + }; + + // Send to storage (critical - must succeed) + // + // IMPORTANT: Storage channel failure means the storage task has died or the + // channel buffer (1024) is full. This is a catastrophic systemic failure, not + // a normal error. If this happens: + // 1. Nothing will be persisted to database + // 2. Continuing would waste LLM API calls for unsaved data + // 3. Client would see tool_call.created but never get completion + // + // We abort the entire request and notify the client with response.error event. + // If you see this error in production, investigate immediately - it indicates + // serious issues with the storage task or database connection. + if let Err(e) = tx_storage.send(tool_call_msg.clone()).await { + error!( + "Critical: Storage channel closed during tool_call for response {} - {:?}", + persisted.response.uuid, e + ); + // Notify client and abort - storage failure is catastrophic + let _ = tx_client + .send(StorageMessage::Error( + "Internal storage failure - request aborted".to_string(), + )) + .await; + return Err(ApiError::InternalServerError); + } + // Send to client (best-effort) + if tx_client.try_send(tool_call_msg).is_err() { + warn!("Client channel full or closed, skipping tool_call event to client"); + } + + debug!("Sent tool_call {} to streams", tool_call_id); + + // Execute web search tool (or capture error as content) + let tool_output = + match tools::execute_tool("web_search", &tool_arguments, state.kagi_client.as_ref()) + .await + { + Ok(output) => { + debug!( + "Tool execution successful, output length: {} chars", + output.len() + ); + output + } + Err(e) => { + warn!("Tool execution failed, including error in output: {:?}", e); + // Failure becomes content, not a skip! + format!("Error: {}", e) + } + }; + + // Send tool_output event through both streams (ALWAYS sent, even on failure) + let tool_output_msg = StorageMessage::ToolOutput { + tool_output_id, + tool_call_id, + output: tool_output.clone(), + }; + + // Send to storage (critical - must succeed) + // + // IMPORTANT: Storage channel failure is catastrophic (see tool_call comment above). + // At this point, client has already seen tool_call.created event. If storage fails + // here, we have an inconsistency: + // - Database has tool_call record but no tool_output + // - Client saw tool_call.created but won't see tool_output.created + // + // We abort and send response.error so the client knows the request failed rather + // than hanging indefinitely waiting for completion. The database inconsistency + // (orphaned tool_call) is acceptable given this is a catastrophic failure scenario. + if let Err(e) = tx_storage.send(tool_output_msg.clone()).await { + error!( + "Critical: Storage channel closed during tool_output for response {} - {:?}", + persisted.response.uuid, e + ); + // Notify client and abort - storage failure is catastrophic + let _ = tx_client + .send(StorageMessage::Error( + "Internal storage failure - request aborted".to_string(), + )) + .await; + return Err(ApiError::InternalServerError); + } + // Send to client (best-effort) + if tx_client.try_send(tool_output_msg).is_err() { + warn!("Client channel full or closed, skipping tool_output event to client"); + } + + info!( + "Successfully sent tool_call {} and tool_output {} to streams for conversation {}", + tool_call_id, tool_output_id, persisted.response.conversation_id + ); + + // Wait for storage task to confirm persistence (with timeout) + match tokio::time::timeout(std::time::Duration::from_secs(5), rx_tool_ack).await { + Ok(Ok(Ok(()))) => { + debug!("Tools persisted successfully to database"); + return Ok(Some(())); + } + Ok(Ok(Err(e))) => { + error!("Failed to persist tools to database: {}", e); + // Continue anyway - best effort + return Ok(Some(())); + } + Ok(Err(_)) => { + error!("Storage task dropped before sending acknowledgment"); + return Ok(Some(())); + } + Err(_) => { + error!("Timeout waiting for tool persistence (5s)"); + return Ok(Some(())); + } + } + } else { + debug!("User message classified as chat, skipping tool execution"); + } + + Ok(None) +} + +/// Phase 6: Setup completion processor /// -/// # Returns -/// Tuple of (client channel receiver, response record) for SSE stream generation +/// Gets completion stream from chat API and spawns processor task. /// -/// # Errors -/// Returns ApiError if chat API call fails or channel creation fails -async fn setup_streaming_pipeline( +/// Operations: +/// - Creates placeholder assistant message (AFTER tools, so timestamp is ordered correctly) +/// - Rebuilds prompt from DB if tools were executed (automatically includes tools) +/// - Calls chat API with streaming enabled +/// - Spawns processor task that converts CompletionChunks to StorageMessages +/// - Processor feeds into dual streams (storage=critical, client=best-effort) +/// - Listens for cancellation signals +#[allow(clippy::too_many_arguments)] +async fn setup_completion_processor( state: &Arc, user: &User, body: &ResponsesCreateRequest, @@ -1206,17 +1391,65 @@ async fn setup_streaming_pipeline( prepared: &PreparedRequest, persisted: &PersistedData, headers: &HeaderMap, -) -> Result< - ( - mpsc::Receiver, - crate::models::responses::Response, - ), - ApiError, -> { + tx_client: mpsc::Sender, + tx_storage: mpsc::Sender, + tools_executed: bool, +) -> Result { + // Create placeholder assistant message with status='in_progress' and NULL content + // + // TIMING: This happens here in Phase 6 (not earlier in Phase 3) for two reasons: + // 1. Must happen AFTER tool execution (Phase 5) to get correct timestamp ordering + // 2. Must happen BEFORE calling completion API (below) so storage task can UPDATE it + // + // Previously, this was created in Phase 3 under the assumption it needed to exist early. + // However, it only needs to exist before the storage task tries to UPDATE it (when + // streaming completes). By creating it here, we ensure proper message ordering: + // user → tool_call → tool_output → assistant (this creation) → assistant content (update) + let placeholder_assistant = NewAssistantMessage { + uuid: prepared.assistant_message_id, + conversation_id: context.conversation.id, + response_id: Some(persisted.response.id), + user_id: user.uuid, + content_enc: None, + completion_tokens: 0, + status: STATUS_IN_PROGRESS.to_string(), + finish_reason: None, + }; + state + .db + .create_assistant_message(placeholder_assistant) + .map_err(|e| { + error!("Error creating placeholder assistant message: {:?}", e); + ApiError::InternalServerError + })?; + + debug!( + "Created placeholder assistant message {} after tool execution", + prepared.assistant_message_id + ); + + // If tools were executed, rebuild prompt from DB (will now include persisted tools) + // Otherwise use the context we built earlier + let prompt_messages = if tools_executed { + debug!("Tools were executed - rebuilding prompt from DB to include tool messages"); + let (rebuilt_messages, _tokens) = build_prompt( + state.db.as_ref(), + context.conversation.id, + user.uuid, + &prepared.user_key, + &body.model, + body.instructions.as_deref(), + )?; + rebuilt_messages + } else { + // Clone out of Arc only when actually needed for the completion request + Arc::as_ref(&context.prompt_messages).clone() + }; + // Build chat completion request let chat_request = json!({ "model": body.model, - "messages": context.prompt_messages, + "messages": prompt_messages, "temperature": body.temperature.unwrap_or(DEFAULT_TEMPERATURE), "top_p": body.top_p.unwrap_or(DEFAULT_TOP_P), "max_tokens": body.max_output_tokens, @@ -1247,23 +1480,19 @@ async fn setup_streaming_pipeline( completion.metadata.provider_name, completion.metadata.model_name ); - // Create channels for storage task and client stream - let (tx_storage, rx_storage) = mpsc::channel::(STORAGE_CHANNEL_BUFFER); - let (tx_client, rx_client) = mpsc::channel::(CLIENT_CHANNEL_BUFFER); - - // Spawn storage task (no longer needs sqs_publisher - billing is centralized!) - let _storage_handle = { - let db = state.db.clone(); - let response_id = persisted.response.id; - let user_key = prepared.user_key; - let message_id = prepared.assistant_message_id; - - tokio::spawn(async move { - storage_task(rx_storage, db, response_id, user_key, message_id).await; - }) - }; + // Signal that assistant message is about to start streaming + // CRITICAL: Must send BEFORE spawning processor to guarantee ordering + // (processor will immediately start sending ContentDelta messages) + if let Err(e) = tx_client + .send(StorageMessage::AssistantMessageStarting) + .await + { + error!("Failed to send AssistantMessageStarting signal: {:?}", e); + // Client channel closed - not critical, continue anyway + } // Spawn stream processor task that converts CompletionChunks to StorageMessages + // and feeds them into the master stream channels (created in Phase 3.5) let _processor_handle = { let mut rx_completion = completion.stream; let message_id = prepared.assistant_message_id; @@ -1315,16 +1544,19 @@ async fn setup_streaming_pipeline( .and_then(|d| d.get("content")) .and_then(|c| c.as_str()) { - let msg = StorageMessage::ContentDelta(content.to_string()); - // Must send to storage (critical, can block) - if tx_storage.send(msg.clone()).await.is_err() { - error!("Storage channel closed unexpectedly"); - break; - } - // Best-effort send to client (non-blocking, never blocks storage) - if client_alive && tx_client.try_send(msg).is_err() { - warn!("Client channel full or closed, terminating client stream"); - client_alive = false; + // Skip empty content deltas to avoid sending unnecessary events to client + if !content.is_empty() { + let msg = StorageMessage::ContentDelta(content.to_string()); + // Must send to storage (critical, can block) + if tx_storage.send(msg.clone()).await.is_err() { + error!("Storage channel closed unexpectedly"); + break; + } + // Best-effort send to client (non-blocking, never blocks storage) + if client_alive && tx_client.try_send(msg).is_err() { + warn!("Client channel full or closed, terminating client stream"); + client_alive = false; + } } } } @@ -1380,7 +1612,7 @@ async fn setup_streaming_pipeline( }) }; - Ok((rx_client, persisted.response.clone())) + Ok(persisted.response.clone()) } async fn create_response_stream( @@ -1395,17 +1627,19 @@ async fn create_response_stream( trace!("Request body: {:?}", body); trace!("Stream requested: {}", body.stream); - // Phase 1: Validate and normalize input (no side effects) + // Phase 1: Validate and normalize input let prepared = validate_and_normalize_input(&state, &user, &body).await?; - // Phase 2: Build context and check billing (read-only, no DB writes) + // Phase 2: Build context and check billing let context = build_context_and_check_billing(&state, &user, &body, &prepared.user_key, &prepared) .await?; - // Check if this is the first user message in the conversation - // The context.prompt_messages includes the NEW user message (added in build_context_and_check_billing) - // Count user and assistant messages - if there's exactly 1 user and 0 assistant, it's the first message + // Phase 3: Persist request data + let persisted = + persist_request_data(&state, &user, &body, &prepared, &context.conversation).await?; + + // Check if first message and spawn title generation task let (user_count, assistant_count) = context .prompt_messages @@ -1417,23 +1651,10 @@ async fn create_response_stream( _ => (users, assistants), } }); - let is_first_message = user_count == 1 && assistant_count == 0; - // Phase 3: Persist to database (only after all checks pass) - let persisted = - persist_request_data(&state, &user, &body, &prepared, &context.conversation).await?; - - // If this is the first message, spawn background task to generate conversation title - if is_first_message { - debug!( - "First message detected, spawning title generation task for conversation {}", - context.conversation.uuid - ); - - // Extract text content for title generation + if user_count == 1 && assistant_count == 0 { let user_content = MessageContentConverter::extract_text_for_token_counting(&prepared.message_content); - spawn_title_generation_task( state.clone(), context.conversation.id, @@ -1445,53 +1666,22 @@ async fn create_response_stream( .await; } - // Phase 4: Setup streaming pipeline - let (mut rx_client, response) = match setup_streaming_pipeline( - &state, &user, &body, &context, &prepared, &persisted, &headers, - ) - .await - { - Ok(result) => result, - Err(e) => { - // Streaming setup failed - clean up the database records - error!( - "Failed to setup streaming pipeline for response {}: {:?}", - persisted.response.uuid, e - ); - - // Update response status to failed - if let Err(db_err) = state.db.update_response_status( - persisted.response.id, - ResponseStatus::Failed, - Some(Utc::now()), - ) { - error!( - "Failed to update response status after pipeline error: {:?}", - db_err - ); - } - - // Update assistant message to incomplete with no content - if let Err(db_err) = state.db.update_assistant_message( - prepared.assistant_message_id, - None, // No content - 0, // No tokens - STATUS_INCOMPLETE.to_string(), - None, // No finish_reason since we failed before streaming - ) { - error!( - "Failed to update assistant message after pipeline error: {:?}", - db_err - ); - } - - return Err(e); - } - }; - + // Capture variables needed inside the stream + let response_for_stream = persisted.response.clone(); + let decrypted_metadata = persisted.decrypted_metadata.clone(); let assistant_message_id = prepared.assistant_message_id; let total_prompt_tokens = context.total_prompt_tokens; - + let response_id = persisted.response.id; + let response_uuid = persisted.response.uuid; + let conversation_id = context.conversation.id; + let user_id = user.uuid; + let user_key = prepared.user_key; + let message_content = prepared.message_content.clone(); + let content_enc = prepared.content_enc.clone(); + let conversation_for_stream = context.conversation.clone(); + let prompt_messages = context.prompt_messages.clone(); + + // Phases 4-6 now happen INSIDE the stream to start sending events ASAP trace!("Creating SSE event stream for client"); let event_stream = async_stream::stream! { trace!("=== STARTING SSE STREAM ==="); @@ -1499,11 +1689,11 @@ async fn create_response_stream( // Initialize the SSE event emitter let mut emitter = SseEventEmitter::new(&state, session_id, 0); - // Send initial response.created event + // Send initial response.created event IMMEDIATELY (before any processing) trace!("Building response.created event"); - let created_response = ResponseBuilder::from_response(&response) + let created_response = ResponseBuilder::from_response(&response_for_stream) .status(STATUS_IN_PROGRESS) - .metadata(persisted.decrypted_metadata.clone()) + .metadata(decrypted_metadata.clone()) .build(); let created_event = ResponseCreatedEvent { @@ -1523,44 +1713,196 @@ async fn create_response_stream( yield Ok(ResponseEvent::InProgress(in_progress_event).to_sse_event(&mut emitter).await); - // Process messages from upstream processor - let mut assistant_content = String::new(); - let mut total_completion_tokens = 0i32; - - // Event 3: response.output_item.added - let output_item_added_event = ResponseOutputItemAddedEvent { - event_type: EVENT_RESPONSE_OUTPUT_ITEM_ADDED, - sequence_number: emitter.sequence_number(), - output_index: 0, - item: OutputItem { - id: assistant_message_id.to_string(), - output_type: OUTPUT_TYPE_MESSAGE.to_string(), - status: STATUS_IN_PROGRESS.to_string(), - role: Some(ROLE_ASSISTANT.to_string()), - content: Some(vec![]), - }, + // Phase 4: Create dual streams and spawn storage task + trace!("Phase 4: Creating dual streams and spawning storage task"); + let (tx_storage, rx_storage) = mpsc::channel::(STORAGE_CHANNEL_BUFFER); + let (tx_client, mut rx_client) = mpsc::channel::(CLIENT_CHANNEL_BUFFER); + + // Create oneshot channel for tool persistence acknowledgment + let (tx_tool_ack, rx_tool_ack) = tokio::sync::oneshot::channel(); + + let _storage_handle = { + let db = state.db.clone(); + + tokio::spawn(async move { + storage_task( + rx_storage, + Some(tx_tool_ack), + db, + response_id, + conversation_id, + user_id, + user_key, + assistant_message_id, + ) + .await; + }) }; - yield Ok(ResponseEvent::OutputItemAdded(output_item_added_event).to_sse_event(&mut emitter).await); + // Spawn orchestrator task for phases 5-6 (runs in background, sends events to tx_client) + trace!("Spawning background orchestrator for phases 5-6"); + let orchestrator_tx_client = tx_client.clone(); + let orchestrator_tx_storage = tx_storage.clone(); + let orchestrator_state = state.clone(); + let orchestrator_user = user.clone(); + let orchestrator_body = body.clone(); + let orchestrator_headers = headers.clone(); + let orchestrator_response = response_for_stream.clone(); + let orchestrator_metadata = decrypted_metadata.clone(); + let orchestrator_conversation = conversation_for_stream.clone(); + let orchestrator_prompt_messages = prompt_messages.clone(); - // Event 4: response.content_part.added - let content_part_added_event = ResponseContentPartAddedEvent { - event_type: EVENT_RESPONSE_CONTENT_PART_ADDED, - sequence_number: emitter.sequence_number(), - item_id: assistant_message_id.to_string(), - output_index: 0, - content_index: 0, - part: ContentPart { - part_type: CONTENT_PART_TYPE_OUTPUT_TEXT.to_string(), - annotations: vec![], - logprobs: vec![], - text: String::new(), - }, - }; + tokio::spawn(async move { + trace!("Orchestrator: Starting phases 5-6 in background"); + + // Subscribe to cancellation broadcast + let mut cancel_rx = orchestrator_state.cancellation_broadcast.subscribe(); + + // Run phases 5-6 with cancellation support + tokio::select! { + _ = async { + // Phase 5: Classify intent and execute tools (if tool_choice allows it AND web_search is enabled AND Kagi client available) + let tools_executed = if is_tool_choice_allowed(&orchestrator_body.tool_choice) + && is_web_search_enabled(&orchestrator_body.tools) + && orchestrator_state.kagi_client.is_some() { + debug!("Orchestrator: tool_choice allows tools, web search enabled, and Kagi client available, proceeding with classification"); + + let prepared_for_tools = PreparedRequest { + user_key, + message_content: message_content.clone(), + user_message_tokens: 0, + content_enc: content_enc.clone(), + assistant_message_id, + }; + + let persisted_for_tools = PersistedData { + response: orchestrator_response.clone(), + decrypted_metadata: orchestrator_metadata.clone(), + }; + + match classify_and_execute_tools( + &orchestrator_state, + &orchestrator_user, + &prepared_for_tools, + &persisted_for_tools, + &orchestrator_tx_client, + &orchestrator_tx_storage, + rx_tool_ack, + ) + .await + { + Ok(result) => result.is_some(), + Err(e) => { + error!("Orchestrator: Critical error during tool execution, treating as cancellation: {:?}", e); + + // Treat critical errors (storage failure) same as cancellation + // Send cancellation to both channels - storage task will handle cleanup + let _ = orchestrator_tx_storage.send(StorageMessage::Cancelled).await; + let _ = orchestrator_tx_client.send(StorageMessage::Cancelled).await; + + // Abort orchestrator - don't waste resources on LLM call + // Storage task will update response status and assistant message + return; + } + } + } else { + debug!("Orchestrator: Web search tool not enabled or Kagi client not available, skipping classification"); + drop(rx_tool_ack); + false + }; + + // Phase 6: Setup completion processor + trace!("Orchestrator: Setting up completion processor"); + + let context_for_completion = BuiltContext { + conversation: orchestrator_conversation, + prompt_messages: orchestrator_prompt_messages, + total_prompt_tokens, + }; + + let prepared_for_completion = PreparedRequest { + user_key, + message_content, + user_message_tokens: 0, + content_enc, + assistant_message_id, + }; + + let persisted_for_completion = PersistedData { + response: orchestrator_response.clone(), + decrypted_metadata: orchestrator_metadata.clone(), + }; + + match setup_completion_processor( + &orchestrator_state, + &orchestrator_user, + &orchestrator_body, + &context_for_completion, + &prepared_for_completion, + &persisted_for_completion, + &orchestrator_headers, + orchestrator_tx_client.clone(), + orchestrator_tx_storage.clone(), + tools_executed, + ) + .await + { + Ok(_) => { + trace!("Orchestrator: Completion processor setup complete"); + // AssistantMessageStarting is now sent from inside setup_completion_processor + // to guarantee it arrives before any completion deltas + } + Err(e) => { + error!("Orchestrator: Failed to setup completion processor: {:?}", e); + + // Update response status to failed + if let Err(db_err) = orchestrator_state.db.update_response_status( + response_id, + ResponseStatus::Failed, + Some(Utc::now()), + ) { + error!("Orchestrator: Failed to update response status: {:?}", db_err); + } + + // Update assistant message to incomplete + if let Err(db_err) = orchestrator_state.db.update_assistant_message( + assistant_message_id, + None, + 0, + STATUS_INCOMPLETE.to_string(), + None, + ) { + error!("Orchestrator: Failed to update assistant message: {:?}", db_err); + } + + // Send error to client via channel (best-effort) + let _ = orchestrator_tx_client.try_send(StorageMessage::Error( + format!("Failed to setup streaming: {:?}", e) + )); + } + } + } => { + trace!("Orchestrator: Phases 5-6 completed normally"); + } + + Ok(cancelled_id) = cancel_rx.recv() => { + if cancelled_id == response_uuid { + debug!("Orchestrator: Received cancellation during phases 5-6 for response {}", response_uuid); - yield Ok(ResponseEvent::ContentPartAdded(content_part_added_event).to_sse_event(&mut emitter).await); + // Send cancellation to both channels + let _ = orchestrator_tx_storage.send(StorageMessage::Cancelled).await; + let _ = orchestrator_tx_client.send(StorageMessage::Cancelled).await; - trace!("Starting to process messages from upstream processor"); + trace!("Orchestrator: Cancellation handled, exiting"); + } + } + } + }); + + // NOW immediately start the event loop - it will receive events from orchestrator as they happen + trace!("Starting event loop to receive messages from background tasks"); + let mut assistant_content = String::new(); + let mut total_completion_tokens = 0i32; while let Some(msg) = rx_client.recv().await { trace!("Client stream received message from upstream processor"); match msg { @@ -1629,11 +1971,11 @@ async fn create_response_stream( .build(); let usage = build_usage(total_prompt_tokens as i32, total_completion_tokens); - let done_response = ResponseBuilder::from_response(&response) + let done_response = ResponseBuilder::from_response(&response_for_stream) .status(STATUS_COMPLETED) .output(vec![output_item]) .usage(usage) - .metadata(persisted.decrypted_metadata.clone()) + .metadata(decrypted_metadata.clone()) .build(); let completed_event = ResponseCompletedEvent { @@ -1675,7 +2017,7 @@ async fn create_response_stream( event_type: EVENT_RESPONSE_CANCELLED, created_at: Utc::now().timestamp(), data: ResponseCancelledData { - id: response.uuid, + id: response_uuid, }, }; @@ -1696,6 +2038,68 @@ async fn create_response_stream( yield Ok(ResponseEvent::Error(error_event).to_sse_event(&mut emitter).await); break; } + StorageMessage::ToolCall { tool_call_id, name, arguments } => { + debug!("Client stream received tool_call event: {} ({})", name, tool_call_id); + // Send tool_call.created event + let tool_call_event = ToolCallCreatedEvent { + event_type: EVENT_TOOL_CALL_CREATED, + sequence_number: emitter.sequence_number(), + tool_call_id, + name, + arguments, + }; + + yield Ok(ResponseEvent::ToolCallCreated(tool_call_event).to_sse_event(&mut emitter).await); + } + StorageMessage::ToolOutput { tool_output_id, tool_call_id, output } => { + debug!("Client stream received tool_output event: {}", tool_output_id); + // Send tool_output.created event + let tool_output_event = ToolOutputCreatedEvent { + event_type: EVENT_TOOL_OUTPUT_CREATED, + sequence_number: emitter.sequence_number(), + tool_output_id, + tool_call_id, + output, + }; + + yield Ok(ResponseEvent::ToolOutputCreated(tool_output_event).to_sse_event(&mut emitter).await); + } + StorageMessage::AssistantMessageStarting => { + debug!("Client stream received assistant message starting signal"); + + // Event 3: response.output_item.added + let output_item_added_event = ResponseOutputItemAddedEvent { + event_type: EVENT_RESPONSE_OUTPUT_ITEM_ADDED, + sequence_number: emitter.sequence_number(), + output_index: 0, + item: OutputItem { + id: assistant_message_id.to_string(), + output_type: OUTPUT_TYPE_MESSAGE.to_string(), + status: STATUS_IN_PROGRESS.to_string(), + role: Some(ROLE_ASSISTANT.to_string()), + content: Some(vec![]), + }, + }; + + yield Ok(ResponseEvent::OutputItemAdded(output_item_added_event).to_sse_event(&mut emitter).await); + + // Event 4: response.content_part.added + let content_part_added_event = ResponseContentPartAddedEvent { + event_type: EVENT_RESPONSE_CONTENT_PART_ADDED, + sequence_number: emitter.sequence_number(), + item_id: assistant_message_id.to_string(), + output_index: 0, + content_index: 0, + part: ContentPart { + part_type: CONTENT_PART_TYPE_OUTPUT_TEXT.to_string(), + annotations: vec![], + logprobs: vec![], + text: String::new(), + }, + }; + + yield Ok(ResponseEvent::ContentPartAdded(content_part_added_event).to_sse_event(&mut emitter).await); + } } } diff --git a/src/web/responses/mod.rs b/src/web/responses/mod.rs index 1848d454..0fb264a4 100644 --- a/src/web/responses/mod.rs +++ b/src/web/responses/mod.rs @@ -8,12 +8,16 @@ pub mod constants; pub mod context_builder; pub mod conversations; pub mod conversions; +pub mod dspy_adapter; +pub mod dspy_modules; pub mod errors; pub mod events; pub mod handlers; pub mod instructions; pub mod pagination; +pub mod prompts; pub mod storage; +pub mod tools; pub mod types; // Re-export commonly used types @@ -22,6 +26,8 @@ pub use builders::{ }; pub use context_builder::build_prompt; pub use conversions::{ConversationItem, ConversationItemConverter, MessageContentConverter}; +pub use dspy_adapter::{OpenSecretAdapter, OpenSecretLM}; +pub use dspy_modules::{IntentClassifier, QueryExtractor}; pub use errors::error_mapping; pub use events::{ResponseEvent, SseEventEmitter}; pub use pagination::Paginator; diff --git a/src/web/responses/prompts.rs b/src/web/responses/prompts.rs new file mode 100644 index 00000000..02dc37f7 --- /dev/null +++ b/src/web/responses/prompts.rs @@ -0,0 +1,196 @@ +//! Prompt templates and DSPy signatures for the Responses API +//! +//! This module contains all prompt templates used for intent classification, +//! query extraction, and other AI-driven features of the Responses API. + +use dspy_rs::Signature; +use serde_json::{json, Value}; + +// ============================================================================ +// DSPy Signatures +// ============================================================================ + +/// Intent classification signature +/// +/// Classifies whether a user message requires web search or is a chat conversation. +/// Uses deterministic temperature (0.0) for consistent classification. +#[Signature] +struct IntentClassificationInner { + /// Classify the user's intent. Return ONLY one of these exact values: + /// - "web_search" if the user needs current information, facts, news, real-time data, or web search + /// - "chat" if the user wants casual conversation, greetings, explanations, or general discussion + /// + /// Examples: + /// - "What's the weather today?" → web_search + /// - "Who is the current president?" → web_search + /// - "What happened in the news today?" → web_search + /// - "Hello, how are you?" → chat + /// - "Explain how photosynthesis works" → chat + /// - "Tell me a joke" → chat + #[input] + pub user_message: String, + + #[output] + pub intent: String, +} + +// Helper function to create IntentClassification (workaround for private struct) +pub fn new_intent_classifier() -> impl dspy_rs::MetaSignature { + IntentClassificationInner::new() +} + +/// Search query extraction signature +/// +/// Extracts a clean, focused search query from a natural language question. +/// Uses deterministic temperature (0.0) for consistent extraction. +#[Signature] +struct QueryExtractionInner { + /// Extract the main search query from the user's question. + /// Return only the search terms, nothing else. Be concise and specific. + /// + /// Examples: + /// - "What's the weather in San Francisco today?" → weather San Francisco today + /// - "Who is the current president of the United States?" → current president United States + /// - "Tell me about the latest SpaceX launch" → latest SpaceX launch + #[input] + pub user_message: String, + + #[output] + pub search_query: String, +} + +// Helper function to create QueryExtraction (workaround for private struct) +pub fn new_query_extractor() -> impl dspy_rs::MetaSignature { + QueryExtractionInner::new() +} + +// ============================================================================ +// Legacy Prompt Templates (DEPRECATED - will be removed after DSPy migration) +// ============================================================================ + +/// System prompt for intent classification +/// +/// This prompt instructs the LLM to classify whether a user's message requires +/// web search or can be handled as a regular chat conversation. +pub const INTENT_CLASSIFIER_PROMPT: &str = "\ +Classify the user's intent. Return ONLY one of these exact values: +- \"web_search\" if the user needs current information, facts, news, real-time data, or web search +- \"chat\" if the user wants casual conversation, greetings, explanations, or general discussion + +Examples: +- \"What's the weather today?\" → web_search +- \"Who is the current president?\" → web_search +- \"What happened in the news today?\" → web_search +- \"Hello, how are you?\" → chat +- \"Explain how photosynthesis works\" → chat +- \"Tell me a joke\" → chat"; + +/// System prompt for search query extraction +/// +/// This prompt instructs the LLM to extract a clean search query from the user's +/// natural language question. +pub const SEARCH_QUERY_EXTRACTOR_PROMPT: &str = "\ +Extract the main search query from the user's question. +Return only the search terms, nothing else. Be concise and specific. + +Examples: +- \"What's the weather in San Francisco today?\" → weather San Francisco today +- \"Who is the current president of the United States?\" → current president United States +- \"Tell me about the latest SpaceX launch\" → latest SpaceX launch"; + +/// Build a chat completion request for intent classification +/// +/// Uses a fast, cheap model (llama-3.3-70b) with temperature=0 for deterministic results. +/// +/// # Arguments +/// * `user_message` - The user's message to classify +/// +/// # Returns +/// A JSON request ready to be sent to `get_chat_completion_response` +pub fn build_intent_classification_request(user_message: &str) -> Value { + json!({ + "model": "llama-3.3-70b", + "messages": [ + { + "role": "system", + "content": INTENT_CLASSIFIER_PROMPT + }, + { + "role": "user", + "content": user_message + } + ], + "temperature": 0.0, + "max_tokens": 10, + "stream": false + }) +} + +/// Build a chat completion request for search query extraction +/// +/// Uses the same fast model as classification to extract a clean search query. +/// +/// # Arguments +/// * `user_message` - The user's message to extract a query from +/// +/// # Returns +/// A JSON request ready to be sent to `get_chat_completion_response` +pub fn build_query_extraction_request(user_message: &str) -> Value { + json!({ + "model": "llama-3.3-70b", + "messages": [ + { + "role": "system", + "content": SEARCH_QUERY_EXTRACTOR_PROMPT + }, + { + "role": "user", + "content": user_message + } + ], + "temperature": 0.0, + "max_tokens": 50, + "stream": false + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_intent_classification_request() { + let request = build_intent_classification_request("What's the weather?"); + + assert_eq!(request["model"], "llama-3.3-70b"); + assert_eq!(request["temperature"], 0.0); + assert_eq!(request["stream"], false); + + let messages = request["messages"].as_array().unwrap(); + assert_eq!(messages.len(), 2); + assert_eq!(messages[0]["role"], "system"); + assert_eq!(messages[1]["role"], "user"); + assert_eq!(messages[1]["content"], "What's the weather?"); + } + + #[test] + fn test_build_query_extraction_request() { + let request = build_query_extraction_request("What's the weather in New York?"); + + assert_eq!(request["model"], "llama-3.3-70b"); + assert_eq!(request["max_tokens"], 50); + + let messages = request["messages"].as_array().unwrap(); + assert_eq!(messages.len(), 2); + assert_eq!(messages[1]["content"], "What's the weather in New York?"); + } + + #[test] + fn test_prompts_contain_examples() { + assert!(INTENT_CLASSIFIER_PROMPT.contains("Examples:")); + assert!(INTENT_CLASSIFIER_PROMPT.contains("web_search")); + assert!(INTENT_CLASSIFIER_PROMPT.contains("chat")); + + assert!(SEARCH_QUERY_EXTRACTOR_PROMPT.contains("Examples:")); + } +} diff --git a/src/web/responses/storage.rs b/src/web/responses/storage.rs index 709161f1..5cf828e2 100644 --- a/src/web/responses/storage.rs +++ b/src/web/responses/storage.rs @@ -81,6 +81,46 @@ impl ContentAccumulator { completion_tokens: self.completion_tokens, }) } + StorageMessage::ToolCall { + tool_call_id, + name, + arguments, + } => { + trace!( + "Storage: received tool_call - id={}, name={}", + tool_call_id, + name + ); + // Signal immediate persistence + AccumulatorState::PersistToolCall { + tool_call_id, + name, + arguments, + } + } + StorageMessage::ToolOutput { + tool_output_id, + tool_call_id, + output, + } => { + trace!( + "Storage: received tool_output - id={}, tool_call_id={}, output_len={}", + tool_output_id, + tool_call_id, + output.len() + ); + // Signal immediate persistence + AccumulatorState::PersistToolOutput { + tool_output_id, + tool_call_id, + output, + } + } + StorageMessage::AssistantMessageStarting => { + trace!("Storage: received assistant message starting signal (no-op for storage)"); + // This is a signal for the client stream only, storage doesn't need to act on it + AccumulatorState::Continue + } } } } @@ -91,6 +131,16 @@ pub enum AccumulatorState { Complete(CompleteData), Cancelled(PartialData), Failed(FailureData), + PersistToolCall { + tool_call_id: Uuid, + name: String, + arguments: serde_json::Value, + }, + PersistToolOutput { + tool_output_id: Uuid, + tool_call_id: Uuid, + output: String, + }, } /// Data for a completed response @@ -259,21 +309,159 @@ impl ResponsePersister { } /// Main storage task that orchestrates accumulation and persistence +#[allow(clippy::too_many_arguments)] pub async fn storage_task( mut rx: mpsc::Receiver, + tool_persist_ack: Option>>, db: Arc, response_id: i64, + conversation_id: i64, + user_id: Uuid, user_key: SecretKey, message_id: Uuid, ) { let mut accumulator = ContentAccumulator::new(); let persister = ResponsePersister::new(db.clone(), response_id, message_id, user_key); + // Track tool acknowledgment channel + let mut tool_ack = tool_persist_ack; + // Accumulate messages until completion or error while let Some(msg) = rx.recv().await { match accumulator.handle_message(msg) { AccumulatorState::Continue => continue, + AccumulatorState::PersistToolCall { + tool_call_id, + name, + arguments, + } => { + // Persist tool call immediately to database + use crate::models::responses::NewToolCall; + + let arguments_json = match serde_json::to_string(&arguments) { + Ok(json) => json, + Err(e) => { + error!("Failed to serialize tool arguments: {:?}", e); + if let Some(ack) = tool_ack.take() { + let _ = ack + .send(Err(format!("Failed to serialize tool arguments: {:?}", e))); + } + continue; + } + }; + let arguments_enc = encrypt_with_key(&user_key, arguments_json.as_bytes()).await; + let token_count = count_tokens(&arguments_json); + let argument_tokens = if token_count > i32::MAX as usize { + warn!( + "Tool argument token count {} exceeds i32::MAX, clamping", + token_count + ); + i32::MAX + } else { + token_count as i32 + }; + + let new_tool_call = NewToolCall { + uuid: tool_call_id, + conversation_id, + response_id: Some(response_id), + user_id, + name, + arguments_enc: Some(arguments_enc), + argument_tokens, + status: "completed".to_string(), + }; + + match db.create_tool_call(new_tool_call) { + Ok(tool_call) => { + debug!( + "Persisted tool_call {} (db id: {})", + tool_call_id, tool_call.id + ); + // No need to track the ID in memory - we'll look it up when needed + } + Err(e) => { + error!("Failed to persist tool_call {}: {:?}", tool_call_id, e); + if let Some(ack) = tool_ack.take() { + let _ = ack.send(Err(format!("Failed to persist tool_call: {:?}", e))); + } + } + } + } + + AccumulatorState::PersistToolOutput { + tool_output_id, + tool_call_id, + output, + } => { + // Persist tool output immediately to database + use crate::models::responses::NewToolOutput; + + // Look up the tool_call by UUID to get its database ID (primary key) + // This is more reliable than tracking in memory across async operations + // Also validates that the tool_call belongs to this user (security check) + let tool_call_fk = match db.get_tool_call_by_uuid(tool_call_id, user_id) { + Ok(tool_call) => tool_call.id, + Err(e) => { + error!( + "Failed to find tool_call {} for tool_output: {:?}", + tool_call_id, e + ); + if let Some(ack) = tool_ack.take() { + let _ = + ack.send(Err(format!("Tool call not found in database: {:?}", e))); + } + continue; + } + }; + + let output_enc = encrypt_with_key(&user_key, output.as_bytes()).await; + let token_count = count_tokens(&output); + let output_tokens = if token_count > i32::MAX as usize { + warn!( + "Tool output token count {} exceeds i32::MAX, clamping", + token_count + ); + i32::MAX + } else { + token_count as i32 + }; + + let new_tool_output = NewToolOutput { + uuid: tool_output_id, + conversation_id, + response_id: Some(response_id), + user_id, + tool_call_fk, + output_enc, + output_tokens, + status: "completed".to_string(), + error: None, + }; + + match db.create_tool_output(new_tool_output) { + Ok(_) => { + debug!( + "Persisted tool_output {} for tool_call {}", + tool_output_id, tool_call_id + ); + + // Send acknowledgment after tool output is persisted + if let Some(ack) = tool_ack.take() { + let _ = ack.send(Ok(())); + } + } + Err(e) => { + error!("Failed to persist tool_output {}: {:?}", tool_output_id, e); + if let Some(ack) = tool_ack.take() { + let _ = + ack.send(Err(format!("Failed to persist tool_output: {:?}", e))); + } + } + } + } + AccumulatorState::Complete(data) => { if let Err(e) = persister.persist_completed(data).await { error!("Failed to persist completed response: {}", e); diff --git a/src/web/responses/tools.rs b/src/web/responses/tools.rs new file mode 100644 index 00000000..f6dfa9de --- /dev/null +++ b/src/web/responses/tools.rs @@ -0,0 +1,254 @@ +//! Tool execution for the Responses API +//! +//! This module handles tool execution including web search, with a clean +//! architecture that can be extended for additional tools in the future. + +use crate::kagi::{KagiClient, SearchRequest}; +use serde_json::{json, Value}; +use std::sync::Arc; +use tracing::{debug, error, info, trace, warn}; + +/// Execute web search using Kagi Search API +/// +/// Requires kagi_client to be provided (initialized at startup with connection pooling). +pub async fn execute_web_search( + query: &str, + kagi_client: Option<&Arc>, +) -> Result { + trace!("Executing web search for query: {}", query); + info!("Executing web search"); + + // Get client from parameter + let client = kagi_client.ok_or_else(|| { + error!("Kagi client not configured"); + "Kagi client not configured".to_string() + })?; + + // Create search request + let search_request = SearchRequest::new(query.to_string()); + + // Execute search + let response = client.search(search_request).await.map_err(|e| { + error!("Kagi search API error: {:?}", e); + format!("Search API error: {:?}", e) + })?; + + // Format results + let mut result_text = String::new(); + + if let Some(data) = response.data { + // Prioritize direct answers + if let Some(direct_answers) = data.direct_answer { + for answer in direct_answers { + result_text.push_str(&format!( + "Direct Answer: {}\n\n", + answer.snippet.unwrap_or_default() + )); + } + } + + // Add weather information if available + if let Some(weather_results) = data.weather { + if !weather_results.is_empty() { + result_text.push_str("Weather:\n\n"); + for result in weather_results.iter().take(1) { + result_text.push_str(&format!( + "{}\n {}\n\n", + result.title, + result.snippet.as_ref().unwrap_or(&String::new()) + )); + } + } + } + + // Add infobox if available (detailed entity information) + if let Some(infobox_results) = data.infobox { + if !infobox_results.is_empty() { + result_text.push_str("Information:\n\n"); + for result in infobox_results.iter().take(1) { + result_text.push_str(&format!( + "{}\n {}\n", + result.title, + result.snippet.as_ref().unwrap_or(&String::new()) + )); + + // Add URL if available for more details + if !result.url.is_empty() { + result_text.push_str(&format!(" More info: {}\n", result.url)); + } + result_text.push('\n'); + } + } + } + + // Add search results + if let Some(search_results) = data.search { + result_text.push_str("Search Results:\n\n"); + for (i, result) in search_results.iter().take(5).enumerate() { + result_text.push_str(&format!( + "{}. {}\n URL: {}\n {}\n\n", + i + 1, + result.title, + result.url, + result.snippet.as_ref().unwrap_or(&String::new()) + )); + } + } + + // Add news results if available + if let Some(news_results) = data.news { + if !news_results.is_empty() { + result_text.push_str("\nNews:\n\n"); + for (i, result) in news_results.iter().take(3).enumerate() { + result_text.push_str(&format!( + "{}. {}\n URL: {}\n {}\n\n", + i + 1, + result.title, + result.url, + result.snippet.as_ref().unwrap_or(&String::new()) + )); + } + } + } + } + + if result_text.is_empty() { + warn!("No search results found for query: {}", query); + return Ok(format!("No results found for query: '{}'", query)); + } + + Ok(result_text) +} + +/// Execute a tool by name with the given arguments +/// +/// This is the main entry point for tool execution. It routes to the appropriate +/// tool implementation based on the tool name. +/// +/// # Arguments +/// * `tool_name` - The name of the tool to execute (e.g., "web_search") +/// * `arguments` - JSON object containing the tool's arguments +/// * `kagi_client` - Optional Kagi client (with connection pooling) +/// +/// # Returns +/// * `Ok(String)` - The tool's output as a string +/// * `Err(String)` - An error message if the tool execution failed +pub async fn execute_tool( + tool_name: &str, + arguments: &Value, + kagi_client: Option<&Arc>, +) -> Result { + trace!( + "Executing tool: {} with arguments: {}", + tool_name, + arguments + ); + debug!("Executing tool: {}", tool_name); + + match tool_name { + "web_search" => { + // Extract the query from arguments + let query = arguments + .get("query") + .and_then(|q| q.as_str()) + .ok_or_else(|| "Missing 'query' argument for web_search".to_string())?; + + execute_web_search(query, kagi_client).await + } + _ => { + error!("Unknown tool requested: {}", tool_name); + Err(format!("Unknown tool: {}", tool_name)) + } + } +} + +/// Tool registry for managing available tools and their schemas +/// +/// This will be expanded in the future to support dynamic tool registration, +/// tool schemas, and validation. +pub struct ToolRegistry { + // Future: Add tool metadata, schemas, validation rules +} + +impl ToolRegistry { + pub fn new() -> Self { + Self {} + } + + /// Get the schema for a specific tool + /// + /// Returns the JSON schema that describes the tool's parameters and usage. + /// This can be used for validation or for passing to LLMs that support function calling. + #[allow(dead_code)] + pub fn get_tool_schema(&self, tool_name: &str) -> Option { + match tool_name { + "web_search" => Some(json!({ + "name": "web_search", + "description": "Search the web for current information, facts, and real-time data", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The search query to execute" + } + }, + "required": ["query"] + } + })), + _ => None, + } + } + + /// Check if a tool is available + #[allow(dead_code)] + pub fn is_tool_available(&self, tool_name: &str) -> bool { + matches!(tool_name, "web_search") + } +} + +impl Default for ToolRegistry { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_execute_web_search_no_client() { + let result = execute_web_search("test query", None).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not configured")); + } + + #[tokio::test] + async fn test_execute_tool_missing_args() { + // Test with None client - should fail on missing args before client check + let args = json!({}); + let result = execute_tool("web_search", &args, None).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Missing 'query'")); + } + + #[tokio::test] + async fn test_execute_tool_unknown() { + let args = json!({"query": "test"}); + let result = execute_tool("unknown_tool", &args, None).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Unknown tool")); + } + + #[test] + fn test_tool_registry() { + let registry = ToolRegistry::new(); + assert!(registry.is_tool_available("web_search")); + assert!(!registry.is_tool_available("unknown_tool")); + + let schema = registry.get_tool_schema("web_search"); + assert!(schema.is_some()); + assert_eq!(schema.unwrap()["name"], "web_search"); + } +}