diff --git a/Cargo.lock b/Cargo.lock index cb88a7345..1c45a2d40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,20 +73,6 @@ dependencies = [ "subtle", ] -[[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" @@ -96,12 +82,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - [[package]] name = "alsa" version = "0.9.1" @@ -317,7 +297,7 @@ checksum = "a78dceaba06f029d8f4d7df20addd4b7370a30206e3926267ecda2915b0f3f66" dependencies = [ "async-channel 2.5.0", "async-compression", - "base64 0.22.1", + "base64", "bytes", "chrono", "futures", @@ -418,7 +398,7 @@ checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ "axum-core", "axum-macros", - "base64 0.22.1", + "base64", "bytes", "form_urlencoded", "futures-util", @@ -501,12 +481,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - [[package]] name = "base64" version = "0.22.1" @@ -688,15 +662,6 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" -[[package]] -name = "castaway" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" -dependencies = [ - "rustversion", -] - [[package]] name = "cbc" version = "0.1.2" @@ -829,7 +794,7 @@ checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" dependencies = [ "glob", "libc", - "libloading 0.8.9", + "libloading", ] [[package]] @@ -936,21 +901,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "compact_str" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "serde", - "static_assertions", -] - [[package]] name = "compression-codecs" version = "0.4.37" @@ -977,19 +927,6 @@ 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 = "console" version = "0.16.3" @@ -1257,16 +1194,6 @@ 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" @@ -1354,50 +1281,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[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]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "dary_heap" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06d2e3287df1c007e74221c49ca10a95d557349e54b3a75dc2fb14712c751f04" -dependencies = [ - "serde", -] - [[package]] name = "dashmap" version = "6.1.0" @@ -1524,37 +1407,6 @@ dependencies = [ "syn 1.0.109", ] -[[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", - "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 = "1.0.0" @@ -1603,7 +1455,7 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" dependencies = [ - "console 0.16.3", + "console", "fuzzy-matcher", "shell-words", "tempfile", @@ -1779,7 +1631,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" dependencies = [ - "base64 0.22.1", + "base64", "memchr", ] @@ -1810,7 +1662,7 @@ version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2b48069eef4227bde0e5a8e0601ddabfbdef887c05b831cd314d2990726a461" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-core", "futures-util", @@ -1915,12 +1767,6 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" -[[package]] -name = "esaxx-rs" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" - [[package]] name = "euclid" version = "0.20.14" @@ -2019,7 +1865,7 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7737298823a6f9ca743e372e8cb03658d55354fbab843424f575706ba9563046" dependencies = [ - "base64 0.22.1", + "base64", "cookie 0.18.1", "http 1.4.0", "http-body-util", @@ -2035,22 +1881,6 @@ dependencies = [ "webdriver", ] -[[package]] -name = "fastembed" -version = "5.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3688aa7e02113db24e0f83aba1edee912f36f515b52cffc9b3c550bbfc3eab87" -dependencies = [ - "anyhow", - "hf-hub", - "ndarray", - "ort", - "safetensors", - "serde", - "serde_json", - "tokenizers", -] - [[package]] name = "fastrand" version = "2.3.0" @@ -2127,12 +1957,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - [[package]] name = "foreign-types" version = "0.3.2" @@ -2469,7 +2293,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash 0.1.5", + "foldhash", ] [[package]] @@ -2477,13 +2301,6 @@ 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 = "hashify" @@ -2511,7 +2328,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "headers-core", "http 1.4.0", @@ -2547,27 +2364,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "hf-hub" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "629d8f3bbeda9d148036d6b0de0a3ab947abd08ce90626327fc3547a49d59d97" -dependencies = [ - "dirs 6.0.0", - "http 1.4.0", - "indicatif", - "libc", - "log", - "native-tls", - "rand 0.9.2", - "reqwest 0.12.28", - "serde", - "serde_json", - "thiserror 2.0.18", - "ureq 2.12.1", - "windows-sys 0.60.2", -] - [[package]] name = "hkdf" version = "0.12.4" @@ -2761,7 +2557,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-channel", "futures-util", @@ -2773,11 +2569,9 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", - "system-configuration", "tokio", "tower-service", "tracing", - "windows-registry", ] [[package]] @@ -2965,12 +2759,6 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - [[package]] name = "idna" version = "1.1.0" @@ -3083,19 +2871,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "indicatif" -version = "0.17.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" -dependencies = [ - "console 0.15.11", - "number_prefix", - "portable-atomic", - "unicode-width", - "web-time", -] - [[package]] name = "inout" version = "0.1.4" @@ -3315,7 +3090,7 @@ version = "0.11.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "471816f3e24b85e820dee02cde962379ea1a669e5242f19c61bcbcffedf4c4fb" dependencies = [ - "base64 0.22.1", + "base64", "email-encoding", "email_address", "fastrand", @@ -3348,16 +3123,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "libloading" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" -dependencies = [ - "cfg-if", - "windows-link", -] - [[package]] name = "libm" version = "0.2.16" @@ -3472,22 +3237,6 @@ dependencies = [ "libc", ] -[[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 = "macroific" version = "2.0.0" @@ -3776,7 +3525,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b6096084cc8d339c03e269ca25534d0f1e88d0097c35a215eb8c311797ec3e9" dependencies = [ "async-trait", - "base64 0.22.1", + "base64", "futures-util", "getrandom 0.2.17", "gloo-utils", @@ -3835,7 +3584,7 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "162a93e83114d5cef25c0ebaea72aa01b9f233df6ec4a2af45f175d01ec26323" dependencies = [ - "base64 0.22.1", + "base64", "blake3", "chacha20poly1305", "getrandom 0.2.17", @@ -3889,16 +3638,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "matrixmultiply" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" -dependencies = [ - "autocfg", - "rawpointer", -] - [[package]] name = "md-5" version = "0.10.6" @@ -4009,28 +3748,6 @@ dependencies = [ "uuid", ] -[[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 = "moxcms" version = "0.8.1" @@ -4064,21 +3781,6 @@ dependencies = [ "tempfile", ] -[[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.8.0" @@ -4184,15 +3886,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[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.1" @@ -4210,15 +3903,6 @@ dependencies = [ "syn 2.0.117", ] -[[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" @@ -4260,19 +3944,13 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "number_prefix" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" - [[package]] name = "oauth2" version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.22.1", + "base64", "chrono", "getrandom 0.2.17", "http 1.4.0", @@ -4612,28 +4290,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "onig" -version = "6.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" -dependencies = [ - "bitflags 2.11.0", - "libc", - "once_cell", - "onig_sys", -] - -[[package]] -name = "onig_sys" -version = "69.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc" -dependencies = [ - "cc", - "pkg-config", -] - [[package]] name = "opaque-debug" version = "0.3.1" @@ -4651,13 +4307,13 @@ dependencies = [ "async-imap", "async-trait", "axum", - "base64 0.22.1", + "base64", "chacha20poly1305", "chrono", "chrono-tz", "clap", "clap_complete", - "console 0.16.3", + "console", "cpal", "cron", "dialoguer", @@ -4667,7 +4323,6 @@ dependencies = [ "enigo", "env_logger", "fantoccini", - "fastembed", "fs2", "futures", "futures-util", @@ -4686,7 +4341,6 @@ dependencies = [ "opentelemetry", "opentelemetry-otlp", "opentelemetry_sdk", - "ort", "parking_lot", "pdf-extract", "postgres", @@ -4855,25 +4509,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "ort" -version = "2.0.0-rc.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5df903c0d2c07b56950f1058104ab0c8557159f2741782223704de9be73c3c" -dependencies = [ - "libloading 0.9.0", - "ndarray", - "ort-sys", - "smallvec", - "tracing", -] - -[[package]] -name = "ort-sys" -version = "2.0.0-rc.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06503bb33f294c5f1ba484011e053bfa6ae227074bdb841e9863492dc5960d4b" - [[package]] name = "os_info" version = "3.14.0" @@ -4936,12 +4571,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - [[package]] name = "pbkdf2" version = "0.12.2" @@ -5206,7 +4835,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56201207dac53e2f38e848e31b4b91616a6bb6e0c7205b77718994a7f49e70fc" dependencies = [ - "base64 0.22.1", + "base64", "byteorder", "bytes", "fallible-iterator 0.2.0", @@ -5625,43 +5254,6 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" -[[package]] -name = "rawpointer" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" - -[[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-cond" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" -dependencies = [ - "either", - "itertools 0.14.0", - "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 = "rdev" version = "0.5.3" @@ -5779,9 +5371,8 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64 0.22.1", + "base64", "bytes", - "encoding_rs", "futures-channel", "futures-core", "futures-util", @@ -5795,7 +5386,6 @@ dependencies = [ "hyper-util", "js-sys", "log", - "mime", "mime_guess", "native-tls", "percent-encoding", @@ -5828,7 +5418,7 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-channel", "futures-core", @@ -5952,7 +5542,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a01993f22d291320b7c9267675e7395775e95269ff526e2c8c3ed5e13175b" dependencies = [ "as_variant", - "base64 0.22.1", + "base64", "bytes", "form_urlencoded", "getrandom 0.2.17", @@ -6070,7 +5660,7 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146ace2cd59b60ec80d3e801a84e7e6a91e3e01d18a9f5d896ea7ca16a6b8e08" dependencies = [ - "base64 0.22.1", + "base64", "ed25519-dalek", "pkcs8", "rand 0.8.5", @@ -6217,17 +5807,6 @@ 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" @@ -6339,7 +5918,7 @@ dependencies = [ "sentry-panic", "sentry-tracing", "tokio", - "ureq 3.3.0", + "ureq", ] [[package]] @@ -6724,17 +6303,6 @@ dependencies = [ "socketioxide-core", ] -[[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 = "spki" version = "0.7.3" @@ -6745,30 +6313,12 @@ dependencies = [ "der", ] -[[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 7.1.3", - "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 = "stop-token" version = "0.7.0" @@ -6884,27 +6434,6 @@ dependencies = [ "windows 0.57.0", ] -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags 2.11.0", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[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 0.8.7", - "libc", -] - [[package]] name = "tagptr" version = "0.2.0" @@ -7063,39 +6592,6 @@ 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 0.14.0", - "log", - "macro_rules_attribute", - "monostate", - "onig", - "paste", - "rand 0.9.2", - "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.50.0" @@ -7230,7 +6726,7 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dad543404f98bfc969aeb71994105c592acfc6c43323fddcd016bb208d1c65cb" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-core", "futures-sink", @@ -7325,7 +6821,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" dependencies = [ "async-trait", - "base64 0.22.1", + "base64", "bytes", "http 1.4.0", "http-body", @@ -7606,27 +7102,12 @@ dependencies = [ "tinyvec", ] -[[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-properties" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" -[[package]] -name = "unicode-segmentation" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" - [[package]] name = "unicode-width" version = "0.2.2" @@ -7639,12 +7120,6 @@ 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 = "universal-hash" version = "0.5.1" @@ -7661,33 +7136,13 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" -[[package]] -name = "ureq" -version = "2.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" -dependencies = [ - "base64 0.22.1", - "flate2", - "log", - "native-tls", - "once_cell", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "socks", - "url", - "webpki-roots 0.26.11", -] - [[package]] name = "ureq" version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" dependencies = [ - "base64 0.22.1", + "base64", "cookie_store", "log", "percent-encoding", @@ -7706,7 +7161,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" dependencies = [ - "base64 0.22.1", + "base64", "http 1.4.0", "httparse", "log", @@ -7793,7 +7248,7 @@ checksum = "c022a277687e4e8685d72b95a7ca3ccfec907daa946678e715f8badaa650883d" dependencies = [ "aes", "arrayvec", - "base64 0.22.1", + "base64", "base64ct", "cbc", "chacha20poly1305", @@ -7824,7 +7279,7 @@ dependencies = [ "anyhow", "async-channel 2.5.0", "async-trait", - "base64 0.22.1", + "base64", "bytes", "chrono", "dashmap", @@ -7891,7 +7346,7 @@ dependencies = [ "anyhow", "async-channel 2.5.0", "async-trait", - "base64 0.22.1", + "base64", "bytes", "chrono", "ctr", @@ -8028,7 +7483,7 @@ dependencies = [ "anyhow", "async-trait", "tokio", - "ureq 3.3.0", + "ureq", "wa-rs-core", ] @@ -8252,7 +7707,7 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91d53921e1bef27512fa358179c9a22428d55778d2c2ae3c5c37a52b82ce6e92" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "cookie 0.16.2", "http 0.2.12", @@ -8520,17 +7975,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - [[package]] name = "windows-result" version = "0.1.2" @@ -8604,15 +8048,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.60.2" diff --git a/Cargo.toml b/Cargo.toml index 9d9577e96..a4b06ef71 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,8 +35,6 @@ anyhow = "1.0" async-trait = "0.1" chacha20poly1305 = "0.10" hex = "0.4" -fastembed = { version = "5.13", default-features = false, features = ["hf-hub-native-tls", "ort-load-dynamic"] } -ort = { version = "=2.0.0-rc.11", default-features = false, features = ["std", "ndarray", "load-dynamic"] } tokio-util = { version = "0.7", features = ["rt"] } tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } futures = "0.3" diff --git a/src/openhuman/config/schema/storage_memory.rs b/src/openhuman/config/schema/storage_memory.rs index 912e44758..117fa1656 100644 --- a/src/openhuman/config/schema/storage_memory.rs +++ b/src/openhuman/config/schema/storage_memory.rs @@ -109,7 +109,7 @@ fn default_true() -> bool { } fn default_embedding_provider() -> String { - "fastembed".into() + "ollama".into() } fn default_hygiene_enabled() -> bool { true @@ -124,10 +124,10 @@ fn default_conversation_retention_days() -> u32 { 30 } fn default_embedding_model() -> String { - "BGESmallENV15".into() + "nomic-embed-text:latest".into() } fn default_embedding_dims() -> usize { - 384 + 768 } fn default_vector_weight() -> f64 { 0.7 diff --git a/src/openhuman/embeddings/mod.rs b/src/openhuman/embeddings/mod.rs new file mode 100644 index 000000000..fd36129f8 --- /dev/null +++ b/src/openhuman/embeddings/mod.rs @@ -0,0 +1,214 @@ +//! Embedding providers for the OpenHuman memory system. +//! +//! Converts text into numerical vectors for semantic search. Providers: +//! +//! - **Ollama** (default): Delegates to a local Ollama server — handles model +//! management, quantization, and GPU acceleration out of the box. +//! - **OpenAI**: Cloud-based embeddings via the OpenAI API or compatible endpoints. +//! - **Noop**: A fallback provider for keyword-only search. + +pub mod noop; +pub mod ollama; +pub mod openai; +pub mod store; + +use std::sync::Arc; + +use async_trait::async_trait; + +pub use noop::NoopEmbedding; +pub use ollama::{OllamaEmbedding, DEFAULT_OLLAMA_DIMENSIONS, DEFAULT_OLLAMA_MODEL}; +pub use openai::OpenAiEmbedding; +pub use store::{bytes_to_vec, cosine_similarity, vec_to_bytes, SearchResult, VectorStore}; + +/// Interface for embedding providers that convert text into numerical vectors. +#[async_trait] +pub trait EmbeddingProvider: Send + Sync { + /// Returns the name of the provider (e.g., "ollama", "openai"). + fn name(&self) -> &str; + + /// Returns the number of dimensions in the generated embeddings. + fn dimensions(&self) -> usize; + + /// Generates embeddings for a batch of strings. + async fn embed(&self, texts: &[&str]) -> anyhow::Result>>; + + /// Generates an embedding for a single string. + async fn embed_one(&self, text: &str) -> anyhow::Result> { + let mut results = self.embed(&[text]).await?; + results + .pop() + .ok_or_else(|| anyhow::anyhow!("Empty embedding result")) + } +} + +// ── Factory ────────────────────────────────────────────────── + +/// Creates an embedding provider based on the specified name and configuration. +/// +/// Supported provider names: +/// - `"ollama"` → local Ollama server (default, preferred) +/// - `"openai"` → OpenAI API +/// - `"custom:"` → OpenAI-compatible endpoint +/// - `"none"` → no-op (keyword-only search, no embeddings) +/// +/// Returns an error for unrecognised provider names so configuration +/// mistakes surface immediately rather than silently degrading to +/// keyword-only search. +pub fn create_embedding_provider( + provider: &str, + api_key: Option<&str>, + model: &str, + dims: usize, +) -> anyhow::Result> { + match provider { + "ollama" => Ok(Box::new(OllamaEmbedding::new("", model, dims))), + "openai" => { + let key = api_key.unwrap_or(""); + Ok(Box::new(OpenAiEmbedding::new( + "https://api.openai.com", + key, + model, + dims, + ))) + } + name if name.starts_with("custom:") => { + let base_url = name.strip_prefix("custom:").unwrap_or(""); + let key = api_key.unwrap_or(""); + Ok(Box::new(OpenAiEmbedding::new(base_url, key, model, dims))) + } + "none" => Ok(Box::new(NoopEmbedding)), + unknown => Err(anyhow::anyhow!( + "unknown embedding provider: \"{unknown}\". \ + Supported: \"ollama\", \"openai\", \"custom:\", \"none\"" + )), + } +} + +/// Returns the default local embedding provider (Ollama-backed). +pub fn default_local_embedding_provider() -> Arc { + Arc::new(OllamaEmbedding::default()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── Trait default method ───────────────────────────────── + + #[test] + fn noop_name_and_dims() { + let p = NoopEmbedding; + assert_eq!(p.name(), "none"); + assert_eq!(p.dimensions(), 0); + } + + #[tokio::test] + async fn noop_embed_returns_empty() { + let p = NoopEmbedding; + let result = p.embed(&["hello"]).await.unwrap(); + assert!(result.is_empty()); + } + + #[tokio::test] + async fn noop_embed_one_returns_error() { + // embed returns empty vec → pop() returns None → error from default impl + let p = NoopEmbedding; + let err = p.embed_one("hello").await.unwrap_err(); + assert!(err.to_string().contains("Empty embedding result")); + } + + #[tokio::test] + async fn noop_embed_empty_batch() { + let p = NoopEmbedding; + let result = p.embed(&[]).await.unwrap(); + assert!(result.is_empty()); + } + + // ── Factory — success ──────────────────────────────────── + + #[test] + fn factory_ollama() { + let p = create_embedding_provider("ollama", None, DEFAULT_OLLAMA_MODEL, 768).unwrap(); + assert_eq!(p.name(), "ollama"); + assert_eq!(p.dimensions(), 768); + } + + #[test] + fn factory_openai() { + let p = create_embedding_provider("openai", Some("key"), "text-embedding-3-small", 1536) + .unwrap(); + assert_eq!(p.name(), "openai"); + assert_eq!(p.dimensions(), 1536); + } + + #[test] + fn factory_openai_no_api_key() { + let p = create_embedding_provider("openai", None, "text-embedding-3-small", 1536).unwrap(); + assert_eq!(p.name(), "openai"); + assert_eq!(p.dimensions(), 1536); + } + + #[test] + fn factory_custom_url() { + let p = + create_embedding_provider("custom:http://localhost:1234", None, "model", 768).unwrap(); + assert_eq!(p.name(), "openai"); // OpenAI-compatible under the hood + assert_eq!(p.dimensions(), 768); + } + + #[test] + fn factory_custom_empty_url() { + let p = create_embedding_provider("custom:", None, "model", 768).unwrap(); + assert_eq!(p.name(), "openai"); + } + + #[test] + fn factory_none() { + let p = create_embedding_provider("none", None, "", 0).unwrap(); + assert_eq!(p.name(), "none"); + assert_eq!(p.dimensions(), 0); + } + + // ── Factory — errors ───────────────────────────────────── + + #[test] + fn factory_unknown_provider_errors() { + let result = create_embedding_provider("cohere", None, "model", 1536); + let msg = result.err().expect("should be an error").to_string(); + assert!( + msg.contains("cohere"), + "should include provider name: {msg}" + ); + assert!(msg.contains("unknown"), "should say unknown: {msg}"); + } + + #[test] + fn factory_empty_string_errors() { + let result = create_embedding_provider("", None, "model", 1536); + assert!(result + .err() + .expect("should error") + .to_string() + .contains("unknown")); + } + + #[test] + fn factory_fastembed_errors() { + let result = create_embedding_provider("fastembed", None, "BGESmallENV15", 384); + assert!(result + .err() + .expect("should error") + .to_string() + .contains("fastembed")); + } + + // ── Default provider ───────────────────────────────────── + + #[test] + fn default_local_provider_uses_ollama() { + let p = default_local_embedding_provider(); + assert_eq!(p.name(), "ollama"); + assert_eq!(p.dimensions(), DEFAULT_OLLAMA_DIMENSIONS); + } +} diff --git a/src/openhuman/embeddings/noop.rs b/src/openhuman/embeddings/noop.rs new file mode 100644 index 000000000..43a0df21a --- /dev/null +++ b/src/openhuman/embeddings/noop.rs @@ -0,0 +1,24 @@ +//! No-op embedding provider for keyword-only search fallback. + +use async_trait::async_trait; + +use super::EmbeddingProvider; + +/// A "no-op" embedding provider used when semantic search is disabled. +/// Returns empty vectors. +pub struct NoopEmbedding; + +#[async_trait] +impl EmbeddingProvider for NoopEmbedding { + fn name(&self) -> &str { + "none" + } + + fn dimensions(&self) -> usize { + 0 + } + + async fn embed(&self, _texts: &[&str]) -> anyhow::Result>> { + Ok(Vec::new()) + } +} diff --git a/src/openhuman/embeddings/ollama.rs b/src/openhuman/embeddings/ollama.rs new file mode 100644 index 000000000..615a50c64 --- /dev/null +++ b/src/openhuman/embeddings/ollama.rs @@ -0,0 +1,527 @@ +//! Ollama-based embedding provider. +//! +//! Calls the local Ollama server's `/api/embed` endpoint for embeddings. +//! This is the preferred local provider: Ollama handles model management, +//! quantization, and GPU acceleration (Metal on macOS, CUDA on Linux/Windows). +//! +//! Default model: `nomic-embed-text:latest` (768 dimensions). + +use async_trait::async_trait; + +use super::EmbeddingProvider; + +/// Default Ollama base URL. +pub const DEFAULT_OLLAMA_URL: &str = "http://localhost:11434"; + +/// Default embedding model for Ollama. +pub const DEFAULT_OLLAMA_MODEL: &str = "nomic-embed-text:latest"; + +/// Default dimensions for nomic-embed-text. +pub const DEFAULT_OLLAMA_DIMENSIONS: usize = 768; + +/// Embedding provider backed by a local Ollama instance. +/// +/// Ollama must be running and have the configured model pulled. +/// On first embed call, if the model isn't available, Ollama will +/// auto-pull it (this may take a moment on first use). +pub struct OllamaEmbedding { + base_url: String, + model: String, + dims: usize, +} + +impl OllamaEmbedding { + /// Creates a new Ollama embedding provider. + /// + /// - `base_url`: Ollama server URL (default: `http://localhost:11434`) + /// - `model`: Model name (default: `nomic-embed-text:latest`) + /// - `dims`: Expected embedding dimensions (default: 768) + pub fn new(base_url: &str, model: &str, dims: usize) -> Self { + let base_url = if base_url.trim().is_empty() { + DEFAULT_OLLAMA_URL.to_string() + } else { + base_url.trim_end_matches('/').to_string() + }; + let model = if model.trim().is_empty() { + DEFAULT_OLLAMA_MODEL.to_string() + } else { + model.trim().to_string() + }; + let dims = if dims == 0 { + DEFAULT_OLLAMA_DIMENSIONS + } else { + dims + }; + + tracing::debug!( + target: "embeddings.ollama", + "[embeddings] OllamaEmbedding created: url={base_url}, model={model}, dims={dims}" + ); + + Self { + base_url, + model, + dims, + } + } + + /// Creates a provider with all defaults. + pub fn default() -> Self { + Self::new( + DEFAULT_OLLAMA_URL, + DEFAULT_OLLAMA_MODEL, + DEFAULT_OLLAMA_DIMENSIONS, + ) + } + + /// Returns the configured base URL. + pub fn base_url(&self) -> &str { + &self.base_url + } + + /// Returns the configured model name. + pub fn model(&self) -> &str { + &self.model + } + + /// Build an HTTP client with proxy support. + fn http_client(&self) -> reqwest::Client { + crate::openhuman::config::build_runtime_proxy_client("embeddings.ollama") + } + + /// The embed endpoint URL. + fn embed_url(&self) -> String { + format!("{}/api/embed", self.base_url) + } +} + +/// Ollama `/api/embed` request body. +#[derive(serde::Serialize)] +struct OllamaEmbedRequest { + model: String, + input: Vec, +} + +/// Ollama `/api/embed` response body. +#[derive(serde::Deserialize)] +struct OllamaEmbedResponse { + #[serde(default)] + embeddings: Vec>, +} + +#[async_trait] +impl EmbeddingProvider for OllamaEmbedding { + fn name(&self) -> &str { + "ollama" + } + + fn dimensions(&self) -> usize { + self.dims + } + + /// Sends texts to Ollama's embed API. + /// + /// Blank/whitespace-only entries are skipped for the remote call but their + /// positions in the result are preserved as zero-vectors so the returned + /// `Vec` always has the same length as `texts`. + async fn embed(&self, texts: &[&str]) -> anyhow::Result>> { + if texts.is_empty() { + return Ok(Vec::new()); + } + + // Build a list of (original_index, trimmed_text) for non-blank entries. + let live: Vec<(usize, String)> = texts + .iter() + .enumerate() + .filter_map(|(i, t)| { + let trimmed = t.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some((i, trimmed)) + } + }) + .collect(); + + if live.is_empty() { + // All entries were blank — return zero-vectors. + return Ok(vec![Vec::new(); texts.len()]); + } + + let input: Vec = live.iter().map(|(_, t)| t.clone()).collect(); + + tracing::debug!( + target: "embeddings.ollama", + "[embeddings] sending {} text(s) to ollama model={} ({} blank skipped)", + input.len(), self.model, texts.len() - input.len() + ); + + let resp = self + .http_client() + .post(self.embed_url()) + .json(&OllamaEmbedRequest { + model: self.model.clone(), + input: input.clone(), + }) + .send() + .await + .map_err(|e| { + anyhow::anyhow!( + "ollama embed request failed (is Ollama running at {}?): {e}", + self.base_url + ) + })?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + let detail = body.trim(); + anyhow::bail!( + "ollama embed failed with status {status}{}", + if detail.is_empty() { + String::new() + } else { + format!(": {detail}") + } + ); + } + + let payload: OllamaEmbedResponse = resp + .json() + .await + .map_err(|e| anyhow::anyhow!("ollama embed response parse failed: {e}"))?; + + // Validate response count matches what we sent. + if payload.embeddings.len() != input.len() { + anyhow::bail!( + "ollama embed count mismatch: sent {} texts, got {} embeddings", + input.len(), + payload.embeddings.len() + ); + } + + // Validate dimensions on every returned vector. + for (i, vec) in payload.embeddings.iter().enumerate() { + if vec.len() != self.dims { + anyhow::bail!( + "ollama embed dimension mismatch at index {i}: expected {}, got {}", + self.dims, + vec.len() + ); + } + } + + tracing::debug!( + target: "embeddings.ollama", + "[embeddings] received {} embeddings, dims={}", + payload.embeddings.len(), + self.dims + ); + + // Reconstruct full-length result with zero-vectors for blank positions. + let mut result = vec![Vec::new(); texts.len()]; + for ((orig_idx, _), embedding) in live.iter().zip(payload.embeddings.into_iter()) { + result[*orig_idx] = embedding; + } + + Ok(result) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{extract::Json, http::StatusCode, routing::post, Router}; + use std::net::SocketAddr; + + /// Spin up a local axum server and return its base URL. + async fn start_mock(app: Router) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr: SocketAddr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + format!("http://127.0.0.1:{}", addr.port()) + } + + // ── Constructor ────────────────────────────────────────── + + #[test] + fn defaults() { + let p = OllamaEmbedding::default(); + assert_eq!(p.base_url, DEFAULT_OLLAMA_URL); + assert_eq!(p.model, DEFAULT_OLLAMA_MODEL); + assert_eq!(p.dims, DEFAULT_OLLAMA_DIMENSIONS); + } + + #[test] + fn name_is_ollama() { + let p = OllamaEmbedding::default(); + assert_eq!(p.name(), "ollama"); + } + + #[test] + fn custom_values() { + let p = OllamaEmbedding::new("http://gpu-box:11434/", "mxbai-embed-large", 1024); + assert_eq!(p.base_url, "http://gpu-box:11434"); + assert_eq!(p.model, "mxbai-embed-large"); + assert_eq!(p.dims, 1024); + } + + #[test] + fn empty_values_use_defaults() { + let p = OllamaEmbedding::new("", "", 0); + assert_eq!(p.base_url, DEFAULT_OLLAMA_URL); + assert_eq!(p.model, DEFAULT_OLLAMA_MODEL); + assert_eq!(p.dims, DEFAULT_OLLAMA_DIMENSIONS); + } + + #[test] + fn whitespace_only_values_use_defaults() { + let p = OllamaEmbedding::new(" ", " ", 0); + assert_eq!(p.base_url, DEFAULT_OLLAMA_URL); + assert_eq!(p.model, DEFAULT_OLLAMA_MODEL); + } + + #[test] + fn trailing_slash_stripped() { + let p = OllamaEmbedding::new("http://host:1234/", "m", 1); + assert_eq!(p.base_url, "http://host:1234"); + } + + #[test] + fn model_trimmed() { + let p = OllamaEmbedding::new("", " nomic-embed-text ", 768); + assert_eq!(p.model, "nomic-embed-text"); + } + + #[test] + fn embed_url_format() { + let p = OllamaEmbedding::default(); + assert_eq!(p.embed_url(), "http://localhost:11434/api/embed"); + } + + #[test] + fn accessor_methods() { + let p = OllamaEmbedding::new("http://x:1", "m", 42); + assert_eq!(p.base_url(), "http://x:1"); + assert_eq!(p.model(), "m"); + assert_eq!(p.dimensions(), 42); + } + + // ── embed — empty / whitespace ────────────────────────── + + #[tokio::test] + async fn empty_input_returns_empty() { + let p = OllamaEmbedding::default(); + let result = p.embed(&[]).await.unwrap(); + assert!(result.is_empty()); + } + + #[tokio::test] + async fn whitespace_only_input_returns_zero_vecs() { + let p = OllamaEmbedding::default(); + let result = p.embed(&[" ", "\t", "\n"]).await.unwrap(); + // Length preserved, all entries are empty zero-vectors. + assert_eq!(result.len(), 3); + assert!(result.iter().all(|v| v.is_empty())); + } + + // ── embed — positional alignment ──────────────────────── + + #[tokio::test] + async fn embed_preserves_positions_for_blanks() { + let app = Router::new().route( + "/api/embed", + post(|Json(body): Json| async move { + let inputs = body["input"].as_array().unwrap(); + // Server receives only non-blank texts. + let embeddings: Vec> = inputs.iter().map(|_| vec![1.0, 2.0]).collect(); + Json(serde_json::json!({ "embeddings": embeddings })) + }), + ); + let url = start_mock(app).await; + let p = OllamaEmbedding::new(&url, "m", 2); + + // Mix of blank and real texts. + let result = p.embed(&["hello", "", " ", "world"]).await.unwrap(); + assert_eq!(result.len(), 4); + assert_eq!(result[0], vec![1.0, 2.0]); // real + assert!(result[1].is_empty()); // blank + assert!(result[2].is_empty()); // blank + assert_eq!(result[3], vec![1.0, 2.0]); // real + } + + // ── embed — successful response ───────────────────────── + + #[tokio::test] + async fn embed_success_single() { + let app = Router::new().route( + "/api/embed", + post(|Json(_body): Json| async { + Json(serde_json::json!({ + "embeddings": [[0.1, 0.2, 0.3]] + })) + }), + ); + let url = start_mock(app).await; + let p = OllamaEmbedding::new(&url, "test-model", 3); + + let result = p.embed(&["hello"]).await.unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0], vec![0.1, 0.2, 0.3]); + } + + #[tokio::test] + async fn embed_success_batch() { + let app = Router::new().route( + "/api/embed", + post(|Json(_body): Json| async { + Json(serde_json::json!({ + "embeddings": [[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]] + })) + }), + ); + let url = start_mock(app).await; + let p = OllamaEmbedding::new(&url, "test-model", 2); + + let result = p.embed(&["a", "b", "c"]).await.unwrap(); + assert_eq!(result.len(), 3); + assert_eq!(result[2], vec![5.0, 6.0]); + } + + #[tokio::test] + async fn embed_verifies_request_body() { + let app = Router::new().route( + "/api/embed", + post(|Json(body): Json| async move { + assert_eq!(body["model"], "my-model"); + let inputs = body["input"].as_array().unwrap(); + assert_eq!(inputs.len(), 1); + assert_eq!(inputs[0], "test text"); + Json(serde_json::json!({ "embeddings": [[1.0]] })) + }), + ); + let url = start_mock(app).await; + let p = OllamaEmbedding::new(&url, "my-model", 1); + + p.embed(&["test text"]).await.unwrap(); + } + + // ── embed — error paths ───────────────────────────────── + + #[tokio::test] + async fn embed_server_error_with_body() { + let app = Router::new().route( + "/api/embed", + post(|| async { (StatusCode::INTERNAL_SERVER_ERROR, "model crashed") }), + ); + let url = start_mock(app).await; + let p = OllamaEmbedding::new(&url, "m", 1); + + let err = p.embed(&["hi"]).await.unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("500"), "should contain status code: {msg}"); + assert!(msg.contains("model crashed"), "should contain body: {msg}"); + } + + #[tokio::test] + async fn embed_server_error_empty_body() { + let app = Router::new().route( + "/api/embed", + post(|| async { (StatusCode::BAD_REQUEST, "") }), + ); + let url = start_mock(app).await; + let p = OllamaEmbedding::new(&url, "m", 1); + + let err = p.embed(&["hi"]).await.unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("400"), "should contain status code: {msg}"); + } + + #[tokio::test] + async fn embed_count_mismatch() { + let app = Router::new().route( + "/api/embed", + post(|| async { + // Return 1 embedding even though 2 texts were sent. + Json(serde_json::json!({ "embeddings": [[1.0]] })) + }), + ); + let url = start_mock(app).await; + let p = OllamaEmbedding::new(&url, "m", 1); + + let err = p.embed(&["a", "b"]).await.unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("count mismatch"), "msg: {msg}"); + } + + #[tokio::test] + async fn embed_dimension_mismatch() { + let app = Router::new().route( + "/api/embed", + post(|| async { + // Return 3-dim vector when provider expects 2. + Json(serde_json::json!({ "embeddings": [[1.0, 2.0, 3.0]] })) + }), + ); + let url = start_mock(app).await; + let p = OllamaEmbedding::new(&url, "m", 2); + + let err = p.embed(&["hi"]).await.unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("dimension mismatch"), "msg: {msg}"); + } + + #[tokio::test] + async fn embed_empty_embeddings_array() { + let app = Router::new().route( + "/api/embed", + post(|| async { Json(serde_json::json!({ "embeddings": [] })) }), + ); + let url = start_mock(app).await; + let p = OllamaEmbedding::new(&url, "m", 1); + + let err = p.embed(&["hi"]).await.unwrap_err(); + assert!(err.to_string().contains("count mismatch")); + } + + #[tokio::test] + async fn embed_malformed_json_response() { + let app = Router::new().route( + "/api/embed", + post(|| async { (StatusCode::OK, "not json at all") }), + ); + let url = start_mock(app).await; + let p = OllamaEmbedding::new(&url, "m", 1); + + let err = p.embed(&["hi"]).await.unwrap_err(); + assert!(err.to_string().contains("parse failed")); + } + + #[tokio::test] + async fn embed_connection_refused() { + let p = OllamaEmbedding::new("http://127.0.0.1:1", "m", 1); + let err = p.embed(&["hi"]).await.unwrap_err(); + assert!( + err.to_string().contains("is Ollama running"), + "should mention Ollama: {}", + err + ); + } + + // ── embed_one (trait default) ─────────────────────────── + + #[tokio::test] + async fn embed_one_success() { + let app = Router::new().route( + "/api/embed", + post(|| async { Json(serde_json::json!({ "embeddings": [[7.0, 8.0]] })) }), + ); + let url = start_mock(app).await; + let p = OllamaEmbedding::new(&url, "m", 2); + + let vec = p.embed_one("test").await.unwrap(); + assert_eq!(vec, vec![7.0, 8.0]); + } +} diff --git a/src/openhuman/embeddings/openai.rs b/src/openhuman/embeddings/openai.rs new file mode 100644 index 000000000..e1485999a --- /dev/null +++ b/src/openhuman/embeddings/openai.rs @@ -0,0 +1,550 @@ +//! OpenAI-compatible embedding provider. +//! +//! Works with OpenAI, LocalAI, Ollama, and any endpoint that implements the +//! `POST /v1/embeddings` contract. + +use async_trait::async_trait; + +use super::EmbeddingProvider; + +/// Embedding provider for OpenAI and compatible APIs (e.g., LocalAI, Ollama). +pub struct OpenAiEmbedding { + base_url: String, + api_key: String, + model: String, + dims: usize, +} + +impl OpenAiEmbedding { + /// Creates a new OpenAI-style provider. + pub fn new(base_url: &str, api_key: &str, model: &str, dims: usize) -> Self { + Self { + base_url: base_url.trim_end_matches('/').to_string(), + api_key: api_key.to_string(), + model: model.to_string(), + dims, + } + } + + /// Returns the configured base URL. + pub fn base_url(&self) -> &str { + &self.base_url + } + + /// Returns the configured model name. + pub fn model(&self) -> &str { + &self.model + } + + /// Internal helper to build an HTTP client with proxy support. + fn http_client(&self) -> reqwest::Client { + crate::openhuman::config::build_runtime_proxy_client("memory.embeddings") + } + + /// Checks if the base URL includes a specific path (e.g., /api/v1). + fn has_explicit_api_path(&self) -> bool { + let Ok(url) = reqwest::Url::parse(&self.base_url) else { + return false; + }; + + let path = url.path().trim_end_matches('/'); + !path.is_empty() && path != "/" + } + + /// Checks if the URL already ends with /embeddings. + fn has_embeddings_endpoint(&self) -> bool { + let Ok(url) = reqwest::Url::parse(&self.base_url) else { + return false; + }; + + url.path().trim_end_matches('/').ends_with("/embeddings") + } + + /// Constructs the final URL for the embeddings endpoint. + pub fn embeddings_url(&self) -> String { + if self.has_embeddings_endpoint() { + return self.base_url.clone(); + } + + if self.has_explicit_api_path() { + format!("{}/embeddings", self.base_url) + } else { + format!("{}/v1/embeddings", self.base_url) + } + } +} + +#[async_trait] +impl EmbeddingProvider for OpenAiEmbedding { + fn name(&self) -> &str { + "openai" + } + + fn dimensions(&self) -> usize { + self.dims + } + + /// Sends a POST request to the embedding API. + async fn embed(&self, texts: &[&str]) -> anyhow::Result>> { + if texts.is_empty() { + return Ok(Vec::new()); + } + + let url = self.embeddings_url(); + + tracing::debug!( + target: "openai::embed", + "[openai] embed: model={}, count={}, url={}", + self.model, texts.len(), url + ); + + let body = serde_json::json!({ + "model": self.model, + "input": texts, + }); + + let mut req = self + .http_client() + .post(&url) + .header("Content-Type", "application/json") + .json(&body); + + // Only set Authorization header when an API key is configured. + if !self.api_key.is_empty() { + req = req.header("Authorization", format!("Bearer {}", self.api_key)); + } + + let resp = req.send().await?; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + tracing::debug!( + target: "openai::embed", + "[openai] embed error: status={status}, body={text}" + ); + anyhow::bail!("Embedding API error {status}: {text}"); + } + + let json: serde_json::Value = resp.json().await?; + let data = json + .get("data") + .and_then(|d| d.as_array()) + .ok_or_else(|| anyhow::anyhow!("Invalid embedding response: missing 'data'"))?; + + // Validate that the response count matches the input count. + if data.len() != texts.len() { + anyhow::bail!( + "openai embed count mismatch: sent {} texts, got {} items in 'data'", + texts.len(), + data.len() + ); + } + + let mut embeddings = Vec::with_capacity(data.len()); + for (i, item) in data.iter().enumerate() { + let embedding = item + .get("embedding") + .and_then(|e| e.as_array()) + .ok_or_else(|| { + anyhow::anyhow!("Invalid embedding item at index {i}: missing 'embedding'") + })?; + + let mut vec = Vec::with_capacity(embedding.len()); + for (j, v) in embedding.iter().enumerate() { + #[allow(clippy::cast_possible_truncation)] + let f = v.as_f64().ok_or_else(|| { + anyhow::anyhow!("non-numeric value at data[{i}].embedding[{j}]: {v}") + })? as f32; + vec.push(f); + } + + // Validate dimensions. + if self.dims > 0 && vec.len() != self.dims { + anyhow::bail!( + "openai embed dimension mismatch at index {i}: expected {}, got {}", + self.dims, + vec.len() + ); + } + + embeddings.push(vec); + } + + tracing::debug!( + target: "openai::embed", + "[openai] embed success: model={}, count={}, dims={}", + self.model, embeddings.len(), + embeddings.first().map(|v| v.len()).unwrap_or(0) + ); + + Ok(embeddings) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{ + extract::Json, + http::{HeaderMap, StatusCode}, + routing::post, + Router, + }; + use std::net::SocketAddr; + + async fn start_mock(app: Router) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr: SocketAddr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + format!("http://127.0.0.1:{}", addr.port()) + } + + // ── Constructor & URL building ────────────────────────── + + #[test] + fn trailing_slash_stripped() { + let p = OpenAiEmbedding::new("https://api.openai.com/", "key", "model", 1536); + assert_eq!(p.base_url, "https://api.openai.com"); + } + + #[test] + fn dimensions_custom() { + let p = OpenAiEmbedding::new("http://localhost", "k", "m", 384); + assert_eq!(p.dimensions(), 384); + } + + #[test] + fn accessors() { + let p = OpenAiEmbedding::new("http://x", "k", "m", 1); + assert_eq!(p.base_url(), "http://x"); + assert_eq!(p.model(), "m"); + assert_eq!(p.name(), "openai"); + } + + #[test] + fn url_standard_openai() { + let p = OpenAiEmbedding::new("https://api.openai.com", "key", "model", 1536); + assert_eq!(p.embeddings_url(), "https://api.openai.com/v1/embeddings"); + } + + #[test] + fn url_base_with_v1_no_duplicate() { + let p = OpenAiEmbedding::new("https://api.example.com/v1", "key", "model", 1536); + assert_eq!(p.embeddings_url(), "https://api.example.com/v1/embeddings"); + } + + #[test] + fn url_non_v1_api_path() { + let p = OpenAiEmbedding::new( + "https://api.example.com/api/coding/v3", + "key", + "model", + 1536, + ); + assert_eq!( + p.embeddings_url(), + "https://api.example.com/api/coding/v3/embeddings" + ); + } + + #[test] + fn url_already_ends_with_embeddings() { + let p = OpenAiEmbedding::new( + "https://my-api.example.com/api/v2/embeddings", + "key", + "model", + 1536, + ); + assert_eq!( + p.embeddings_url(), + "https://my-api.example.com/api/v2/embeddings" + ); + } + + #[test] + fn url_already_ends_with_embeddings_trailing_slash() { + let p = OpenAiEmbedding::new( + "https://api.example.com/v1/embeddings/", + "key", + "model", + 1536, + ); + assert_eq!(p.embeddings_url(), "https://api.example.com/v1/embeddings"); + } + + #[test] + fn url_root_only() { + let p = OpenAiEmbedding::new("http://localhost:8080", "k", "m", 1); + assert_eq!(p.embeddings_url(), "http://localhost:8080/v1/embeddings"); + } + + #[test] + fn url_root_with_trailing_slash() { + let p = OpenAiEmbedding::new("http://localhost:8080/", "k", "m", 1); + assert_eq!(p.embeddings_url(), "http://localhost:8080/v1/embeddings"); + } + + #[test] + fn has_explicit_api_path_invalid_url() { + let p = OpenAiEmbedding::new("not-a-url", "k", "m", 1); + assert!(!p.has_explicit_api_path()); + } + + #[test] + fn has_embeddings_endpoint_invalid_url() { + let p = OpenAiEmbedding::new("not-a-url", "k", "m", 1); + assert!(!p.has_embeddings_endpoint()); + } + + // ── embed — empty input ───────────────────────────────── + + #[tokio::test] + async fn empty_input_returns_empty() { + let p = OpenAiEmbedding::new("http://unused", "k", "m", 1); + let result = p.embed(&[]).await.unwrap(); + assert!(result.is_empty()); + } + + // ── embed — success ───────────────────────────────────── + + #[tokio::test] + async fn embed_success_single() { + let app = Router::new().route( + "/v1/embeddings", + post(|| async { + Json(serde_json::json!({ + "data": [{ "embedding": [0.1, 0.2, 0.3] }] + })) + }), + ); + let url = start_mock(app).await; + let p = OpenAiEmbedding::new(&url, "test-key", "test-model", 3); + + let result = p.embed(&["hello"]).await.unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0], vec![0.1_f32, 0.2, 0.3]); + } + + #[tokio::test] + async fn embed_success_batch() { + let app = Router::new().route( + "/v1/embeddings", + post(|| async { + Json(serde_json::json!({ + "data": [ + { "embedding": [1.0, 2.0] }, + { "embedding": [3.0, 4.0] } + ] + })) + }), + ); + let url = start_mock(app).await; + let p = OpenAiEmbedding::new(&url, "k", "m", 2); + + let result = p.embed(&["a", "b"]).await.unwrap(); + assert_eq!(result.len(), 2); + assert_eq!(result[1], vec![3.0_f32, 4.0]); + } + + #[tokio::test] + async fn embed_sends_auth_header() { + let app = Router::new().route( + "/v1/embeddings", + post( + |headers: HeaderMap, Json(body): Json| async move { + let auth = headers.get("Authorization").unwrap().to_str().unwrap(); + assert_eq!(auth, "Bearer my-secret-key"); + assert_eq!(body["model"], "text-embedding-3-small"); + Json(serde_json::json!({ + "data": [{ "embedding": [1.0] }] + })) + }, + ), + ); + let url = start_mock(app).await; + let p = OpenAiEmbedding::new(&url, "my-secret-key", "text-embedding-3-small", 1); + + p.embed(&["test"]).await.unwrap(); + } + + #[tokio::test] + async fn embed_skips_auth_header_when_key_empty() { + let app = Router::new().route( + "/v1/embeddings", + post(|headers: HeaderMap| async move { + // No Authorization header should be present. + assert!( + headers.get("Authorization").is_none(), + "should not send auth header when key is empty" + ); + Json(serde_json::json!({ + "data": [{ "embedding": [1.0] }] + })) + }), + ); + let url = start_mock(app).await; + let p = OpenAiEmbedding::new(&url, "", "m", 1); + + p.embed(&["test"]).await.unwrap(); + } + + // ── embed — error paths ───────────────────────────────── + + #[tokio::test] + async fn embed_server_error() { + let app = Router::new().route( + "/v1/embeddings", + post(|| async { (StatusCode::INTERNAL_SERVER_ERROR, "rate limited") }), + ); + let url = start_mock(app).await; + let p = OpenAiEmbedding::new(&url, "k", "m", 1); + + let err = p.embed(&["hi"]).await.unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("500"), "status: {msg}"); + assert!(msg.contains("rate limited"), "body: {msg}"); + } + + #[tokio::test] + async fn embed_missing_data_field() { + let app = Router::new().route( + "/v1/embeddings", + post(|| async { Json(serde_json::json!({ "result": "ok" })) }), + ); + let url = start_mock(app).await; + let p = OpenAiEmbedding::new(&url, "k", "m", 1); + + let err = p.embed(&["hi"]).await.unwrap_err(); + assert!(err.to_string().contains("missing 'data'")); + } + + #[tokio::test] + async fn embed_missing_embedding_field_in_item() { + let app = Router::new().route( + "/v1/embeddings", + post(|| async { + Json(serde_json::json!({ + "data": [{ "index": 0 }] + })) + }), + ); + let url = start_mock(app).await; + let p = OpenAiEmbedding::new(&url, "k", "m", 1); + + let err = p.embed(&["hi"]).await.unwrap_err(); + assert!(err.to_string().contains("missing 'embedding'")); + } + + #[tokio::test] + async fn embed_non_numeric_value_errors() { + let app = Router::new().route( + "/v1/embeddings", + post(|| async { + Json(serde_json::json!({ + "data": [{ "embedding": [1.0, "not_a_number", 3.0] }] + })) + }), + ); + let url = start_mock(app).await; + let p = OpenAiEmbedding::new(&url, "k", "m", 3); + + let err = p.embed(&["hi"]).await.unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("non-numeric"), "msg: {msg}"); + } + + #[tokio::test] + async fn embed_count_mismatch() { + let app = Router::new().route( + "/v1/embeddings", + post(|| async { + Json(serde_json::json!({ + "data": [{ "embedding": [1.0] }] + })) + }), + ); + let url = start_mock(app).await; + let p = OpenAiEmbedding::new(&url, "k", "m", 1); + + let err = p.embed(&["a", "b"]).await.unwrap_err(); + assert!(err.to_string().contains("count mismatch")); + } + + #[tokio::test] + async fn embed_dimension_mismatch() { + let app = Router::new().route( + "/v1/embeddings", + post(|| async { + Json(serde_json::json!({ + "data": [{ "embedding": [1.0, 2.0, 3.0] }] + })) + }), + ); + let url = start_mock(app).await; + let p = OpenAiEmbedding::new(&url, "k", "m", 2); + + let err = p.embed(&["hi"]).await.unwrap_err(); + assert!(err.to_string().contains("dimension mismatch")); + } + + #[tokio::test] + async fn embed_malformed_json() { + let app = Router::new().route( + "/v1/embeddings", + post(|| async { (StatusCode::OK, "not json") }), + ); + let url = start_mock(app).await; + let p = OpenAiEmbedding::new(&url, "k", "m", 1); + + let err = p.embed(&["hi"]).await.unwrap_err(); + assert!(err.is::()); + } + + #[tokio::test] + async fn embed_connection_refused() { + let p = OpenAiEmbedding::new("http://127.0.0.1:1", "k", "m", 1); + let err = p.embed(&["hi"]).await.unwrap_err(); + assert!(err.is::()); + } + + // ── embed_one (trait default) ─────────────────────────── + + #[tokio::test] + async fn embed_one_success() { + let app = Router::new().route( + "/v1/embeddings", + post(|| async { + Json(serde_json::json!({ + "data": [{ "embedding": [9.0, 8.0, 7.0] }] + })) + }), + ); + let url = start_mock(app).await; + let p = OpenAiEmbedding::new(&url, "k", "m", 3); + + let vec = p.embed_one("test").await.unwrap(); + assert_eq!(vec, vec![9.0_f32, 8.0, 7.0]); + } + + // ── URL building — custom endpoint ────────────────────── + + #[tokio::test] + async fn embed_with_explicit_api_path() { + let app = Router::new().route( + "/custom/api/embeddings", + post(|| async { + Json(serde_json::json!({ + "data": [{ "embedding": [1.0] }] + })) + }), + ); + let url = start_mock(app).await; + let p = OpenAiEmbedding::new(&format!("{url}/custom/api"), "k", "m", 1); + + let result = p.embed(&["test"]).await.unwrap(); + assert_eq!(result.len(), 1); + } +} diff --git a/src/openhuman/embeddings/store.rs b/src/openhuman/embeddings/store.rs new file mode 100644 index 000000000..6116a5039 --- /dev/null +++ b/src/openhuman/embeddings/store.rs @@ -0,0 +1,884 @@ +//! Local vector store backed by SQLite. +//! +//! Provides a self-contained vector database for storing, searching, and +//! managing text embeddings. Uses SQLite for persistence and brute-force +//! cosine similarity for retrieval (fast enough for on-device workloads up +//! to ~100K vectors). +//! +//! # Usage +//! +//! ```ignore +//! let embedder = Arc::new(OllamaEmbedding::default()); +//! let store = VectorStore::open(db_path, embedder)?; +//! +//! store.insert("doc-1", "notes", "The quick brown fox", json!({})).await?; +//! let results = store.search("notes", "fast animal", 5).await?; +//! ``` + +use std::path::Path; +use std::sync::Arc; + +use parking_lot::Mutex; +use rusqlite::Connection; + +use super::EmbeddingProvider; + +/// SQL to create the vector store schema. +const INIT_SQL: &str = " + PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + + CREATE TABLE IF NOT EXISTS vectors ( + id TEXT NOT NULL, + namespace TEXT NOT NULL, + text TEXT NOT NULL, + embedding BLOB NOT NULL, + metadata TEXT NOT NULL DEFAULT '{}', + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + PRIMARY KEY (namespace, id) + ); + CREATE INDEX IF NOT EXISTS idx_vectors_ns ON vectors(namespace); + + CREATE TABLE IF NOT EXISTS store_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at REAL NOT NULL + ); +"; + +/// A single search result from the vector store. +#[derive(Debug, Clone)] +pub struct SearchResult { + /// The stored document ID. + pub id: String, + /// The namespace. + pub namespace: String, + /// The original text. + pub text: String, + /// Cosine similarity score (0.0 – 1.0). + pub score: f64, + /// Arbitrary JSON metadata attached at insert time. + pub metadata: serde_json::Value, +} + +/// SQLite-backed local vector store. +/// +/// Thread-safe: the inner connection is behind a `parking_lot::Mutex` and +/// the struct is `Send + Sync`. Embedding calls are async and run through +/// the configured [`EmbeddingProvider`]. +pub struct VectorStore { + conn: Arc>, + embedder: Arc, +} + +impl VectorStore { + /// Opens (or creates) a vector store at the given SQLite database path. + /// + /// On first open the embedding provider name, model-name-hint, and + /// dimensions are persisted to a `store_meta` table. On subsequent opens + /// the stored dimensions are compared against the runtime embedder and an + /// error is returned if they mismatch (prevents silent cosine-similarity + /// corruption from mixed-dimension vectors). + pub fn open(db_path: &Path, embedder: Arc) -> anyhow::Result { + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let conn = Connection::open(db_path)?; + conn.execute_batch(INIT_SQL)?; + + Self::check_or_store_meta(&conn, &*embedder)?; + + tracing::debug!( + target: "embeddings.store", + "[vector-store] opened at {}, embedder={}, dims={}", + db_path.display(), + embedder.name(), + embedder.dimensions() + ); + + Ok(Self { + conn: Arc::new(Mutex::new(conn)), + embedder, + }) + } + + /// Opens an in-memory vector store (useful for tests). + pub fn open_in_memory(embedder: Arc) -> anyhow::Result { + let conn = Connection::open_in_memory()?; + conn.execute_batch(INIT_SQL)?; + Self::check_or_store_meta(&conn, &*embedder)?; + Ok(Self { + conn: Arc::new(Mutex::new(conn)), + embedder, + }) + } + + /// Returns a reference to the embedding provider. + pub fn embedder(&self) -> &dyn EmbeddingProvider { + self.embedder.as_ref() + } + + /// Persist or validate the embedding configuration in `store_meta`. + fn check_or_store_meta( + conn: &Connection, + embedder: &dyn EmbeddingProvider, + ) -> anyhow::Result<()> { + let now = now_ts(); + let stored_dims: Option = conn + .query_row( + "SELECT value FROM store_meta WHERE key = 'embed_dims'", + [], + |row| row.get(0), + ) + .ok(); + + match stored_dims { + None => { + // First open — persist metadata. + let stmts: &[(&str, &str)] = &[ + ("embed_provider", embedder.name()), + ("embed_dims", &embedder.dimensions().to_string()), + ]; + for (key, value) in stmts { + conn.execute( + "INSERT OR REPLACE INTO store_meta (key, value, updated_at) VALUES (?1, ?2, ?3)", + rusqlite::params![key, value, now], + )?; + } + tracing::debug!( + target: "embeddings.store", + "[vector-store] stored meta: provider={}, dims={}", + embedder.name(), + embedder.dimensions() + ); + } + Some(dims_str) => { + let stored: usize = dims_str.parse().unwrap_or(0); + let runtime = embedder.dimensions(); + if stored != 0 && runtime != 0 && stored != runtime { + anyhow::bail!( + "vector store dimension mismatch: database was created with \ + {stored}-dim embeddings but the current provider ({}) uses \ + {runtime} dims. Delete the database or reconfigure the provider.", + embedder.name() + ); + } + } + } + + Ok(()) + } + + // ── Write operations ───────────────────────────────────── + + /// Inserts or updates a text entry. The text is embedded automatically. + /// + /// If an entry with the same `(namespace, id)` already exists it is replaced. + pub async fn insert( + &self, + id: &str, + namespace: &str, + text: &str, + metadata: serde_json::Value, + ) -> anyhow::Result<()> { + tracing::trace!( + target: "embeddings.store", + "[vector-store] insert: id={id}, ns={namespace}, text_len={}", + text.len() + ); + let embedding = self.embedder.embed_one(text).await?; + self.insert_with_vector(id, namespace, text, &embedding, metadata) + } + + /// Inserts with a pre-computed embedding vector (skips the embed call). + pub fn insert_with_vector( + &self, + id: &str, + namespace: &str, + text: &str, + embedding: &[f32], + metadata: serde_json::Value, + ) -> anyhow::Result<()> { + let blob = vec_to_bytes(embedding); + let meta_str = serde_json::to_string(&metadata)?; + let now = now_ts(); + + let conn = self.conn.lock(); + conn.execute( + "INSERT OR REPLACE INTO vectors (id, namespace, text, embedding, metadata, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![id, namespace, text, blob, meta_str, now, now], + )?; + + tracing::trace!( + target: "embeddings.store", + "[vector-store] inserted id={id} ns={namespace} dims={}", + embedding.len() + ); + + Ok(()) + } + + /// Bulk-insert multiple entries. Each text is embedded automatically. + pub async fn insert_batch( + &self, + namespace: &str, + entries: &[(&str, &str, serde_json::Value)], // (id, text, metadata) + ) -> anyhow::Result<()> { + if entries.is_empty() { + return Ok(()); + } + + tracing::debug!( + target: "embeddings.store", + "[vector-store] insert_batch: ns={namespace}, count={}", + entries.len() + ); + + let texts: Vec<&str> = entries.iter().map(|(_, text, _)| *text).collect(); + let embeddings = self.embedder.embed(&texts).await?; + + if embeddings.len() != entries.len() { + anyhow::bail!( + "embedding count mismatch: got {} embeddings for {} entries", + embeddings.len(), + entries.len() + ); + } + + let now = now_ts(); + let conn = self.conn.lock(); + let tx = conn.unchecked_transaction()?; + + for ((id, text, metadata), embedding) in entries.iter().zip(embeddings.iter()) { + let blob = vec_to_bytes(embedding); + let meta_str = serde_json::to_string(metadata)?; + tx.execute( + "INSERT OR REPLACE INTO vectors (id, namespace, text, embedding, metadata, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![id, namespace, text, blob, meta_str, now, now], + )?; + } + + tx.commit()?; + + tracing::debug!( + target: "embeddings.store", + "[vector-store] batch inserted {} entries in ns={namespace}", + entries.len() + ); + + Ok(()) + } + + // ── Search ─────────────────────────────────────────────── + + /// Searches for the `limit` most similar entries to `query` within a namespace. + /// + /// The query is embedded via the configured provider and compared against + /// all stored vectors using cosine similarity. + pub async fn search( + &self, + namespace: &str, + query: &str, + limit: usize, + ) -> anyhow::Result> { + tracing::trace!( + target: "embeddings.store", + "[vector-store] search: ns={namespace}, limit={limit}, query_len={}", + query.len() + ); + let query_vec = self.embedder.embed_one(query).await?; + self.search_by_vector(namespace, &query_vec, limit) + } + + /// Searches using a pre-computed query vector. + pub fn search_by_vector( + &self, + namespace: &str, + query_vec: &[f32], + limit: usize, + ) -> anyhow::Result> { + if limit == 0 { + tracing::trace!( + target: "embeddings.store", + "[vector-store] search_by_vector: limit=0, returning empty" + ); + return Ok(Vec::new()); + } + + let conn = self.conn.lock(); + let mut stmt = conn.prepare( + "SELECT id, namespace, text, embedding, metadata FROM vectors WHERE namespace = ?1", + )?; + + let rows: Vec<(String, String, String, Vec, String)> = stmt + .query_map(rusqlite::params![namespace], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, Vec>(3)?, + row.get::<_, String>(4)?, + )) + })? + .collect::>>()?; + + let mut scored: Vec = rows + .into_iter() + .map(|(id, ns, text, blob, meta_str)| { + let stored_vec = bytes_to_vec(&blob); + let score = cosine_similarity(query_vec, &stored_vec); + let metadata = serde_json::from_str(&meta_str).unwrap_or(serde_json::Value::Null); + SearchResult { + id, + namespace: ns, + text, + score, + metadata, + } + }) + .collect(); + + // Sort descending by score. + scored.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + }); + scored.truncate(limit); + + tracing::trace!( + target: "embeddings.store", + "[vector-store] search_by_vector: ns={namespace}, scanned={}, returned={}", + scored.len() + scored.capacity() - scored.len(), // approximate total before truncate + scored.len() + ); + + Ok(scored) + } + + // ── Delete / management ────────────────────────────────── + + /// Deletes a single entry by ID within a namespace. + /// + /// Returns `true` if a row was actually deleted. + pub fn delete(&self, namespace: &str, id: &str) -> anyhow::Result { + let conn = self.conn.lock(); + let affected = conn.execute( + "DELETE FROM vectors WHERE namespace = ?1 AND id = ?2", + rusqlite::params![namespace, id], + )?; + + tracing::trace!( + target: "embeddings.store", + "[vector-store] delete: ns={namespace}, id={id}, affected={affected}" + ); + + Ok(affected > 0) + } + + /// Deletes all entries in a namespace. + /// + /// Returns the number of deleted rows. + pub fn clear_namespace(&self, namespace: &str) -> anyhow::Result { + let conn = self.conn.lock(); + let affected = conn.execute( + "DELETE FROM vectors WHERE namespace = ?1", + rusqlite::params![namespace], + )?; + + tracing::debug!( + target: "embeddings.store", + "[vector-store] cleared namespace={namespace}, deleted={affected}" + ); + + Ok(affected) + } + + /// Returns the number of entries in a namespace (or all if `None`). + pub fn count(&self, namespace: Option<&str>) -> anyhow::Result { + let conn = self.conn.lock(); + let count: usize = match namespace { + Some(ns) => conn.query_row( + "SELECT COUNT(*) FROM vectors WHERE namespace = ?1", + rusqlite::params![ns], + |row| row.get(0), + )?, + None => conn.query_row("SELECT COUNT(*) FROM vectors", [], |row| row.get(0))?, + }; + Ok(count) + } + + /// Lists all distinct namespaces. + pub fn list_namespaces(&self) -> anyhow::Result> { + let conn = self.conn.lock(); + let mut stmt = conn.prepare("SELECT DISTINCT namespace FROM vectors ORDER BY namespace")?; + let namespaces: Vec = stmt + .query_map([], |row| row.get(0))? + .collect::>>()?; + Ok(namespaces) + } +} + +// ── Vector math utilities ──────────────────────────────────── + +/// Serializes a float vector to little-endian bytes for SQLite BLOB storage. +pub fn vec_to_bytes(v: &[f32]) -> Vec { + let mut bytes = Vec::with_capacity(v.len() * 4); + for &f in v { + bytes.extend_from_slice(&f.to_le_bytes()); + } + bytes +} + +/// Deserializes little-endian bytes back to a float vector. +pub fn bytes_to_vec(bytes: &[u8]) -> Vec { + bytes + .chunks_exact(4) + .map(|chunk| { + let arr: [u8; 4] = chunk.try_into().unwrap_or([0; 4]); + f32::from_le_bytes(arr) + }) + .collect() +} + +/// Computes cosine similarity between two vectors. Returns 0.0 for +/// mismatched lengths, empty vectors, or zero-magnitude vectors. +pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f64 { + if a.len() != b.len() || a.is_empty() { + return 0.0; + } + let mut dot = 0.0_f64; + let mut norm_a = 0.0_f64; + let mut norm_b = 0.0_f64; + for (x, y) in a.iter().zip(b.iter()) { + let x = f64::from(*x); + let y = f64::from(*y); + dot += x * y; + norm_a += x * x; + norm_b += y * y; + } + let denom = norm_a.sqrt() * norm_b.sqrt(); + if denom <= f64::EPSILON { + return 0.0; + } + (dot / denom).clamp(0.0, 1.0) +} + +fn now_ts() -> f64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs_f64()) + .unwrap_or(0.0) +} + +// ── Tests ──────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// A test embedding provider that returns deterministic vectors. + struct FakeEmbedding { + dims: usize, + } + + #[async_trait::async_trait] + impl EmbeddingProvider for FakeEmbedding { + fn name(&self) -> &str { + "fake" + } + fn dimensions(&self) -> usize { + self.dims + } + async fn embed(&self, texts: &[&str]) -> anyhow::Result>> { + Ok(texts.iter().map(|t| text_to_vec(t, self.dims)).collect()) + } + } + + fn text_to_vec(text: &str, dims: usize) -> Vec { + let mut vec = vec![0.0_f32; dims]; + for (i, byte) in text.bytes().enumerate() { + vec[i % dims] += byte as f32 / 255.0; + } + let norm: f32 = vec.iter().map(|x| x * x).sum::().sqrt(); + if norm > 0.0 { + for x in &mut vec { + *x /= norm; + } + } + vec + } + + struct MismatchEmbedding; + + #[async_trait::async_trait] + impl EmbeddingProvider for MismatchEmbedding { + fn name(&self) -> &str { + "mismatch" + } + fn dimensions(&self) -> usize { + 2 + } + async fn embed(&self, _texts: &[&str]) -> anyhow::Result>> { + Ok(vec![vec![1.0, 0.0]]) + } + } + + fn fake_store(dims: usize) -> VectorStore { + VectorStore::open_in_memory(Arc::new(FakeEmbedding { dims })).unwrap() + } + + // ── vec_to_bytes / bytes_to_vec ───────────────────────── + + #[test] + fn roundtrip_vec_bytes() { + let original = vec![1.0_f32, -2.5, 3.14, 0.0, f32::MAX, f32::MIN]; + let bytes = vec_to_bytes(&original); + assert_eq!(bytes.len(), original.len() * 4); + assert_eq!(original, bytes_to_vec(&bytes)); + } + + #[test] + fn empty_vec_roundtrip() { + assert!(bytes_to_vec(&vec_to_bytes(&[])).is_empty()); + } + + #[test] + fn bytes_to_vec_truncates_partial_bytes() { + assert_eq!(bytes_to_vec(&[0u8; 5]).len(), 1); + } + + // ── cosine_similarity ─────────────────────────────────── + + #[test] + fn cosine_identical() { + let v = vec![1.0_f32, 2.0, 3.0]; + assert!((cosine_similarity(&v, &v) - 1.0).abs() < 1e-6); + } + + #[test] + fn cosine_orthogonal() { + assert!(cosine_similarity(&[1.0, 0.0], &[0.0, 1.0]).abs() < 1e-6); + } + + #[test] + fn cosine_opposite() { + assert!(cosine_similarity(&[1.0, 0.0], &[-1.0, 0.0]).abs() < 1e-6); + } + + #[test] + fn cosine_mismatched_lengths() { + assert_eq!(cosine_similarity(&[1.0, 2.0], &[1.0, 2.0, 3.0]), 0.0); + } + + #[test] + fn cosine_empty() { + assert_eq!(cosine_similarity(&[], &[]), 0.0); + } + + #[test] + fn cosine_zero_vector() { + assert_eq!(cosine_similarity(&[0.0, 0.0], &[1.0, 0.0]), 0.0); + } + + #[test] + fn cosine_similar_high() { + assert!(cosine_similarity(&[1.0, 2.0, 3.0], &[1.1, 2.1, 3.1]) > 0.99); + } + + // ── VectorStore: open / metadata ──────────────────────── + + #[test] + fn open_in_memory_succeeds() { + let store = fake_store(3); + assert_eq!(store.count(None).unwrap(), 0); + } + + #[test] + fn open_on_disk() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("sub/dir/vectors.db"); + let store = VectorStore::open(&db_path, Arc::new(FakeEmbedding { dims: 3 })).unwrap(); + assert_eq!(store.count(None).unwrap(), 0); + assert!(db_path.exists()); + } + + #[test] + fn open_reopen_same_dims_succeeds() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("v.db"); + VectorStore::open(&db_path, Arc::new(FakeEmbedding { dims: 4 })).unwrap(); + // Reopen with same dims — should work. + VectorStore::open(&db_path, Arc::new(FakeEmbedding { dims: 4 })).unwrap(); + } + + #[test] + fn open_reopen_different_dims_errors() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("v.db"); + VectorStore::open(&db_path, Arc::new(FakeEmbedding { dims: 4 })).unwrap(); + let result = VectorStore::open(&db_path, Arc::new(FakeEmbedding { dims: 8 })); + let msg = result.err().expect("should be an error").to_string(); + assert!(msg.contains("dimension mismatch"), "msg: {msg}"); + assert!(msg.contains("4"), "should mention stored dims: {msg}"); + assert!(msg.contains("8"), "should mention runtime dims: {msg}"); + } + + #[test] + fn embedder_accessor() { + let store = fake_store(3); + assert_eq!(store.embedder().name(), "fake"); + assert_eq!(store.embedder().dimensions(), 3); + } + + // ── insert + count ────────────────────────────────────── + + #[tokio::test] + async fn insert_and_count() { + let store = fake_store(4); + store.insert("a", "ns1", "hello", json!({})).await.unwrap(); + store.insert("b", "ns1", "world", json!({})).await.unwrap(); + store.insert("c", "ns2", "other", json!({})).await.unwrap(); + assert_eq!(store.count(Some("ns1")).unwrap(), 2); + assert_eq!(store.count(Some("ns2")).unwrap(), 1); + assert_eq!(store.count(None).unwrap(), 3); + } + + #[tokio::test] + async fn insert_upsert_replaces() { + let store = fake_store(4); + store + .insert("a", "ns", "original", json!({"v": 1})) + .await + .unwrap(); + store + .insert("a", "ns", "updated", json!({"v": 2})) + .await + .unwrap(); + assert_eq!(store.count(Some("ns")).unwrap(), 1); + let results = store + .search_by_vector("ns", &text_to_vec("updated", 4), 10) + .unwrap(); + assert_eq!(results[0].text, "updated"); + assert_eq!(results[0].metadata["v"], 2); + } + + #[test] + fn insert_with_vector_sync() { + let store = fake_store(3); + store + .insert_with_vector("id1", "ns", "text", &[1.0, 0.0, 0.0], json!({"k": "v"})) + .unwrap(); + assert_eq!(store.count(Some("ns")).unwrap(), 1); + } + + // ── insert_batch ──────────────────────────────────────── + + #[tokio::test] + async fn insert_batch_multiple() { + let store = fake_store(4); + let entries = vec![ + ("a", "alpha", json!({})), + ("b", "beta", json!({})), + ("c", "gamma", json!({})), + ]; + store.insert_batch("ns", &entries).await.unwrap(); + assert_eq!(store.count(Some("ns")).unwrap(), 3); + } + + #[tokio::test] + async fn insert_batch_empty() { + let store = fake_store(4); + store.insert_batch("ns", &[]).await.unwrap(); + assert_eq!(store.count(None).unwrap(), 0); + } + + #[tokio::test] + async fn insert_batch_mismatch_error() { + let store = VectorStore::open_in_memory(Arc::new(MismatchEmbedding)).unwrap(); + let entries = vec![("a", "alpha", json!({})), ("b", "beta", json!({}))]; + let err = store.insert_batch("ns", &entries).await.unwrap_err(); + assert!(err.to_string().contains("mismatch")); + } + + // ── search ────────────────────────────────────────────── + + #[tokio::test] + async fn search_returns_ranked_results() { + let store = fake_store(8); + store + .insert("a", "ns", "the quick brown fox", json!({})) + .await + .unwrap(); + store + .insert("b", "ns", "a lazy dog sleeps", json!({})) + .await + .unwrap(); + store + .insert("c", "ns", "the quick brown fox jumps", json!({})) + .await + .unwrap(); + let results = store.search("ns", "the quick brown fox", 2).await.unwrap(); + assert_eq!(results.len(), 2); + assert!(results[0].score >= results[1].score); + } + + #[tokio::test] + async fn search_respects_limit() { + let store = fake_store(4); + for i in 0..10 { + store + .insert(&format!("id-{i}"), "ns", &format!("text {i}"), json!({})) + .await + .unwrap(); + } + assert_eq!(store.search("ns", "text", 3).await.unwrap().len(), 3); + } + + #[tokio::test] + async fn search_empty_namespace() { + let store = fake_store(4); + assert!(store.search("empty", "query", 10).await.unwrap().is_empty()); + } + + #[tokio::test] + async fn search_namespace_isolation() { + let store = fake_store(4); + store.insert("a", "ns1", "hello", json!({})).await.unwrap(); + store.insert("b", "ns2", "hello", json!({})).await.unwrap(); + assert_eq!(store.search("ns1", "hello", 10).await.unwrap()[0].id, "a"); + assert_eq!(store.search("ns2", "hello", 10).await.unwrap()[0].id, "b"); + } + + // ── search_by_vector ──────────────────────────────────── + + #[test] + fn search_by_vector_limit_zero() { + let store = fake_store(3); + store + .insert_with_vector("a", "ns", "t", &[1.0, 0.0, 0.0], json!({})) + .unwrap(); + assert!(store + .search_by_vector("ns", &[1.0, 0.0, 0.0], 0) + .unwrap() + .is_empty()); + } + + #[test] + fn search_by_vector_scores_correct() { + let store = fake_store(3); + store + .insert_with_vector("x", "ns", "x", &[1.0, 0.0, 0.0], json!({})) + .unwrap(); + store + .insert_with_vector("y", "ns", "y", &[0.0, 1.0, 0.0], json!({})) + .unwrap(); + let results = store.search_by_vector("ns", &[1.0, 0.0, 0.0], 2).unwrap(); + assert_eq!(results[0].id, "x"); + assert!((results[0].score - 1.0).abs() < 1e-6); + assert!(results[1].score < 1e-6); + } + + #[test] + fn search_by_vector_preserves_metadata() { + let store = fake_store(2); + store + .insert_with_vector("a", "ns", "t", &[1.0, 0.0], json!({"key": "value"})) + .unwrap(); + assert_eq!( + store.search_by_vector("ns", &[1.0, 0.0], 1).unwrap()[0].metadata["key"], + "value" + ); + } + + #[test] + fn search_handles_invalid_metadata_json() { + let store = fake_store(2); + { + let conn = store.conn.lock(); + conn.execute( + "INSERT INTO vectors (id, namespace, text, embedding, metadata, created_at, updated_at) + VALUES ('bad', 'ns', 'text', ?1, 'not-json', 0.0, 0.0)", + rusqlite::params![vec_to_bytes(&[1.0, 0.0])], + ).unwrap(); + } + let results = store.search_by_vector("ns", &[1.0, 0.0], 1).unwrap(); + assert_eq!(results[0].id, "bad"); + assert!(results[0].metadata.is_null()); + } + + // ── delete ────────────────────────────────────────────── + + #[tokio::test] + async fn delete_existing() { + let store = fake_store(4); + store.insert("a", "ns", "text", json!({})).await.unwrap(); + assert!(store.delete("ns", "a").unwrap()); + assert_eq!(store.count(Some("ns")).unwrap(), 0); + } + + #[test] + fn delete_nonexistent() { + assert!(!fake_store(3).delete("ns", "no-such-id").unwrap()); + } + + #[tokio::test] + async fn delete_wrong_namespace() { + let store = fake_store(4); + store.insert("a", "ns1", "text", json!({})).await.unwrap(); + assert!(!store.delete("ns2", "a").unwrap()); + assert_eq!(store.count(Some("ns1")).unwrap(), 1); + } + + // ── clear_namespace ───────────────────────────────────── + + #[tokio::test] + async fn clear_namespace_removes_all() { + let store = fake_store(4); + store.insert("a", "ns", "one", json!({})).await.unwrap(); + store.insert("b", "ns", "two", json!({})).await.unwrap(); + store + .insert("c", "other", "three", json!({})) + .await + .unwrap(); + assert_eq!(store.clear_namespace("ns").unwrap(), 2); + assert_eq!(store.count(Some("ns")).unwrap(), 0); + assert_eq!(store.count(Some("other")).unwrap(), 1); + } + + #[test] + fn clear_empty_namespace() { + assert_eq!(fake_store(3).clear_namespace("empty").unwrap(), 0); + } + + // ── list_namespaces ───────────────────────────────────── + + #[tokio::test] + async fn list_namespaces_empty() { + assert!(fake_store(3).list_namespaces().unwrap().is_empty()); + } + + #[tokio::test] + async fn list_namespaces_populated() { + let store = fake_store(4); + store.insert("a", "beta", "t", json!({})).await.unwrap(); + store.insert("b", "alpha", "t", json!({})).await.unwrap(); + store.insert("c", "beta", "t", json!({})).await.unwrap(); + assert_eq!(store.list_namespaces().unwrap(), vec!["alpha", "beta"]); + } + + // ── count ─────────────────────────────────────────────── + + #[test] + fn count_empty() { + let store = fake_store(3); + assert_eq!(store.count(None).unwrap(), 0); + assert_eq!(store.count(Some("ns")).unwrap(), 0); + } +} diff --git a/src/openhuman/memory/embeddings.rs b/src/openhuman/memory/embeddings.rs index 45d42398c..b096591b5 100644 --- a/src/openhuman/memory/embeddings.rs +++ b/src/openhuman/memory/embeddings.rs @@ -1,596 +1,7 @@ -//! Embedding providers for the OpenHuman memory system. +//! Re-exports from the top-level `openhuman::embeddings` module. //! -//! This module provides a unified interface for converting text into vector -//! embeddings. It supports multiple providers: -//! - **Fastembed**: Local, high-performance embeddings using ONNX runtime. -//! - **OpenAI**: Cloud-based embeddings via the OpenAI API or compatible endpoints. -//! - **Noop**: A fallback provider for keyword-only search. +//! The canonical embedding logic now lives in `src/openhuman/embeddings/`. +//! This file keeps the old `memory::embeddings::*` import paths working so +//! that existing call sites do not need to change immediately. -use async_trait::async_trait; -use parking_lot::Mutex; -use std::env; -use std::path::PathBuf; -use std::str::FromStr; -use std::sync::Arc; - -/// Default model name for Fastembed. -pub const DEFAULT_FASTEMBED_MODEL: &str = "BGESmallENV15"; -/// Default dimensions for the BGESmallENV15 model. -pub const DEFAULT_FASTEMBED_DIMENSIONS: usize = 384; -/// Interface for embedding providers that convert text into numerical vectors. -#[async_trait] -pub trait EmbeddingProvider: Send + Sync { - /// Returns the name of the provider (e.g., "fastembed", "openai"). - fn name(&self) -> &str; - - /// Returns the number of dimensions in the generated embeddings. - fn dimensions(&self) -> usize; - - /// Generates embeddings for a batch of strings. - async fn embed(&self, texts: &[&str]) -> anyhow::Result>>; - - /// Generates an embedding for a single string. - async fn embed_one(&self, text: &str) -> anyhow::Result> { - let mut results = self.embed(&[text]).await?; - results - .pop() - .ok_or_else(|| anyhow::anyhow!("Empty embedding result")) - } -} - -// ── Noop provider (keyword-only fallback) ──────────────────── - -/// A "no-op" embedding provider used when semantic search is disabled. -/// Returns empty vectors. -pub struct NoopEmbedding; - -#[async_trait] -impl EmbeddingProvider for NoopEmbedding { - fn name(&self) -> &str { - "none" - } - - fn dimensions(&self) -> usize { - 0 - } - - async fn embed(&self, _texts: &[&str]) -> anyhow::Result>> { - Ok(Vec::new()) - } -} - -/// Represents the initialization state of the local Fastembed model. -enum FastembedState { - /// Initial state before the model is loaded. - Uninitialized, - /// Model is loaded into memory and ready for inference. - Ready(Box), - /// An error occurred during model loading. - Failed(String), -} - -/// Local embedding provider using the `fastembed-rs` library. -/// Executes in a dedicated blocking thread to avoid stalling the async runtime. -pub struct FastembedEmbedding { - model: String, - dims: usize, - state: Arc>, -} - -impl FastembedEmbedding { - /// Creates a new Fastembed provider with the specified model and dimensions. - pub fn new(model: &str, dims: usize) -> Self { - Self { - model: if model.trim().is_empty() { - DEFAULT_FASTEMBED_MODEL.to_string() - } else { - model.trim().to_string() - }, - dims: if dims == 0 { - DEFAULT_FASTEMBED_DIMENSIONS - } else { - dims - }, - state: Arc::new(Mutex::new(FastembedState::Uninitialized)), - } - } - - /// Maps a string model name to a `fastembed::EmbeddingModel` enum. - fn resolve_model(&self) -> fastembed::EmbeddingModel { - fastembed::EmbeddingModel::from_str(&self.model) - .unwrap_or(fastembed::EmbeddingModel::BGESmallENV15) - } - - /// Internal helper to initialize the model on first use. - fn init_model(&self) -> anyhow::Result { - ensure_fastembed_ort_dylib_path(); - fastembed::TextEmbedding::try_new( - fastembed::InitOptions::new(self.resolve_model()).with_show_download_progress(false), - ) - .map_err(|e| anyhow::anyhow!("fastembed init failed for {}: {e}", self.model)) - } -} - -/// Configures the search path for the ONNX Runtime dynamic library. -/// -/// This is critical for Fastembed to function across different platforms and -/// installation methods (e.g., local dev, bundled app). It checks several -/// locations in order of priority: -/// 1. `ORT_DYLIB_PATH` environment variable. -/// 2. `ORT_LIB_LOCATION` environment variable. -/// 3. OpenHuman-specific cache directories. -/// 4. Standard system library paths (Linux only). -fn ensure_fastembed_ort_dylib_path() { - if env::var_os("ORT_DYLIB_PATH").is_some() { - return; - } - - // Check for explicit library location override. - if let Some(lib_path) = env::var_os("ORT_LIB_LOCATION") { - let candidate = PathBuf::from(lib_path); - if candidate.is_file() { - env::set_var("ORT_DYLIB_PATH", candidate); - return; - } - - #[cfg(target_os = "windows")] - let runtime_lib = candidate.join("onnxruntime.dll"); - #[cfg(target_os = "macos")] - let runtime_lib = candidate.join("libonnxruntime.dylib"); - #[cfg(target_os = "linux")] - let runtime_lib = candidate.join("libonnxruntime.so"); - - if runtime_lib.exists() { - env::set_var("ORT_DYLIB_PATH", runtime_lib); - } - } - - // Fallback to system-wide paths on Linux. - #[cfg(target_os = "linux")] - { - for candidate in [ - "/usr/lib/x86_64-linux-gnu/libonnxruntime.so", - "/usr/local/lib/libonnxruntime.so", - "/usr/lib/libonnxruntime.so", - ] { - let candidate = PathBuf::from(candidate); - if candidate.exists() { - env::set_var("ORT_DYLIB_PATH", candidate); - return; - } - } - } -} - -#[async_trait] -impl EmbeddingProvider for FastembedEmbedding { - fn name(&self) -> &str { - "fastembed" - } - - fn dimensions(&self) -> usize { - self.dims - } - - /// Performs embedding using a blocking task to prevent executor starvation. - async fn embed(&self, texts: &[&str]) -> anyhow::Result>> { - if texts.is_empty() { - return Ok(Vec::new()); - } - - let items = texts - .iter() - .map(|text| (*text).to_string()) - .collect::>(); - let state = Arc::clone(&self.state); - let provider = self.model.clone(); - - let join_result = tokio::task::spawn_blocking(move || -> anyhow::Result>> { - ensure_fastembed_ort_dylib_path(); - let mut guard = state.lock(); - - // Lazy initialization of the model on the first request. - // - // `fastembed::TextEmbedding::try_new` reaches into the `ort` - // crate's global environment, which uses a `std::sync::Mutex`. - // If any previous caller panicked while that mutex was held - // (common when the ONNX Runtime dylib path is wrong or a - // background init failed), every subsequent call panics with - // `"Mutex poisoned"`. Without `catch_unwind`, that panic - // propagates out of this `spawn_blocking` closure, kills the - // tokio blocking worker, and surfaces as a process-level - // stack trace — even though the caller only wanted an error. - // - // We trap the panic here, flip our own state to `Failed`, and - // return a regular `anyhow::Error` so every later call short- - // circuits on the cached failure without touching `ort` again. - if matches!(*guard, FastembedState::Uninitialized) { - let provider_for_init = provider.clone(); - let init_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - fastembed::TextEmbedding::try_new( - fastembed::InitOptions::new( - fastembed::EmbeddingModel::from_str(&provider_for_init) - .unwrap_or(fastembed::EmbeddingModel::BGESmallENV15), - ) - .with_show_download_progress(false), - ) - })); - - match init_result { - Ok(Ok(model)) => *guard = FastembedState::Ready(Box::new(model)), - Ok(Err(err)) => { - let message = format!("fastembed init failed for {provider}: {err}"); - tracing::error!(target: "memory.embeddings", "[embeddings] {message}"); - *guard = FastembedState::Failed(message); - } - Err(panic_payload) => { - let panic_msg = extract_panic_message(&panic_payload); - let message = format!( - "fastembed init panicked for {provider}: {panic_msg} — \ - the ONNX Runtime global environment is in a poisoned state. \ - Check ORT_DYLIB_PATH / ORT_LIB_LOCATION and restart the \ - process to retry." - ); - tracing::error!(target: "memory.embeddings", "[embeddings] {message}"); - *guard = FastembedState::Failed(message); - } - } - } - - match &mut *guard { - FastembedState::Ready(model) => { - // Also guard the actual embed call — fastembed / ort - // can panic on certain inputs or runtime errors, and - // we want to surface those as regular errors too. - let embed_result = - std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - model.embed(items, None) - })); - match embed_result { - Ok(Ok(vectors)) => Ok(vectors), - Ok(Err(e)) => Err(anyhow::anyhow!("fastembed embed failed: {e}")), - Err(panic_payload) => { - let panic_msg = extract_panic_message(&panic_payload); - Err(anyhow::anyhow!("fastembed embed panicked: {panic_msg}")) - } - } - } - FastembedState::Failed(message) => Err(anyhow::anyhow!(message.clone())), - FastembedState::Uninitialized => { - Err(anyhow::anyhow!("fastembed provider did not initialize")) - } - } - }) - .await; - - join_result.map_err(|e| anyhow::anyhow!("fastembed task join failed: {e}"))? - } -} - -/// Best-effort extraction of a readable message from a `catch_unwind` payload. -/// Panics produced by `panic!("...")` downcast to `&'static str` or `String`; -/// everything else falls back to a generic label. -fn extract_panic_message(panic: &Box) -> String { - if let Some(s) = panic.downcast_ref::<&'static str>() { - (*s).to_string() - } else if let Some(s) = panic.downcast_ref::() { - s.clone() - } else { - "unknown panic payload".to_string() - } -} - -// ── OpenAI-compatible embedding provider ───────────────────── - -/// Embedding provider for OpenAI and compatible APIs (e.g., LocalAI, Ollama). -pub struct OpenAiEmbedding { - base_url: String, - api_key: String, - model: String, - dims: usize, -} - -impl OpenAiEmbedding { - /// Creates a new OpenAI-style provider. - pub fn new(base_url: &str, api_key: &str, model: &str, dims: usize) -> Self { - Self { - base_url: base_url.trim_end_matches('/').to_string(), - api_key: api_key.to_string(), - model: model.to_string(), - dims, - } - } - - /// Internal helper to build an HTTP client with proxy support. - fn http_client(&self) -> reqwest::Client { - crate::openhuman::config::build_runtime_proxy_client("memory.embeddings") - } - - /// Checks if the base URL includes a specific path (e.g., /api/v1). - fn has_explicit_api_path(&self) -> bool { - let Ok(url) = reqwest::Url::parse(&self.base_url) else { - return false; - }; - - let path = url.path().trim_end_matches('/'); - !path.is_empty() && path != "/" - } - - /// Checks if the URL already ends with /embeddings. - fn has_embeddings_endpoint(&self) -> bool { - let Ok(url) = reqwest::Url::parse(&self.base_url) else { - return false; - }; - - url.path().trim_end_matches('/').ends_with("/embeddings") - } - - /// Constructs the final URL for the embeddings endpoint. - fn embeddings_url(&self) -> String { - if self.has_embeddings_endpoint() { - return self.base_url.clone(); - } - - if self.has_explicit_api_path() { - format!("{}/embeddings", self.base_url) - } else { - format!("{}/v1/embeddings", self.base_url) - } - } -} - -#[async_trait] -impl EmbeddingProvider for OpenAiEmbedding { - fn name(&self) -> &str { - "openai" - } - - fn dimensions(&self) -> usize { - self.dims - } - - /// Sends a POST request to the embedding API. - async fn embed(&self, texts: &[&str]) -> anyhow::Result>> { - if texts.is_empty() { - return Ok(Vec::new()); - } - - let body = serde_json::json!({ - "model": self.model, - "input": texts, - }); - - let resp = self - .http_client() - .post(self.embeddings_url()) - .header("Authorization", format!("Bearer {}", self.api_key)) - .header("Content-Type", "application/json") - .json(&body) - .send() - .await?; - - if !resp.status().is_success() { - let status = resp.status(); - let text = resp.text().await.unwrap_or_default(); - anyhow::bail!("Embedding API error {status}: {text}"); - } - - let json: serde_json::Value = resp.json().await?; - let data = json - .get("data") - .and_then(|d| d.as_array()) - .ok_or_else(|| anyhow::anyhow!("Invalid embedding response: missing 'data'"))?; - - let mut embeddings = Vec::with_capacity(data.len()); - for item in data { - let embedding = item - .get("embedding") - .and_then(|e| e.as_array()) - .ok_or_else(|| anyhow::anyhow!("Invalid embedding item"))?; - - #[allow(clippy::cast_possible_truncation)] - let vec: Vec = embedding - .iter() - .filter_map(|v| v.as_f64().map(|f| f as f32)) - .collect(); - - embeddings.push(vec); - } - - Ok(embeddings) - } -} - -// ── Factory ────────────────────────────────────────────────── - -/// Creates an embedding provider based on the specified name and configuration. -/// -/// Supports "fastembed", "openai", and "custom:". -pub fn create_embedding_provider( - provider: &str, - api_key: Option<&str>, - model: &str, - dims: usize, -) -> Box { - match provider { - "fastembed" => Box::new(FastembedEmbedding::new(model, dims)), - "openai" => { - let key = api_key.unwrap_or(""); - Box::new(OpenAiEmbedding::new( - "https://api.openai.com", - key, - model, - dims, - )) - } - name if name.starts_with("custom:") => { - let base_url = name.strip_prefix("custom:").unwrap_or(""); - let key = api_key.unwrap_or(""); - Box::new(OpenAiEmbedding::new(base_url, key, model, dims)) - } - _ => Box::new(NoopEmbedding), - } -} - -/// Returns the default local embedding provider (Fastembed). -pub fn default_local_embedding_provider() -> Arc { - Arc::new(FastembedEmbedding::new( - DEFAULT_FASTEMBED_MODEL, - DEFAULT_FASTEMBED_DIMENSIONS, - )) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn noop_name() { - let p = NoopEmbedding; - assert_eq!(p.name(), "none"); - assert_eq!(p.dimensions(), 0); - } - - #[tokio::test] - async fn noop_embed_returns_empty() { - let p = NoopEmbedding; - let result = p.embed(&["hello"]).await.unwrap(); - assert!(result.is_empty()); - } - - #[test] - fn factory_none() { - let p = create_embedding_provider("none", None, "model", 1536); - assert_eq!(p.name(), "none"); - } - - #[test] - fn factory_openai() { - let p = create_embedding_provider("openai", Some("key"), "text-embedding-3-small", 1536); - assert_eq!(p.name(), "openai"); - assert_eq!(p.dimensions(), 1536); - } - - #[test] - fn factory_fastembed() { - let p = create_embedding_provider("fastembed", None, DEFAULT_FASTEMBED_MODEL, 384); - assert_eq!(p.name(), "fastembed"); - assert_eq!(p.dimensions(), 384); - } - - #[test] - fn factory_custom_url() { - let p = create_embedding_provider("custom:http://localhost:1234", None, "model", 768); - assert_eq!(p.name(), "openai"); // uses OpenAiEmbedding internally - assert_eq!(p.dimensions(), 768); - } - - // ── Edge cases ─────────────────────────────────────────────── - - #[tokio::test] - async fn noop_embed_one_returns_error() { - let p = NoopEmbedding; - // embed returns empty vec → pop() returns None → error - let result = p.embed_one("hello").await; - assert!(result.is_err()); - } - - #[tokio::test] - async fn noop_embed_empty_batch() { - let p = NoopEmbedding; - let result = p.embed(&[]).await.unwrap(); - assert!(result.is_empty()); - } - - #[tokio::test] - async fn noop_embed_multiple_texts() { - let p = NoopEmbedding; - let result = p.embed(&["a", "b", "c"]).await.unwrap(); - assert!(result.is_empty()); - } - - #[test] - fn factory_empty_string_returns_noop() { - let p = create_embedding_provider("", None, "model", 1536); - assert_eq!(p.name(), "none"); - } - - #[test] - fn factory_unknown_provider_returns_noop() { - let p = create_embedding_provider("cohere", None, "model", 1536); - assert_eq!(p.name(), "none"); - } - - #[test] - fn default_local_provider_uses_fastembed_defaults() { - let p = default_local_embedding_provider(); - assert_eq!(p.name(), "fastembed"); - assert_eq!(p.dimensions(), DEFAULT_FASTEMBED_DIMENSIONS); - } - - #[test] - fn factory_custom_empty_url() { - // "custom:" with no URL — should still construct without panic - let p = create_embedding_provider("custom:", None, "model", 768); - assert_eq!(p.name(), "openai"); - } - - #[test] - fn factory_openai_no_api_key() { - let p = create_embedding_provider("openai", None, "text-embedding-3-small", 1536); - assert_eq!(p.name(), "openai"); - assert_eq!(p.dimensions(), 1536); - } - - #[test] - fn openai_trailing_slash_stripped() { - let p = OpenAiEmbedding::new("https://api.openai.com/", "key", "model", 1536); - assert_eq!(p.base_url, "https://api.openai.com"); - } - - #[test] - fn openai_dimensions_custom() { - let p = OpenAiEmbedding::new("http://localhost", "k", "m", 384); - assert_eq!(p.dimensions(), 384); - } - - #[test] - fn embeddings_url_standard_openai() { - let p = OpenAiEmbedding::new("https://api.openai.com", "key", "model", 1536); - assert_eq!(p.embeddings_url(), "https://api.openai.com/v1/embeddings"); - } - - #[test] - fn embeddings_url_base_with_v1_no_duplicate() { - let p = OpenAiEmbedding::new("https://api.example.com/v1", "key", "model", 1536); - assert_eq!(p.embeddings_url(), "https://api.example.com/v1/embeddings"); - } - - #[test] - fn embeddings_url_non_v1_api_path_uses_raw_suffix() { - let p = OpenAiEmbedding::new( - "https://api.example.com/api/coding/v3", - "key", - "model", - 1536, - ); - assert_eq!( - p.embeddings_url(), - "https://api.example.com/api/coding/v3/embeddings" - ); - } - - #[test] - fn embeddings_url_custom_full_endpoint() { - let p = OpenAiEmbedding::new( - "https://my-api.example.com/api/v2/embeddings", - "key", - "model", - 1536, - ); - assert_eq!( - p.embeddings_url(), - "https://my-api.example.com/api/v2/embeddings" - ); - } -} +pub use crate::openhuman::embeddings::*; diff --git a/src/openhuman/memory/store/client.rs b/src/openhuman/memory/store/client.rs index f24a36047..e14a794e1 100644 --- a/src/openhuman/memory/store/client.rs +++ b/src/openhuman/memory/store/client.rs @@ -72,7 +72,7 @@ impl MemoryClient { std::fs::create_dir_all(&workspace_dir) .map_err(|e| format!("Create workspace dir {}: {e}", workspace_dir.display()))?; - // Initialize the default local embedding provider (e.g., FastEmbed). + // Initialize the default local embedding provider (Ollama). let embedder: Arc = embeddings::default_local_embedding_provider(); // Create the underlying UnifiedMemory instance. diff --git a/src/openhuman/memory/store/factories.rs b/src/openhuman/memory/store/factories.rs index 18aac3653..60f1109be 100644 --- a/src/openhuman/memory/store/factories.rs +++ b/src/openhuman/memory/store/factories.rs @@ -77,7 +77,7 @@ pub fn create_memory_with_storage_and_routes( api_key, &config.embedding_model, config.embedding_dimensions, - )); + )?); // 2. Instantiate UnifiedMemory which handles SQLite and vector storage. let mem = UnifiedMemory::new(workspace_dir, embedder, config.sqlite_open_timeout_secs)?; diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index 6d061926e..2cbe69139 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -30,6 +30,7 @@ pub mod credentials; pub mod cron; pub mod dev_paths; pub mod doctor; +pub mod embeddings; pub mod encryption; pub mod health; pub mod heartbeat;