diff --git a/Cargo.lock b/Cargo.lock index 866c33d..9d0ef20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,57 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "agent-client-protocol" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4361ba6627e51de955b10f3c77fb9eb959c85191a236c1c2c84e32f4ff240faf" +dependencies = [ + "agent-client-protocol-derive", + "agent-client-protocol-schema", + "async-process", + "blocking", + "futures", + "futures-concurrency", + "jsonrpcmsg", + "rmcp", + "rustc-hash", + "schemars 1.2.1", + "serde", + "serde_json", + "shell-words", + "tokio", + "tokio-util", + "tracing", + "uuid", +] + +[[package]] +name = "agent-client-protocol-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cabdc9d845d08ec7ed2d0c9de1ae4a1b198301407d55855261572761be90ec9f" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "agent-client-protocol-schema" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b957d8391ac3933e2a940446171c508d2b8ffc386d8fa7d0b9c936a2575b463e" +dependencies = [ + "anyhow", + "derive_more", + "schemars 1.2.1", + "serde", + "serde_json", + "serde_with", + "strum", + "tracing", +] + [[package]] name = "ahash" version = "0.8.12" @@ -102,6 +153,111 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -262,6 +418,28 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -421,6 +599,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.44" @@ -505,6 +689,15 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "console" version = "0.15.11" @@ -518,6 +711,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -705,8 +907,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -723,13 +935,37 @@ dependencies = [ "syn", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", "quote", "syn", ] @@ -743,6 +979,16 @@ dependencies = [ "serde", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + [[package]] name = "derive_builder" version = "0.20.2" @@ -758,7 +1004,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", "syn", @@ -774,6 +1020,29 @@ dependencies = [ "syn", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-xid", +] + [[package]] name = "digest" version = "0.10.7" @@ -822,6 +1091,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "dyn-stack" version = "0.13.2" @@ -899,6 +1174,27 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "eventsource-stream" version = "0.2.3" @@ -947,6 +1243,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.9" @@ -1052,6 +1354,19 @@ dependencies = [ "futures-sink", ] +[[package]] +name = "futures-concurrency" +version = "7.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" +dependencies = [ + "fixedbitset", + "futures-core", + "futures-lite", + "pin-project", + "smallvec", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -1075,6 +1390,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -1370,8 +1698,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1381,9 +1711,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1417,7 +1749,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -1439,6 +1771,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.15.5" @@ -1473,12 +1811,41 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "helexa-acp" +version = "0.1.16" +dependencies = [ + "agent-client-protocol", + "anyhow", + "async-stream", + "async-trait", + "eventsource-stream", + "futures", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "toml", + "tracing", + "tracing-subscriber", + "url", +] + [[package]] name = "hermit-abi" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hf-hub" version = "0.4.3" @@ -1584,6 +1951,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots 1.0.7", ] [[package]] @@ -1766,6 +2134,17 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1856,6 +2235,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonrpcmsg" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d833a15225c779251e13929203518c2ff26e2fe0f322d584b213f4f4dad37bd" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1936,6 +2325,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "macro_rules_attribute" version = "0.2.2" @@ -2004,7 +2399,7 @@ dependencies = [ "hyper", "hyper-rustls", "hyper-util", - "indexmap", + "indexmap 2.14.0", "ipnet", "metrics", "metrics-util", @@ -2186,6 +2581,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-integer" version = "0.1.46" @@ -2327,6 +2728,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2356,6 +2763,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pear" version = "0.2.9" @@ -2385,18 +2798,63 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -2412,6 +2870,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2505,6 +2969,61 @@ dependencies = [ "winapi", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -2640,6 +3159,26 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "regex" version = "1.12.3" @@ -2694,6 +3233,8 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", "rustls-pki-types", "serde", "serde_json", @@ -2701,6 +3242,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -2710,6 +3252,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", + "webpki-roots 1.0.7", ] [[package]] @@ -2726,6 +3269,56 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0810a9f717d9828f475fe1f629f4c305c8464b7f496c3a854b58d29e65f4058e" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chrono", + "futures", + "pastey", + "pin-project-lite", + "rmcp-macros", + "schemars 1.2.1", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp-macros" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aefac48c364756e97f04c0401ba3231e8607882c7c1d92da0437dc16307904d" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "serde_json", + "syn", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.4" @@ -2773,6 +3366,7 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ + "web-time", "zeroize", ] @@ -2839,6 +3433,44 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2910,6 +3542,17 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_json" version = "1.0.149" @@ -2964,6 +3607,38 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha2" version = "0.10.9" @@ -2984,6 +3659,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -3075,6 +3756,27 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -3209,6 +3911,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -3219,6 +3952,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokenizers" version = "0.22.2" @@ -3254,9 +4002,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.51.1" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -3319,7 +4067,9 @@ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] @@ -3351,7 +4101,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde", "serde_spanned", "toml_datetime", @@ -3624,6 +4374,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -3644,6 +4395,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -3777,7 +4539,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.14.0", "wasm-encoder", "wasmparser", ] @@ -3803,7 +4565,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", "semver", ] @@ -4166,7 +4928,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.14.0", "prettyplease", "syn", "wasm-metadata", @@ -4197,7 +4959,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -4216,7 +4978,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.14.0", "log", "semver", "serde", @@ -4372,7 +5134,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0" dependencies = [ "crc32fast", - "indexmap", + "indexmap 2.14.0", "memchr", "typed-path", ] diff --git a/Cargo.toml b/Cargo.toml index da2ffaf..2ad8471 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/cortex-gateway", "crates/cortex-cli", "crates/neuron", + "crates/helexa-acp", ] [workspace.package] diff --git a/crates/helexa-acp/Cargo.toml b/crates/helexa-acp/Cargo.toml new file mode 100644 index 0000000..641da93 --- /dev/null +++ b/crates/helexa-acp/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "helexa-acp" +version = "0.1.16" +edition = "2024" +license = "Apache-2.0" +repository = "https://git.lair.cafe/helexa/cortex" +description = """ +Agent Client Protocol bridge for the helexa self-hosted LLM stack. +Speaks ACP to ACP-compatible editor clients (Zed, etc.) and forwards +the conversation to any OpenAI-compatible HTTP endpoint — defaulting +to cortex (helexa's reverse-proxy / fleet gateway). +""" + +# This crate is intentionally self-contained — no dependencies on other +# workspace crates (cortex-core, cortex-gateway, neuron). The goal is +# a painless migration to a dedicated GitHub repo in the future if the +# project grows beyond helexa's needs. All deps are crates.io. +[dependencies] +agent-client-protocol = "0.12" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "io-util", "process", "signal"] } +reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls"], default-features = false } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.8" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +anyhow = "1" +thiserror = "2" +async-trait = "0.1" +futures = "0.3" +tokio-stream = "0.1" +tokio-util = { version = "0.7", features = ["rt"] } +eventsource-stream = "0.2" +async-stream = "0.3" +url = { version = "2", features = ["serde"] } + +[[bin]] +name = "helexa-acp" +path = "src/main.rs" diff --git a/crates/helexa-acp/src/config.rs b/crates/helexa-acp/src/config.rs new file mode 100644 index 0000000..9a40f99 --- /dev/null +++ b/crates/helexa-acp/src/config.rs @@ -0,0 +1,378 @@ +//! Configuration for the helexa-acp bridge. +//! +//! Loaded from `$XDG_CONFIG_HOME/helexa-acp/config.toml` (or +//! `~/.config/helexa-acp/config.toml` as a fallback). If no config file +//! exists, falls back to building a single anonymous endpoint from env +//! vars — that keeps "just point at one cortex" frictionless without +//! requiring a config file on disk. +//! +//! The design goal is "the missing ACP binary for users with multiple +//! API endpoints (possibly on a private LAN, possibly mixing wire +//! types)". Hence: every endpoint is named, has its own wire API, and +//! has its own default model. The agent's selected model id can be +//! prefixed `endpoint:model` to route across endpoints; a bare +//! `model` falls through to the configured `default_endpoint`. +//! +//! ### Example TOML +//! +//! ```toml +//! default_endpoint = "helexa" +//! +//! [[endpoints]] +//! name = "helexa" +//! base_url = "http://hanzalova.internal:31313/v1" +//! wire_api = "openai-chat" +//! default_model = "helexa/large" +//! +//! [[endpoints]] +//! name = "openrouter" +//! base_url = "https://openrouter.ai/api/v1" +//! wire_api = "openai-chat" +//! api_key_env = "OPENROUTER_API_KEY" +//! default_model = "anthropic/claude-opus-4" +//! +//! [[endpoints]] +//! name = "lmstudio" +//! base_url = "http://localhost:1234/v1" +//! wire_api = "openai-chat" +//! default_model = "auto" +//! ``` + +use anyhow::{Context, anyhow}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use url::Url; + +const DEFAULT_BASE_URL: &str = "http://hanzalova.internal:31313/v1"; +const DEFAULT_MODEL: &str = "helexa/large"; +const DEFAULT_ENDPOINT_NAME: &str = "default"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + /// Name of the endpoint used when a request doesn't pick one + /// explicitly. Must reference an entry in `endpoints`. Defaults to + /// the first endpoint declared if unset. + #[serde(default)] + pub default_endpoint: Option, + /// Per-endpoint configuration. At least one entry is required. + #[serde(default)] + pub endpoints: Vec, + /// Optional path to a system-prompt file. When unset, the built-in + /// default prompt from `prompt.rs` is used. + #[serde(default)] + pub system_prompt_path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EndpointConfig { + /// Short identifier used in `endpoint:model` routing and in logs. + pub name: String, + /// Base URL of the OpenAI-compatible API. Must include the `/v1` + /// (or equivalent) suffix — paths like `chat/completions` and + /// `models` are joined onto this. + pub base_url: Url, + /// Wire protocol the endpoint speaks. Phase 1 supports + /// [`WireApi::OpenAiChat`] only; `openai-responses` and + /// `anthropic-messages` land later behind their own providers. + #[serde(default)] + pub wire_api: WireApi, + /// Model to use when the client hasn't picked one via + /// `session/set_model`. + #[serde(default)] + pub default_model: Option, + /// Static API key to send as `Authorization: Bearer …`. Prefer + /// `api_key_env` for anything sensitive — keys in plain TOML are a + /// liability. + #[serde(default)] + pub api_key: Option, + /// Env var name to read for the API key. Resolved at startup so a + /// missing env var yields a clear error rather than silent + /// unauthenticated calls. + #[serde(default)] + pub api_key_env: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum WireApi { + /// `POST {base}/chat/completions` returning OpenAI-format SSE. + /// Compatible with cortex, LM Studio, Ollama (compat mode), + /// OpenRouter, OpenAI itself. + #[default] + #[serde(rename = "openai-chat")] + OpenAiChat, + /// `POST {base}/responses` — OpenAI's newer Responses API. Not + /// implemented yet; the variant is reserved so endpoint configs + /// can be authored ahead of provider support. + #[serde(rename = "openai-responses")] + OpenAiResponses, + /// `POST {base}/messages` — Anthropic format. Reserved. + #[serde(rename = "anthropic-messages")] + AnthropicMessages, +} + +impl EndpointConfig { + /// Resolve the API key from `api_key` (literal) or `api_key_env` + /// (env-var lookup). Returns `Ok(None)` when neither is set; + /// `Err` when `api_key_env` references a missing variable. + pub fn resolve_api_key(&self) -> anyhow::Result> { + if let Some(literal) = &self.api_key { + return Ok(Some(literal.clone())); + } + if let Some(var) = &self.api_key_env { + return Ok(Some(std::env::var(var).with_context(|| { + format!( + "endpoint '{}' references missing env var {}", + self.name, var + ) + })?)); + } + Ok(None) + } + + /// `{base_url}/chat/completions`. + pub fn chat_completions_url(&self) -> Url { + join_segments(&self.base_url, &["chat", "completions"]) + } + + /// `{base_url}/models`. + pub fn models_url(&self) -> Url { + join_segments(&self.base_url, &["models"]) + } +} + +impl Config { + /// Load from TOML at the standard config path, or build from env + /// vars if no file exists. Env-fallback yields a single endpoint + /// named `"default"`. + pub fn load() -> anyhow::Result { + let path = config_path(); + if let Some(path) = &path + && path.exists() + { + return Self::from_file(path); + } + Self::from_env() + } + + /// Single-endpoint config constructed from `HELEXA_ACP_BASE_URL`, + /// `HELEXA_ACP_MODEL`, `HELEXA_ACP_API_KEY`, + /// `HELEXA_ACP_SYSTEM_PROMPT_PATH`. + pub fn from_env() -> anyhow::Result { + let base_url = std::env::var("HELEXA_ACP_BASE_URL") + .ok() + .unwrap_or_else(|| DEFAULT_BASE_URL.into()); + let base_url = Url::parse(&base_url) + .with_context(|| format!("HELEXA_ACP_BASE_URL is not a valid URL ({base_url})"))?; + let default_model = std::env::var("HELEXA_ACP_MODEL") + .ok() + .unwrap_or_else(|| DEFAULT_MODEL.into()); + let api_key = std::env::var("HELEXA_ACP_API_KEY") + .ok() + .filter(|s| !s.is_empty()); + let system_prompt_path = std::env::var("HELEXA_ACP_SYSTEM_PROMPT_PATH") + .ok() + .filter(|s| !s.is_empty()) + .map(PathBuf::from); + Ok(Self { + default_endpoint: Some(DEFAULT_ENDPOINT_NAME.into()), + endpoints: vec![EndpointConfig { + name: DEFAULT_ENDPOINT_NAME.into(), + base_url, + wire_api: WireApi::OpenAiChat, + default_model: Some(default_model), + api_key, + api_key_env: None, + }], + system_prompt_path, + }) + } + + pub fn from_file(path: &Path) -> anyhow::Result { + let text = std::fs::read_to_string(path) + .with_context(|| format!("read config {}", path.display()))?; + let mut cfg: Self = + toml::from_str(&text).with_context(|| format!("parse config {}", path.display()))?; + cfg.validate()?; + Ok(cfg) + } + + fn validate(&mut self) -> anyhow::Result<()> { + if self.endpoints.is_empty() { + return Err(anyhow!("config has no [[endpoints]] entries")); + } + for (i, ep) in self.endpoints.iter().enumerate() { + if ep.name.is_empty() { + return Err(anyhow!("endpoints[{i}] has empty name")); + } + if ep.name.contains(':') { + return Err(anyhow!( + "endpoints[{i}].name '{}' contains ':' which would clash \ + with the endpoint:model selector syntax", + ep.name + )); + } + } + // Pick a default endpoint if none was named. + if self.default_endpoint.is_none() { + self.default_endpoint = Some(self.endpoints[0].name.clone()); + } + let default_name = self.default_endpoint.as_deref().unwrap(); + if !self.endpoints.iter().any(|e| e.name == default_name) { + return Err(anyhow!( + "default_endpoint '{default_name}' is not declared in [[endpoints]]" + )); + } + Ok(()) + } + + /// Look up an endpoint by name. Returns `None` if not configured. + pub fn endpoint(&self, name: &str) -> Option<&EndpointConfig> { + self.endpoints.iter().find(|e| e.name == name) + } + + /// The default endpoint (guaranteed to exist after `validate`). + pub fn default_endpoint(&self) -> &EndpointConfig { + let name = self + .default_endpoint + .as_deref() + .expect("default_endpoint set by validate"); + self.endpoint(name) + .expect("default_endpoint resolves after validate") + } +} + +/// Parse an ACP-side `model` field into (endpoint name, raw model id). +/// +/// `helexa:helexa/large` → (`Some("helexa")`, `"helexa/large"`). +/// `helexa/large` → (`None`, `"helexa/large"`). +/// +/// The split happens at the FIRST colon. Model ids commonly contain +/// `/` (HuggingFace style) but rarely `:`; if a model id ever does, the +/// user can quote-prefix with the default endpoint name. +pub fn parse_model_selector(input: &str) -> (Option<&str>, &str) { + match input.split_once(':') { + Some((endpoint, model)) if !endpoint.is_empty() && !model.is_empty() => { + (Some(endpoint), model) + } + _ => (None, input), + } +} + +fn config_path() -> Option { + if let Ok(override_path) = std::env::var("HELEXA_ACP_CONFIG_PATH") { + return Some(PathBuf::from(override_path)); + } + let xdg = std::env::var("XDG_CONFIG_HOME") + .ok() + .filter(|s| !s.is_empty()); + let base = xdg.map(PathBuf::from).or_else(|| { + std::env::var("HOME") + .ok() + .map(|h| PathBuf::from(h).join(".config")) + })?; + Some(base.join("helexa-acp").join("config.toml")) +} + +fn join_segments(base: &Url, segments: &[&str]) -> Url { + let mut out = base.clone(); + if let Ok(mut path) = out.path_segments_mut() { + path.pop_if_empty().extend(segments.iter().copied()); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn url_join_handles_trailing_slash() { + let ep = EndpointConfig { + name: "x".into(), + base_url: Url::parse("http://h.internal:31313/v1").unwrap(), + wire_api: WireApi::OpenAiChat, + default_model: None, + api_key: None, + api_key_env: None, + }; + assert_eq!( + ep.chat_completions_url().as_str(), + "http://h.internal:31313/v1/chat/completions" + ); + assert_eq!( + ep.models_url().as_str(), + "http://h.internal:31313/v1/models" + ); + } + + #[test] + fn parses_model_selector() { + assert_eq!( + parse_model_selector("helexa:helexa/large"), + (Some("helexa"), "helexa/large") + ); + assert_eq!(parse_model_selector("helexa/large"), (None, "helexa/large")); + assert_eq!(parse_model_selector("gpt-5"), (None, "gpt-5")); + // Edge case: a leading colon → no endpoint. + assert_eq!(parse_model_selector(":gpt-5"), (None, ":gpt-5")); + } + + #[test] + fn env_fallback_builds_single_endpoint() { + // Don't actually set env vars (would race with other tests); + // just confirm the default path constructs cleanly. + unsafe { + std::env::remove_var("HELEXA_ACP_BASE_URL"); + std::env::remove_var("HELEXA_ACP_MODEL"); + std::env::remove_var("HELEXA_ACP_API_KEY"); + } + let cfg = Config::from_env().unwrap(); + assert_eq!(cfg.endpoints.len(), 1); + assert_eq!(cfg.endpoints[0].name, "default"); + assert_eq!(cfg.endpoints[0].base_url.as_str(), DEFAULT_BASE_URL); + assert_eq!( + cfg.endpoints[0].default_model.as_deref(), + Some(DEFAULT_MODEL) + ); + } + + #[test] + fn toml_parses_multi_endpoint() { + let toml_text = r#" + default_endpoint = "helexa" + + [[endpoints]] + name = "helexa" + base_url = "http://hanzalova.internal:31313/v1" + default_model = "helexa/large" + + [[endpoints]] + name = "openrouter" + base_url = "https://openrouter.ai/api/v1" + wire_api = "openai-chat" + api_key_env = "OPENROUTER_API_KEY" + default_model = "anthropic/claude-opus-4" + "#; + let mut cfg: Config = toml::from_str(toml_text).unwrap(); + cfg.validate().unwrap(); + assert_eq!(cfg.endpoints.len(), 2); + assert_eq!(cfg.default_endpoint().name, "helexa"); + assert_eq!(cfg.endpoints[0].wire_api, WireApi::OpenAiChat); + assert_eq!( + cfg.endpoints[1].api_key_env.as_deref(), + Some("OPENROUTER_API_KEY") + ); + } + + #[test] + fn validate_rejects_colon_in_endpoint_name() { + let toml_text = r#" + [[endpoints]] + name = "bad:name" + base_url = "http://x/v1" + "#; + let mut cfg: Config = toml::from_str(toml_text).unwrap(); + let err = cfg.validate().unwrap_err(); + assert!(format!("{err}").contains("clash")); + } +} diff --git a/crates/helexa-acp/src/main.rs b/crates/helexa-acp/src/main.rs new file mode 100644 index 0000000..452b81b --- /dev/null +++ b/crates/helexa-acp/src/main.rs @@ -0,0 +1,121 @@ +//! helexa-acp — Agent Client Protocol bridge for multi-endpoint LLM +//! setups (helexa, LM Studio, Ollama, OpenRouter, OpenAI, Anthropic, +//! …) with a clean per-endpoint wire-format selector. +//! +//! Speaks ACP over stdio to an editor client (Zed today). The +//! conversation is forwarded to one of the configured endpoints via +//! a wire-format-specific [`provider::Provider`] implementation. +//! The agent loop itself is provider-agnostic — adding e.g. an +//! Anthropic /v1/messages provider doesn't touch `agent.rs`. +//! +//! Config: `$XDG_CONFIG_HOME/helexa-acp/config.toml` for the multi- +//! endpoint case; env vars (`HELEXA_ACP_BASE_URL`, etc.) for the +//! single-endpoint case when no config file exists. + +use agent_client_protocol::schema::{AgentCapabilities, InitializeRequest, InitializeResponse}; +use agent_client_protocol::{Agent, Client, ConnectionTo, Dispatch, Result, Stdio}; +use std::sync::Arc; + +mod config; +mod provider; + +use config::{Config, EndpointConfig, WireApi}; +use provider::{Provider, openai_chat::OpenAIChatProvider}; + +/// Build a provider for `endpoint` according to its declared +/// `wire_api`. Future wire types (OpenAI Responses, Anthropic +/// /v1/messages, Ollama native) slot in here without changing the +/// caller. +fn build_provider(endpoint: EndpointConfig) -> anyhow::Result> { + match endpoint.wire_api { + WireApi::OpenAiChat => Ok(Arc::new(OpenAIChatProvider::new(endpoint)?)), + WireApi::OpenAiResponses => Err(anyhow::anyhow!( + "endpoint '{}' wire_api 'openai-responses' is reserved for a future provider; \ + use 'openai-chat' for now or wait for the OpenAIResponsesProvider impl", + endpoint.name + )), + WireApi::AnthropicMessages => Err(anyhow::anyhow!( + "endpoint '{}' wire_api 'anthropic-messages' is reserved for a future provider", + endpoint.name + )), + } +} + +#[tokio::main] +async fn main() -> Result<()> { + // Logs go to stderr — stdout is reserved for the JSON-RPC stream. + tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + let cfg = Config::load() + .map_err(|e| agent_client_protocol::util::internal_error(format!("config: {e:#}")))?; + tracing::info!( + endpoints = cfg.endpoints.len(), + default_endpoint = %cfg.default_endpoint().name, + default_model = ?cfg.default_endpoint().default_model, + "helexa-acp starting" + ); + + // Build a provider for each configured endpoint up-front. Cheap — + // just sets up a reqwest::Client and resolves the API key — and + // surfaces config mistakes (missing API key env var, unsupported + // wire_api) before the editor even sends an initialize request. + let mut providers: Vec> = Vec::with_capacity(cfg.endpoints.len()); + for endpoint in &cfg.endpoints { + match build_provider(endpoint.clone()) { + Ok(p) => { + tracing::info!( + endpoint = %endpoint.name, + base_url = %endpoint.base_url, + wire_api = ?endpoint.wire_api, + "registered provider" + ); + providers.push(p); + } + Err(e) => { + tracing::warn!( + endpoint = %endpoint.name, + error = %format!("{e:#}"), + "skipping endpoint with invalid config" + ); + } + } + } + if providers.is_empty() { + return Err(agent_client_protocol::util::internal_error( + "no usable endpoints — check config", + )); + } + + Agent + .builder() + .name("helexa-acp") + .on_receive_request( + async move |initialize: InitializeRequest, responder, _connection| { + // Phase 1 wiring — capabilities only. Real session + // handling lands in the next iteration (agent.rs). + responder.respond( + InitializeResponse::new(initialize.protocol_version) + .agent_capabilities(AgentCapabilities::new()), + ) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_dispatch( + async move |message: Dispatch, cx: ConnectionTo| { + tracing::warn!(method = ?message.method(), "unhandled ACP message"); + message.respond_with_error( + agent_client_protocol::util::internal_error("not implemented yet"), + cx, + ) + }, + agent_client_protocol::on_receive_dispatch!(), + ) + .connect_to(Stdio::new()) + .await +} diff --git a/crates/helexa-acp/src/provider/mod.rs b/crates/helexa-acp/src/provider/mod.rs new file mode 100644 index 0000000..2fc69e7 --- /dev/null +++ b/crates/helexa-acp/src/provider/mod.rs @@ -0,0 +1,162 @@ +//! Provider trait — the seam between the ACP-side agent loop and +//! whatever wire protocol an endpoint actually speaks. +//! +//! Every concrete provider (OpenAI chat completions, OpenAI Responses, +//! Anthropic /v1/messages, Ollama native, …) implements +//! [`Provider`]. The agent constructs a [`CompletionRequest`] using +//! provider-agnostic types and consumes a stream of +//! [`CompletionEvent`]s — neither end knows which wire format is on +//! the other side of the trait. +//! +//! Day-1 provider: [`openai_chat::OpenAIChatProvider`]. Day-N +//! providers slot in without touching `agent.rs`. + +// Many fields and variants in the public surface here aren't read yet: +// the agent loop that consumes `CompletionEvent`s and constructs +// `CompletionRequest`s lands in the next commit. They're not +// speculative — the unit tests in `provider::openai_chat::tests` +// already verify the encoder/decoder produces them. Once `agent.rs` +// arrives this allow comes off. +#![allow(dead_code)] + +use async_trait::async_trait; +use futures::stream::BoxStream; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tokio_util::sync::CancellationToken; + +pub mod openai_chat; + +/// Provider-agnostic LLM endpoint. Implementations translate between +/// [`CompletionRequest`] / [`CompletionEvent`] and whatever wire +/// format their endpoint speaks. +#[async_trait] +pub trait Provider: Send + Sync { + /// Endpoint name as configured by the user (e.g. `"helexa"`, + /// `"openrouter"`). Used in logs and in the `endpoint:model` + /// selector. + fn name(&self) -> &str; + + /// List models available at this endpoint. Used to build the + /// model-picker dropdown in editor clients. Should return quickly + /// (cache if necessary). + async fn list_models(&self) -> anyhow::Result>; + + /// Run a chat completion. Returns a stream of provider-agnostic + /// events. The stream stops when the upstream finishes, when + /// `cancel` is fired, or when the stream is dropped. + async fn complete( + &self, + request: CompletionRequest, + cancel: CancellationToken, + ) -> anyhow::Result>>; +} + +/// One model exposed by a provider. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelInfo { + pub id: String, + /// Human-friendly name, if the endpoint exposes one. Otherwise + /// `id` is used as the display name. + #[serde(default)] + pub display_name: Option, +} + +/// Inputs to a completion. Provider-agnostic — concrete providers +/// translate this into their wire format. +#[derive(Debug, Clone)] +pub struct CompletionRequest { + /// Endpoint-local model id (without the `endpoint:` prefix). + pub model: String, + pub messages: Vec, + /// Tools the model is allowed to call. Empty list means no tool + /// support advertised. + pub tools: Vec, + pub temperature: Option, + pub top_p: Option, + pub max_tokens: Option, +} + +#[derive(Debug, Clone)] +pub struct Message { + pub role: Role, + pub content: MessageContent, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Role { + System, + User, + Assistant, + /// Tool result message. Provider impls turn this into whatever + /// shape the upstream wire format wants (OpenAI uses + /// `role: "tool"` + `tool_call_id`; Anthropic uses content blocks). + Tool, +} + +#[derive(Debug, Clone)] +pub enum MessageContent { + Text(String), + /// Assistant turn that called one or more tools. + ToolCalls { + /// Optional text the assistant said alongside the tool calls. + text: Option, + calls: Vec, + }, + /// Tool result. `tool_call_id` matches the assistant's call id. + ToolResult { + tool_call_id: String, + content: String, + }, +} + +#[derive(Debug, Clone)] +pub struct ToolCall { + /// Provider-assigned id that ties the call to its result. + pub id: String, + pub name: String, + /// JSON-encoded arguments. Kept as a string because providers + /// stream argument bytes incrementally and only validate at the + /// end; the agent decodes once the call is complete. + pub arguments: String, +} + +#[derive(Debug, Clone)] +pub struct ToolSpec { + pub name: String, + pub description: String, + /// JSON Schema of the arguments object. + pub parameters: Value, +} + +/// Events emitted by a provider during a streaming completion. +#[derive(Debug, Clone)] +pub enum CompletionEvent { + /// Incremental visible text from the assistant. + TextDelta(String), + /// Incremental "reasoning" / thought text, if the model emits one + /// (e.g. Qwen3 with `` tags surfaced as a separate stream, + /// or OpenAI reasoning models). + ReasoningDelta(String), + /// A new tool call has started. + ToolCallStart { + index: usize, + id: String, + name: String, + }, + /// More argument bytes for a tool call already announced via + /// [`Self::ToolCallStart`]. + ToolCallArgsDelta { index: usize, args_delta: String }, + /// Stream finished. Carries the upstream `finish_reason` if it + /// gave one (`"stop"`, `"length"`, `"tool_calls"`, …). + Finish { reason: Option }, + /// Final usage stats, if the provider supplied them. + Usage(UsageStats), +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct UsageStats { + pub prompt_tokens: u64, + pub completion_tokens: u64, + pub total_tokens: u64, +} diff --git a/crates/helexa-acp/src/provider/openai_chat.rs b/crates/helexa-acp/src/provider/openai_chat.rs new file mode 100644 index 0000000..c04236a --- /dev/null +++ b/crates/helexa-acp/src/provider/openai_chat.rs @@ -0,0 +1,645 @@ +//! OpenAI `/v1/chat/completions` provider. +//! +//! Covers cortex, LM Studio, Ollama (compat mode), OpenRouter, and +//! OpenAI itself. The wire format is well-documented and stable; +//! tool calls follow the `tools` request param + `tool_calls` +//! response delta convention shared by every reasonably-modern +//! OpenAI-compatible server. + +use async_trait::async_trait; +use eventsource_stream::Eventsource; +use futures::{Stream, StreamExt, stream::BoxStream}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use tokio_util::sync::CancellationToken; + +use super::{ + CompletionEvent, CompletionRequest, Message, MessageContent, ModelInfo, Provider, Role, + ToolCall, ToolSpec, UsageStats, +}; +use crate::config::EndpointConfig; + +// Several fields and types in this module are only used through the +// async HTTP path in `complete()` and `list_models()`. Tests don't +// stand up a mock HTTP server (we'd be over-engineering for the +// payoff), so clippy's dead-code pass under `--tests` flags them. +// Each `allow(dead_code)` below names exactly what's exercised only +// at runtime, with a one-line rationale so the next reader can tell +// it's intentional. +pub struct OpenAIChatProvider { + endpoint: EndpointConfig, + /// Read by `list_models` and `complete` (bearer auth header). + #[allow(dead_code)] + api_key: Option, + /// Read by `list_models` and `complete` (request builder). + #[allow(dead_code)] + http: reqwest::Client, +} + +impl OpenAIChatProvider { + pub fn new(endpoint: EndpointConfig) -> anyhow::Result { + let api_key = endpoint.resolve_api_key()?; + let http = reqwest::Client::builder() + // Generous timeout: cortex may need to cold-load a model + // before serving the first chunk, which can be tens of + // seconds. We rely on cancellation for early termination, + // not on timeout. + .timeout(std::time::Duration::from_secs(600)) + .build()?; + Ok(Self { + endpoint, + api_key, + http, + }) + } +} + +#[async_trait] +impl Provider for OpenAIChatProvider { + fn name(&self) -> &str { + &self.endpoint.name + } + + async fn list_models(&self) -> anyhow::Result> { + let mut req = self.http.get(self.endpoint.models_url()); + if let Some(key) = &self.api_key { + req = req.bearer_auth(key); + } + let resp = req + .send() + .await + .map_err(|e| anyhow::anyhow!("{} list_models: {e}", self.endpoint.name))?; + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!( + "{} list_models returned {}: {}", + self.endpoint.name, + status, + body + ); + } + let body: WireModelsResponse = resp.json().await?; + Ok(body + .data + .into_iter() + .map(|m| ModelInfo { + id: m.id, + display_name: None, + }) + .collect()) + } + + async fn complete( + &self, + request: CompletionRequest, + cancel: CancellationToken, + ) -> anyhow::Result>> { + let body = encode_request(&request); + let mut req = self + .http + .post(self.endpoint.chat_completions_url()) + .json(&body); + if let Some(key) = &self.api_key { + req = req.bearer_auth(key); + } + let resp = req + .send() + .await + .map_err(|e| anyhow::anyhow!("{} chat_completion send: {e}", self.endpoint.name))?; + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!( + "{} chat_completion returned {}: {}", + self.endpoint.name, + status, + body + ); + } + let sse = resp.bytes_stream().eventsource(); + let stream = decode_stream(sse, cancel); + Ok(Box::pin(stream)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::stream; + use url::Url; + + fn ep() -> EndpointConfig { + EndpointConfig { + name: "test".into(), + base_url: Url::parse("http://localhost:9999/v1").unwrap(), + wire_api: crate::config::WireApi::OpenAiChat, + default_model: None, + api_key: None, + api_key_env: None, + } + } + + #[test] + fn encodes_text_only_request() { + let req = CompletionRequest { + model: "helexa/large".into(), + messages: vec![ + Message { + role: Role::System, + content: MessageContent::Text("you are helpful".into()), + }, + Message { + role: Role::User, + content: MessageContent::Text("hi".into()), + }, + ], + tools: vec![], + temperature: Some(0.7), + top_p: None, + max_tokens: Some(256), + }; + let body = encode_request(&req); + assert_eq!(body["model"], "helexa/large"); + assert_eq!(body["stream"], true); + assert_eq!(body["temperature"], 0.7); + assert_eq!(body["max_tokens"], 256); + assert!(body.get("top_p").is_none(), "absent options are omitted"); + let messages = body["messages"].as_array().unwrap(); + assert_eq!(messages.len(), 2); + assert_eq!(messages[0]["role"], "system"); + assert_eq!(messages[1]["role"], "user"); + assert_eq!(messages[1]["content"], "hi"); + assert!(body.get("tools").is_none(), "empty tools omitted"); + assert_eq!(body["stream_options"]["include_usage"], true); + } + + #[test] + fn encodes_tool_call_round_trip() { + let req = CompletionRequest { + model: "x".into(), + messages: vec![ + Message { + role: Role::Assistant, + content: MessageContent::ToolCalls { + text: Some("calling read_file".into()), + calls: vec![ToolCall { + id: "call_1".into(), + name: "read_file".into(), + arguments: "{\"path\":\"/tmp/a.txt\"}".into(), + }], + }, + }, + Message { + role: Role::Tool, + content: MessageContent::ToolResult { + tool_call_id: "call_1".into(), + content: "file contents".into(), + }, + }, + ], + tools: vec![ToolSpec { + name: "read_file".into(), + description: "Read a file".into(), + parameters: json!({"type": "object", "properties": {"path": {"type": "string"}}}), + }], + temperature: None, + top_p: None, + max_tokens: None, + }; + let body = encode_request(&req); + // Tool defs flow through: + let tools = body["tools"].as_array().unwrap(); + assert_eq!(tools[0]["function"]["name"], "read_file"); + // Assistant tool_calls flow through: + let asst = &body["messages"][0]; + assert_eq!(asst["role"], "assistant"); + assert_eq!(asst["tool_calls"][0]["id"], "call_1"); + assert_eq!(asst["tool_calls"][0]["function"]["name"], "read_file"); + // Tool result flows through: + let tool = &body["messages"][1]; + assert_eq!(tool["role"], "tool"); + assert_eq!(tool["tool_call_id"], "call_1"); + assert_eq!(tool["content"], "file contents"); + } + + /// Build a fake eventsource stream from canned SSE `data:` lines. + fn fake_sse( + lines: Vec<&'static str>, + ) -> impl Stream< + Item = std::result::Result< + eventsource_stream::Event, + eventsource_stream::EventStreamError, + >, + > { + stream::iter(lines.into_iter().map(|data| { + Ok(eventsource_stream::Event { + event: "message".into(), + data: data.into(), + id: String::new(), + retry: None, + }) + })) + } + + #[tokio::test] + async fn decodes_text_then_finish() { + let sse = fake_sse(vec![ + r#"{"choices":[{"delta":{"content":"hel"},"finish_reason":null}]}"#, + r#"{"choices":[{"delta":{"content":"lo"},"finish_reason":null}]}"#, + r#"{"choices":[{"delta":{},"finish_reason":"stop"}]}"#, + r#"{"choices":[],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}"#, + "[DONE]", + ]); + let stream = decode_stream(sse, CancellationToken::new()); + let events: Vec<_> = stream.collect().await; + let events: Vec<_> = events.into_iter().map(|r| r.unwrap()).collect(); + + assert!(matches!(&events[0], CompletionEvent::TextDelta(s) if s == "hel")); + assert!(matches!(&events[1], CompletionEvent::TextDelta(s) if s == "lo")); + assert!( + matches!(&events[2], CompletionEvent::Finish { reason } if reason.as_deref() == Some("stop")) + ); + assert!(matches!(&events[3], CompletionEvent::Usage(u) if u.total_tokens == 7)); + assert_eq!(events.len(), 4); + } + + #[tokio::test] + async fn decodes_tool_call_progressively() { + let sse = fake_sse(vec![ + r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","function":{"name":"read_file"}}]}}]}"#, + r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"pa"}}]}}]}"#, + r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"th\":\"/tmp/a\"}"}}]}}]}"#, + r#"{"choices":[{"delta":{},"finish_reason":"tool_calls"}]}"#, + "[DONE]", + ]); + let events: Vec<_> = decode_stream(sse, CancellationToken::new()) + .collect::>() + .await + .into_iter() + .map(|r| r.unwrap()) + .collect(); + + assert!(matches!( + &events[0], + CompletionEvent::ToolCallStart { index: 0, id, name } + if id == "c1" && name == "read_file" + )); + assert!(matches!( + &events[1], + CompletionEvent::ToolCallArgsDelta { index: 0, args_delta } + if args_delta == "{\"pa" + )); + assert!(matches!( + &events[2], + CompletionEvent::ToolCallArgsDelta { index: 0, args_delta } + if args_delta == "th\":\"/tmp/a\"}" + )); + assert!(matches!( + &events[3], + CompletionEvent::Finish { reason } if reason.as_deref() == Some("tool_calls") + )); + } + + #[tokio::test] + async fn cancellation_ends_stream() { + let sse = fake_sse(vec![ + r#"{"choices":[{"delta":{"content":"hello"}}]}"#, + // These chunks should NOT be consumed once we cancel. + r#"{"choices":[{"delta":{"content":" world"}}]}"#, + ]); + let cancel = CancellationToken::new(); + cancel.cancel(); // pre-cancel so the first select! arm wins. + let events: Vec<_> = decode_stream(sse, cancel).collect().await; + assert!(events.is_empty(), "cancelled stream yields nothing"); + } + + #[tokio::test] + async fn skips_malformed_chunks() { + let sse = fake_sse(vec![ + r#"{"choices":[{"delta":{"content":"before"}}]}"#, + r#"not valid json"#, + r#"{"choices":[{"delta":{"content":"after"}}]}"#, + "[DONE]", + ]); + let events: Vec<_> = decode_stream(sse, CancellationToken::new()) + .collect::>() + .await + .into_iter() + .map(|r| r.unwrap()) + .collect(); + // The bad chunk is skipped with a warn; the bracketing + // chunks both come through. + assert!(matches!(&events[0], CompletionEvent::TextDelta(s) if s == "before")); + assert!(matches!(&events[1], CompletionEvent::TextDelta(s) if s == "after")); + assert_eq!(events.len(), 2); + } + + #[test] + fn provider_construction_is_cheap() { + // Ensures construction doesn't accidentally make any HTTP calls + // — important because helexa-acp builds a provider per + // configured endpoint at startup, before the editor has + // necessarily connected. + let p = OpenAIChatProvider::new(ep()).expect("construction"); + assert_eq!(p.name(), "test"); + } +} + +// ── Request encoding ──────────────────────────────────────────────── + +fn encode_request(req: &CompletionRequest) -> Value { + let messages: Vec = req.messages.iter().map(encode_message).collect(); + let mut body = json!({ + "model": req.model, + "messages": messages, + "stream": true, + }); + if let Value::Object(map) = &mut body { + if let Some(t) = req.temperature { + map.insert("temperature".into(), json!(t)); + } + if let Some(p) = req.top_p { + map.insert("top_p".into(), json!(p)); + } + if let Some(m) = req.max_tokens { + map.insert("max_tokens".into(), json!(m)); + } + if !req.tools.is_empty() { + map.insert("tools".into(), encode_tools(&req.tools)); + } + // Some servers (cortex via neuron, OpenAI) report usage at the + // end of the stream only when explicitly requested. + map.insert("stream_options".into(), json!({ "include_usage": true })); + } + body +} + +fn encode_message(m: &Message) -> Value { + match (m.role, &m.content) { + (Role::System, MessageContent::Text(s)) => json!({"role": "system", "content": s}), + (Role::User, MessageContent::Text(s)) => json!({"role": "user", "content": s}), + (Role::Assistant, MessageContent::Text(s)) => json!({"role": "assistant", "content": s}), + (Role::Assistant, MessageContent::ToolCalls { text, calls }) => { + let calls_json: Vec = calls + .iter() + .map(|c| { + json!({ + "id": c.id, + "type": "function", + "function": { + "name": c.name, + "arguments": c.arguments, + } + }) + }) + .collect(); + json!({ + "role": "assistant", + "content": text.clone().unwrap_or_default(), + "tool_calls": calls_json, + }) + } + ( + Role::Tool, + MessageContent::ToolResult { + tool_call_id, + content, + }, + ) => json!({ + "role": "tool", + "tool_call_id": tool_call_id, + "content": content, + }), + // Mismatched (role, content) combinations shouldn't happen + // — the agent constructs them in pairs. If they do, degrade + // gracefully to a plain text turn so the request still goes + // out rather than crashing the conversation. + (role, content) => { + tracing::warn!( + ?role, + ?content, + "encode_message: unexpected (role, content) shape" + ); + json!({"role": role_str(role), "content": content_as_text(content)}) + } + } +} + +fn role_str(r: Role) -> &'static str { + match r { + Role::System => "system", + Role::User => "user", + Role::Assistant => "assistant", + Role::Tool => "tool", + } +} + +fn content_as_text(c: &MessageContent) -> String { + match c { + MessageContent::Text(s) => s.clone(), + MessageContent::ToolCalls { text, .. } => text.clone().unwrap_or_default(), + MessageContent::ToolResult { content, .. } => content.clone(), + } +} + +fn encode_tools(tools: &[ToolSpec]) -> Value { + let arr: Vec = tools + .iter() + .map(|t| { + json!({ + "type": "function", + "function": { + "name": t.name, + "description": t.description, + "parameters": t.parameters, + } + }) + }) + .collect(); + Value::Array(arr) +} + +// ── Response decoding ─────────────────────────────────────────────── + +// Both types are deserialised through `list_models()`. Tests don't +// exercise that path (no mock HTTP server), so clippy --tests reports +// them as dead; in real use they're hit on every Zed model-picker +// refresh. +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +struct WireModelsResponse { + data: Vec, +} + +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +struct WireModelObject { + id: String, +} + +#[derive(Debug, Deserialize)] +struct WireChunk { + #[serde(default)] + choices: Vec, + #[serde(default)] + usage: Option, +} + +#[derive(Debug, Deserialize)] +struct WireChunkChoice { + #[serde(default)] + delta: WireDelta, + #[serde(default)] + finish_reason: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct WireDelta { + #[serde(default)] + content: Option, + /// Some servers expose chain-of-thought text via this field + /// (mirroring OpenAI's reasoning-model schema). When present we + /// surface it as `ReasoningDelta`. + #[serde(default)] + reasoning_content: Option, + #[serde(default)] + tool_calls: Vec, +} + +#[derive(Debug, Deserialize)] +struct WireToolCallDelta { + #[serde(default)] + index: usize, + #[serde(default)] + id: Option, + #[serde(default)] + function: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct WireFunctionDelta { + #[serde(default)] + name: Option, + #[serde(default)] + arguments: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +struct WireUsage { + prompt_tokens: u64, + completion_tokens: u64, + total_tokens: u64, +} + +/// Convert the eventsource-stream byte SSE into provider-agnostic +/// events. Bails the stream on the first parse failure with a logged +/// warning — partial state is preferable to silently corrupting a +/// conversation by skipping bad events. +fn decode_stream( + sse: S, + cancel: CancellationToken, +) -> impl Stream> +where + S: Stream< + Item = Result< + eventsource_stream::Event, + eventsource_stream::EventStreamError, + >, + > + Send + + 'static, +{ + async_stream::stream! { + // Track which (index) tool calls we've already announced. The + // OpenAI stream emits the id and name only on the first delta + // for each tool call; later deltas just carry argument bytes. + let mut announced: std::collections::HashSet = Default::default(); + + let mut sse = Box::pin(sse); + loop { + tokio::select! { + // `biased;` checks `cancel.cancelled()` first on every + // poll — without it, a pre-cancelled token loses to a + // ready SSE chunk, and a mid-stream cancellation could + // still consume one more chunk before noticing. + biased; + _ = cancel.cancelled() => { + tracing::debug!("openai_chat: cancellation requested, ending stream"); + break; + } + next = sse.next() => { + let Some(event) = next else { break }; + let event = match event { + Ok(e) => e, + Err(e) => { + yield Err(anyhow::anyhow!("SSE transport: {e}")); + break; + } + }; + let data = event.data; + if data == "[DONE]" { + break; + } + let chunk: WireChunk = match serde_json::from_str(&data) { + Ok(c) => c, + Err(e) => { + tracing::warn!( + error = %e, + raw = %data, + "openai_chat: failed to parse SSE chunk; skipping" + ); + continue; + } + }; + for choice in chunk.choices { + if let Some(text) = choice.delta.content + && !text.is_empty() + { + yield Ok(CompletionEvent::TextDelta(text)); + } + if let Some(reasoning) = choice.delta.reasoning_content + && !reasoning.is_empty() + { + yield Ok(CompletionEvent::ReasoningDelta(reasoning)); + } + for tc in choice.delta.tool_calls { + let idx = tc.index; + if announced.insert(idx) { + let id = tc.id.unwrap_or_default(); + let name = tc + .function + .as_ref() + .and_then(|f| f.name.clone()) + .unwrap_or_default(); + yield Ok(CompletionEvent::ToolCallStart { + index: idx, + id, + name, + }); + } + if let Some(f) = tc.function + && let Some(args) = f.arguments + && !args.is_empty() + { + yield Ok(CompletionEvent::ToolCallArgsDelta { + index: idx, + args_delta: args, + }); + } + } + if let Some(reason) = choice.finish_reason { + yield Ok(CompletionEvent::Finish { reason: Some(reason) }); + } + } + if let Some(u) = chunk.usage { + yield Ok(CompletionEvent::Usage(UsageStats { + prompt_tokens: u.prompt_tokens, + completion_tokens: u.completion_tokens, + total_tokens: u.total_tokens, + })); + } + } + } + } + } +}