From ed7a11a3f6cab888997c9abe8f8d9b20abab8cb2 Mon Sep 17 00:00:00 2001 From: itsitsiridakis Date: Thu, 9 Apr 2026 22:38:33 +0300 Subject: [PATCH] feat(acp-agent-llm): add model-agnostic LLM adapter crate Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: itsitsiridakis --- .mise.toml | 5 + devops/docker/compose/.env.example | 7 + rsworkspace/Cargo.lock | 212 ++++++++++-------- rsworkspace/Cargo.toml | 4 + rsworkspace/crates/acp-agent-llm/Cargo.toml | 41 ++++ .../crates/acp-agent-llm/src/api_key.rs | 42 ++++ .../crates/acp-agent-llm/src/base_url.rs | 69 ++++++ .../crates/acp-agent-llm/src/config.rs | 188 ++++++++++++++++ rsworkspace/crates/acp-agent-llm/src/error.rs | 62 +++++ rsworkspace/crates/acp-agent-llm/src/main.rs | 100 +++++++++ .../acp-agent-llm/src/model/anthropic.rs | 203 +++++++++++++++++ .../crates/acp-agent-llm/src/model/mod.rs | 82 +++++++ .../crates/acp-agent-llm/src/model/openai.rs | 176 +++++++++++++++ .../crates/acp-agent-llm/src/model_id.rs | 55 +++++ .../acp-agent-llm/src/provider/anthropic.rs | 102 +++++++++ .../crates/acp-agent-llm/src/provider/mod.rs | 35 +++ .../acp-agent-llm/src/provider/openai.rs | 100 +++++++++ .../crates/acp-agent-llm/src/provider_name.rs | 56 +++++ .../crates/acp-agent-llm/src/session.rs | 121 ++++++++++ .../crates/acp-agent-llm/src/session_store.rs | 83 +++++++ .../acp-agent-llm/src/telemetry/metrics.rs | 21 ++ .../crates/acp-agent-llm/src/telemetry/mod.rs | 1 + .../crates/acp-telemetry/src/service_name.rs | 4 + 23 files changed, 1674 insertions(+), 95 deletions(-) create mode 100644 rsworkspace/crates/acp-agent-llm/Cargo.toml create mode 100644 rsworkspace/crates/acp-agent-llm/src/api_key.rs create mode 100644 rsworkspace/crates/acp-agent-llm/src/base_url.rs create mode 100644 rsworkspace/crates/acp-agent-llm/src/config.rs create mode 100644 rsworkspace/crates/acp-agent-llm/src/error.rs create mode 100644 rsworkspace/crates/acp-agent-llm/src/main.rs create mode 100644 rsworkspace/crates/acp-agent-llm/src/model/anthropic.rs create mode 100644 rsworkspace/crates/acp-agent-llm/src/model/mod.rs create mode 100644 rsworkspace/crates/acp-agent-llm/src/model/openai.rs create mode 100644 rsworkspace/crates/acp-agent-llm/src/model_id.rs create mode 100644 rsworkspace/crates/acp-agent-llm/src/provider/anthropic.rs create mode 100644 rsworkspace/crates/acp-agent-llm/src/provider/mod.rs create mode 100644 rsworkspace/crates/acp-agent-llm/src/provider/openai.rs create mode 100644 rsworkspace/crates/acp-agent-llm/src/provider_name.rs create mode 100644 rsworkspace/crates/acp-agent-llm/src/session.rs create mode 100644 rsworkspace/crates/acp-agent-llm/src/session_store.rs create mode 100644 rsworkspace/crates/acp-agent-llm/src/telemetry/metrics.rs create mode 100644 rsworkspace/crates/acp-agent-llm/src/telemetry/mod.rs diff --git a/.mise.toml b/.mise.toml index b1549c28b..40b30406a 100644 --- a/.mise.toml +++ b/.mise.toml @@ -21,6 +21,11 @@ depends = ["check:rust"] dir = "rsworkspace" run = "cargo clippy --workspace && cargo fmt --check" +[tasks."run:agent"] +description = "Run the LLM agent" +dir = "rsworkspace" +run = "cargo run -p acp-agent-llm" + [tasks."rsc"] description = "Format and check all Rust code (fmt, clippy, all targets, all features)" dir = "rsworkspace" diff --git a/devops/docker/compose/.env.example b/devops/docker/compose/.env.example index f72a98fba..77df84406 100644 --- a/devops/docker/compose/.env.example +++ b/devops/docker/compose/.env.example @@ -52,6 +52,13 @@ # TROGON_SOURCE_SLACK_NATS_ACK_TIMEOUT_SECS=10 # TROGON_SOURCE_SLACK_TIMESTAMP_MAX_DRIFT_SECS=300 +# --- LLM Agent --- +# LLM_PROVIDER=anthropic +# LLM_MODEL=claude-sonnet-4-6 +# ANTHROPIC_API_KEY= +# OPENAI_API_KEY= +# LLM_BASE_URL= + # --- Logging --- # RUST_LOG=info diff --git a/rsworkspace/Cargo.lock b/rsworkspace/Cargo.lock index 3df6e3aa1..6cfabb55a 100644 --- a/rsworkspace/Cargo.lock +++ b/rsworkspace/Cargo.lock @@ -2,6 +2,29 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "acp-agent-llm" +version = "0.0.1" +dependencies = [ + "acp-nats", + "acp-nats-agent", + "acp-telemetry", + "agent-client-protocol", + "async-trait", + "clap", + "futures", + "opentelemetry", + "reqwest", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", + "trogon-nats", + "trogon-std", + "uuid", +] + [[package]] name = "acp-nats" version = "0.1.0" @@ -479,7 +502,7 @@ dependencies = [ "iana-time-zone", "num-traits", "serde", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -622,30 +645,6 @@ dependencies = [ "libc", ] -[[package]] -name = "critical-section" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" - -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1153,9 +1152,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hickory-proto" -version = "0.25.2" +version = "0.24.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +checksum = "92652067c9ce6f66ce53cc38d1169daa36e6e7eb7dd3b63b5103bd9d97117248" dependencies = [ "async-trait", "cfg-if", @@ -1167,9 +1166,8 @@ dependencies = [ "idna", "ipnet", "once_cell", - "rand 0.9.2", - "ring", - "thiserror 2.0.18", + "rand 0.8.5", + "thiserror 1.0.69", "tinyvec", "tokio", "tracing", @@ -1178,21 +1176,21 @@ dependencies = [ [[package]] name = "hickory-resolver" -version = "0.25.2" +version = "0.24.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +checksum = "cbb117a1ca520e111743ab2f6688eddee69db4e0ea242545a604dce8a66fd22e" dependencies = [ "cfg-if", "futures-util", "hickory-proto", "ipconfig", - "moka", + "lru-cache", "once_cell", "parking_lot", - "rand 0.9.2", + "rand 0.8.5", "resolv-conf", "smallvec", - "thiserror 2.0.18", + "thiserror 1.0.69", "tokio", "tracing", ] @@ -1340,16 +1338,13 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64 0.22.1", "bytes", "futures-channel", "futures-util", "http", "http-body", "hyper", - "ipnet", "libc", - "percent-encoding", "pin-project-lite", "socket2", "tokio", @@ -1542,8 +1537,8 @@ checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" dependencies = [ "socket2", "widestring", - "windows-registry", - "windows-result", + "windows-registry 0.6.1", + "windows-result 0.4.1", "windows-sys 0.61.2", ] @@ -1553,16 +1548,6 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1623,6 +1608,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1650,6 +1641,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-cache" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -1694,23 +1694,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "moka" -version = "0.12.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" -dependencies = [ - "crossbeam-channel", - "crossbeam-epoch", - "crossbeam-utils", - "equivalent", - "parking_lot", - "portable-atomic", - "smallvec", - "tagptr", - "uuid", -] - [[package]] name = "nkeys" version = "0.4.5" @@ -1755,10 +1738,6 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -dependencies = [ - "critical-section", - "portable-atomic", -] [[package]] name = "once_cell_polyfill" @@ -1910,7 +1889,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2291,9 +2270,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" -version = "0.12.28" +version = "0.12.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb" dependencies = [ "base64 0.22.1", "bytes", @@ -2309,6 +2288,7 @@ dependencies = [ "hyper", "hyper-rustls 0.27.7", "hyper-util", + "ipnet", "js-sys", "log", "mime", @@ -2318,6 +2298,7 @@ dependencies = [ "quinn", "rustls 0.23.37", "rustls-native-certs 0.8.3", + "rustls-pemfile", "rustls-pki-types", "serde", "serde_json", @@ -2325,14 +2306,16 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls 0.26.4", + "tokio-util", "tower", - "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", - "webpki-roots 1.0.6", + "webpki-roots 0.26.11", + "windows-registry 0.4.0", ] [[package]] @@ -2936,12 +2919,6 @@ dependencies = [ "syn", ] -[[package]] -name = "tagptr" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" - [[package]] name = "tempfile" version = "3.19.1" @@ -3333,12 +3310,9 @@ checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags", "bytes", - "futures-util", "http", "http-body", - "iri-string", "pin-project-lite", - "tower", "tower-layer", "tower-service", "tracing", @@ -3913,6 +3887,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" @@ -3999,9 +3986,9 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] @@ -4026,21 +4013,47 @@ dependencies = [ "syn", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" +dependencies = [ + "windows-result 0.3.4", + "windows-strings 0.3.1", + "windows-targets 0.53.5", +] + [[package]] name = "windows-registry" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -4049,7 +4062,16 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -4058,7 +4080,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -4094,7 +4116,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -4134,7 +4156,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link", + "windows-link 0.2.1", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", diff --git a/rsworkspace/Cargo.toml b/rsworkspace/Cargo.toml index be43ab5a0..37ebc29e7 100644 --- a/rsworkspace/Cargo.toml +++ b/rsworkspace/Cargo.toml @@ -12,6 +12,7 @@ all = "deny" [workspace.dependencies] # Internal crates acp-nats = { path = "crates/acp-nats" } +acp-nats-agent = { path = "crates/acp-nats-agent" } acp-telemetry = { path = "crates/acp-telemetry" } trogon-nats = { path = "crates/trogon-nats" } trogon-source-discord = { path = "crates/trogon-source-discord" } @@ -58,6 +59,9 @@ tracing-subscriber = "=0.3.23" twilight-gateway = { version = "=0.17.1", default-features = false, features = ["rustls-native-roots"] } twilight-model = "=0.17.1" +# HTTP client +reqwest = { version = "=0.12.15", default-features = false, features = ["json", "rustls-tls", "stream"] } + # Misc tempfile = "=3.19.1" uuid = { version = "=1.23.0", features = ["v4", "v7"] } diff --git a/rsworkspace/crates/acp-agent-llm/Cargo.toml b/rsworkspace/crates/acp-agent-llm/Cargo.toml new file mode 100644 index 000000000..eb8108ae8 --- /dev/null +++ b/rsworkspace/crates/acp-agent-llm/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "acp-agent-llm" +version = "0.0.1" +edition = "2024" + +[lints] +workspace = true + +[dependencies] +acp-nats = { workspace = true } +acp-nats-agent = { workspace = true } +acp-telemetry = { workspace = true } +agent-client-protocol = { workspace = true, features = [ + "unstable_auth_methods", + "unstable_boolean_config", + "unstable_cancel_request", + "unstable_message_id", + "unstable_session_close", + "unstable_session_fork", + "unstable_session_model", + "unstable_session_resume", + "unstable_session_usage", +] } +async-trait = { workspace = true } +clap = { workspace = true, features = ["derive", "env"] } +futures = { workspace = true } +opentelemetry = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["rt", "rt-multi-thread", "macros", "sync", "signal"] } +tracing = { workspace = true } +trogon-nats = { workspace = true } +trogon-std = { workspace = true, features = ["clap"] } +uuid = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } +tracing-subscriber = { workspace = true, features = ["fmt"] } +trogon-nats = { workspace = true, features = ["test-support"] } +trogon-std = { workspace = true, features = ["test-support"] } diff --git a/rsworkspace/crates/acp-agent-llm/src/api_key.rs b/rsworkspace/crates/acp-agent-llm/src/api_key.rs new file mode 100644 index 000000000..833b241bf --- /dev/null +++ b/rsworkspace/crates/acp-agent-llm/src/api_key.rs @@ -0,0 +1,42 @@ +use trogon_std::secret_string::{EmptySecret, SecretString}; + +#[derive(Clone)] +pub struct ApiKey(SecretString); + +impl ApiKey { + pub fn new(s: impl AsRef) -> Result { + SecretString::new(s).map(Self) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl std::fmt::Debug for ApiKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("ApiKey(****)") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_non_empty() { + let key = ApiKey::new("sk-test-123").unwrap(); + assert_eq!(key.as_str(), "sk-test-123"); + } + + #[test] + fn rejects_empty() { + assert!(ApiKey::new("").is_err()); + } + + #[test] + fn debug_redacts() { + let key = ApiKey::new("sk-secret").unwrap(); + assert_eq!(format!("{key:?}"), "ApiKey(****)"); + } +} diff --git a/rsworkspace/crates/acp-agent-llm/src/base_url.rs b/rsworkspace/crates/acp-agent-llm/src/base_url.rs new file mode 100644 index 000000000..7e14b52e4 --- /dev/null +++ b/rsworkspace/crates/acp-agent-llm/src/base_url.rs @@ -0,0 +1,69 @@ +#[derive(Debug, Clone)] +pub struct BaseUrl(String); + +#[derive(Debug, Clone, PartialEq)] +pub enum BaseUrlError { + Empty, + MissingScheme, +} + +impl std::fmt::Display for BaseUrlError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Empty => f.write_str("base URL cannot be empty"), + Self::MissingScheme => f.write_str("base URL must start with http:// or https://"), + } + } +} + +impl std::error::Error for BaseUrlError {} + +impl BaseUrl { + pub fn new(s: impl Into) -> Result { + let s = s.into(); + if s.is_empty() { + return Err(BaseUrlError::Empty); + } + if !s.starts_with("http://") && !s.starts_with("https://") { + return Err(BaseUrlError::MissingScheme); + } + Ok(Self(s)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_string(self) -> String { + self.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_https() { + let url = BaseUrl::new("https://api.anthropic.com").unwrap(); + assert_eq!(url.as_str(), "https://api.anthropic.com"); + } + + #[test] + fn accepts_http() { + assert!(BaseUrl::new("http://localhost:8080").is_ok()); + } + + #[test] + fn rejects_empty() { + assert_eq!(BaseUrl::new("").unwrap_err(), BaseUrlError::Empty); + } + + #[test] + fn rejects_missing_scheme() { + assert_eq!( + BaseUrl::new("api.anthropic.com").unwrap_err(), + BaseUrlError::MissingScheme + ); + } +} diff --git a/rsworkspace/crates/acp-agent-llm/src/config.rs b/rsworkspace/crates/acp-agent-llm/src/config.rs new file mode 100644 index 000000000..facfb1edb --- /dev/null +++ b/rsworkspace/crates/acp-agent-llm/src/config.rs @@ -0,0 +1,188 @@ +use crate::api_key::ApiKey; +use crate::model_id::ModelId; +use crate::provider_name::ProviderName; +use acp_nats::{AcpPrefix, Config as AcpConfig, NatsConfig}; +use clap::Parser; +use trogon_std::ParseArgs; +use trogon_std::env::ReadEnv; + +#[derive(Parser, Debug, Clone)] +#[command(name = "acp-agent-llm")] +#[command(about = "Model-agnostic LLM agent for ACP over NATS", long_about = None)] +pub struct Args { + #[arg(long = "acp-prefix")] + pub acp_prefix: Option, + + #[arg(long = "provider")] + pub provider: Option, + + #[arg(long = "model")] + pub model: Option, + + #[arg(long = "base-url")] + pub base_url: Option, +} + +pub struct LlmConfig { + pub acp: AcpConfig, + pub default_provider: ProviderName, + pub default_model: ModelId, + pub anthropic_api_key: Option, + pub openai_api_key: Option, + pub base_url: Option, +} + +#[derive(Debug)] +pub enum ConfigError { + AcpPrefix(acp_nats::AcpPrefixError), + Provider(crate::provider_name::UnknownProvider), + Model(crate::model_id::EmptyModelId), +} + +impl std::fmt::Display for ConfigError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::AcpPrefix(e) => write!(f, "invalid ACP prefix: {e}"), + Self::Provider(e) => write!(f, "invalid provider: {e}"), + Self::Model(e) => write!(f, "invalid model: {e}"), + } + } +} + +impl std::error::Error for ConfigError {} + +impl From for ConfigError { + fn from(e: acp_nats::AcpPrefixError) -> Self { + Self::AcpPrefix(e) + } +} + +impl From for ConfigError { + fn from(e: crate::provider_name::UnknownProvider) -> Self { + Self::Provider(e) + } +} + +impl From for ConfigError { + fn from(e: crate::model_id::EmptyModelId) -> Self { + Self::Model(e) + } +} + +pub fn build_config, E: ReadEnv>( + parser: &P, + env: &E, +) -> Result { + let args = parser.parse_args(); + build_config_from_args(args, env) +} + +fn build_config_from_args(args: Args, env: &E) -> Result { + let raw_prefix = args + .acp_prefix + .or_else(|| env.var(acp_nats::ENV_ACP_PREFIX).ok()) + .unwrap_or_else(|| acp_nats::DEFAULT_ACP_PREFIX.to_string()); + let prefix = AcpPrefix::new(raw_prefix)?; + + let provider_str = args + .provider + .or_else(|| env.var("LLM_PROVIDER").ok()) + .unwrap_or_else(|| "anthropic".to_string()); + let provider = ProviderName::new(provider_str)?; + + let default_model = match args.model.or_else(|| env.var("LLM_MODEL").ok()) { + Some(m) => ModelId::new(m)?, + None => match provider.as_str() { + "anthropic" => ModelId::new("claude-sonnet-4-6").expect("known model"), + "openai" => ModelId::new("gpt-4o").expect("known model"), + _ => unreachable!(), + }, + }; + + let anthropic_key = env + .var("ANTHROPIC_API_KEY") + .ok() + .and_then(|k| ApiKey::new(k).ok()); + let openai_key = env + .var("OPENAI_API_KEY") + .ok() + .and_then(|k| ApiKey::new(k).ok()); + let base_url = args.base_url.or_else(|| env.var("LLM_BASE_URL").ok()); + + let acp = AcpConfig::with_prefix(prefix, NatsConfig::from_env(env)); + + Ok(LlmConfig { + acp, + default_provider: provider, + default_model, + anthropic_api_key: anthropic_key, + openai_api_key: openai_key, + base_url, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use trogon_std::FixedArgs; + use trogon_std::env::InMemoryEnv; + + #[test] + fn default_config() { + let env = InMemoryEnv::new(); + let parser = FixedArgs(Args { + acp_prefix: None, + provider: None, + model: None, + base_url: None, + }); + let config = build_config(&parser, &env).unwrap(); + assert_eq!(config.default_provider.as_str(), "anthropic"); + assert_eq!(config.default_model.as_str(), "claude-sonnet-4-6"); + assert!(config.anthropic_api_key.is_none()); + } + + #[test] + fn provider_from_env() { + let env = InMemoryEnv::new(); + env.set("LLM_PROVIDER", "openai"); + let parser = FixedArgs(Args { + acp_prefix: None, + provider: None, + model: None, + base_url: None, + }); + let config = build_config(&parser, &env).unwrap(); + assert_eq!(config.default_provider.as_str(), "openai"); + assert_eq!(config.default_model.as_str(), "gpt-4o"); + } + + #[test] + fn api_key_from_env() { + let env = InMemoryEnv::new(); + env.set("ANTHROPIC_API_KEY", "sk-test"); + let parser = FixedArgs(Args { + acp_prefix: None, + provider: None, + model: None, + base_url: None, + }); + let config = build_config(&parser, &env).unwrap(); + assert!(config.anthropic_api_key.is_some()); + } + + #[test] + fn args_override_env() { + let env = InMemoryEnv::new(); + env.set("LLM_PROVIDER", "openai"); + let parser = FixedArgs(Args { + acp_prefix: None, + provider: Some("anthropic".to_string()), + model: Some("claude-opus-4-6".to_string()), + base_url: None, + }); + let config = build_config(&parser, &env).unwrap(); + assert_eq!(config.default_provider.as_str(), "anthropic"); + assert_eq!(config.default_model.as_str(), "claude-opus-4-6"); + } +} diff --git a/rsworkspace/crates/acp-agent-llm/src/error.rs b/rsworkspace/crates/acp-agent-llm/src/error.rs new file mode 100644 index 000000000..97233c1e3 --- /dev/null +++ b/rsworkspace/crates/acp-agent-llm/src/error.rs @@ -0,0 +1,62 @@ +use crate::model_id::ModelId; +use crate::provider_name::ProviderName; + +/// Errors from the LLM agent that map to ACP error responses. +#[derive(Debug)] +pub enum LlmAgentError { + SessionNotFound(String), + ProviderNotConfigured(ProviderName), + ModelNotFound(ModelId), + CompletionError(CompletionError), + NoTextContent, +} + +impl std::fmt::Display for LlmAgentError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::SessionNotFound(id) => write!(f, "session not found: {id}"), + Self::ProviderNotConfigured(name) => write!(f, "provider not configured: {name}"), + Self::ModelNotFound(id) => write!(f, "model not found: {id}"), + Self::CompletionError(e) => write!(f, "completion error: {e}"), + Self::NoTextContent => f.write_str("prompt contains no text content"), + } + } +} + +impl std::error::Error for LlmAgentError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::CompletionError(e) => Some(e), + _ => None, + } + } +} + +/// Errors from an LLM provider HTTP call. +#[derive(Debug)] +pub enum CompletionError { + Http(reqwest::Error), + Api { status: u16, message: String }, + StreamParse(String), + Cancelled, +} + +impl std::fmt::Display for CompletionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Http(e) => write!(f, "HTTP error: {e}"), + Self::Api { status, message } => write!(f, "API error ({status}): {message}"), + Self::StreamParse(msg) => write!(f, "stream parse error: {msg}"), + Self::Cancelled => f.write_str("request cancelled"), + } + } +} + +impl std::error::Error for CompletionError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Http(e) => Some(e), + _ => None, + } + } +} diff --git a/rsworkspace/crates/acp-agent-llm/src/main.rs b/rsworkspace/crates/acp-agent-llm/src/main.rs new file mode 100644 index 000000000..3ebe1b767 --- /dev/null +++ b/rsworkspace/crates/acp-agent-llm/src/main.rs @@ -0,0 +1,100 @@ +// TODO: remove once LlmAgent is wired up +#![allow(dead_code)] + +mod api_key; +mod base_url; +mod config; +mod error; +mod model; +mod model_id; +mod provider; +mod provider_name; +mod session; +mod session_store; +mod telemetry; + +use provider::LanguageModelProvider; + +#[cfg(not(coverage))] +use { + acp_nats::nats, + acp_telemetry::ServiceName, + tracing::{error, info}, + trogon_std::env::SystemEnv, + trogon_std::fs::SystemFs, +}; + +#[cfg(not(coverage))] +#[tokio::main] +async fn main() -> Result<(), Box> { + let config = config::build_config(&trogon_std::CliArgs::::new(), &SystemEnv)?; + + acp_telemetry::init_logger( + ServiceName::AcpAgentLlm, + config.acp.acp_prefix(), + &SystemEnv, + &SystemFs, + ); + + info!("acp-agent-llm starting"); + + let nats_connect_timeout = acp_nats::nats_connect_timeout(&SystemEnv); + let _nats_client = nats::connect(config.acp.nats(), nats_connect_timeout).await?; + + // Build providers — only those with API keys. + // Each provider fetches its available models from the API at startup. + let mut providers: std::collections::HashMap< + provider_name::ProviderName, + Box, + > = std::collections::HashMap::new(); + + if let Some(key) = config.anthropic_api_key { + let mut p = provider::anthropic::AnthropicProvider::new(key, config.base_url.clone()); + p.fetch_models().await?; + info!( + models = p.provided_models().len(), + "Anthropic models loaded" + ); + providers.insert( + provider_name::ProviderName::new("anthropic").expect("known"), + Box::new(p), + ); + } + if let Some(key) = config.openai_api_key { + let mut p = provider::openai::OpenAiProvider::new(key, config.base_url.clone()); + p.fetch_models().await?; + info!(models = p.provided_models().len(), "OpenAI models loaded"); + providers.insert( + provider_name::ProviderName::new("openai").expect("known"), + Box::new(p), + ); + } + + if providers.is_empty() { + error!("No API keys configured. Set ANTHROPIC_API_KEY or OPENAI_API_KEY."); + return Err("no providers configured".into()); + } + + info!( + provider = config.default_provider.as_str(), + model = config.default_model.as_str(), + "Default model configured" + ); + + // TODO: build LlmAgent and run on LocalSet with AgentSideNatsConnection + // This will be wired up once the LlmAgent Agent trait impl is complete. + info!("acp-agent-llm ready (agent loop not yet implemented)"); + + // Wait for shutdown + acp_telemetry::signal::shutdown_signal().await; + info!("acp-agent-llm stopped"); + + if let Err(e) = acp_telemetry::shutdown_otel() { + error!(error = %e, "OpenTelemetry shutdown failed"); + } + + Ok(()) +} + +#[cfg(coverage)] +fn main() {} diff --git a/rsworkspace/crates/acp-agent-llm/src/model/anthropic.rs b/rsworkspace/crates/acp-agent-llm/src/model/anthropic.rs new file mode 100644 index 000000000..ef351bb57 --- /dev/null +++ b/rsworkspace/crates/acp-agent-llm/src/model/anthropic.rs @@ -0,0 +1,203 @@ +use super::{ChatMessage, ChatRole, CompletionEvent, LanguageModel, StopReason}; +use crate::api_key::ApiKey; +use crate::error::CompletionError; +use crate::model_id::ModelId; +use crate::provider_name::ProviderName; +use futures::StreamExt; +use reqwest::Client; +use tokio::sync::{mpsc, watch}; + +pub struct AnthropicModelConfig { + pub id: String, + pub max_tokens: u64, + pub api_key: ApiKey, + pub base_url: String, + pub client: Client, + pub supports_tools: bool, + pub supports_images: bool, + pub supports_thinking: bool, +} + +pub struct AnthropicModel { + id: ModelId, + provider: ProviderName, + max_tokens: u64, + client: Client, + api_key: ApiKey, + base_url: String, + supports_tools: bool, + supports_images: bool, + supports_thinking: bool, +} + +impl AnthropicModel { + pub fn new(config: AnthropicModelConfig) -> Self { + Self { + id: ModelId::new(config.id).expect("model id from API should be non-empty"), + provider: ProviderName::new("anthropic").expect("known provider"), + max_tokens: config.max_tokens, + client: config.client, + api_key: config.api_key, + base_url: config.base_url, + supports_tools: config.supports_tools, + supports_images: config.supports_images, + supports_thinking: config.supports_thinking, + } + } +} + +#[async_trait::async_trait(?Send)] +impl LanguageModel for AnthropicModel { + fn id(&self) -> &ModelId { + &self.id + } + fn provider_id(&self) -> &ProviderName { + &self.provider + } + fn max_token_count(&self) -> u64 { + self.max_tokens + } + fn supports_tools(&self) -> bool { + self.supports_tools + } + fn supports_images(&self) -> bool { + self.supports_images + } + fn supports_thinking(&self) -> bool { + self.supports_thinking + } + + async fn stream_completion( + &self, + messages: &[ChatMessage], + event_tx: mpsc::Sender, + mut cancel: watch::Receiver, + ) -> Result<(), CompletionError> { + let api_messages: Vec = messages + .iter() + .filter(|m| m.role != ChatRole::System) + .map(|m| { + serde_json::json!({ + "role": match m.role { + ChatRole::User => "user", + ChatRole::Assistant => "assistant", + ChatRole::System => unreachable!(), + }, + "content": m.content, + }) + }) + .collect(); + + let system = messages + .iter() + .find(|m| m.role == ChatRole::System) + .map(|m| m.content.as_str()); + + let mut body = serde_json::json!({ + "model": self.id.as_str(), + "messages": api_messages, + "max_tokens": 4096, + "stream": true, + }); + if let Some(sys) = system { + body["system"] = serde_json::json!(sys); + } + + let response = self + .client + .post(format!("{}/v1/messages", self.base_url)) + .header("x-api-key", self.api_key.as_str()) + .header("anthropic-version", "2023-06-01") + .header("content-type", "application/json") + .json(&body) + .send() + .await + .map_err(CompletionError::Http)?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let text = response.text().await.unwrap_or_default(); + return Err(CompletionError::Api { + status, + message: text, + }); + } + + let mut stream = response.bytes_stream(); + let mut buffer = String::new(); + + loop { + tokio::select! { + chunk = stream.next() => { + match chunk { + Some(Ok(bytes)) => { + buffer.push_str(&String::from_utf8_lossy(&bytes)); + self.parse_sse_buffer(&mut buffer, &event_tx).await; + } + Some(Err(e)) => return Err(CompletionError::Http(e)), + None => break, + } + } + _ = cancel.changed() => { + if *cancel.borrow() { + return Err(CompletionError::Cancelled); + } + } + } + } + + Ok(()) + } +} + +impl AnthropicModel { + async fn parse_sse_buffer( + &self, + buffer: &mut String, + event_tx: &mpsc::Sender, + ) { + while let Some(line_end) = buffer.find('\n') { + let line = buffer[..line_end].trim_end().to_string(); + *buffer = buffer[line_end + 1..].to_string(); + + let Some(data) = line.strip_prefix("data: ") else { + continue; + }; + if data == "[DONE]" { + break; + } + + let Ok(event) = serde_json::from_str::(data) else { + continue; + }; + + if event["type"] == "content_block_delta" + && let Some(text) = event["delta"]["text"].as_str() + { + let _ = event_tx.send(CompletionEvent::Text(text.to_string())).await; + } + + if event["type"] == "message_delta" { + if let Some(reason) = event["delta"]["stop_reason"].as_str() { + let stop = match reason { + "end_turn" => StopReason::EndTurn, + "max_tokens" => StopReason::MaxTokens, + other => StopReason::Other(other.to_string()), + }; + let _ = event_tx.send(CompletionEvent::Stop(stop)).await; + } + if let (Some(input), Some(output)) = ( + event["usage"]["input_tokens"].as_u64(), + event["usage"]["output_tokens"].as_u64(), + ) { + let _ = event_tx + .send(CompletionEvent::Usage { + input_tokens: input, + output_tokens: output, + }) + .await; + } + } + } + } +} diff --git a/rsworkspace/crates/acp-agent-llm/src/model/mod.rs b/rsworkspace/crates/acp-agent-llm/src/model/mod.rs new file mode 100644 index 000000000..aece0b73c --- /dev/null +++ b/rsworkspace/crates/acp-agent-llm/src/model/mod.rs @@ -0,0 +1,82 @@ +pub mod anthropic; +pub mod openai; + +use crate::error::CompletionError; +use crate::model_id::ModelId; +use crate::provider_name::ProviderName; +use tokio::sync::{mpsc, watch}; + +/// A single message in a conversation. +#[derive(Debug, Clone)] +pub struct ChatMessage { + pub role: ChatRole, + pub content: String, +} + +/// Who said it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ChatRole { + /// Instructions to the model (e.g., "you are a helpful assistant"). + System, + /// What the human typed. + User, + /// What the LLM replied. + Assistant, +} + +/// Events streamed during a completion. +#[derive(Debug, Clone)] +pub enum CompletionEvent { + /// A chunk of streamed text. + Text(String), + /// The model finished generating. + Stop(StopReason), + /// Token usage update (if the provider reports it). + Usage { + input_tokens: u64, + output_tokens: u64, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum StopReason { + EndTurn, + MaxTokens, + Other(String), +} + +/// A specific model that can stream completions. +/// One instance per model (claude-opus-4-6, gpt-4o, etc.). +/// +/// `?Send` because the ACP Agent trait runs on a single-threaded `LocalSet`. +#[async_trait::async_trait(?Send)] +pub trait LanguageModel { + fn id(&self) -> &ModelId; + fn provider_id(&self) -> &ProviderName; + fn max_token_count(&self) -> u64; + + /// Whether this model supports tool use. Ready for future use. + fn supports_tools(&self) -> bool { + false + } + /// Whether this model supports image input. Ready for future use. + fn supports_images(&self) -> bool { + false + } + /// Whether this model supports extended thinking. Ready for future use. + fn supports_thinking(&self) -> bool { + false + } + + /// Stream a completion. Sends `CompletionEvent`s via `event_tx` as they arrive + /// from the provider's SSE stream. + /// + /// Monitors `cancel` — when it becomes `true`, aborts the HTTP request + /// and returns `CompletionError::Cancelled`. + async fn stream_completion( + &self, + messages: &[ChatMessage], + event_tx: mpsc::Sender, + cancel: watch::Receiver, + ) -> Result<(), CompletionError>; +} diff --git a/rsworkspace/crates/acp-agent-llm/src/model/openai.rs b/rsworkspace/crates/acp-agent-llm/src/model/openai.rs new file mode 100644 index 000000000..acdf16f4a --- /dev/null +++ b/rsworkspace/crates/acp-agent-llm/src/model/openai.rs @@ -0,0 +1,176 @@ +use super::{ChatMessage, ChatRole, CompletionEvent, LanguageModel, StopReason}; +use crate::api_key::ApiKey; +use crate::error::CompletionError; +use crate::model_id::ModelId; +use crate::provider_name::ProviderName; +use futures::StreamExt; +use reqwest::Client; +use tokio::sync::{mpsc, watch}; + +pub struct OpenAiModel { + id: ModelId, + provider: ProviderName, + max_tokens: u64, + client: Client, + api_key: ApiKey, + base_url: String, +} + +impl OpenAiModel { + pub fn new( + id: &str, + max_tokens: u64, + api_key: ApiKey, + base_url: String, + client: Client, + ) -> Self { + Self { + id: ModelId::new(id).expect("model id from API should be non-empty"), + provider: ProviderName::new("openai").expect("known provider"), + max_tokens, + client, + api_key, + base_url, + } + } +} + +#[async_trait::async_trait(?Send)] +impl LanguageModel for OpenAiModel { + fn id(&self) -> &ModelId { + &self.id + } + fn provider_id(&self) -> &ProviderName { + &self.provider + } + fn max_token_count(&self) -> u64 { + self.max_tokens + } + + async fn stream_completion( + &self, + messages: &[ChatMessage], + event_tx: mpsc::Sender, + mut cancel: watch::Receiver, + ) -> Result<(), CompletionError> { + let api_messages: Vec = messages + .iter() + .map(|m| { + serde_json::json!({ + "role": match m.role { + ChatRole::System => "system", + ChatRole::User => "user", + ChatRole::Assistant => "assistant", + }, + "content": m.content, + }) + }) + .collect(); + + let body = serde_json::json!({ + "model": self.id.as_str(), + "messages": api_messages, + "stream": true, + }); + + let response = self + .client + .post(format!("{}/v1/chat/completions", self.base_url)) + .header("authorization", format!("Bearer {}", self.api_key.as_str())) + .header("content-type", "application/json") + .json(&body) + .send() + .await + .map_err(CompletionError::Http)?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let text = response.text().await.unwrap_or_default(); + return Err(CompletionError::Api { + status, + message: text, + }); + } + + let mut stream = response.bytes_stream(); + let mut buffer = String::new(); + + loop { + tokio::select! { + chunk = stream.next() => { + match chunk { + Some(Ok(bytes)) => { + buffer.push_str(&String::from_utf8_lossy(&bytes)); + self.parse_sse_buffer(&mut buffer, &event_tx).await; + } + Some(Err(e)) => return Err(CompletionError::Http(e)), + None => break, + } + } + _ = cancel.changed() => { + if *cancel.borrow() { + return Err(CompletionError::Cancelled); + } + } + } + } + + Ok(()) + } +} + +impl OpenAiModel { + async fn parse_sse_buffer( + &self, + buffer: &mut String, + event_tx: &mpsc::Sender, + ) { + while let Some(line_end) = buffer.find('\n') { + let line = buffer[..line_end].trim_end().to_string(); + *buffer = buffer[line_end + 1..].to_string(); + + let Some(data) = line.strip_prefix("data: ") else { + continue; + }; + if data == "[DONE]" { + break; + } + + let Ok(event) = serde_json::from_str::(data) else { + continue; + }; + + // OpenAI streams: choices[0].delta.content for text + if let Some(content) = event["choices"][0]["delta"]["content"].as_str() + && !content.is_empty() + { + let _ = event_tx + .send(CompletionEvent::Text(content.to_string())) + .await; + } + + // choices[0].finish_reason for stop + if let Some(reason) = event["choices"][0]["finish_reason"].as_str() { + let stop = match reason { + "stop" => StopReason::EndTurn, + "length" => StopReason::MaxTokens, + other => StopReason::Other(other.to_string()), + }; + let _ = event_tx.send(CompletionEvent::Stop(stop)).await; + } + + // usage object (sent in final chunk if stream_options.include_usage is true) + if let (Some(input), Some(output)) = ( + event["usage"]["prompt_tokens"].as_u64(), + event["usage"]["completion_tokens"].as_u64(), + ) { + let _ = event_tx + .send(CompletionEvent::Usage { + input_tokens: input, + output_tokens: output, + }) + .await; + } + } + } +} diff --git a/rsworkspace/crates/acp-agent-llm/src/model_id.rs b/rsworkspace/crates/acp-agent-llm/src/model_id.rs new file mode 100644 index 000000000..f51603738 --- /dev/null +++ b/rsworkspace/crates/acp-agent-llm/src/model_id.rs @@ -0,0 +1,55 @@ +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ModelId(String); + +#[derive(Debug, Clone, PartialEq)] +pub struct EmptyModelId; + +impl std::fmt::Display for EmptyModelId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("model id cannot be empty") + } +} + +impl std::error::Error for EmptyModelId {} + +impl ModelId { + pub fn new(s: impl Into) -> Result { + let s = s.into(); + if s.is_empty() { + return Err(EmptyModelId); + } + Ok(Self(s)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for ModelId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_non_empty() { + let id = ModelId::new("claude-opus-4-6").unwrap(); + assert_eq!(id.as_str(), "claude-opus-4-6"); + } + + #[test] + fn rejects_empty() { + assert_eq!(ModelId::new("").unwrap_err(), EmptyModelId); + } + + #[test] + fn display_returns_value() { + let id = ModelId::new("gpt-4o").unwrap(); + assert_eq!(format!("{id}"), "gpt-4o"); + } +} diff --git a/rsworkspace/crates/acp-agent-llm/src/provider/anthropic.rs b/rsworkspace/crates/acp-agent-llm/src/provider/anthropic.rs new file mode 100644 index 000000000..2fa023703 --- /dev/null +++ b/rsworkspace/crates/acp-agent-llm/src/provider/anthropic.rs @@ -0,0 +1,102 @@ +use super::LanguageModelProvider; +use crate::api_key::ApiKey; +use crate::error::CompletionError; +use crate::model::LanguageModel; +use crate::model::anthropic::{AnthropicModel, AnthropicModelConfig}; +use crate::model_id::ModelId; +use crate::provider_name::ProviderName; +use reqwest::Client; + +/// Manages Anthropic authentication and available models. +/// Models are fetched from `GET /v1/models` at startup — not hardcoded. +pub struct AnthropicProvider { + name: ProviderName, + api_key: ApiKey, + base_url: String, + client: Client, + models: Vec, +} + +impl AnthropicProvider { + pub fn new(api_key: ApiKey, base_url: Option) -> Self { + let base = base_url.unwrap_or_else(|| "https://api.anthropic.com".to_string()); + Self { + name: ProviderName::new("anthropic").expect("known provider"), + api_key, + base_url: base, + client: Client::new(), + models: Vec::new(), + } + } +} + +#[async_trait::async_trait(?Send)] +impl LanguageModelProvider for AnthropicProvider { + fn id(&self) -> &ProviderName { + &self.name + } + + fn is_authenticated(&self) -> bool { + true + } + + fn default_model(&self) -> ModelId { + ModelId::new("claude-sonnet-4-6").expect("known model id") + } + + fn provided_models(&self) -> Vec { + self.models.iter().map(|m| m.id().clone()).collect() + } + + fn model(&self, id: &ModelId) -> Option<&dyn LanguageModel> { + self.models + .iter() + .find(|m| m.id() == id) + .map(|m| m as &dyn LanguageModel) + } + + async fn fetch_models(&mut self) -> Result<(), CompletionError> { + let response = self + .client + .get(format!("{}/v1/models", self.base_url)) + .header("x-api-key", self.api_key.as_str()) + .header("anthropic-version", "2023-06-01") + .send() + .await + .map_err(CompletionError::Http)?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let text = response.text().await.unwrap_or_default(); + return Err(CompletionError::Api { + status, + message: text, + }); + } + + let body: serde_json::Value = response.json().await.map_err(CompletionError::Http)?; + + self.models = body["data"] + .as_array() + .unwrap_or(&vec![]) + .iter() + .filter_map(|m| { + let id = m["id"].as_str()?; + let max_input = m["max_input_tokens"].as_u64().unwrap_or(200_000); + let capabilities = &m["capabilities"]; + Some(AnthropicModel::new(AnthropicModelConfig { + id: id.to_string(), + max_tokens: max_input, + api_key: self.api_key.clone(), + base_url: self.base_url.clone(), + client: self.client.clone(), + supports_tools: capabilities["tool_use"].as_bool().unwrap_or(false), + supports_images: capabilities["image_input"].as_bool().unwrap_or(false), + supports_thinking: capabilities["thinking"].as_bool().unwrap_or(false), + })) + }) + .collect(); + + Ok(()) + } +} diff --git a/rsworkspace/crates/acp-agent-llm/src/provider/mod.rs b/rsworkspace/crates/acp-agent-llm/src/provider/mod.rs new file mode 100644 index 000000000..242aaeb48 --- /dev/null +++ b/rsworkspace/crates/acp-agent-llm/src/provider/mod.rs @@ -0,0 +1,35 @@ +pub mod anthropic; +pub mod openai; + +use crate::error::CompletionError; +use crate::model::LanguageModel; +use crate::model_id::ModelId; +use crate::provider_name::ProviderName; + +/// Manages authentication and lists available models for a provider. +/// One instance per provider (Anthropic, OpenAI, etc.). +/// +/// Models are fetched from the provider's API at startup via `fetch_models()`, +/// not hardcoded. +#[async_trait::async_trait(?Send)] +pub trait LanguageModelProvider { + /// Provider identity (e.g., "anthropic"). + fn id(&self) -> &ProviderName; + + /// Whether the provider has a valid API key configured. + fn is_authenticated(&self) -> bool; + + /// The provider's recommended default model. + fn default_model(&self) -> ModelId; + + /// All models this provider offers. + fn provided_models(&self) -> Vec; + + /// Look up a specific model by ID. + fn model(&self, id: &ModelId) -> Option<&dyn LanguageModel>; + + /// Fetch available models from the provider's API and populate the internal registry. + /// Called once at startup. Both Anthropic (`GET /v1/models`) and OpenAI (`GET /v1/models`) + /// support this. + async fn fetch_models(&mut self) -> Result<(), CompletionError>; +} diff --git a/rsworkspace/crates/acp-agent-llm/src/provider/openai.rs b/rsworkspace/crates/acp-agent-llm/src/provider/openai.rs new file mode 100644 index 000000000..91374de08 --- /dev/null +++ b/rsworkspace/crates/acp-agent-llm/src/provider/openai.rs @@ -0,0 +1,100 @@ +use super::LanguageModelProvider; +use crate::api_key::ApiKey; +use crate::error::CompletionError; +use crate::model::LanguageModel; +use crate::model::openai::OpenAiModel; +use crate::model_id::ModelId; +use crate::provider_name::ProviderName; +use reqwest::Client; + +/// Manages OpenAI authentication and available models. +/// Models are fetched from `GET /v1/models` at startup. +pub struct OpenAiProvider { + name: ProviderName, + api_key: ApiKey, + base_url: String, + client: Client, + models: Vec, +} + +impl OpenAiProvider { + pub fn new(api_key: ApiKey, base_url: Option) -> Self { + let base = base_url.unwrap_or_else(|| "https://api.openai.com".to_string()); + Self { + name: ProviderName::new("openai").expect("known provider"), + api_key, + base_url: base, + client: Client::new(), + models: Vec::new(), + } + } +} + +#[async_trait::async_trait(?Send)] +impl LanguageModelProvider for OpenAiProvider { + fn id(&self) -> &ProviderName { + &self.name + } + + fn is_authenticated(&self) -> bool { + true + } + + fn default_model(&self) -> ModelId { + ModelId::new("gpt-4o").expect("known model id") + } + + fn provided_models(&self) -> Vec { + self.models.iter().map(|m| m.id().clone()).collect() + } + + fn model(&self, id: &ModelId) -> Option<&dyn LanguageModel> { + self.models + .iter() + .find(|m| m.id() == id) + .map(|m| m as &dyn LanguageModel) + } + + async fn fetch_models(&mut self) -> Result<(), CompletionError> { + let response = self + .client + .get(format!("{}/v1/models", self.base_url)) + .header("authorization", format!("Bearer {}", self.api_key.as_str())) + .send() + .await + .map_err(CompletionError::Http)?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let text = response.text().await.unwrap_or_default(); + return Err(CompletionError::Api { + status, + message: text, + }); + } + + let body: serde_json::Value = response.json().await.map_err(CompletionError::Http)?; + + // OpenAI's /v1/models returns a list of model objects. + // Token limits are not included in the response — use sensible defaults. + self.models = body["data"] + .as_array() + .unwrap_or(&vec![]) + .iter() + .filter_map(|m| { + let id = m["id"].as_str()?; + // Default to 128k context — OpenAI doesn't expose this in the list endpoint + let max_tokens = 128_000u64; + Some(OpenAiModel::new( + id, + max_tokens, + self.api_key.clone(), + self.base_url.clone(), + self.client.clone(), + )) + }) + .collect(); + + Ok(()) + } +} diff --git a/rsworkspace/crates/acp-agent-llm/src/provider_name.rs b/rsworkspace/crates/acp-agent-llm/src/provider_name.rs new file mode 100644 index 000000000..a1df6c192 --- /dev/null +++ b/rsworkspace/crates/acp-agent-llm/src/provider_name.rs @@ -0,0 +1,56 @@ +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ProviderName(String); + +#[derive(Debug, Clone, PartialEq)] +pub struct UnknownProvider(pub String); + +impl std::fmt::Display for UnknownProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "unknown provider: {}", self.0) + } +} + +impl std::error::Error for UnknownProvider {} + +impl ProviderName { + pub fn new(s: impl Into) -> Result { + let s = s.into(); + match s.as_str() { + "anthropic" | "openai" => Ok(Self(s)), + _ => Err(UnknownProvider(s)), + } + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for ProviderName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_known_providers() { + assert!(ProviderName::new("anthropic").is_ok()); + assert!(ProviderName::new("openai").is_ok()); + } + + #[test] + fn rejects_unknown_provider() { + let err = ProviderName::new("unknown").unwrap_err(); + assert_eq!(err, UnknownProvider("unknown".to_string())); + } + + #[test] + fn as_str_returns_value() { + let name = ProviderName::new("anthropic").unwrap(); + assert_eq!(name.as_str(), "anthropic"); + } +} diff --git a/rsworkspace/crates/acp-agent-llm/src/session.rs b/rsworkspace/crates/acp-agent-llm/src/session.rs new file mode 100644 index 000000000..cf05ef4aa --- /dev/null +++ b/rsworkspace/crates/acp-agent-llm/src/session.rs @@ -0,0 +1,121 @@ +use crate::model::ChatMessage; +use crate::model_id::ModelId; +use crate::provider_name::ProviderName; +use tokio::sync::watch; + +pub struct Session { + provider_name: ProviderName, + model_id: ModelId, + messages: Vec, + cancel_tx: watch::Sender, + cancel_rx: watch::Receiver, +} + +impl Session { + pub fn new(provider_name: ProviderName, model_id: ModelId) -> Self { + let (cancel_tx, cancel_rx) = watch::channel(false); + Self { + provider_name, + model_id, + messages: Vec::new(), + cancel_tx, + cancel_rx, + } + } + + pub fn provider_name(&self) -> &ProviderName { + &self.provider_name + } + pub fn model_id(&self) -> &ModelId { + &self.model_id + } + pub fn messages(&self) -> &[ChatMessage] { + &self.messages + } + pub fn push_message(&mut self, msg: ChatMessage) { + self.messages.push(msg); + } + + pub fn set_model(&mut self, model: ModelId) { + self.model_id = model; + } + pub fn set_provider(&mut self, provider: ProviderName) { + self.provider_name = provider; + } + + /// Get a cancel receiver for the current prompt. + pub fn cancel_rx(&self) -> watch::Receiver { + self.cancel_rx.clone() + } + + /// Signal cancellation of the active prompt. + pub fn cancel(&self) { + let _ = self.cancel_tx.send(true); + } + + /// Reset the cancel signal for the next prompt. + pub fn reset_cancel(&self) { + let _ = self.cancel_tx.send(false); + } + + /// Clone history into a new session (for fork). + pub fn fork(&self, provider: ProviderName, model: ModelId) -> Self { + let (cancel_tx, cancel_rx) = watch::channel(false); + Self { + provider_name: provider, + model_id: model, + messages: self.messages.clone(), + cancel_tx, + cancel_rx, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::ChatRole; + + #[test] + fn new_session_starts_empty() { + let session = Session::new( + ProviderName::new("anthropic").unwrap(), + ModelId::new("claude-sonnet-4-6").unwrap(), + ); + assert!(session.messages().is_empty()); + assert_eq!(session.provider_name().as_str(), "anthropic"); + assert_eq!(session.model_id().as_str(), "claude-sonnet-4-6"); + } + + #[test] + fn push_message_appends() { + let mut session = Session::new( + ProviderName::new("anthropic").unwrap(), + ModelId::new("test").unwrap(), + ); + session.push_message(ChatMessage { + role: ChatRole::User, + content: "hello".to_string(), + }); + assert_eq!(session.messages().len(), 1); + } + + #[test] + fn fork_clones_history() { + let mut session = Session::new( + ProviderName::new("anthropic").unwrap(), + ModelId::new("test").unwrap(), + ); + session.push_message(ChatMessage { + role: ChatRole::User, + content: "hello".to_string(), + }); + let forked = session.fork( + ProviderName::new("openai").unwrap(), + ModelId::new("gpt-4o").unwrap(), + ); + assert_eq!(forked.messages().len(), 1); + assert_eq!(forked.provider_name().as_str(), "openai"); + assert_eq!(forked.model_id().as_str(), "gpt-4o"); + } +} diff --git a/rsworkspace/crates/acp-agent-llm/src/session_store.rs b/rsworkspace/crates/acp-agent-llm/src/session_store.rs new file mode 100644 index 000000000..8664789dc --- /dev/null +++ b/rsworkspace/crates/acp-agent-llm/src/session_store.rs @@ -0,0 +1,83 @@ +use crate::session::Session; +use std::cell::RefCell; +use std::collections::HashMap; + +/// In-memory storage for active sessions. +/// Uses `RefCell` (not `Arc`) because the agent runs on a +/// single-threaded `LocalSet` — matching the `?Send` ACP Agent trait. +pub struct SessionStore { + sessions: RefCell>, +} + +impl SessionStore { + pub fn new() -> Self { + Self { + sessions: RefCell::new(HashMap::new()), + } + } + + pub fn insert(&self, id: String, session: Session) { + self.sessions.borrow_mut().insert(id, session); + } + + pub fn remove(&self, id: &str) -> Option { + self.sessions.borrow_mut().remove(id) + } + + /// Access a session mutably by ID. Returns `None` if not found. + pub fn with(&self, id: &str, f: F) -> Option + where + F: FnOnce(&mut Session) -> R, + { + self.sessions.borrow_mut().get_mut(id).map(f) + } + + pub fn list_ids(&self) -> Vec { + self.sessions.borrow().keys().cloned().collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model_id::ModelId; + use crate::provider_name::ProviderName; + + fn test_session() -> Session { + Session::new( + ProviderName::new("anthropic").unwrap(), + ModelId::new("test").unwrap(), + ) + } + + #[test] + fn insert_and_list() { + let store = SessionStore::new(); + store.insert("s1".to_string(), test_session()); + store.insert("s2".to_string(), test_session()); + assert_eq!(store.list_ids().len(), 2); + } + + #[test] + fn remove_returns_session() { + let store = SessionStore::new(); + store.insert("s1".to_string(), test_session()); + assert!(store.remove("s1").is_some()); + assert!(store.remove("s1").is_none()); + } + + #[test] + fn with_accesses_session() { + let store = SessionStore::new(); + store.insert("s1".to_string(), test_session()); + let provider = store.with("s1", |s| s.provider_name().as_str().to_string()); + assert_eq!(provider, Some("anthropic".to_string())); + } + + #[test] + fn with_returns_none_for_missing() { + let store = SessionStore::new(); + let result: Option<()> = store.with("missing", |_| ()); + assert!(result.is_none()); + } +} diff --git a/rsworkspace/crates/acp-agent-llm/src/telemetry/metrics.rs b/rsworkspace/crates/acp-agent-llm/src/telemetry/metrics.rs new file mode 100644 index 000000000..a4d8acade --- /dev/null +++ b/rsworkspace/crates/acp-agent-llm/src/telemetry/metrics.rs @@ -0,0 +1,21 @@ +use opentelemetry::metrics::{Counter, Histogram, Meter}; + +pub struct Metrics { + pub llm_requests: Counter, + pub llm_errors: Counter, + pub llm_duration: Histogram, + pub llm_tokens_in: Counter, + pub llm_tokens_out: Counter, +} + +impl Metrics { + pub fn new(meter: &Meter) -> Self { + Self { + llm_requests: meter.u64_counter("llm.requests").build(), + llm_errors: meter.u64_counter("llm.errors").build(), + llm_duration: meter.f64_histogram("llm.duration").build(), + llm_tokens_in: meter.u64_counter("llm.tokens.input").build(), + llm_tokens_out: meter.u64_counter("llm.tokens.output").build(), + } + } +} diff --git a/rsworkspace/crates/acp-agent-llm/src/telemetry/mod.rs b/rsworkspace/crates/acp-agent-llm/src/telemetry/mod.rs new file mode 100644 index 000000000..e14488328 --- /dev/null +++ b/rsworkspace/crates/acp-agent-llm/src/telemetry/mod.rs @@ -0,0 +1 @@ +pub mod metrics; diff --git a/rsworkspace/crates/acp-telemetry/src/service_name.rs b/rsworkspace/crates/acp-telemetry/src/service_name.rs index 67789bbb0..a06a1622b 100644 --- a/rsworkspace/crates/acp-telemetry/src/service_name.rs +++ b/rsworkspace/crates/acp-telemetry/src/service_name.rs @@ -3,6 +3,7 @@ /// guarantees the values are path-safe. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ServiceName { + AcpAgentLlm, AcpNatsStdio, AcpNatsWs, TrogonGateway, @@ -17,6 +18,7 @@ pub enum ServiceName { impl ServiceName { pub fn as_str(self) -> &'static str { match self { + Self::AcpAgentLlm => "acp-agent-llm", Self::AcpNatsStdio => "acp-nats-stdio", Self::AcpNatsWs => "acp-nats-ws", Self::TrogonGateway => "trogon-gateway", @@ -42,6 +44,7 @@ mod tests { #[test] fn as_str_returns_expected_values() { + assert_eq!(ServiceName::AcpAgentLlm.as_str(), "acp-agent-llm"); assert_eq!(ServiceName::AcpNatsStdio.as_str(), "acp-nats-stdio"); assert_eq!(ServiceName::AcpNatsWs.as_str(), "acp-nats-ws"); assert_eq!(ServiceName::TrogonGateway.as_str(), "trogon-gateway"); @@ -73,6 +76,7 @@ mod tests { #[test] fn display_delegates_to_as_str() { + assert_eq!(format!("{}", ServiceName::AcpAgentLlm), "acp-agent-llm"); assert_eq!(format!("{}", ServiceName::AcpNatsStdio), "acp-nats-stdio"); assert_eq!(format!("{}", ServiceName::AcpNatsWs), "acp-nats-ws"); assert_eq!(format!("{}", ServiceName::TrogonGateway), "trogon-gateway");