diff --git a/Cargo.lock b/Cargo.lock index 3b2e21b..3e94304 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,20 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[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", + "serde", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -32,6 +46,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -124,6 +144,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.21.7" @@ -308,6 +334,15 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.63" @@ -359,6 +394,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.44" @@ -381,16 +422,62 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", +] + +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + [[package]] name = "cookie" version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ + "percent-encoding", "time", "version_check", ] +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -458,6 +545,25 @@ dependencies = [ "crossbeam-utils", ] +[[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.21" @@ -513,14 +619,38 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", ] [[package]] @@ -536,17 +666,37 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core", + "darling_core 0.23.0", "quote", "syn 2.0.117", ] +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" +dependencies = [ + "serde", +] + [[package]] name = "dbus" version = "0.9.11" @@ -568,6 +718,37 @@ dependencies = [ "serde_core", ] +[[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.11", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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.117", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -666,6 +847,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dom_query" version = "0.27.0" @@ -749,6 +939,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "embed-resource" version = "3.0.9" @@ -769,6 +965,12 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.2" @@ -796,6 +998,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "esaxx-rs" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" + +[[package]] +name = "fastembed" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add59222e7bc3787285f993744b244cd454d78571845623606bdc45b22b23a4e" +dependencies = [ + "anyhow", + "hf-hub", + "ndarray", + "ort", + "safetensors", + "serde", + "serde_json", + "tokenizers", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -1108,8 +1332,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1119,9 +1345,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1313,6 +1541,19 @@ dependencies = [ "foldhash 0.1.5", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", + "serde", + "serde_core", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -1337,6 +1578,32 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hf-hub" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef3982638978efa195ff11b305f51f1f22f4f0a6cabee7af79b383ebee6a213" +dependencies = [ + "dirs", + "http", + "indicatif", + "libc", + "log", + "rand", + "reqwest 0.12.28", + "serde", + "serde_json", + "thiserror 2.0.18", + "ureq", + "windows-sys 0.61.2", +] + +[[package]] +name = "hmac-sha256" +version = "1.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f" + [[package]] name = "html5ever" version = "0.38.0" @@ -1406,6 +1673,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1601,6 +1884,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indicatif" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" +dependencies = [ + "console", + "portable-atomic", + "unicode-width", + "unit-prefix", + "web-time", +] + [[package]] name = "infer" version = "0.19.0" @@ -1617,9 +1913,11 @@ dependencies = [ "application", "async-trait", "domain", + "fastembed", "git2", "notify", "portable-pty", + "reqwest 0.12.28", "serde", "serde_json", "tokio", @@ -1652,6 +1950,15 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[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.18" @@ -1900,6 +2207,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -1915,6 +2228,34 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lzma-rust2" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e20f57f9918e5bd7bc58c22cdd70a6afc7375d4dd9683af5f2b34bd3d2bba619" + +[[package]] +name = "macro_rules_attribute" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65049d7923698040cd0b1ddcced9b0eb14dd22c5f86ae59c3740eab64a676520" +dependencies = [ + "macro_rules_attribute-proc_macro", + "paste", +] + +[[package]] +name = "macro_rules_attribute-proc_macro" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" + [[package]] name = "markup5ever" version = "0.38.0" @@ -1926,6 +2267,16 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "memchr" version = "2.8.1" @@ -1947,6 +2298,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1980,6 +2337,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "monostate" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" +dependencies = [ + "monostate-impl", + "serde", + "serde_core", +] + +[[package]] +name = "monostate-impl" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "muda" version = "0.19.2" @@ -2001,6 +2380,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2039,10 +2433,20 @@ checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ "bitflags 2.12.1", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.1.1", "libc", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "notify" version = "6.1.1" @@ -2062,12 +2466,30 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2301,12 +2723,58 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "onig" +version = "6.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" +dependencies = [ + "bitflags 2.12.1", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ort" +version = "2.0.0-rc.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133" +dependencies = [ + "ndarray", + "ort-sys", + "smallvec", + "tracing", + "ureq", +] + +[[package]] +name = "ort-sys" +version = "2.0.0-rc.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90" +dependencies = [ + "hmac-sha256", + "lzma-rust2", + "ureq", +] + [[package]] name = "pango" version = "0.18.3" @@ -2355,6 +2823,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2465,6 +2939,21 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "portable-pty" version = "0.9.0" @@ -2501,6 +2990,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "precomputed-hash" version = "0.1.1" @@ -2588,6 +3086,61 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases 0.2.1", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "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 0.2.1", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -2609,12 +3162,78 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[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", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "raw-window-handle" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-cond" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" +dependencies = [ + "either", + "itertools", + "rayon", +] + +[[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 = "redox_syscall" version = "0.5.18" @@ -2684,6 +3303,47 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "webpki-roots", +] + [[package]] name = "reqwest" version = "0.13.4" @@ -2714,7 +3374,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.5.0", "web-sys", ] @@ -2742,6 +3402,20 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -2757,12 +3431,65 @@ dependencies = [ "semver", ] +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "safetensors" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "675656c1eabb620b921efea4f9199f97fc86e36dd6ffd1fbbe48d0f59a4987f5" +dependencies = [ + "hashbrown 0.16.1", + "serde", + "serde_json", +] + [[package]] name = "same-file" version = "1.0.6" @@ -2953,6 +3680,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.20.0" @@ -2979,7 +3718,7 @@ version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" dependencies = [ - "darling", + "darling 0.23.0", "proc-macro2", "quote", "syn 2.0.117", @@ -3110,6 +3849,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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 = "softbuffer" version = "0.4.8" @@ -3158,12 +3908,30 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spm_precompiled" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" +dependencies = [ + "base64 0.13.1", + "nom", + "serde", + "unicode-segmentation", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "string_cache" version = "0.9.0" @@ -3194,6 +3962,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3346,7 +4120,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.4", "serde", "serde_json", "serde_repr", @@ -3693,6 +4467,39 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokenizers" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223" +dependencies = [ + "ahash", + "aho-corasick", + "compact_str", + "dary_heap", + "derive_builder", + "esaxx-rs", + "getrandom 0.3.4", + "itertools", + "log", + "macro_rules_attribute", + "monostate", + "onig", + "paste", + "rand", + "rayon", + "rayon-cond", + "regex", + "regex-syntax", + "serde", + "serde_json", + "spm_precompiled", + "thiserror 2.0.18", + "unicode-normalization-alignments", + "unicode-segmentation", + "unicode_categories", +] + [[package]] name = "tokio" version = "1.52.3" @@ -3720,6 +4527,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4004,18 +4821,84 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization-alignments" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de" +dependencies = [ + "smallvec", +] + [[package]] name = "unicode-segmentation" version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64 0.22.1", + "cookie_store", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "socks", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -4047,6 +4930,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4224,6 +5113,19 @@ dependencies = [ "wasmparser", ] +[[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 = "wasm-streams" version = "0.5.0" @@ -4259,6 +5161,16 @@ 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 = "web_atoms" version = "0.2.4" @@ -4315,6 +5227,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -4554,6 +5475,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -5073,6 +6003,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -5094,6 +6044,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/crates/app-tauri/Cargo.toml b/crates/app-tauri/Cargo.toml index 0615c00..b9282e4 100644 --- a/crates/app-tauri/Cargo.toml +++ b/crates/app-tauri/Cargo.toml @@ -31,6 +31,12 @@ serde_json = { workspace = true } thiserror = { workspace = true } uuid = { workspace = true } +[features] +# Passthrough toggles to enable the real embedders in an IDE build. OFF by default +# (founding posture: `none` ⇒ naïve recall, zero dependency). +vector-http = ["infrastructure/vector-http"] +vector-onnx = ["infrastructure/vector-onnx"] + [dev-dependencies] uuid = { workspace = true } async-trait = { workspace = true } diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index 4d6203e..bea724a 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -35,7 +35,7 @@ use infrastructure::{ FsEmbedderProfileStore, FsMemoryStore, FsOrchestratorWatcher, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore, Git2Repository, IdeaiContextStore, LocalFileSystem, LocalProcessSpawner, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, - SystemClock, TokioBroadcastEventBus, UuidGenerator, VectorMemoryRecall, + SystemClock, TokioBroadcastEventBus, UuidGenerator, VectorMemoryRecall, ONNX_CACHE_SUBDIR, }; use crate::pty::PtyBridge; @@ -393,10 +393,12 @@ impl AppState { .flatten() }) .unwrap_or_else(EmbedderProfile::none); + let onnx_cache_dir = app_data_dir.join(ONNX_CACHE_SUBDIR); let memory_recall_port = build_memory_recall( Arc::clone(&fs_port), Arc::clone(&memory_store_port), &embedder_profile, + &onnx_cache_dir, ); let create_agent = Arc::new(CreateAgentFromScratch::new( @@ -711,9 +713,10 @@ pub(crate) fn build_memory_recall( fs: Arc, store: Arc, profile: &EmbedderProfile, + onnx_cache_dir: &std::path::Path, ) -> Arc { let naive = Arc::new(NaiveMemoryRecall::new(Arc::clone(&store))) as Arc; - match embedder_from_profile(profile) { + match embedder_from_profile(profile, onnx_cache_dir) { None => naive, Some(embedder) => { let embedder: Arc = Arc::from(embedder); @@ -839,7 +842,12 @@ mod tests { async fn build_memory_recall_none_profile_matches_naive_recall() { let (fs, store) = seed_store().await; - let wired = build_memory_recall(Arc::clone(&fs), Arc::clone(&store), &EmbedderProfile::none()); + let wired = build_memory_recall( + Arc::clone(&fs), + Arc::clone(&store), + &EmbedderProfile::none(), + std::path::Path::new("/unused-onnx-cache"), + ); let naive = NaiveMemoryRecall::new(Arc::clone(&store)); // 0 ⇒ empty; mid budgets ⇒ partial truncation; huge ⇒ full index order. diff --git a/crates/infrastructure/Cargo.toml b/crates/infrastructure/Cargo.toml index 9aeb9a1..f2417cb 100644 --- a/crates/infrastructure/Cargo.toml +++ b/crates/infrastructure/Cargo.toml @@ -23,3 +23,20 @@ git2 = { workspace = true } # Filesystem change notifications used to *wake* the orchestrator poll loop early # (the poll loop remains the robust cross-platform correctness guarantee). notify = "6" +# Optional HTTP client for the real `localServer`/`api` embedders (LOT C1a, +# §14.5.3). `default-features = false` + `rustls-tls` keeps it OpenSSL-free so the +# AppImage stays portable; pulled in *only* under the `vector-http` feature so the +# zero-dependency default (`none` strategy) compiles nothing extra. +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true } +# Optional in-process ONNX embedder (LOT C1b, §14.5.3). rustls all the way down +# (HF model download + ort binaries fetched at build time, NOT load-dynamic) so the +# AppImage stays self-contained and OpenSSL-free. Pulled in *only* under the +# `vector-onnx` feature; the zero-dependency default compiles nothing extra. +fastembed = { version = "5", default-features = false, features = ["hf-hub-rustls-tls", "ort-download-binaries-rustls-tls"], optional = true } + +[features] +# Real HTTP-backed embedders (`localServer` Ollama/llama.cpp, `api` OpenAI/Voyage…). +# OFF by default: the founding posture is `none` ⇒ naïve recall, zero dependency. +vector-http = ["dep:reqwest"] +# Real in-process ONNX embedder (`localOnnx`). OFF by default, same posture. +vector-onnx = ["dep:fastembed"] diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index 1b9e70d..ac4ccff 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -40,8 +40,12 @@ pub use pty::PortablePtyAdapter; pub use remote::{remote_host, LocalHost}; pub use runtime::CliAgentRuntime; pub use store::{ - embedder_from_profile, index_token_size, should_use_vector, AdaptiveMemoryRecall, - FsEmbedderProfileStore, FsMemoryStore, FsProfileStore, FsProjectStore, FsSkillStore, - FsTemplateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, StubEmbedder, - VectorMemoryRecall, + embedder_from_profile, index_token_size, onnx_model_is_cached, should_use_vector, + AdaptiveMemoryRecall, FsEmbedderProfileStore, FsMemoryStore, FsProfileStore, FsProjectStore, + FsSkillStore, FsTemplateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, + OnnxModelInfo, StubEmbedder, VectorMemoryRecall, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, }; +#[cfg(feature = "vector-http")] +pub use store::{detect_ollama, HttpEmbedder, DEFAULT_LOCAL_EMBED_ENDPOINT}; +#[cfg(feature = "vector-onnx")] +pub use store::OnnxEmbedder; diff --git a/crates/infrastructure/src/store/embedder.rs b/crates/infrastructure/src/store/embedder.rs index 8161da6..2c02da8 100644 --- a/crates/infrastructure/src/store/embedder.rs +++ b/crates/infrastructure/src/store/embedder.rs @@ -31,28 +31,57 @@ use domain::profile::{EmbedderProfile, EmbedderStrategy}; /// Builds the concrete [`Embedder`] for a profile, or `None` when the strategy is /// [`EmbedderStrategy::None`] (recall stays naïve). /// -/// The concrete engines are stubs ([`StubEmbedder`]) until their real ONNX/HTTP -/// integration lands; they fail cleanly with [`EmbedderError::Unsupported`] rather -/// than panic, so a composing recall degrades to naïve. +/// The remaining concrete engines are stubs ([`StubEmbedder`]) until their real +/// HTTP/ONNX integration is enabled via the matching feature; they fail cleanly +/// with [`EmbedderError::Unsupported`] rather than panic, so a composing recall +/// degrades to naïve. +/// +/// `onnx_cache_dir` is the directory under which a `localOnnx` engine caches its +/// model files (`/embedders/onnx`, see [`ONNX_CACHE_SUBDIR`]). It is +/// ignored by every other strategy. #[must_use] -pub fn embedder_from_profile(profile: &EmbedderProfile) -> Option> { +pub fn embedder_from_profile( + profile: &EmbedderProfile, + onnx_cache_dir: &std::path::Path, +) -> Option> { + let _ = onnx_cache_dir; // used only under `vector-onnx`; silence the unused warning otherwise. match profile.strategy { EmbedderStrategy::None => None, - EmbedderStrategy::LocalOnnx => Some(Box::new(StubEmbedder::new( - profile.id.clone(), - profile.dimension, - "localOnnx", - ))), - EmbedderStrategy::LocalServer => Some(Box::new(StubEmbedder::new( - profile.id.clone(), - profile.dimension, - "localServer", - ))), - EmbedderStrategy::Api => Some(Box::new(StubEmbedder::new( - profile.id.clone(), - profile.dimension, - "api", - ))), + EmbedderStrategy::LocalOnnx => { + #[cfg(feature = "vector-onnx")] + { + Some(Box::new(OnnxEmbedder::from_profile(profile, onnx_cache_dir))) + } + #[cfg(not(feature = "vector-onnx"))] + { + Some(Box::new(StubEmbedder::new( + profile.id.clone(), + profile.dimension, + "localOnnx", + ))) + } + } + // Real HTTP engines under the `vector-http` feature; the dependency-free + // build keeps the documented stub so the default posture is unchanged. + EmbedderStrategy::LocalServer | EmbedderStrategy::Api => { + #[cfg(feature = "vector-http")] + { + Some(Box::new(HttpEmbedder::from_profile(profile))) + } + #[cfg(not(feature = "vector-http"))] + { + let strategy = if profile.strategy == EmbedderStrategy::Api { + "api" + } else { + "localServer" + }; + Some(Box::new(StubEmbedder::new( + profile.id.clone(), + profile.dimension, + strategy, + ))) + } + } } } @@ -171,3 +200,411 @@ fn fnv1a(bytes: &[u8]) -> u64 { } hash } + +// --------------------------------------------------------------------------- +// HttpEmbedder — real `localServer` / `api` engine (LOT C1a, feature `vector-http`). +// --------------------------------------------------------------------------- + +/// Default Ollama (and llama.cpp `--api`) OpenAI-compatible embeddings endpoint, +/// used when a `localServer` profile leaves [`EmbedderProfile::endpoint`] empty. +#[cfg(feature = "vector-http")] +pub const DEFAULT_LOCAL_EMBED_ENDPOINT: &str = "http://localhost:11434/v1/embeddings"; + +/// A real [`Embedder`] talking to an **OpenAI-compatible** `/v1/embeddings` +/// endpoint over HTTP — the shape served by Ollama, llama.cpp's server, HuggingFace +/// text-embeddings-inference, and the OpenAI/Voyage/Together APIs alike. One adapter +/// covers both [`EmbedderStrategy::LocalServer`] (no auth) and +/// [`EmbedderStrategy::Api`] (a `Bearer` token read from an env var — never the key +/// itself in config). +/// +/// **Best-effort by contract** (see [`Embedder`]): an unreachable host, a non-2xx +/// status, a missing API key, or a malformed body all map to a clean +/// [`EmbedderError`] (never a panic), so a composing [`crate::store::AdaptiveMemoryRecall`] +/// degrades to the naïve recall. Construction is cheap and infallible; nothing +/// hits the network until [`embed`](Embedder::embed) is called (so the +/// zero-download posture holds until the embedder is actually used). +#[cfg(feature = "vector-http")] +pub struct HttpEmbedder { + id: String, + dimension: usize, + endpoint: String, + model: Option, + /// `Some(var)` for the `api` strategy: the env var carrying the bearer token. + api_key_env: Option, + client: reqwest::Client, +} + +#[cfg(feature = "vector-http")] +#[derive(serde::Serialize)] +struct EmbeddingsRequest<'a> { + #[serde(skip_serializing_if = "Option::is_none")] + model: Option<&'a str>, + input: &'a [String], +} + +#[cfg(feature = "vector-http")] +#[derive(serde::Deserialize)] +struct EmbeddingsResponse { + data: Vec, +} + +#[cfg(feature = "vector-http")] +#[derive(serde::Deserialize)] +struct EmbeddingDatum { + embedding: Vec, + /// Position in the input batch; the OpenAI shape guarantees it, and we sort by + /// it to restore input order regardless of how the server returns the array. + #[serde(default)] + index: usize, +} + +#[cfg(feature = "vector-http")] +impl HttpEmbedder { + /// Builds the embedder from a declarative profile. `localServer` profiles with + /// no `endpoint` fall back to [`DEFAULT_LOCAL_EMBED_ENDPOINT`]; `api` profiles + /// carry their endpoint explicitly. `api_key_env` is retained only for the + /// `api` strategy. + #[must_use] + pub fn from_profile(profile: &EmbedderProfile) -> Self { + let is_api = profile.strategy == EmbedderStrategy::Api; + let endpoint = profile + .endpoint + .clone() + .filter(|e| !e.is_empty()) + .unwrap_or_else(|| DEFAULT_LOCAL_EMBED_ENDPOINT.to_owned()); + Self { + id: profile.id.clone(), + dimension: profile.dimension, + endpoint, + model: profile.model.clone(), + api_key_env: if is_api { + profile.api_key_env.clone() + } else { + None + }, + // A bounded timeout keeps a hung/slow endpoint from stalling a recall + // forever; the composing `AdaptiveMemoryRecall` then degrades to naïve. + // Fall back to a default client if the builder cannot be constructed. + client: reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()), + } + } +} + +#[cfg(feature = "vector-http")] +#[async_trait] +impl Embedder for HttpEmbedder { + fn id(&self) -> &str { + &self.id + } + + async fn embed(&self, texts: &[String]) -> Result>, EmbedderError> { + if texts.is_empty() { + return Ok(Vec::new()); + } + + let mut request = self.client.post(&self.endpoint).json(&EmbeddingsRequest { + model: self.model.as_deref(), + input: texts, + }); + + // `api` strategy: attach the bearer token read from the configured env var. + // A configured-but-unset var is an "unavailable" condition, not a panic. + if let Some(var) = &self.api_key_env { + match std::env::var(var) { + Ok(key) if !key.is_empty() => { + request = request.bearer_auth(key); + } + _ => { + return Err(EmbedderError::Unavailable(format!( + "API key env var `{var}` is not set" + ))); + } + } + } + + let response = request + .send() + .await + .map_err(|e| EmbedderError::Unavailable(format!("request to `{}` failed: {e}", self.endpoint)))?; + + if !response.status().is_success() { + return Err(EmbedderError::Unavailable(format!( + "embeddings endpoint `{}` returned status {}", + self.endpoint, + response.status() + ))); + } + + let body: EmbeddingsResponse = response + .json() + .await + .map_err(|e| EmbedderError::Io(format!("malformed embeddings response: {e}")))?; + + if body.data.len() != texts.len() { + return Err(EmbedderError::Io(format!( + "embeddings count mismatch: expected {}, got {}", + texts.len(), + body.data.len() + ))); + } + + // Restore input order, then validate each vector's dimension. + let mut data = body.data; + data.sort_by_key(|d| d.index); + let mut vectors = Vec::with_capacity(data.len()); + for datum in data { + if datum.embedding.len() != self.dimension { + return Err(EmbedderError::Io(format!( + "embedding dimension mismatch: profile declares {}, server returned {}", + self.dimension, + datum.embedding.len() + ))); + } + vectors.push(datum.embedding); + } + Ok(vectors) + } + + fn dimension(&self) -> usize { + self.dimension + } +} + +/// Best-effort probe of whether an Ollama-style local embedding server is reachable +/// at `base_url` (e.g. `http://localhost:11434`). Used by the contextual +/// "configure an embedder?" prompt (C3) to detect an already-installed local engine +/// before suggesting any download — the Linux-spirit "detect the existing first" +/// rule. Never errors: a failure to reach the host simply means "not detected". +#[cfg(feature = "vector-http")] +pub async fn detect_ollama(base_url: &str) -> bool { + let base = base_url.trim_end_matches('/'); + let url = format!("{base}/api/tags"); + let client = match reqwest::Client::builder() + .timeout(std::time::Duration::from_millis(800)) + .build() + { + Ok(c) => c, + Err(_) => return false, + }; + matches!(client.get(&url).send().await, Ok(r) if r.status().is_success()) +} + +// --------------------------------------------------------------------------- +// OnnxEmbedder — real in-process `localOnnx` engine (LOT C1b, feature `vector-onnx`). +// --------------------------------------------------------------------------- +// +// The data/inspection helpers below (`OnnxModelInfo`, `RECOMMENDED_ONNX_MODELS`, +// `ONNX_CACHE_SUBDIR`, `onnx_model_is_cached`) are **always** compiled — they are +// pure data and pure-FS inspection, with no dependency on `fastembed`, so the C2/C3 +// UI can describe and probe the ONNX models even in a build without the feature. + +/// Cache subdirectory (under the app data dir) where the `localOnnx` engine stores +/// its downloaded model files. Forced explicitly so nothing ever lands in the +/// global hf-hub cache outside IdeA's data directory. +pub const ONNX_CACHE_SUBDIR: &str = "embedders/onnx"; + +/// Static, dependency-free description of a recommendable local ONNX model, for the +/// "configure an embedder?" UI (C2/C3): display name, vector dimension, approximate +/// on-disk size, and whether it is the recommended default. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OnnxModelInfo { + /// Stable model id accepted by a `localOnnx` profile's `model` field. + pub id: &'static str, + /// Human-readable name for the UI. + pub display_name: &'static str, + /// Length of the vectors this model produces. + pub dimension: usize, + /// Approximate download/disk size in megabytes (for the UI download hint). + pub approx_size_mb: u32, + /// Whether this is the recommended default model. + pub recommended: bool, +} + +/// The curated list of local ONNX models IdeA can offer to download. Kept tiny on +/// purpose (the Linux-spirit "small, multilingual, good enough" default). +pub const RECOMMENDED_ONNX_MODELS: &[OnnxModelInfo] = &[OnnxModelInfo { + id: "multilingual-e5-small", + display_name: "Multilingual E5 Small", + dimension: 384, + approx_size_mb: 118, + recommended: true, +}]; + +/// Best-effort, **pure-FS** probe of whether a `localOnnx` model already lives in +/// `cache_dir` (no network, no `fastembed` dependency — available even without the +/// `vector-onnx` feature). Heuristic: a non-empty subdirectory whose name contains +/// the model token, mirroring hf-hub's `models----` cache layout. +/// +/// A `false` here only means "not detected"; it never blocks anything. +#[must_use] +pub fn onnx_model_is_cached(cache_dir: &std::path::Path, model: &str) -> bool { + let needle = model.replace(['/', '_'], "-").to_ascii_lowercase(); + let Ok(entries) = std::fs::read_dir(cache_dir) else { + return false; + }; + for entry in entries.flatten() { + if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + continue; + } + let name = entry.file_name().to_string_lossy().to_ascii_lowercase(); + if !name.contains(&needle) { + continue; + } + // Non-empty directory ⇒ treat as a present cached model. + if std::fs::read_dir(entry.path()) + .map(|mut d| d.next().is_some()) + .unwrap_or(false) + { + return true; + } + } + false +} + +#[cfg(feature = "vector-onnx")] +mod onnx { + use std::path::{Path, PathBuf}; + use std::sync::Arc; + + use async_trait::async_trait; + use fastembed::{EmbeddingModel, InitOptions, TextEmbedding}; + use tokio::sync::{Mutex, OnceCell}; + + use domain::ports::{Embedder, EmbedderError}; + use domain::profile::EmbedderProfile; + + /// Resolves a profile `model` string to a concrete fastembed model + its + /// dimension. `None` model ⇒ the recommended default (Multilingual E5 Small, + /// 384). An unknown, non-empty string ⇒ `None` (caller maps to `Unsupported`). + pub(super) fn resolve_onnx_model(model: Option<&str>) -> Option<(EmbeddingModel, usize)> { + match model { + None => Some((EmbeddingModel::MultilingualE5Small, 384)), + Some(m) => match m.trim().to_ascii_lowercase().as_str() { + "multilingual-e5-small" | "e5-small" => { + Some((EmbeddingModel::MultilingualE5Small, 384)) + } + _ => None, + }, + } + } + + /// A real in-process [`Embedder`] running a quantised ONNX model via `fastembed` + /// (`localOnnx` strategy). The model is loaded **lazily** on first + /// [`embed`](Embedder::embed) (downloaded once into `cache_dir` if missing), then + /// reused; all CPU-bound work (model load + inference) runs on a blocking thread. + /// + /// **Best-effort by contract** (see [`Embedder`]): a failed download/load, + /// an unknown model, or a profile/model dimension mismatch all map to a clean + /// [`EmbedderError`] (never a panic), so a composing + /// [`crate::store::AdaptiveMemoryRecall`] degrades to naïve. Construction is + /// cheap and infallible — nothing touches disk or the network until `embed`. + pub struct OnnxEmbedder { + id: String, + dimension: usize, + /// `Some(model)` when the profile's model string resolved to a known model; + /// `None` for an unknown string ⇒ `embed` returns [`EmbedderError::Unsupported`]. + model: Option, + /// The original (unknown) model string, for the `Unsupported` message. + requested_model: Option, + cache_dir: PathBuf, + cell: OnceCell>>, + } + + impl OnnxEmbedder { + /// Builds the embedder from a declarative profile. **Cheap and infallible**: + /// no I/O, no download. An unknown model is detected lazily at `embed` time + /// (mapped to [`EmbedderError::Unsupported`]); here it is stored as-is and the + /// fallback model is recorded so the struct stays valid. + #[must_use] + pub fn from_profile(profile: &EmbedderProfile, cache_dir: &Path) -> Self { + // Record the resolved model when known; an unknown string is kept so + // construction never fails — `embed` returns `Unsupported` for it. + let model = resolve_onnx_model(profile.model.as_deref()).map(|(m, _)| m); + Self { + id: profile.id.clone(), + dimension: profile.dimension, + model, + requested_model: profile.model.clone(), + cache_dir: cache_dir.to_path_buf(), + cell: OnceCell::new(), + } + } + + /// Lazily loads (and caches) the `TextEmbedding`, returning the shared + /// `Mutex`-guarded handle. Heavy work runs on a blocking thread. + async fn engine(&self) -> Result>, EmbedderError> { + let Some(model) = self.model.clone() else { + return Err(EmbedderError::Unsupported(format!( + "unknown ONNX model `{}` (supported: multilingual-e5-small)", + self.requested_model.as_deref().unwrap_or("") + ))); + }; + self.cell + .get_or_try_init(|| async { + let cache_dir = self.cache_dir.clone(); + let built = tokio::task::spawn_blocking(move || { + TextEmbedding::try_new( + InitOptions::new(model).with_cache_dir(cache_dir), + ) + }) + .await + .map_err(|e| EmbedderError::Io(format!("onnx init task failed: {e}")))? + .map_err(|e| { + EmbedderError::Unavailable(format!( + "failed to load/download ONNX model: {e}" + )) + })?; + Ok(Arc::new(Mutex::new(built))) + }) + .await + .cloned() + } + } + + #[async_trait] + impl Embedder for OnnxEmbedder { + fn id(&self) -> &str { + &self.id + } + + async fn embed(&self, texts: &[String]) -> Result>, EmbedderError> { + // Short-circuit BEFORE any model load: an empty batch never downloads. + if texts.is_empty() { + return Ok(Vec::new()); + } + + // `engine()` surfaces `Unsupported` for an unknown model and + // `Unavailable` for a failed load/download — never a panic. + let expected = self.dimension; + let engine = self.engine().await?; + let texts_owned = texts.to_vec(); + let vectors = tokio::task::spawn_blocking(move || { + let mut guard = engine.blocking_lock(); + guard.embed(texts_owned, None) + }) + .await + .map_err(|e| EmbedderError::Io(format!("onnx inference task failed: {e}")))? + .map_err(|e| EmbedderError::Io(format!("onnx inference failed: {e}")))?; + + for v in &vectors { + if v.len() != expected { + return Err(EmbedderError::Unavailable(format!( + "embedding dimension mismatch: profile declares {expected}, model produces {}", + v.len() + ))); + } + } + Ok(vectors) + } + + fn dimension(&self) -> usize { + self.dimension + } + } +} + +#[cfg(feature = "vector-onnx")] +pub use onnx::OnnxEmbedder; diff --git a/crates/infrastructure/src/store/mod.rs b/crates/infrastructure/src/store/mod.rs index 81ed768..84f3062 100644 --- a/crates/infrastructure/src/store/mod.rs +++ b/crates/infrastructure/src/store/mod.rs @@ -14,7 +14,14 @@ mod template; mod vector; pub use context::IdeaiContextStore; -pub use embedder::{embedder_from_profile, HashEmbedder, StubEmbedder}; +pub use embedder::{ + embedder_from_profile, onnx_model_is_cached, HashEmbedder, OnnxModelInfo, StubEmbedder, + ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, +}; +#[cfg(feature = "vector-http")] +pub use embedder::{detect_ollama, HttpEmbedder, DEFAULT_LOCAL_EMBED_ENDPOINT}; +#[cfg(feature = "vector-onnx")] +pub use embedder::OnnxEmbedder; pub use memory::{index_token_size, FsMemoryStore, NaiveMemoryRecall}; pub use profile::{FsEmbedderProfileStore, FsProfileStore}; pub use project::FsProjectStore; diff --git a/crates/infrastructure/tests/http_embedder.rs b/crates/infrastructure/tests/http_embedder.rs new file mode 100644 index 0000000..582f3e6 --- /dev/null +++ b/crates/infrastructure/tests/http_embedder.rs @@ -0,0 +1,202 @@ +//! Tests for the real HTTP-backed embedder (LOT C1a, §14.5.3), gated by the +//! `vector-http` feature. They exercise [`HttpEmbedder`] against a **minimal, +//! one-shot, in-process HTTP server** (raw tokio `TcpListener`, no new test +//! dependency) and the [`detect_ollama`] probe. +//! +//! The whole file is compiled out unless `--features vector-http` is set, so the +//! default dependency-free build is unaffected. +#![cfg(feature = "vector-http")] + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; + +use domain::ports::{Embedder, EmbedderError}; +use domain::profile::{EmbedderProfile, EmbedderStrategy}; +use infrastructure::{detect_ollama, HttpEmbedder}; + +/// Spawns a one-shot HTTP server on `127.0.0.1:0` that, for the next single +/// connection, reads the full request (honouring `Content-Length`) then writes +/// `response` verbatim and closes. Returns the bound `base` URL (`http://host:port`). +async fn one_shot_server(response: &'static str) -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + if let Ok((mut stream, _)) = listener.accept().await { + drain_request(&mut stream).await; + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.flush().await; + } + }); + format!("http://{addr}") +} + +/// Reads an HTTP request off `stream` until headers (and any `Content-Length` +/// body) are fully consumed, so the client never sees a premature reset. +async fn drain_request(stream: &mut tokio::net::TcpStream) { + let mut buf = Vec::new(); + let mut tmp = [0u8; 1024]; + loop { + let headers_end = find_subslice(&buf, b"\r\n\r\n"); + if let Some(h) = headers_end { + let header_text = String::from_utf8_lossy(&buf[..h]).to_ascii_lowercase(); + let content_len = header_text + .lines() + .find_map(|l| l.strip_prefix("content-length:")) + .and_then(|v| v.trim().parse::().ok()) + .unwrap_or(0); + if buf.len() >= h + 4 + content_len { + return; + } + } + match stream.read(&mut tmp).await { + Ok(0) => return, + Ok(n) => buf.extend_from_slice(&tmp[..n]), + Err(_) => return, + } + } +} + +fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|w| w == needle) +} + +/// Builds an HTTP `200 OK` response with a JSON body and the right `Content-Length`. +fn ok_json(body: &str) -> String { + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + ) +} + +fn local_server_profile(endpoint: &str, dimension: usize) -> EmbedderProfile { + EmbedderProfile::new( + "test-local", + "Test Local", + EmbedderStrategy::LocalServer, + Some("test-model".to_string()), + Some(endpoint.to_string()), + None, + dimension, + ) + .unwrap() +} + +#[tokio::test] +async fn http_embedder_parses_vectors_and_restores_input_order() { + // The server returns the two embeddings with their `index` fields swapped; the + // embedder must sort by `index` so the output matches the *input* order. + let body = r#"{"data":[ + {"embedding":[0.0,1.0],"index":1}, + {"embedding":[1.0,0.0],"index":0} + ]}"#; + let base = one_shot_server_leaked(ok_json(body)).await; + let embedder = HttpEmbedder::from_profile(&local_server_profile(&base, 2)); + + let out = embedder + .embed(&["first".to_string(), "second".to_string()]) + .await + .expect("embed must succeed"); + assert_eq!(out, vec![vec![1.0, 0.0], vec![0.0, 1.0]], "input order restored by index"); +} + +#[tokio::test] +async fn http_embedder_empty_input_short_circuits_without_network() { + // A closed/never-bound endpoint: no request must be made for empty input. + let embedder = HttpEmbedder::from_profile(&local_server_profile( + "http://127.0.0.1:1/v1/embeddings", + 4, + )); + let out = embedder.embed(&[]).await.expect("empty input ⇒ empty output, no I/O"); + assert!(out.is_empty()); +} + +#[tokio::test] +async fn http_embedder_non_2xx_is_unavailable() { + let resp = "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\n\r\n".to_string(); + let base = one_shot_server_leaked(resp).await; + let embedder = HttpEmbedder::from_profile(&local_server_profile(&base, 2)); + let err = embedder.embed(&["x".to_string()]).await.unwrap_err(); + assert!(matches!(err, EmbedderError::Unavailable(_)), "got {err:?}"); +} + +#[tokio::test] +async fn http_embedder_unreachable_host_is_unavailable() { + // Port 1 on loopback: nothing listens ⇒ connection refused ⇒ Unavailable. + let embedder = HttpEmbedder::from_profile(&local_server_profile( + "http://127.0.0.1:1/v1/embeddings", + 2, + )); + let err = embedder.embed(&["x".to_string()]).await.unwrap_err(); + assert!(matches!(err, EmbedderError::Unavailable(_)), "got {err:?}"); +} + +#[tokio::test] +async fn http_embedder_malformed_body_is_io() { + let base = one_shot_server_leaked(ok_json("not json at all")).await; + let embedder = HttpEmbedder::from_profile(&local_server_profile(&base, 2)); + let err = embedder.embed(&["x".to_string()]).await.unwrap_err(); + assert!(matches!(err, EmbedderError::Io(_)), "got {err:?}"); +} + +#[tokio::test] +async fn http_embedder_count_mismatch_is_io() { + // Two inputs but the server returns a single embedding. + let body = r#"{"data":[{"embedding":[1.0,0.0],"index":0}]}"#; + let base = one_shot_server_leaked(ok_json(body)).await; + let embedder = HttpEmbedder::from_profile(&local_server_profile(&base, 2)); + let err = embedder + .embed(&["a".to_string(), "b".to_string()]) + .await + .unwrap_err(); + assert!(matches!(err, EmbedderError::Io(_)), "got {err:?}"); +} + +#[tokio::test] +async fn http_embedder_dimension_mismatch_is_io() { + // Profile declares dimension 4 but the server returns a length-2 vector. + let body = r#"{"data":[{"embedding":[1.0,0.0],"index":0}]}"#; + let base = one_shot_server_leaked(ok_json(body)).await; + let embedder = HttpEmbedder::from_profile(&local_server_profile(&base, 4)); + let err = embedder.embed(&["x".to_string()]).await.unwrap_err(); + assert!(matches!(err, EmbedderError::Io(_)), "got {err:?}"); +} + +#[tokio::test] +async fn http_embedder_api_strategy_missing_key_is_unavailable() { + // `api` strategy whose configured key env var is guaranteed unset ⇒ Unavailable + // *before* any network call (we point at a dead endpoint to prove no request). + let profile = EmbedderProfile::new( + "test-api", + "Test API", + EmbedderStrategy::Api, + Some("model".to_string()), + Some("http://127.0.0.1:1/v1/embeddings".to_string()), + Some("IDEA_TEST_DEFINITELY_UNSET_KEY_VAR".to_string()), + 2, + ) + .unwrap(); + let embedder = HttpEmbedder::from_profile(&profile); + let err = embedder.embed(&["x".to_string()]).await.unwrap_err(); + assert!(matches!(err, EmbedderError::Unavailable(_)), "got {err:?}"); +} + +#[tokio::test] +async fn detect_ollama_true_when_tags_endpoint_ok() { + let base = one_shot_server_leaked(ok_json(r#"{"models":[]}"#)).await; + assert!(detect_ollama(&base).await, "a 200 on /api/tags ⇒ detected"); +} + +#[tokio::test] +async fn detect_ollama_false_when_nothing_listening() { + // Port 1 on loopback: connection refused ⇒ not detected, never panics. + assert!(!detect_ollama("http://127.0.0.1:1").await); +} + +/// Like [`one_shot_server`] but takes an owned `String` and leaks it to obtain the +/// `'static` lifetime the spawned task needs (test-only; the process is short-lived). +async fn one_shot_server_leaked(response: String) -> String { + let leaked: &'static str = Box::leak(response.into_boxed_str()); + one_shot_server(leaked).await +} diff --git a/crates/infrastructure/tests/onnx_embedder.rs b/crates/infrastructure/tests/onnx_embedder.rs new file mode 100644 index 0000000..80903da --- /dev/null +++ b/crates/infrastructure/tests/onnx_embedder.rs @@ -0,0 +1,215 @@ +//! Tests for the real in-process ONNX-backed embedder (LOT C1b, §14.5.3), gated by +//! the `vector-onnx` feature. They exercise [`OnnxEmbedder`] and the +//! [`embedder_from_profile`] mapping *with* the feature on. +//! +//! The whole file is compiled out unless `--features vector-onnx` is set, so the +//! default dependency-free build is unaffected. +//! +//! ## What runs by default vs. behind `#[ignore]` +//! +//! - **No-network tests** (always run with the feature on): every path that +//! short-circuits *before* `fastembed`'s `try_new`/download — empty input, an +//! unknown model, and cheap/infallible construction. These never touch disk or +//! the network, so they are safe in CI. +//! - **Real-download tests** (`#[ignore]`, never run by default): the ones that +//! actually load/download the ~118 MB e5-small model. Run them on demand with +//! `--features vector-onnx --test onnx_embedder -- --ignored`. +#![cfg(feature = "vector-onnx")] + +use std::path::PathBuf; + +use domain::ports::{Embedder, EmbedderError}; +use domain::profile::{EmbedderProfile, EmbedderStrategy}; +use infrastructure::{embedder_from_profile, OnnxEmbedder}; +use uuid::Uuid; + +// --------------------------------------------------------------------------- +// A unique, self-cleaning scratch dir under the OS temp dir (the project's +// established test convention — see e.g. tests/project_store.rs — rather than a +// new `tempfile` dev-dependency). It is created lazily by callers when a real +// download is involved; the no-network tests use a never-created path on purpose. +// --------------------------------------------------------------------------- + +struct TempDir(PathBuf); +impl TempDir { + fn new() -> Self { + let p = std::env::temp_dir().join(format!("idea-onnx-{}", Uuid::new_v4())); + std::fs::create_dir_all(&p).unwrap(); + Self(p) + } + fn path(&self) -> &std::path::Path { + &self.0 + } +} +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// A `localOnnx` profile with the given (optional) model string and dimension. +fn onnx_profile(model: Option<&str>, dimension: usize) -> EmbedderProfile { + EmbedderProfile::new( + "test-onnx", + "Test ONNX", + EmbedderStrategy::LocalOnnx, + model.map(str::to_string), + None, + None, + dimension, + ) + .unwrap() +} + +// =========================================================================== +// No-network tests (always run with the feature on). +// =========================================================================== + +#[tokio::test] +async fn onnx_unknown_model_is_unsupported() { + // A non-empty model string outside the whitelist must surface `Unsupported` + // *before* any `try_new`/download — so this is safe without a network. We point + // the cache at a path that is never created to prove no download is attempted. + let cache = std::path::Path::new("/idea-onnx-cache-that-never-exists"); + let embedder = OnnxEmbedder::from_profile(&onnx_profile(Some("definitely-unknown"), 384), cache); + + let err = embedder + .embed(&["x".to_string()]) + .await + .expect_err("unknown model must error, not embed"); + assert!( + matches!(err, EmbedderError::Unsupported(_)), + "unknown model ⇒ Unsupported (no download), got {err:?}" + ); + // The cache dir must not have been created by the failed call. + assert!( + !cache.exists(), + "an unknown model must not trigger any I/O / download" + ); +} + +#[tokio::test] +async fn onnx_empty_input_short_circuits() { + // An empty batch returns `Ok(vec![])` before any model load — even with a known + // model and a never-created cache dir, so it can never download. + let cache = std::path::Path::new("/idea-onnx-cache-that-never-exists-2"); + let embedder = OnnxEmbedder::from_profile(&onnx_profile(None, 384), cache); + + let out = embedder + .embed(&[]) + .await + .expect("empty input ⇒ empty output, no load"); + assert!(out.is_empty(), "empty batch ⇒ empty result"); + assert!( + !cache.exists(), + "empty input must not trigger any I/O / download" + ); +} + +#[tokio::test] +async fn onnx_construction_is_cheap() { + // `from_profile` is documented cheap & infallible: no panic, no I/O, and it + // advertises the profile's id and dimension verbatim. Try both a known model + // and an unknown one (construction never fails for either). + let cache = std::path::Path::new("/idea-onnx-cache-that-never-exists-3"); + + let known = OnnxEmbedder::from_profile(&onnx_profile(Some("multilingual-e5-small"), 384), cache); + assert_eq!(known.id(), "test-onnx"); + assert_eq!(known.dimension(), 384); + + let default_model = OnnxEmbedder::from_profile(&onnx_profile(None, 384), cache); + assert_eq!(default_model.dimension(), 384); + + let unknown = OnnxEmbedder::from_profile(&onnx_profile(Some("definitely-unknown"), 384), cache); + // Construction still succeeds for an unknown model (the error is deferred to embed). + assert_eq!(unknown.id(), "test-onnx"); + assert_eq!(unknown.dimension(), 384); + + assert!( + !cache.exists(), + "construction must perform no I/O whatsoever" + ); +} + +#[tokio::test] +async fn embedder_from_profile_localonnx_is_real_engine_not_unsupported_stub() { + // With the feature on, `localOnnx` must map to the real OnnxEmbedder, NOT the + // Unsupported stub. We can prove "not the stub" without a download: a *known* + // model with an empty batch returns Ok(vec![]) (the real engine short-circuits), + // whereas the StubEmbedder would return Unsupported even for an empty batch. + let cache = std::path::Path::new("/idea-onnx-cache-that-never-exists-4"); + let known = onnx_profile(Some("multilingual-e5-small"), 384); + let embedder = + embedder_from_profile(&known, cache).expect("localOnnx must yield an embedder"); + assert_eq!(embedder.dimension(), 384); + let out = embedder + .embed(&[]) + .await + .expect("real engine short-circuits empty input to Ok(vec![])"); + assert!(out.is_empty(), "empty batch ⇒ empty result on the real engine"); + + // And an *unknown* model still surfaces Unsupported (deferred resolution), so the + // mapping is the real engine in both cases (the stub would also say Unsupported, + // but the empty-batch check above already disproves the stub for the known case). + let unknown = onnx_profile(Some("definitely-unknown"), 384); + let embedder = + embedder_from_profile(&unknown, cache).expect("localOnnx must yield an embedder"); + let err = embedder + .embed(&["x".to_string()]) + .await + .expect_err("unknown model ⇒ Unsupported"); + assert!( + matches!(err, EmbedderError::Unsupported(_)), + "unknown model via mapping ⇒ Unsupported, got {err:?}" + ); + assert!(!cache.exists(), "no I/O for these short-circuiting paths"); +} + +// =========================================================================== +// Real-download tests — IGNORED by default (they fetch the ~118 MB e5-small model). +// Run on demand: `cargo test -p infrastructure --features vector-onnx \ +// --test onnx_embedder -- --ignored`. +// =========================================================================== + +#[tokio::test] +#[ignore = "downloads the ~118 MB e5-small ONNX model; run explicitly with --ignored"] +async fn onnx_embeds_e5_small_real_model() { + let cache = TempDir::new(); + let embedder = OnnxEmbedder::from_profile(&onnx_profile(None, 384), cache.path()); + + let texts = vec!["query: hello".to_string(), "passage: world".to_string()]; + let out = embedder + .embed(&texts) + .await + .expect("real e5-small embedding must succeed"); + + assert_eq!(out.len(), 2, "one vector per input"); + for v in &out { + assert_eq!(v.len(), 384, "e5-small produces 384-dim vectors"); + let norm = v.iter().map(|x| x * x).sum::().sqrt(); + assert!((norm - 1.0).abs() < 1e-2, "fastembed L2-normalises; norm ≈ 1, got {norm}"); + } + + // Deterministic across calls (model already cached after the first call). + let again = embedder.embed(&texts).await.expect("second embedding must succeed"); + assert_eq!(out, again, "embedding must be deterministic across calls"); +} + +#[tokio::test] +#[ignore = "loads the e5-small model to observe the dimension-mismatch validation; run with --ignored"] +async fn onnx_dimension_mismatch_is_unavailable() { + // The profile declares 999 dimensions but e5-small produces 384; the model loads + // fine, then per-vector validation rejects the mismatch as `Unavailable`. This + // requires the real model load, hence `#[ignore]`. + let cache = TempDir::new(); + let embedder = OnnxEmbedder::from_profile(&onnx_profile(Some("e5-small"), 999), cache.path()); + + let err = embedder + .embed(&["query: hello".to_string()]) + .await + .expect_err("a profile/model dimension mismatch must error"); + assert!( + matches!(err, EmbedderError::Unavailable(_)), + "dimension mismatch ⇒ Unavailable, got {err:?}" + ); +} diff --git a/crates/infrastructure/tests/vector_recall.rs b/crates/infrastructure/tests/vector_recall.rs index 5398df8..a7b48b8 100644 --- a/crates/infrastructure/tests/vector_recall.rs +++ b/crates/infrastructure/tests/vector_recall.rs @@ -26,8 +26,9 @@ use domain::ports::{ use domain::profile::{EmbedderProfile, EmbedderStrategy}; use domain::project::ProjectPath; use infrastructure::{ - embedder_from_profile, should_use_vector, AdaptiveMemoryRecall, FsMemoryStore, HashEmbedder, - NaiveMemoryRecall, StubEmbedder, VectorMemoryRecall, + embedder_from_profile, onnx_model_is_cached, should_use_vector, AdaptiveMemoryRecall, + FsMemoryStore, HashEmbedder, NaiveMemoryRecall, StubEmbedder, VectorMemoryRecall, + ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, }; // --------------------------------------------------------------------------- @@ -255,26 +256,48 @@ async fn stub_embedder_returns_unsupported() { #[tokio::test] async fn embedder_from_profile_maps_each_strategy() { - // none ⇒ no embedder (recall stays naïve). - assert!(embedder_from_profile(&EmbedderProfile::none()).is_none()); + // The ONNX cache dir is irrelevant to every strategy exercised here (none of + // these short-circuiting cases loads a model), so a throwaway path is fine. + let onnx_cache = std::path::Path::new("/unused-onnx-cache"); - // localOnnx / localServer / api ⇒ a StubEmbedder (Unsupported), dimension kept. - for strategy in [ - EmbedderStrategy::LocalOnnx, - EmbedderStrategy::LocalServer, - EmbedderStrategy::Api, - ] { - let profile = - EmbedderProfile::new("e", "E", strategy, None, None, None, 24).unwrap(); - let embedder = embedder_from_profile(&profile) - .unwrap_or_else(|| panic!("{strategy:?} must yield an embedder")); - assert_eq!(embedder.dimension(), 24); + // none ⇒ no embedder (recall stays naïve). + assert!(embedder_from_profile(&EmbedderProfile::none(), onnx_cache).is_none()); + + // localOnnx without the `vector-onnx` feature maps to a StubEmbedder + // (Unsupported); with the feature it is the real OnnxEmbedder. Dimension is + // preserved either way (the real engine validates dimension only at embed time). + let onnx = + EmbedderProfile::new("e", "E", EmbedderStrategy::LocalOnnx, None, None, None, 24).unwrap(); + let embedder = + embedder_from_profile(&onnx, onnx_cache).expect("localOnnx must yield an embedder"); + assert_eq!(embedder.dimension(), 24); + #[cfg(not(feature = "vector-onnx"))] + { let err = embedder.embed(&["t".to_string()]).await.unwrap_err(); assert!( matches!(err, EmbedderError::Unsupported(_)), - "{strategy:?} stub must be Unsupported, got {err:?}" + "localOnnx stub must be Unsupported without vector-onnx, got {err:?}" ); } + + // localServer / api: a stub *without* the `vector-http` feature (default, + // dependency-free build), a real HttpEmbedder *with* it. Either way an embedder + // is produced with the declared dimension. + for strategy in [EmbedderStrategy::LocalServer, EmbedderStrategy::Api] { + let profile = EmbedderProfile::new("e", "E", strategy, None, None, None, 24).unwrap(); + let embedder = embedder_from_profile(&profile, onnx_cache) + .unwrap_or_else(|| panic!("{strategy:?} must yield an embedder")); + assert_eq!(embedder.dimension(), 24); + + #[cfg(not(feature = "vector-http"))] + { + let err = embedder.embed(&["t".to_string()]).await.unwrap_err(); + assert!( + matches!(err, EmbedderError::Unsupported(_)), + "{strategy:?} stub must be Unsupported without vector-http, got {err:?}" + ); + } + } } // =========================================================================== @@ -597,6 +620,93 @@ async fn adaptive_falls_back_to_naive_when_embedder_fails() { assert!(!got.is_empty(), "the degraded result still returns entries"); } +// =========================================================================== +// 7. Always-available ONNX data/inspection helpers (compiled WITHOUT the +// `vector-onnx` feature — they are pure data + pure-FS, no `fastembed`). +// =========================================================================== + +/// A unique, self-cleaning scratch dir under the OS temp dir (project convention; +/// see tests/project_store.rs), used to exercise `onnx_model_is_cached` on real FS. +struct TempDir(std::path::PathBuf); +impl TempDir { + fn new() -> Self { + let p = std::env::temp_dir().join(format!("idea-onnx-cached-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&p).unwrap(); + Self(p) + } + fn path(&self) -> &std::path::Path { + &self.0 + } +} +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[test] +fn recommended_onnx_models_advertise_e5_small_384() { + assert_eq!(RECOMMENDED_ONNX_MODELS.len(), 1, "exactly one curated model"); + let m = RECOMMENDED_ONNX_MODELS[0]; + assert_eq!(m.id, "multilingual-e5-small"); + assert_eq!(m.dimension, 384); + assert!(m.recommended, "the single curated model is the recommended default"); +} + +#[test] +fn onnx_cache_subdir_is_stable() { + assert_eq!(ONNX_CACHE_SUBDIR, "embedders/onnx"); +} + +#[test] +fn onnx_model_is_cached_false_on_empty_dir() { + // A fresh, empty cache dir ⇒ nothing cached. + let tmp = TempDir::new(); + assert!( + !onnx_model_is_cached(tmp.path(), "multilingual-e5-small"), + "an empty cache dir reports no cached model" + ); +} + +#[test] +fn onnx_model_is_cached_false_when_dir_missing() { + // A path that does not exist must report `false`, never panic. + let missing = std::path::Path::new("/idea-onnx-cache-definitely-missing-xyz"); + assert!(!onnx_model_is_cached(missing, "multilingual-e5-small")); +} + +#[test] +fn onnx_model_is_cached_true_when_model_subdir_present_and_nonempty() { + // The heuristic looks for a non-empty subdirectory whose (lowercased) name + // *contains* the model token (slashes/underscores normalised to `-`), mirroring + // hf-hub's `models----` layout. + let tmp = TempDir::new(); + let model_dir = tmp.path().join("models--Qdrant--multilingual-e5-small-onnx"); + std::fs::create_dir_all(&model_dir).unwrap(); + // The subdir must be non-empty to count as "cached". + std::fs::write(model_dir.join("model.onnx"), b"not really a model").unwrap(); + + assert!( + onnx_model_is_cached(tmp.path(), "multilingual-e5-small"), + "a non-empty matching subdir ⇒ cached" + ); +} + +#[test] +fn onnx_model_is_cached_false_when_matching_subdir_is_empty() { + // A matching but *empty* subdir does not count as a present cached model. + let tmp = TempDir::new(); + std::fs::create_dir_all(tmp.path().join("models--Qdrant--multilingual-e5-small-onnx")).unwrap(); + assert!( + !onnx_model_is_cached(tmp.path(), "multilingual-e5-small"), + "an empty matching subdir is not a cached model" + ); +} + +// =========================================================================== +// 8. AdaptiveMemoryRecall — the étage-1/étage-2 switch (cont.). +// =========================================================================== + #[tokio::test] async fn adaptive_empty_memory_and_zero_budget_are_empty() { // Liskov common contract: empty list, never an error, both paths.