diff --git a/Cargo.lock b/Cargo.lock index 70b8df452..9c3e2a095 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -59,6 +59,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "adobe-cmap-parser" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae8abfa9a4688de8fc9f42b3f013b6fffec18ed8a554f5f113577e0b9b3212a3" +dependencies = [ + "pom", +] + [[package]] name = "aead" version = "0.5.2" @@ -194,7 +203,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -205,7 +214,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -680,6 +689,37 @@ dependencies = [ "tungstenite 0.28.0", ] +[[package]] +name = "async-utility" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a34a3b57207a7a1007832416c3e4862378c8451b4e8e093e436f48c2d3d2c151" +dependencies = [ + "futures-util", + "gloo-timers", + "tokio", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-wsocket" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c92385c7c8b3eb2de1b78aeca225212e4c9a69a78b802832759b108681a5069" +dependencies = [ + "async-utility", + "futures", + "futures-util", + "js-sys", + "tokio", + "tokio-rustls 0.26.4", + "tokio-socks", + "tokio-tungstenite 0.26.2", + "url", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "asynk-strim" version = "0.1.5" @@ -699,6 +739,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "atomic-destructor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef49f5882e4b6afaac09ad239a4f8c70a24b8f2b0897edb1f706008efd109cf4" + [[package]] name = "atomic-polyfill" version = "1.0.3" @@ -952,6 +998,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + [[package]] name = "benchmarks" version = "0.1.0" @@ -992,6 +1044,34 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0669d5a35b64fdb5ab7fb19cae13148b6b5cbdf4b8247faf54ece47f699c8cef" +[[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes", + "serde", + "unicode-normalization", +] + +[[package]] +name = "bitcoin-io" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" + +[[package]] +name = "bitcoin_hashes" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" +dependencies = [ + "bitcoin-io", + "hex-conservative", + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -1307,6 +1387,12 @@ dependencies = [ "nom 7.1.3", ] +[[package]] +name = "cff-parser" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31f5b6e9141c036f3ff4ce7b2f7e432b0f00dee416ddcd4f17741d189ddc2e9d" + [[package]] name = "cfg-if" version = "1.0.4" @@ -2641,7 +2727,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2769,6 +2855,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecb" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a8bfa975b1aec2145850fcaa1c6fe269a16578c44705a532ae3edc92b8881c7" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "ecdsa" version = "0.16.9" @@ -2888,6 +2983,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "encoding_rs_io" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cc3c5651fb62ab8aa3103998dade57efdd028544bd300516baa31840c252a83" +dependencies = [ + "encoding_rs", +] + [[package]] name = "endian-type" version = "0.1.2" @@ -2971,7 +3075,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2994,6 +3098,15 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "euclid" +version = "0.20.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bb7ef65b3777a325d1eeefefab5b6d4959da54747e33bd6258e789640f307ad" +dependencies = [ + "num-traits", +] + [[package]] name = "event-listener" version = "2.5.3" @@ -4491,6 +4604,19 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "gloo-timers" version = "0.3.0" @@ -4516,6 +4642,43 @@ dependencies = [ "web-sys", ] +[[package]] +name = "grep-matcher" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36d7b71093325ab22d780b40d7df3066ae4aebb518ba719d38c697a8228a8023" +dependencies = [ + "memchr", +] + +[[package]] +name = "grep-regex" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce0c256c3ad82bcc07b812c15a45ec1d398122e8e15124f96695234db7112ef" +dependencies = [ + "bstr", + "grep-matcher", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "grep-searcher" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac63295322dc48ebb20a25348147905d816318888e64f531bfc2a2bc0577dc34" +dependencies = [ + "bstr", + "encoding_rs", + "encoding_rs_io", + "grep-matcher", + "log", + "memchr", + "memmap2", +] + [[package]] name = "group" version = "0.13.0" @@ -4742,6 +4905,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + [[package]] name = "hkdf" version = "0.12.4" @@ -5304,6 +5476,22 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "image" version = "0.25.9" @@ -5462,6 +5650,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" dependencies = [ "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", ] [[package]] @@ -5632,7 +5823,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6027,6 +6218,40 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lopdf" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7184fdea2bc3cd272a1acec4030c321a8f9875e877b3f92a53f2f6033fdc289" +dependencies = [ + "aes 0.8.4", + "bitflags 2.10.0", + "cbc", + "ecb", + "encoding_rs", + "flate2", + "getrandom 0.3.4", + "indexmap 2.13.0", + "itoa", + "log", + "md-5", + "nom 8.0.0", + "nom_locate", + "rand 0.9.2", + "rangemap", + "sha2", + "stringprep", + "thiserror 2.0.18", + "ttf-parser", + "weezl", +] + +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" + [[package]] name = "lru-slab" version = "0.1.2" @@ -6801,6 +7026,7 @@ dependencies = [ "axum", "base64 0.22.1", "dashmap 6.1.0", + "hmac", "moltis-config", "moltis-tools", "moltis-vault", @@ -7089,6 +7315,7 @@ dependencies = [ "moltis-msteams", "moltis-network-filter", "moltis-node-exec-types", + "moltis-nostr", "moltis-oauth", "moltis-onboarding", "moltis-openclaw-import", @@ -7430,6 +7657,26 @@ dependencies = [ "which 8.0.0", ] +[[package]] +name = "moltis-nostr" +version = "0.1.0" +dependencies = [ + "async-trait", + "moltis-channels", + "moltis-common", + "moltis-config", + "moltis-metrics", + "nostr-sdk", + "secrecy 0.8.0", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "moltis-oauth" version = "0.1.0" @@ -7820,7 +8067,12 @@ dependencies = [ "base64 0.22.1", "bytes", "futures", + "globset", + "grep-matcher", + "grep-regex", + "grep-searcher", "html2text", + "ignore", "image", "ipnet", "mockito", @@ -7836,6 +8088,7 @@ dependencies = [ "moltis-providers", "moltis-sessions", "moltis-skills", + "pdf-extract", "regex", "reqwest 0.12.28", "rstest", @@ -7845,6 +8098,7 @@ dependencies = [ "sha2", "shell-words", "sqlx", + "tar", "tempfile", "thiserror 2.0.18", "time", @@ -8100,6 +8354,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "negentropy" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0efe882e02d206d8d279c20eb40e03baf7cb5136a1476dc084a324fbc3ec42d" + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -8219,6 +8479,95 @@ dependencies = [ "nom 8.0.0", ] +[[package]] +name = "nom_locate" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d" +dependencies = [ + "bytecount", + "memchr", + "nom 8.0.0", +] + +[[package]] +name = "nostr" +version = "0.44.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aa5e3b6a278ed061835fe1ee293b71641e6bf8b401cfe4e1834bbf4ef0a34e1" +dependencies = [ + "aes 0.8.4", + "base64 0.22.1", + "bech32", + "bip39", + "bitcoin_hashes", + "cbc", + "chacha20 0.9.1", + "chacha20poly1305", + "getrandom 0.2.17", + "hex", + "instant", + "scrypt", + "secp256k1", + "serde", + "serde_json", + "unicode-normalization", + "url", +] + +[[package]] +name = "nostr-database" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7462c9d8ae5ef6a28d66a192d399ad2530f1f2130b13186296dbb11bdef5b3d1" +dependencies = [ + "lru", + "nostr", + "tokio", +] + +[[package]] +name = "nostr-gossip" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade30de16869618919c6b5efc8258f47b654a98b51541eb77f85e8ec5e3c83a6" +dependencies = [ + "nostr", +] + +[[package]] +name = "nostr-relay-pool" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b1073ccfbaea5549fb914a9d52c68dab2aecda61535e5143dd73e95445a804b" +dependencies = [ + "async-utility", + "async-wsocket", + "atomic-destructor", + "hex", + "lru", + "negentropy", + "nostr", + "nostr-database", + "tokio", + "tracing", +] + +[[package]] +name = "nostr-sdk" +version = "0.44.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471732576710e779b64f04c55e3f8b5292f865fea228436daf19694f0bf70393" +dependencies = [ + "async-utility", + "nostr", + "nostr-database", + "nostr-gossip", + "nostr-relay-pool", + "tokio", + "tracing", +] + [[package]] name = "notify" version = "8.2.0" @@ -8274,7 +8623,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8654,6 +9003,23 @@ dependencies = [ "hmac", ] +[[package]] +name = "pdf-extract" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28ba1758a3d3f361459645780e09570b573fc3c82637449e9963174c813a98" +dependencies = [ + "adobe-cmap-parser", + "cff-parser", + "encoding_rs", + "euclid", + "log", + "lopdf", + "postscript", + "type1-encoding-parser", + "unicode-normalization", +] + [[package]] name = "pem" version = "0.8.3" @@ -8964,6 +9330,12 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "pom" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60f6ce597ecdcc9a098e7fddacb1065093a3d66446fa16c675e7e71d1b5c28e6" + [[package]] name = "portable-atomic" version = "1.13.1" @@ -9013,6 +9385,12 @@ dependencies = [ "serde", ] +[[package]] +name = "postscript" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78451badbdaebaf17f053fd9152b3ffb33b516104eacb45e7864aaa9c712f306" + [[package]] name = "potential_utf" version = "0.1.4" @@ -9164,7 +9542,7 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ - "heck 0.4.1", + "heck 0.5.0", "itertools 0.14.0", "log", "multimap", @@ -9509,6 +9887,12 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rangemap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" + [[package]] name = "raw-cpuid" version = "11.6.0" @@ -10208,7 +10592,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -10322,7 +10706,7 @@ dependencies = [ "security-framework 3.5.1", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -10386,6 +10770,15 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "same-file" version = "1.0.6" @@ -10443,6 +10836,18 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "password-hash", + "pbkdf2", + "salsa20", + "sha2", +] + [[package]] name = "sdd" version = "3.0.10" @@ -10485,6 +10890,26 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "rand 0.8.5", + "secp256k1-sys", + "serde", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + [[package]] name = "secrecy" version = "0.8.0" @@ -11760,7 +12185,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.3", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -12077,8 +12502,12 @@ checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" dependencies = [ "futures-util", "log", + "rustls 0.23.36", + "rustls-pki-types", "tokio", + "tokio-rustls 0.26.4", "tungstenite 0.26.2", + "webpki-roots 0.26.11", ] [[package]] @@ -12541,6 +12970,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + [[package]] name = "tungstenite" version = "0.21.0" @@ -12574,6 +13009,8 @@ dependencies = [ "httparse", "log", "rand 0.9.2", + "rustls 0.23.36", + "rustls-pki-types", "sha1", "thiserror 2.0.18", "utf-8", @@ -12596,6 +13033,15 @@ dependencies = [ "utf-8", ] +[[package]] +name = "type1-encoding-parser" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa10c302f5a53b7ad27fd42a3996e23d096ba39b5b8dd6d9e683a05b01bee749" +dependencies = [ + "pom", +] + [[package]] name = "typemap_rev" version = "0.3.0" @@ -13824,6 +14270,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "whatsapp-rust" version = "0.2.0" @@ -13993,7 +14445,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/config/src/schema.rs b/crates/config/src/schema.rs index 0a72d9ea6..56a390499 100644 --- a/crates/config/src/schema.rs +++ b/crates/config/src/schema.rs @@ -469,6 +469,9 @@ pub struct VoiceOpenAiConfig { deserialize_with = "crate::schema::deserialize_option_secret" )] pub api_key: Option>, + /// API base URL (default: https://api.openai.com/v1). + /// Override for OpenAI-compatible TTS servers (e.g. Chatterbox). + pub base_url: Option, /// Voice to use for TTS (alloy, echo, fable, onyx, nova, shimmer). pub voice: Option, /// Model to use for TTS (tts-1, tts-1-hd). @@ -661,6 +664,9 @@ pub struct VoiceWhisperConfig { deserialize_with = "crate::schema::deserialize_option_secret" )] pub api_key: Option>, + /// API base URL (default: https://api.openai.com/v1). + /// Override for OpenAI-compatible STT servers (e.g. faster-whisper-server). + pub base_url: Option, /// Model to use (whisper-1). pub model: Option, /// Language hint (ISO 639-1 code). diff --git a/crates/config/src/template.rs b/crates/config/src/template.rs index ae9091346..c08135e12 100644 --- a/crates/config/src/template.rs +++ b/crates/config/src/template.rs @@ -767,6 +767,7 @@ providers = ["whisper", "mistral", "elevenlabs"] # UI allowlist (empty = show al # No api_key needed for OpenAI TTS/Whisper when OpenAI is configured as an LLM provider. # [voice.tts.openai] +# base_url = "https://api.openai.com/v1" # API endpoint (change for Chatterbox, etc.) # voice = "alloy" # alloy, echo, fable, onyx, nova, shimmer # model = "tts-1" # tts-1 or tts-1-hd diff --git a/crates/config/src/validate.rs b/crates/config/src/validate.rs index f50fc0b4d..a6ab7feeb 100644 --- a/crates/config/src/validate.rs +++ b/crates/config/src/validate.rs @@ -658,6 +658,7 @@ fn build_schema_map() -> KnownKeys { "openai", Struct(HashMap::from([ ("api_key", Leaf), + ("base_url", Leaf), ("voice", Leaf), ("model", Leaf), ])), @@ -693,6 +694,7 @@ fn build_schema_map() -> KnownKeys { "whisper", Struct(HashMap::from([ ("api_key", Leaf), + ("base_url", Leaf), ("model", Leaf), ("language", Leaf), ])), diff --git a/crates/gateway/src/methods/services.rs b/crates/gateway/src/methods/services.rs index 88aefcb5b..a3126a44a 100644 --- a/crates/gateway/src/methods/services.rs +++ b/crates/gateway/src/methods/services.rs @@ -4042,7 +4042,7 @@ pub(super) fn register(reg: &mut MethodRegistry) { "enabled": config.voice.tts.enabled, "provider": config.voice.tts.provider, "elevenlabs_configured": config.voice.tts.elevenlabs.api_key.is_some(), - "openai_configured": config.voice.tts.openai.api_key.is_some(), + "openai_configured": config.voice.tts.openai.api_key.is_some() || config.voice.tts.openai.base_url.is_some(), }, "stt": { "enabled": config.voice.stt.enabled, diff --git a/crates/gateway/src/methods/voice.rs b/crates/gateway/src/methods/voice.rs index 1f82916b3..b0667fd9f 100644 --- a/crates/gateway/src/methods/voice.rs +++ b/crates/gateway/src/methods/voice.rs @@ -404,6 +404,7 @@ pub(super) async fn detect_voice_providers( "tts", "cloud", config.voice.tts.openai.api_key.is_some() + || config.voice.tts.openai.base_url.is_some() || env_openai_key.is_some() || llm_openai_key.is_some(), config.voice.tts.provider == "openai" && config.voice.tts.enabled, diff --git a/crates/gateway/src/voice.rs b/crates/gateway/src/voice.rs index 3257613d4..22a2cb9e4 100644 --- a/crates/gateway/src/voice.rs +++ b/crates/gateway/src/voice.rs @@ -112,6 +112,7 @@ impl LiveTtsService { }, openai: moltis_voice::OpenAiTtsConfig { api_key: resolve_openai_key(cfg.voice.tts.openai.api_key.as_ref(), &cfg), + base_url: cfg.voice.tts.openai.base_url.clone(), voice: cfg.voice.tts.openai.voice.clone(), model: cfg.voice.tts.openai.model.clone(), speed: None, @@ -150,13 +151,19 @@ impl LiveTtsService { config.elevenlabs.model.clone(), )) as Box }), - TtsProviderId::OpenAi => config.openai.api_key.as_ref().map(|key| { - Box::new(OpenAiTts::with_defaults( - Some(key.clone()), + TtsProviderId::OpenAi => { + let provider = OpenAiTts::with_defaults( + config.openai.api_key.clone(), + config.openai.base_url.clone(), config.openai.voice.clone(), config.openai.model.clone(), - )) as Box - }), + ); + if provider.is_configured() { + Some(Box::new(provider) as Box) + } else { + None + } + }, TtsProviderId::Google => config.google.api_key.as_ref().map(|_| { Box::new(GoogleTts::new(&config.google)) as Box }), @@ -187,7 +194,10 @@ impl LiveTtsService { TtsProviderId::ElevenLabs, config.elevenlabs.api_key.is_some(), ), - (TtsProviderId::OpenAi, config.openai.api_key.is_some()), + ( + TtsProviderId::OpenAi, + config.openai.api_key.is_some() || config.openai.base_url.is_some(), + ), (TtsProviderId::Google, config.google.api_key.is_some()), (TtsProviderId::Piper, config.piper.model_path.is_some()), (TtsProviderId::Coqui, true), // Always available if server running @@ -518,13 +528,17 @@ impl LiveSttService { match provider_id { SttProviderId::Whisper => { let key = resolve_openai_key(cfg.voice.stt.whisper.api_key.as_ref(), &cfg); - key.map(|k| { - Box::new(WhisperStt::with_options( - Some(k), - cfg.voice.stt.whisper.model.clone(), - cfg.voice.stt.whisper.language.clone(), - )) as Box - }) + let provider = WhisperStt::with_options( + key, + cfg.voice.stt.whisper.base_url.clone(), + cfg.voice.stt.whisper.model.clone(), + cfg.voice.stt.whisper.language.clone(), + ); + if provider.is_configured() { + Some(Box::new(provider) as Box) + } else { + None + } }, SttProviderId::Groq => cfg.voice.stt.groq.api_key.as_ref().map(|key| { Box::new(GroqStt::with_options( @@ -607,7 +621,7 @@ impl LiveSttService { vec![ ( SttProviderId::Whisper, - cfg.voice.stt.whisper.api_key.is_some(), + cfg.voice.stt.whisper.api_key.is_some() || cfg.voice.stt.whisper.base_url.is_some(), ), (SttProviderId::Groq, cfg.voice.stt.groq.api_key.is_some()), ( @@ -798,7 +812,39 @@ impl SttService for LiveSttService { #[allow(clippy::unwrap_used, clippy::expect_used)] #[cfg(all(test, feature = "voice"))] mod tests { - use {super::*, secrecy::ExposeSecret, serde_json::json}; + use {super::*, secrecy::ExposeSecret, serde_json::json, tempfile::TempDir}; + + struct VoiceConfigTestGuard { + _lock: std::sync::MutexGuard<'static, ()>, + _config_dir: TempDir, + _data_dir: TempDir, + } + + impl VoiceConfigTestGuard { + fn with_config(config_toml: &str) -> Self { + let lock = crate::config_override_test_lock(); + let config_dir = tempfile::tempdir() + .unwrap_or_else(|error| panic!("config tempdir should be created: {error}")); + let data_dir = tempfile::tempdir() + .unwrap_or_else(|error| panic!("data tempdir should be created: {error}")); + std::fs::write(config_dir.path().join("moltis.toml"), config_toml) + .unwrap_or_else(|error| panic!("config should be written: {error}")); + moltis_config::set_config_dir(config_dir.path().to_path_buf()); + moltis_config::set_data_dir(data_dir.path().to_path_buf()); + Self { + _lock: lock, + _config_dir: config_dir, + _data_dir: data_dir, + } + } + } + + impl Drop for VoiceConfigTestGuard { + fn drop(&mut self) { + moltis_config::clear_config_dir(); + moltis_config::clear_data_dir(); + } + } #[test] fn test_resolve_openai_key_prefers_voice_key_over_llm_provider_key() { @@ -855,6 +901,30 @@ mod tests { assert!(LiveSttService::resolve_provider(None).is_some()); } + #[test] + fn test_live_stt_whisper_base_url_counts_as_configured() { + let _guard = VoiceConfigTestGuard::with_config( + r#" +[server] +port = 18080 + +[voice.stt.whisper] +base_url = "http://127.0.0.1:8001/" +"#, + ); + + let providers = LiveSttService::list_providers(); + let whisper = providers + .into_iter() + .find(|(id, _)| *id == SttProviderId::Whisper); + + assert_eq!(whisper, Some((SttProviderId::Whisper, true))); + assert_eq!( + LiveSttService::resolve_provider(None), + Some(SttProviderId::Whisper) + ); + } + #[tokio::test] async fn test_live_tts_service_status() { let service = LiveTtsService::new(TtsConfig::default()); diff --git a/crates/voice/src/config.rs b/crates/voice/src/config.rs index 0143ed761..50d959e43 100644 --- a/crates/voice/src/config.rs +++ b/crates/voice/src/config.rs @@ -272,6 +272,10 @@ pub struct OpenAiTtsConfig { )] pub api_key: Option>, + /// API base URL (default: https://api.openai.com/v1). + /// Override for OpenAI-compatible TTS servers (e.g. Chatterbox, local TTS). + pub base_url: Option, + /// Voice to use (alloy, echo, fable, onyx, nova, shimmer). pub voice: Option, @@ -445,6 +449,10 @@ pub struct WhisperConfig { )] pub api_key: Option>, + /// API base URL (default: https://api.openai.com/v1). + /// Override for OpenAI-compatible STT servers (e.g. faster-whisper-server). + pub base_url: Option, + /// Model to use (whisper-1). pub model: Option, diff --git a/crates/voice/src/stt/whisper.rs b/crates/voice/src/stt/whisper.rs index 4c6a54d79..c546192e5 100644 --- a/crates/voice/src/stt/whisper.rs +++ b/crates/voice/src/stt/whisper.rs @@ -30,6 +30,7 @@ const DEFAULT_MODEL: &str = "whisper-1"; pub struct WhisperStt { client: Client, api_key: Option>, + base_url: String, model: String, language: Option, } @@ -38,6 +39,7 @@ impl std::fmt::Debug for WhisperStt { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("WhisperStt") .field("api_key", &"[REDACTED]") + .field("base_url", &self.base_url) .field("model", &self.model) .field("language", &self.language) .finish() @@ -51,40 +53,41 @@ impl Default for WhisperStt { } impl WhisperStt { + fn normalize_base_url(base_url: Option) -> String { + base_url + .map(|url| url.trim_end_matches('/').to_string()) + .unwrap_or_else(|| API_BASE.into()) + } + /// Create a new Whisper STT provider. #[must_use] pub fn new(api_key: Option>) -> Self { - Self::with_options(api_key, None, None) + Self::with_options(api_key, None, None, None) } - /// Create with custom model. + /// Create with custom model (no base URL or language override). #[must_use] pub fn with_model(api_key: Option>, model: Option) -> Self { - Self::with_options(api_key, model, None) + Self::with_options(api_key, None, model, None) } - /// Create with custom model and language. + /// Create with custom base URL, model, and language. #[must_use] pub fn with_options( api_key: Option>, + base_url: Option, model: Option, language: Option, ) -> Self { Self { client: Client::new(), api_key, + base_url: Self::normalize_base_url(base_url), model: model.unwrap_or_else(|| DEFAULT_MODEL.into()), language, } } - /// Get the API key, returning an error if not configured. - fn get_api_key(&self) -> Result<&Secret> { - self.api_key - .as_ref() - .ok_or_else(|| anyhow!("OpenAI API key not configured for Whisper")) - } - /// Get file extension for audio format. fn file_extension(format: AudioFormat) -> &'static str { format.extension() @@ -107,12 +110,12 @@ impl SttProvider for WhisperStt { } fn is_configured(&self) -> bool { - self.api_key.is_some() + // Configured if API key is set, or if using a custom base URL (local servers + // like faster-whisper-server don't require auth). + self.api_key.is_some() || self.base_url != API_BASE } async fn transcribe(&self, request: TranscribeRequest) -> Result { - let api_key = self.get_api_key()?; - let filename = format!("audio.{}", Self::file_extension(request.format)); let mime_type = Self::mime_type(request.format); @@ -136,14 +139,20 @@ impl SttProvider for WhisperStt { form = form.text("prompt", prompt); } - let response = self + let mut req = self .client - .post(format!("{API_BASE}/audio/transcriptions")) - .header( + .post(format!("{}/audio/transcriptions", self.base_url)) + .multipart(form); + + // Only add auth header if an API key is configured (local servers skip auth). + if let Some(api_key) = &self.api_key { + req = req.header( "Authorization", format!("Bearer {}", api_key.expose_secret()), - ) - .multipart(form) + ); + } + + let response = req .send() .await .context("failed to send Whisper transcription request")?; @@ -242,25 +251,45 @@ mod tests { prompt: None, }; + // Without API key and default base URL, the request will fail + // (either connection refused to api.openai.com or auth error). let result = provider.transcribe(request).await; assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("not configured")); } #[test] - fn test_with_model() { - let provider = WhisperStt::with_model( + fn test_with_options() { + let provider = WhisperStt::with_options( Some(Secret::new("key".into())), + None, Some("whisper-large-v3".into()), + None, ); assert_eq!(provider.model, "whisper-large-v3"); + assert_eq!(provider.base_url, API_BASE); assert!(provider.language.is_none()); } + #[test] + fn test_with_custom_base_url() { + let provider = + WhisperStt::with_options(None, Some("http://10.1.2.30:8001".into()), None, None); + assert!(provider.is_configured()); + assert_eq!(provider.base_url, "http://10.1.2.30:8001"); + } + + #[test] + fn test_with_custom_base_url_trims_trailing_slash() { + let provider = + WhisperStt::with_options(None, Some("http://10.1.2.30:8001/".into()), None, None); + assert_eq!(provider.base_url, "http://10.1.2.30:8001"); + } + #[test] fn test_with_options_sets_model_and_language() { let provider = WhisperStt::with_options( Some(Secret::new("key".into())), + None, Some("whisper-large-v3".into()), Some("ru".into()), ); @@ -270,7 +299,7 @@ mod tests { #[test] fn test_with_options_defaults() { - let provider = WhisperStt::with_options(Some(Secret::new("key".into())), None, None); + let provider = WhisperStt::with_options(Some(Secret::new("key".into())), None, None, None); assert_eq!(provider.model, DEFAULT_MODEL); assert!(provider.language.is_none()); } @@ -286,6 +315,7 @@ mod tests { fn test_debug_includes_language() { let provider = WhisperStt::with_options( Some(Secret::new("super-secret-key".into())), + None, Some("whisper-large-v3".into()), Some("ru".into()), ); diff --git a/crates/voice/src/tts/openai.rs b/crates/voice/src/tts/openai.rs index d500cbdb0..83822a133 100644 --- a/crates/voice/src/tts/openai.rs +++ b/crates/voice/src/tts/openai.rs @@ -38,6 +38,7 @@ const VOICES: &[(&str, &str)] = &[ pub struct OpenAiTts { client: Client, api_key: Option>, + base_url: String, default_voice: String, default_model: String, } @@ -46,6 +47,7 @@ impl std::fmt::Debug for OpenAiTts { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("OpenAiTts") .field("api_key", &"[REDACTED]") + .field("base_url", &self.base_url) .field("default_voice", &self.default_voice) .field("default_model", &self.default_model) .finish() @@ -59,39 +61,41 @@ impl Default for OpenAiTts { } impl OpenAiTts { + fn normalize_base_url(base_url: Option) -> String { + base_url + .map(|url| url.trim_end_matches('/').to_string()) + .unwrap_or_else(|| API_BASE.into()) + } + /// Create a new OpenAI TTS provider. #[must_use] pub fn new(api_key: Option>) -> Self { Self { client: Client::new(), api_key, + base_url: Self::normalize_base_url(None), default_voice: DEFAULT_VOICE.into(), default_model: DEFAULT_MODEL.into(), } } - /// Create with custom default voice and model. + /// Create with custom default voice, model, and optional base URL. #[must_use] pub fn with_defaults( api_key: Option>, + base_url: Option, voice: Option, model: Option, ) -> Self { Self { client: Client::new(), api_key, + base_url: Self::normalize_base_url(base_url), default_voice: voice.unwrap_or_else(|| DEFAULT_VOICE.into()), default_model: model.unwrap_or_else(|| DEFAULT_MODEL.into()), } } - /// Get the API key, returning an error if not configured. - fn get_api_key(&self) -> Result<&Secret> { - self.api_key - .as_ref() - .ok_or_else(|| anyhow!("OpenAI API key not configured")) - } - /// Map audio format to OpenAI response format. fn response_format(format: AudioFormat) -> &'static str { match format { @@ -114,7 +118,9 @@ impl TtsProvider for OpenAiTts { } fn is_configured(&self) -> bool { - self.api_key.is_some() + // Configured if API key is set, or if using a custom base URL (local servers + // like Chatterbox don't require auth). + self.api_key.is_some() || self.base_url != API_BASE } async fn voices(&self) -> Result> { @@ -131,7 +137,6 @@ impl TtsProvider for OpenAiTts { } async fn synthesize(&self, request: SynthesizeRequest) -> Result { - let api_key = self.get_api_key()?; let voice = request.voice_id.as_deref().unwrap_or(&self.default_voice); let model = request.model.as_deref().unwrap_or(&self.default_model); let body = TtsRequest { @@ -142,15 +147,21 @@ impl TtsProvider for OpenAiTts { speed: request.speed, }; - let response = self + let mut req = self .client - .post(format!("{API_BASE}/audio/speech")) - .header( + .post(format!("{}/audio/speech", self.base_url)) + .header("Content-Type", "application/json") + .json(&body); + + // Only add auth header if an API key is configured (local servers skip auth). + if let Some(api_key) = &self.api_key { + req = req.header( "Authorization", format!("Bearer {}", api_key.expose_secret()), - ) - .header("Content-Type", "application/json") - .json(&body) + ); + } + + let response = req .send() .await .context("failed to send OpenAI TTS request")?; @@ -237,20 +248,38 @@ mod tests { ..Default::default() }; + // Without API key and default base URL, the request will fail + // (either connection error or auth rejection from OpenAI). let result = provider.synthesize(request).await; assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("not configured")); } #[test] fn test_with_defaults() { let provider = OpenAiTts::with_defaults( Some(Secret::new("key".into())), + None, Some("nova".into()), Some("tts-1-hd".into()), ); assert_eq!(provider.default_voice, "nova"); assert_eq!(provider.default_model, "tts-1-hd"); + assert_eq!(provider.base_url, API_BASE); + } + + #[test] + fn test_with_custom_base_url() { + let provider = + OpenAiTts::with_defaults(None, Some("http://10.1.2.30:8003".into()), None, None); + assert!(provider.is_configured()); + assert_eq!(provider.base_url, "http://10.1.2.30:8003"); + } + + #[test] + fn test_with_custom_base_url_trims_trailing_slash() { + let provider = + OpenAiTts::with_defaults(None, Some("http://10.1.2.30:8003/".into()), None, None); + assert_eq!(provider.base_url, "http://10.1.2.30:8003"); } #[test] diff --git a/docs/src/voice.md b/docs/src/voice.md index e8f168ad0..cc95e3d46 100644 --- a/docs/src/voice.md +++ b/docs/src/voice.md @@ -104,6 +104,7 @@ similarity_boost = 0.75 [voice.tts.openai] # No api_key needed if OpenAI is configured as an LLM provider or OPENAI_API_KEY is set. # api_key = "sk-..." +# base_url = "http://10.1.2.30:8003" # Override for OpenAI-compatible servers (e.g. Chatterbox) voice = "alloy" # alloy, echo, fable, onyx, nova, shimmer model = "tts-1" speed = 1.0 @@ -314,6 +315,7 @@ providers = [] # Optional UI allowlist, empty = show all STT providers [voice.stt.whisper] # No api_key needed if OpenAI is configured as an LLM provider or OPENAI_API_KEY is set. # api_key = "sk-..." +# base_url = "http://10.1.2.30:8001" # Override for OpenAI-compatible servers (e.g. faster-whisper-server) model = "whisper-1" language = "en" # Optional ISO 639-1 hint