diff --git a/Cargo.lock b/Cargo.lock index 0f6742a..734fec6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,436 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "leg" version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "ureq", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" +dependencies = [ + "base64", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 5e0044d..851cb02 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,3 +10,8 @@ license-file = "LICENSE" [[bin]] name = "leg" path = "src/main.rs" + +[dependencies] +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" +ureq = "3.3.0" diff --git a/README.md b/README.md index d45290d..41c3dc5 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,20 @@ `leg` binary skeleton. +## Usage + +``` +ANTHROPIC_API_KEY=sk-... leg ask [--model ] "prompt" +``` + +Prints the assistant reply on success. A provider or delivery failure +(bad credentials, unreachable base URL, etc.) prints a `baton.message/v1` +envelope with `"kind":"error"` instead of exiting non-zero — only a +configuration failure (missing/malformed env vars) exits non-zero. +Also accepts `ANTHROPIC_AUTH_TOKEN`/`CLAUDE_CODE_OAUTH_TOKEN`, +`ANTHROPIC_BASE_URL`, `LEG_MODEL`, `LEG_TIMEOUT_SECS`, `LEG_MAX_TOKENS`, +and `LEG_SYSTEM_PROMPT`. + ## CI-supported targets - x86_64-unknown-linux-gnu diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..c8e733b --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,384 @@ +//! The command-line entry surface. + +use std::io::Write; + +use crate::config::LegConfig; +use crate::error::{LegError, Result}; +use crate::events::ExchangeMeta; +use crate::message::{MessageEnvelope, MessageKind}; +use crate::participant::{LocalParticipant, Participant}; +use crate::transport::claude::ClaudeClient; + +/// The one-line usage summary, shared by `--help` output and usage errors. +const USAGE: &str = "usage: leg [--version|-V] [--help|-h] | leg ask [--model ] "; + +/// A parsed command line. +#[derive(Debug, PartialEq, Eq)] +enum Command { + /// Prints the crate version. + Version, + /// Prints usage help. + Help, + /// Runs one single-turn provider exchange. + Ask { + /// The user prompt text. + prompt: String, + /// `--model` override, replacing `LEG_MODEL`/the configured default. + model: Option, + }, +} + +/// Process entry point: parse arguments and dispatch. +pub fn run() -> Result<()> { + let args: Vec = std::env::args().skip(1).collect(); + match parse_args(&args)? { + None => Ok(()), + Some(Command::Version) => { + println!("leg {}", env!("CARGO_PKG_VERSION")); + Ok(()) + } + Some(Command::Help) => { + println!("{}", help_text()); + Ok(()) + } + Some(Command::Ask { prompt, model }) => { + let stdout = std::io::stdout(); + execute_ask(&prompt, model, stdout.lock()) + } + } +} + +/// The full `--help` body: the usage summary plus the env vars `ask` reads. +fn help_text() -> String { + format!( + "{USAGE}\n\n\ + Reads credentials from ANTHROPIC_API_KEY (or ANTHROPIC_AUTH_TOKEN /\n\ + CLAUDE_CODE_OAUTH_TOKEN). Also honours ANTHROPIC_BASE_URL, LEG_MODEL,\n\ + LEG_TIMEOUT_SECS, LEG_MAX_TOKENS, and LEG_SYSTEM_PROMPT." + ) +} + +/// Parses `args` into a [`Command`]. `None` means "do nothing" (no arguments), +/// matching leg#1's original no-op skeleton behaviour. +fn parse_args(args: &[String]) -> Result> { + let mut iter = args.iter(); + let Some(first) = iter.next() else { + return Ok(None); + }; + + match first.as_str() { + "--version" | "-V" => Ok(Some(Command::Version)), + "--help" | "-h" => Ok(Some(Command::Help)), + "ask" => parse_ask(iter).map(Some), + other => Err(LegError::Usage(format!( + "unrecognised argument {other:?}; {USAGE}" + ))), + } +} + +/// Parses the arguments following `ask`: an optional `--model ` flag (in +/// any position) plus exactly one non-blank positional prompt. +fn parse_ask<'a>(iter: impl Iterator) -> Result { + let mut model = None; + let mut prompt = None; + + let mut iter = iter.peekable(); + while let Some(arg) = iter.next() { + if arg == "--model" { + let value = iter + .next() + .ok_or_else(|| LegError::Usage("--model requires a value".to_string()))?; + model = Some(value.clone()); + } else if prompt.is_some() { + return Err(LegError::Usage(format!( + "unexpected extra argument {arg:?}; ask takes exactly one prompt" + ))); + } else { + prompt = Some(arg.clone()); + } + } + + let prompt = prompt.ok_or_else(|| LegError::Usage("ask requires a prompt".to_string()))?; + if prompt.trim().is_empty() { + return Err(LegError::Usage( + "ask's prompt must not be blank".to_string(), + )); + } + + Ok(Command::Ask { prompt, model }) +} + +/// Runs one single-turn exchange and writes its result to `output`. +/// +/// Config-load failures (bad/missing credential, malformed env values) +/// propagate as `Err` — nothing has been sent to the provider yet. Once a +/// [`LocalParticipant`] answers, the result is infallible per the +/// [`Participant`] contract: a success prints the reply text; a provider or +/// delivery failure prints the response `MessageEnvelope` as JSON +/// (`"kind":"error"`) instead — both exit 0. +fn execute_ask(prompt: &str, model: Option, output: impl Write) -> Result<()> { + let mut config = LegConfig::from_env()?; + apply_model_override(&mut config, model); + let meta = ExchangeMeta { + model: config.model.clone(), + base_url: config.base_url.clone(), + }; + let client = ClaudeClient::from_config(config); + let participant = LocalParticipant::new(client, meta); + run_ask(&participant, prompt, output) +} + +/// Testable core of [`execute_ask`], parameterised over a [`Participant`] so +/// the success/error stdout contract is exercisable without a network. +fn run_ask(participant: &impl Participant, prompt: &str, mut output: impl Write) -> Result<()> { + let request = MessageEnvelope::new( + "ask-1", + "ask", + "user", + "assistant", + MessageKind::Request, + prompt, + crate::events::now_ms(), + ); + let response = participant.respond(&request); + + match response.kind { + MessageKind::Response => writeln!(output, "{}", response.body).map_err(io_err), + _ => { + let json = serde_json::to_string(&response).expect("MessageEnvelope always serializes"); + writeln!(output, "{json}").map_err(io_err) + } + } +} + +fn io_err(err: std::io::Error) -> LegError { + LegError::Io(err.to_string()) +} + +/// Applies the `--model` override (if any) onto a loaded config, in place. +fn apply_model_override(config: &mut LegConfig, model: Option) { + if let Some(model) = model { + config.model = model; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::AssistantReply; + use crate::transport::Transport; + + fn argv(args: &[&str]) -> Vec { + args.iter().map(|s| s.to_string()).collect() + } + + struct FakeTransport(std::result::Result); + + impl Transport for FakeTransport { + fn send_conversation(&self, _messages: &[crate::model::Message]) -> Result { + match &self.0 { + Ok(reply) => Ok(reply.clone()), + Err(LegError::Auth(msg)) => Err(LegError::Auth(msg.clone())), + Err(other) => Err(LegError::Transport(other.to_string())), + } + } + } + + fn meta() -> ExchangeMeta { + ExchangeMeta { + model: "claude-test-model".to_string(), + base_url: "https://api.anthropic.com".to_string(), + } + } + + #[test] + fn run_ask_prints_only_reply_text_on_success() { + let participant = + LocalParticipant::new(FakeTransport(Ok(AssistantReply::new("hi there"))), meta()); + let mut buf = Vec::new(); + run_ask(&participant, "hello", &mut buf).expect("infallible per Participant contract"); + assert_eq!(String::from_utf8(buf).unwrap(), "hi there\n"); + } + + #[test] + fn run_ask_prints_error_envelope_json_on_delivery_failure_and_does_not_err() { + let participant = LocalParticipant::new( + FakeTransport(Err(LegError::Auth("bad credentials".to_string()))), + meta(), + ); + let mut buf = Vec::new(); + run_ask(&participant, "hello", &mut buf).expect("infallible per Participant contract"); + let printed = String::from_utf8(buf).unwrap(); + let value: serde_json::Value = serde_json::from_str(printed.trim()).expect("valid json"); + assert_eq!(value["kind"], "error"); + assert_eq!(value["body"], "authentication error: bad credentials"); + } + + #[test] + fn no_arguments_is_a_no_op() { + assert_eq!(parse_args(&argv(&[])).unwrap(), None); + } + + #[test] + fn version_flags_parse() { + assert_eq!( + parse_args(&argv(&["--version"])).unwrap(), + Some(Command::Version) + ); + assert_eq!(parse_args(&argv(&["-V"])).unwrap(), Some(Command::Version)); + } + + #[test] + fn help_flags_parse() { + assert_eq!(parse_args(&argv(&["--help"])).unwrap(), Some(Command::Help)); + assert_eq!(parse_args(&argv(&["-h"])).unwrap(), Some(Command::Help)); + } + + #[test] + fn help_text_mentions_ask_usage_and_env_vars() { + let text = help_text(); + assert!(text.contains("leg ask [--model ] ")); + assert!(text.contains("ANTHROPIC_API_KEY")); + assert!(text.contains("LEG_MODEL")); + } + + #[test] + fn ask_parses_positional_prompt() { + assert_eq!( + parse_args(&argv(&["ask", "hello"])).unwrap(), + Some(Command::Ask { + prompt: "hello".to_string(), + model: None, + }) + ); + } + + #[test] + fn ask_parses_model_override_before_or_after_prompt() { + assert_eq!( + parse_args(&argv(&["ask", "--model", "claude-opus-4-8", "hello"])).unwrap(), + Some(Command::Ask { + prompt: "hello".to_string(), + model: Some("claude-opus-4-8".to_string()), + }) + ); + assert_eq!( + parse_args(&argv(&["ask", "hello", "--model", "claude-opus-4-8"])).unwrap(), + Some(Command::Ask { + prompt: "hello".to_string(), + model: Some("claude-opus-4-8".to_string()), + }) + ); + } + + #[test] + fn ask_without_prompt_is_usage_error() { + assert!(matches!( + parse_args(&argv(&["ask"])).unwrap_err(), + LegError::Usage(_) + )); + } + + #[test] + fn ask_with_blank_prompt_is_usage_error() { + assert!(matches!( + parse_args(&argv(&["ask", " "])).unwrap_err(), + LegError::Usage(_) + )); + } + + #[test] + fn ask_with_extra_positional_argument_is_usage_error() { + assert!(matches!( + parse_args(&argv(&["ask", "hello", "extra"])).unwrap_err(), + LegError::Usage(_) + )); + } + + #[test] + fn model_flag_without_value_is_usage_error() { + assert!(matches!( + parse_args(&argv(&["ask", "--model"])).unwrap_err(), + LegError::Usage(_) + )); + } + + #[test] + fn unrecognised_argument_is_usage_error() { + assert!(matches!( + parse_args(&argv(&["bogus"])).unwrap_err(), + LegError::Usage(_) + )); + } + + #[test] + fn execute_ask_propagates_config_error_without_calling_the_provider() { + // SAFETY: this crate's tests run single-threaded w.r.t. process env + // mutation is avoided entirely here — no ANTHROPIC_* var is read + // through `execute_ask`'s `LegConfig::from_env`, which fails closed + // when unset in this test process's environment. If some other test + // or the outer environment happens to export a credential, this test + // is skipped rather than flaking on shared state. + if std::env::var("ANTHROPIC_API_KEY").is_ok() + || std::env::var("ANTHROPIC_AUTH_TOKEN").is_ok() + || std::env::var("CLAUDE_CODE_OAUTH_TOKEN").is_ok() + { + return; + } + let mut buf = Vec::new(); + let err = execute_ask("hello", None, &mut buf).unwrap_err(); + assert!(matches!(err, LegError::Config(_))); + assert!(buf.is_empty()); + } + + /// A network-free [`crate::transport::http::HttpClient`] fake that + /// captures the JSON body of the last request it served, via a shared + /// handle a test retains after the fake is moved into a [`ClaudeClient`]. + struct RecordingHttp { + captured_body: std::rc::Rc>>, + } + + impl crate::transport::http::HttpClient for RecordingHttp { + fn post_json( + &self, + _url: &str, + _headers: &[(&str, &str)], + body: &str, + ) -> Result { + *self.captured_body.borrow_mut() = Some(body.to_string()); + Ok(crate::transport::http::HttpResponse { + status: 200, + body: r#"{"content":[{"type":"text","text":"hi"}]}"#.to_string(), + }) + } + } + + /// End-to-end (network-free): `--model` reaches `execute_ask`'s config + /// override, which is stamped onto the outgoing Claude Messages request. + #[test] + fn model_override_reaches_the_outgoing_claude_request() { + let mut config = LegConfig::from_lookup(|key| { + (key == "ANTHROPIC_API_KEY").then(|| "secret".to_string()) + }) + .expect("config loads"); + apply_model_override(&mut config, Some("claude-opus-4-8".to_string())); + let meta = ExchangeMeta { + model: config.model.clone(), + base_url: config.base_url.clone(), + }; + + let captured = std::rc::Rc::new(std::cell::RefCell::new(None)); + let http = RecordingHttp { + captured_body: std::rc::Rc::clone(&captured), + }; + let client = ClaudeClient::with_http(config, http); + let participant = LocalParticipant::new(client, meta); + + let mut buf = Vec::new(); + run_ask(&participant, "hello", &mut buf).expect("infallible per Participant contract"); + + let sent = captured.borrow().clone().expect("request body captured"); + let value: serde_json::Value = serde_json::from_str(&sent).expect("valid json"); + assert_eq!(value["model"], "claude-opus-4-8"); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..7e6d741 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,290 @@ +//! Environment-backed runtime configuration. +//! +//! [`LegConfig`] holds everything the single-turn first-reply path needs to +//! reach a provider. Loading is split into [`LegConfig::from_env`] (the real +//! entry point) and a pure [`LegConfig::from_lookup`] so parsing can be tested +//! deterministically without mutating the process environment. +//! +//! Authentication is modelled as a typed [`Credential`] so leg accepts either +//! an Anthropic API key (`ANTHROPIC_API_KEY`) or an OAuth bearer token +//! (`ANTHROPIC_AUTH_TOKEN` / `CLAUDE_CODE_OAUTH_TOKEN`). The first present +//! variable in that precedence order is the resolved credential; the +//! transport then picks the matching `x-api-key` or `Authorization: Bearer` +//! header from the variant. + +use std::time::Duration; + +use crate::error::{LegError, Result}; + +/// Default base URL for the Claude-compatible Messages API. +pub const DEFAULT_BASE_URL: &str = "https://api.anthropic.com"; + +/// Default model id used when `LEG_MODEL` is unset. +pub const DEFAULT_MODEL: &str = "claude-sonnet-4-6"; + +/// Default request timeout in seconds when `LEG_TIMEOUT_SECS` is unset. +pub const DEFAULT_TIMEOUT_SECS: u64 = 60; + +/// Default `max_tokens` requested per reply when `LEG_MAX_TOKENS` is unset. +pub const DEFAULT_MAX_TOKENS: u32 = 1024; + +/// An authentication credential accepted by the provider transport. +/// +/// Variants map 1:1 onto the wire-format header the transport emits: +/// `ApiKey` -> `x-api-key`, `OAuth` -> `Authorization: Bearer `. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Credential { + /// An Anthropic API key, sent as the `x-api-key` header. + ApiKey(String), + /// An OAuth bearer token, sent as the `Authorization: Bearer ` + /// header. + OAuth(String), +} + +/// Runtime configuration for leg's first-reply path. +#[derive(Debug, Clone)] +pub struct LegConfig { + /// Resolved provider credential (API key or OAuth bearer token). + pub credential: Credential, + /// Base URL for the Messages API. From `ANTHROPIC_BASE_URL`, defaulting to + /// [`DEFAULT_BASE_URL`]. + pub base_url: String, + /// Model id to request. From `LEG_MODEL`, defaulting to [`DEFAULT_MODEL`]. + pub model: String, + /// Per-request timeout. Derived from `LEG_TIMEOUT_SECS`, defaulting to + /// [`DEFAULT_TIMEOUT_SECS`]. Must be a positive integer; zero is rejected + /// because a zero deadline fails every request immediately. + pub timeout: Duration, + /// Maximum output tokens to request per reply. From `LEG_MAX_TOKENS`, + /// defaulting to [`DEFAULT_MAX_TOKENS`]. Must be a positive integer; zero is + /// rejected because the API rejects it. + pub max_tokens: u32, + /// Optional system prompt. When `LEG_SYSTEM_PROMPT` names a readable file, + /// this holds its content; the transport then sends it as the request's + /// `system` field. Unset or blank leaves this `None` and omits the field. + pub system_prompt: Option, +} + +impl LegConfig { + /// Loads configuration from the process environment. + pub fn from_env() -> Result { + Self::from_lookup(|key| std::env::var(key).ok()) + } + + /// Loads configuration from an arbitrary key lookup. + /// + /// `lookup` returns the value for a variable name, or `None` when it is + /// unset. This is the testable core behind [`LegConfig::from_env`]. + pub fn from_lookup(lookup: impl Fn(&str) -> Option) -> Result { + let credential = resolve_credential(&lookup)?; + + let base_url = + non_empty(lookup("ANTHROPIC_BASE_URL")).unwrap_or_else(|| DEFAULT_BASE_URL.to_string()); + let model = non_empty(lookup("LEG_MODEL")).unwrap_or_else(|| DEFAULT_MODEL.to_string()); + + let timeout_secs = match non_empty(lookup("LEG_TIMEOUT_SECS")) { + Some(raw) => { + let parsed = raw.parse::().map_err(|_| { + LegError::Config(format!( + "LEG_TIMEOUT_SECS must be a positive integer, got {raw:?}" + )) + })?; + if parsed == 0 { + return Err(LegError::Config( + "LEG_TIMEOUT_SECS must be greater than zero".to_string(), + )); + } + parsed + } + None => DEFAULT_TIMEOUT_SECS, + }; + + let max_tokens = match non_empty(lookup("LEG_MAX_TOKENS")) { + Some(raw) => { + let parsed = raw.parse::().map_err(|_| { + LegError::Config(format!( + "LEG_MAX_TOKENS must be a positive integer, got {raw:?}" + )) + })?; + if parsed == 0 { + return Err(LegError::Config( + "LEG_MAX_TOKENS must be greater than zero".to_string(), + )); + } + parsed + } + None => DEFAULT_MAX_TOKENS, + }; + + let system_prompt = resolve_system_prompt(non_empty(lookup("LEG_SYSTEM_PROMPT")))?; + + Ok(Self { + credential, + base_url, + model, + timeout: Duration::from_secs(timeout_secs), + max_tokens, + system_prompt, + }) + } +} + +/// Resolves the optional system prompt from the `LEG_SYSTEM_PROMPT` path. +/// +/// `path` is the already-trimmed value of the variable (or `None` when unset or +/// blank). When present, the file at that path is read and its content returned; +/// a missing or unreadable file is a [`LegError::Config`] naming both the +/// variable and the path, so the command fails at startup before any network +/// call. An unset or blank variable yields `None`, preserving the no-system +/// behaviour. +fn resolve_system_prompt(path: Option) -> Result> { + let Some(path) = path else { + return Ok(None); + }; + std::fs::read_to_string(&path).map(Some).map_err(|err| { + LegError::Config(format!( + "LEG_SYSTEM_PROMPT points to a file that could not be read ({path}): {err}" + )) + }) +} + +/// Resolves the provider credential with the documented precedence. +/// +/// Iterates the candidate `(variable, variant)` pairs in order; the first +/// variable whose lookup returns `Some` is the resolved credential. A +/// present-but-blank value is an error (and does *not* fall through to a later +/// candidate), because exporting a credential variable empty is almost always +/// a misconfiguration rather than an explicit "skip me" signal. If no +/// variable is present at all, that is also an error. +type CredentialBuilder = fn(String) -> Credential; + +fn resolve_credential(lookup: &impl Fn(&str) -> Option) -> Result { + let candidates: [(&str, CredentialBuilder); 3] = [ + ("ANTHROPIC_API_KEY", Credential::ApiKey), + ("ANTHROPIC_AUTH_TOKEN", Credential::OAuth), + ("CLAUDE_CODE_OAUTH_TOKEN", Credential::OAuth), + ]; + + for (var, make) in candidates { + let Some(raw) = lookup(var) else { + continue; + }; + if raw.trim().is_empty() { + return Err(LegError::Config(format!("{var} is set but empty"))); + } + return Ok(make(raw)); + } + + Err(LegError::Config( + "no Anthropic credential set: set one of ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, or CLAUDE_CODE_OAUTH_TOKEN".to_string(), + )) +} + +/// Treats a present-but-blank value as absent so a defaulted variable that is +/// exported empty still falls back to its default. +fn non_empty(value: Option) -> Option { + value.filter(|v| !v.trim().is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn lookup_from(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option { + let map: HashMap = pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + move |key: &str| map.get(key).cloned() + } + + #[test] + fn applies_defaults_when_only_api_key_present() { + let cfg = LegConfig::from_lookup(lookup_from(&[("ANTHROPIC_API_KEY", "secret")])) + .expect("config should load"); + assert_eq!(cfg.credential, Credential::ApiKey("secret".to_string())); + assert_eq!(cfg.base_url, DEFAULT_BASE_URL); + assert_eq!(cfg.model, DEFAULT_MODEL); + assert_eq!(cfg.timeout, Duration::from_secs(DEFAULT_TIMEOUT_SECS)); + assert_eq!(cfg.max_tokens, DEFAULT_MAX_TOKENS); + assert_eq!(cfg.system_prompt, None); + } + + #[test] + fn overrides_are_honored() { + let cfg = LegConfig::from_lookup(lookup_from(&[ + ("ANTHROPIC_API_KEY", "secret"), + ("ANTHROPIC_BASE_URL", "https://proxy.example"), + ("LEG_MODEL", "claude-opus-4-8"), + ("LEG_TIMEOUT_SECS", "5"), + ("LEG_MAX_TOKENS", "42"), + ])) + .expect("config should load"); + assert_eq!(cfg.base_url, "https://proxy.example"); + assert_eq!(cfg.model, "claude-opus-4-8"); + assert_eq!(cfg.timeout, Duration::from_secs(5)); + assert_eq!(cfg.max_tokens, 42); + } + + #[test] + fn oauth_token_precedence_over_missing_api_key() { + let cfg = LegConfig::from_lookup(lookup_from(&[("ANTHROPIC_AUTH_TOKEN", "tok")])) + .expect("config should load"); + assert_eq!(cfg.credential, Credential::OAuth("tok".to_string())); + } + + #[test] + fn api_key_takes_precedence_over_oauth_vars() { + let cfg = LegConfig::from_lookup(lookup_from(&[ + ("ANTHROPIC_API_KEY", "secret"), + ("ANTHROPIC_AUTH_TOKEN", "tok"), + ])) + .expect("config should load"); + assert_eq!(cfg.credential, Credential::ApiKey("secret".to_string())); + } + + #[test] + fn missing_credential_is_config_error() { + let err = LegConfig::from_lookup(lookup_from(&[])).unwrap_err(); + assert!(matches!(err, LegError::Config(_))); + } + + #[test] + fn blank_credential_is_config_error_without_fallthrough() { + let err = LegConfig::from_lookup(lookup_from(&[("ANTHROPIC_API_KEY", " ")])).unwrap_err(); + assert!(matches!(err, LegError::Config(_))); + } + + #[test] + fn zero_timeout_is_rejected() { + let err = LegConfig::from_lookup(lookup_from(&[ + ("ANTHROPIC_API_KEY", "secret"), + ("LEG_TIMEOUT_SECS", "0"), + ])) + .unwrap_err(); + assert!(matches!(err, LegError::Config(_))); + } + + #[test] + fn zero_max_tokens_is_rejected() { + let err = LegConfig::from_lookup(lookup_from(&[ + ("ANTHROPIC_API_KEY", "secret"), + ("LEG_MAX_TOKENS", "0"), + ])) + .unwrap_err(); + assert!(matches!(err, LegError::Config(_))); + } + + #[test] + fn blank_optional_vars_fall_back_to_defaults() { + let cfg = LegConfig::from_lookup(lookup_from(&[ + ("ANTHROPIC_API_KEY", "secret"), + ("LEG_MODEL", " "), + ("ANTHROPIC_BASE_URL", ""), + ])) + .expect("config should load"); + assert_eq!(cfg.model, DEFAULT_MODEL); + assert_eq!(cfg.base_url, DEFAULT_BASE_URL); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..6cbf1dd --- /dev/null +++ b/src/error.rs @@ -0,0 +1,95 @@ +//! Error types shared across leg's runtime surfaces. +//! +//! Configuration failures and the provider transport's failure modes are +//! modelled as distinct variants so callers can react to them explicitly. The +//! Messages client maps HTTP and decode failures onto these variants rather +//! than collapsing everything into a single opaque error. + +use std::fmt; + +/// Convenience alias for results produced by leg's runtime. +pub type Result = std::result::Result; + +/// Top-level error type for leg. +#[derive(Debug)] +pub enum LegError { + /// A command-line argument was missing, unrecognised, or malformed. Carries + /// a human-readable explanation plus the one-line usage summary. + Usage(String), + /// Configuration could not be loaded or was invalid (e.g. a missing or + /// malformed environment variable). + Config(String), + /// A transport-level failure with no HTTP response: connection refused, + /// DNS failure, TLS error, timeout, etc. + Transport(String), + /// The provider rejected the credentials (HTTP 401). + Auth(String), + /// The provider rate-limited the request (HTTP 429). + RateLimited(String), + /// The provider returned a server-side failure (HTTP 5xx). + Server { + /// The HTTP status code. + status: u16, + /// The provider's error message, or the raw body when it could not be + /// parsed. + message: String, + }, + /// The provider returned some other non-success status (e.g. 400 Bad + /// Request) that does not map to a more specific variant. + Api { + /// The HTTP status code. + status: u16, + /// The provider's error message, or the raw body when it could not be + /// parsed. + message: String, + }, + /// A 2xx response could not be decoded into an [`AssistantReply`], because + /// the body was malformed, partial, or carried no assistant text. + /// + /// [`AssistantReply`]: crate::model::AssistantReply + Decode(String), + /// A local I/O operation failed. + Io(String), +} + +impl LegError { + /// A stable, machine-readable class for this error. + /// + /// Used by the delivered-error envelope so consumers can branch on the + /// failure kind without parsing the human-readable message. + pub fn kind(&self) -> &'static str { + match self { + LegError::Usage(_) => "usage", + LegError::Config(_) => "config", + LegError::Transport(_) => "transport", + LegError::Auth(_) => "auth", + LegError::RateLimited(_) => "rate_limited", + LegError::Server { .. } => "server", + LegError::Api { .. } => "api", + LegError::Decode(_) => "decode", + LegError::Io(_) => "io", + } + } +} + +impl fmt::Display for LegError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + LegError::Usage(msg) => write!(f, "usage error: {msg}"), + LegError::Config(msg) => write!(f, "configuration error: {msg}"), + LegError::Transport(msg) => write!(f, "transport error: {msg}"), + LegError::Auth(msg) => write!(f, "authentication error: {msg}"), + LegError::RateLimited(msg) => write!(f, "rate limited: {msg}"), + LegError::Server { status, message } => { + write!(f, "provider server error ({status}): {message}") + } + LegError::Api { status, message } => { + write!(f, "provider error ({status}): {message}") + } + LegError::Decode(msg) => write!(f, "response decode error: {msg}"), + LegError::Io(msg) => write!(f, "io error: {msg}"), + } + } +} + +impl std::error::Error for LegError {} diff --git a/src/events.rs b/src/events.rs new file mode 100644 index 0000000..639a7cf --- /dev/null +++ b/src/events.rs @@ -0,0 +1,99 @@ +//! Structured exchange-record types shared by the peer-message envelope. +//! +//! [`ExchangeMeta`] is the replay-relevant metadata ([`model`](ExchangeMeta::model) +//! / [`base_url`](ExchangeMeta::base_url)) known before a provider call is made. +//! [`Exchange`] (paired with [`RequestRecord`]/[`Outcome`]) is the record of one +//! completed call — nested inside a [`crate::message::MessageEnvelope`] via +//! [`crate::message::WrappedExchange`] so a reply is observable in-band, in +//! memory, with no side trail. +//! +//! Baton's upstream module of the same name additionally owns a JSONL side +//! trail (`BATON_EVENT_LOG`) and its `ExchangeEvent`/`EventSink` write path; +//! that machinery is not ported here — no acceptance criterion for `leg ask` +//! needs a persisted trail, and reading/replaying one is a later slice +//! (`leg log`, leg#3). `Exchange`/`RequestRecord`/`Outcome` themselves are +//! relocated here (rather than a `log` module, per that upstream module's +//! split) since a later `leg log` can import them from here without a +//! breaking move. + +use serde::{Deserialize, Serialize}; + +/// Schema discriminator stamped on the nested exchange record. +pub const SCHEMA: &str = "baton.exchange/v1"; + +/// Replay-relevant metadata about an exchange, known before the call is made. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExchangeMeta { + /// Model id the request targets. + pub model: String, + /// Base URL the request is sent to. + pub base_url: String, +} + +/// One request paired with its single outcome. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Exchange { + /// The recorded request (carries everything needed to replay it). + pub request: RequestRecord, + /// The recorded terminal outcome (success reply or failure). + pub outcome: Outcome, +} + +/// The replay-relevant fields of a request. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RequestRecord { + /// Wall-clock emission time, Unix epoch milliseconds. + pub ts_ms: u64, + /// Model id the request targeted. + pub model: String, + /// Base URL the request was sent to. + pub base_url: String, + /// The user prompt text. + pub prompt: String, +} + +/// The terminal outcome of an exchange. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "event")] +pub enum Outcome { + /// The call succeeded. + #[serde(rename = "response_ok")] + Ok { + /// Wall-clock emission time, Unix epoch milliseconds. + ts_ms: u64, + /// Time spent in the provider call, milliseconds. + duration_ms: u64, + /// The assistant reply text. + reply: String, + /// Provider-reported input (prompt) tokens; omitted when unknown. + #[serde(default, skip_serializing_if = "Option::is_none")] + input_tokens: Option, + /// Provider-reported output (completion) tokens; omitted when unknown. + #[serde(default, skip_serializing_if = "Option::is_none")] + output_tokens: Option, + /// Provider-reported terminal reason; omitted when unknown. + #[serde(default, skip_serializing_if = "Option::is_none")] + stop_reason: Option, + }, + /// The call failed; `kind` is the stable machine class. + #[serde(rename = "response_error")] + Error { + /// Wall-clock emission time, Unix epoch milliseconds. + ts_ms: u64, + /// Time spent before the failure resolved, milliseconds. + duration_ms: u64, + /// Stable machine-readable error class (mirrors [`crate::error::LegError::kind`]). + kind: String, + /// Human-readable error description. + message: String, + }, +} + +/// Current wall-clock time as Unix epoch milliseconds. +pub fn now_ms() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock is before the Unix epoch") + .as_millis() as u64 +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..188ac67 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,23 @@ +//! leg: a standalone, agent-friendly headless LLM client. +//! +//! This slice ports the local-LLM-agent half of `baton`'s core — the +//! infallible [`participant::Participant`] contract plus a single-turn `ask` +//! driver — without the harness (mailbox/service/registry/converse-ring). +//! +//! - [`config`] — environment-backed runtime configuration. +//! - [`model`] — typed prompt/reply structures. +//! - [`transport`] — the provider transport boundary. +//! - [`events`] — the exchange-record types nested in a peer message. +//! - [`message`] — the `baton.message/v1` peer-message envelope. +//! - [`participant`] — the envelope-in / envelope-out participant seam. +//! - [`error`] — shared error and result types. +//! - [`cli`] — the command-line entry surface. + +pub mod cli; +pub mod config; +pub mod error; +pub mod events; +pub mod message; +pub mod model; +pub mod participant; +pub mod transport; diff --git a/src/main.rs b/src/main.rs index e0da697..cd23061 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,11 @@ -use std::env; use std::process::ExitCode; fn main() -> ExitCode { - let mut args = env::args().skip(1); - if let Some(arg) = args.next() - && (arg == "--version" || arg == "-V") - { - println!("leg {}", env!("CARGO_PKG_VERSION")); + match leg::cli::run() { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("error: {err}"); + ExitCode::FAILURE + } } - ExitCode::SUCCESS } diff --git a/src/message.rs b/src/message.rs new file mode 100644 index 0000000..0613db3 --- /dev/null +++ b/src/message.rs @@ -0,0 +1,285 @@ +//! The `baton.message/v1` peer-message envelope — the A2A lingua franca. +//! +//! [`crate::events`] describes a single provider *call* (the `baton.exchange/v1` +//! record). This module adds the distinct contract for a *peer message*: who +//! it is from/to, which conversation and turn it belongs to, what kind of +//! message it is, and its body. +//! +//! ## Nesting over `baton.exchange/v1` +//! +//! An envelope is nested *over* the exchange trail: one peer message may wrap +//! zero-or-one provider-call record ([`MessageEnvelope::exchange`]). A message +//! that triggered an LLM call carries the resulting exchange as a +//! [`WrappedExchange`], which pairs the `baton.exchange/v1` schema discriminator +//! with the owned [`crate::events::Exchange`] value (its `request` + terminal +//! `outcome`). A message that triggered no call leaves the field `None`. +//! +//! ## Forward-compatibility +//! +//! Reads skip unknown fields (serde's default — these types deliberately do +//! **not** set `deny_unknown_fields`), matching the exchange trail. That is what +//! lets a later slice add fields — or a `kind` such as `notify`, intentionally +//! omitted here — without a schema break. + +use serde::{Deserialize, Serialize}; + +use crate::events::Exchange; + +/// Schema discriminator stamped on every envelope. +/// +/// Bump the version suffix if the shape changes incompatibly so downstream +/// consumers can branch on it. +pub const SCHEMA: &str = "baton.message/v1"; + +/// What kind of peer message this envelope carries. +/// +/// Serializes to the snake_case wire values `request` / `response` / `done` / +/// `error` / `notify`. The unknown-field/variant skip (see the module doc) +/// is what let `notify` join this enum as a non-breaking addition once +/// [`crate::task`] needed a fire-and-forget lifecycle event kind distinct +/// from a conversation turn. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MessageKind { + /// A message asking the peer to act (e.g. a prompt to relay). + Request, + /// A message answering a prior [`MessageKind::Request`]. + Response, + /// A terminal marker: the conversation turn is complete. + Done, + /// A terminal marker: the turn failed. + Error, + /// A fire-and-forget lifecycle event with no expected reply — e.g. a + /// `baton task` milestone or terminal event. + Notify, +} + +/// The provider-call record a message wraps, self-describing via its schema. +/// +/// Pairs the `baton.exchange/v1` discriminator with the owned +/// [`Exchange`](crate::events::Exchange) value so the nesting is explicit on the +/// wire: `{"schema":"baton.exchange/v1","exchange":{"request":…,"outcome":…}}`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WrappedExchange { + /// Schema discriminator of the wrapped record ([`crate::events::SCHEMA`], + /// `baton.exchange/v1`). + pub schema: String, + /// The wrapped provider call: its request paired with its terminal outcome. + pub exchange: Exchange, +} + +impl WrappedExchange { + /// Wraps an [`Exchange`], stamping the `baton.exchange/v1` discriminator + /// (owned copy of [`crate::events::SCHEMA`], the write-path constant). + pub fn new(exchange: Exchange) -> Self { + Self { + schema: crate::events::SCHEMA.to_string(), + exchange, + } + } +} + +/// A single `baton.message/v1` peer message. +/// +/// Constructed with [`MessageEnvelope::new`] for the common case (schema stamped, +/// no reply link, no wrapped exchange); the remaining fields are public so a +/// caller can set [`in_reply_to`](Self::in_reply_to) and +/// [`exchange`](Self::exchange) directly. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MessageEnvelope { + /// Schema discriminator ([`SCHEMA`]). + pub schema: String, + /// Unique id of this message. + pub message_id: String, + /// Id of the conversation this message belongs to. + pub conversation_id: String, + /// Sender address. + pub from: String, + /// Recipient address. + pub to: String, + /// The `message_id` this message replies to, if any. + pub in_reply_to: Option, + /// What kind of message this is. + pub kind: MessageKind, + /// The message body. + pub body: String, + /// Wall-clock emission time, Unix epoch milliseconds. + pub ts_ms: u64, + /// The provider call this message wrapped, if any (zero-or-one). + pub exchange: Option, +} + +impl MessageEnvelope { + /// Builds an envelope with the schema stamped, no reply link, and no wrapped + /// exchange. Set [`in_reply_to`](Self::in_reply_to) / + /// [`exchange`](Self::exchange) on the returned value when needed. + pub fn new( + message_id: impl Into, + conversation_id: impl Into, + from: impl Into, + to: impl Into, + kind: MessageKind, + body: impl Into, + ts_ms: u64, + ) -> Self { + Self { + schema: SCHEMA.to_string(), + message_id: message_id.into(), + conversation_id: conversation_id.into(), + from: from.into(), + to: to.into(), + in_reply_to: None, + kind, + body: body.into(), + ts_ms, + exchange: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::events::{Outcome, RequestRecord}; + use serde_json::Value; + + fn base() -> MessageEnvelope { + MessageEnvelope::new( + "m-1", + "c-1", + "agent-a", + "agent-b", + MessageKind::Request, + "hello", + 1_700_000_000_000, + ) + } + + fn wrapped() -> WrappedExchange { + WrappedExchange::new(Exchange { + request: RequestRecord { + ts_ms: 1_700_000_000_000, + model: "claude-sonnet-4-6".to_string(), + base_url: "https://api.anthropic.com".to_string(), + prompt: "hello".to_string(), + }, + outcome: Outcome::Ok { + ts_ms: 1_700_000_000_420, + duration_ms: 418, + reply: "hi there".to_string(), + input_tokens: None, + output_tokens: None, + stop_reason: Some("max_tokens".to_string()), + }, + }) + } + + /// Serialize → parse → equal, with no reply link and no wrapped exchange. + #[test] + fn round_trips_minimal_envelope() { + let msg = base(); + let json = serde_json::to_string(&msg).expect("serializes"); + let back: MessageEnvelope = serde_json::from_str(&json).expect("parses"); + assert_eq!(msg, back); + } + + /// The nullable `in_reply_to` round-trips in both its `Some` and `None` + /// states. + #[test] + fn round_trips_in_reply_to_none_and_some() { + let none = base(); + assert_eq!(none.in_reply_to, None); + let back: MessageEnvelope = + serde_json::from_str(&serde_json::to_string(&none).unwrap()).unwrap(); + assert_eq!(none, back); + + let mut some = base(); + some.in_reply_to = Some("m-0".to_string()); + let back: MessageEnvelope = + serde_json::from_str(&serde_json::to_string(&some).unwrap()).unwrap(); + assert_eq!(some, back); + assert_eq!(back.in_reply_to.as_deref(), Some("m-0")); + } + + /// Every `kind` variant round-trips and carries its snake_case wire value. + #[test] + fn round_trips_every_kind_variant() { + for (kind, wire) in [ + (MessageKind::Request, "request"), + (MessageKind::Response, "response"), + (MessageKind::Done, "done"), + (MessageKind::Error, "error"), + (MessageKind::Notify, "notify"), + ] { + let mut msg = base(); + msg.kind = kind; + let json = serde_json::to_string(&msg).expect("serializes"); + let value: Value = serde_json::from_str(&json).expect("json"); + assert_eq!(value["kind"], wire, "wire value for {kind:?}"); + let back: MessageEnvelope = serde_json::from_str(&json).expect("parses"); + assert_eq!(msg, back); + } + } + + /// A wrapped exchange round-trips and the nested object carries the + /// `baton.exchange/v1` discriminator plus the trail's outcome tag. + #[test] + fn round_trips_wrapped_exchange_with_schema_discriminator() { + let mut msg = base(); + msg.exchange = Some(wrapped()); + let json = serde_json::to_string(&msg).expect("serializes"); + let value: Value = serde_json::from_str(&json).expect("json"); + assert_eq!(value["exchange"]["schema"], crate::events::SCHEMA); + assert_eq!(value["schema"], SCHEMA); + // The nested outcome tag matches the on-disk exchange trail. + assert_eq!( + value["exchange"]["exchange"]["outcome"]["event"], + "response_ok" + ); + assert_eq!( + value["exchange"]["exchange"]["outcome"]["stop_reason"], + "max_tokens" + ); + + let back: MessageEnvelope = serde_json::from_str(&json).expect("parses"); + assert_eq!(msg, back); + match &back.exchange.expect("wrapped").exchange.outcome { + Outcome::Ok { stop_reason, .. } => { + assert_eq!(stop_reason.as_deref(), Some("max_tokens")); + } + other => panic!("expected Ok outcome, got {other:?}"), + } + } + + /// A message with no wrapped exchange round-trips with `exchange: null`. + #[test] + fn round_trips_without_wrapped_exchange() { + let msg = base(); + assert_eq!(msg.exchange, None); + let json = serde_json::to_string(&msg).expect("serializes"); + let back: MessageEnvelope = serde_json::from_str(&json).expect("parses"); + assert_eq!(msg, back); + } + + /// Unknown top-level fields are ignored on read, not errors — the + /// forward-compatibility guarantee (no `deny_unknown_fields`). + #[test] + fn unknown_fields_are_ignored_on_read() { + let json = r#"{ + "schema": "baton.message/v1", + "message_id": "m-1", + "conversation_id": "c-1", + "from": "agent-a", + "to": "agent-b", + "in_reply_to": null, + "kind": "request", + "body": "hello", + "ts_ms": 1700000000000, + "exchange": null, + "future_field": {"added": "by a newer baton"}, + "another_unknown": 42 + }"#; + let back: MessageEnvelope = serde_json::from_str(json).expect("ignores unknown fields"); + assert_eq!(back, base()); + } +} diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..2b0ecaf --- /dev/null +++ b/src/model.rs @@ -0,0 +1,256 @@ +//! Typed data structures for the prompt/reply and multi-turn session flows. +//! +//! [`Prompt`] and [`AssistantReply`] model the single-turn `ask` path. Multi-turn +//! sessions build on [`Message`] (a role-tagged turn) and [`Conversation`] (the +//! accumulated history that is resent with every request). Tool calling and +//! streaming remain out of scope, so a message is plain text with a [`Role`]. + +/// The author of a single conversation turn. +/// +/// Maps 1:1 onto the Messages API `role` field; [`Role::as_str`] is the wire +/// value the transport serializes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Role { + /// A turn authored by the user / calling agent. + User, + /// A turn authored by the assistant (a prior reply). + Assistant, +} + +impl Role { + /// The Messages API wire value for this role. + pub fn as_str(self) -> &'static str { + match self { + Role::User => "user", + Role::Assistant => "assistant", + } + } +} + +/// A single role-tagged turn in a conversation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Message { + /// Who authored this turn. + pub role: Role, + /// The turn's text content. + pub content: String, +} + +impl Message { + /// Creates a user turn from anything string-like. + pub fn user(content: impl Into) -> Self { + Self { + role: Role::User, + content: content.into(), + } + } + + /// Creates an assistant turn from anything string-like. + pub fn assistant(content: impl Into) -> Self { + Self { + role: Role::Assistant, + content: content.into(), + } + } +} + +/// An ordered, in-memory accumulation of conversation turns. +/// +/// This is the unit-testable core of a multi-turn session: each turn is appended +/// in order, and [`Conversation::messages`] returns the full history that is +/// resent with every request. It deliberately holds no provider state — it is +/// pure data the transport reads. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Conversation { + messages: Vec, +} + +impl Conversation { + /// Creates an empty conversation. + pub fn new() -> Self { + Self::default() + } + + /// Appends a user turn. + pub fn push_user(&mut self, content: impl Into) { + self.messages.push(Message::user(content)); + } + + /// Appends an assistant turn. + pub fn push_assistant(&mut self, content: impl Into) { + self.messages.push(Message::assistant(content)); + } + + /// Removes and returns the most recent turn, if any. + /// + /// Used to roll back a just-appended user turn when its request fails, so + /// the history never holds two consecutive same-role turns (which the + /// Messages API rejects). + pub fn pop(&mut self) -> Option { + self.messages.pop() + } + + /// The full history in order, oldest turn first. + pub fn messages(&self) -> &[Message] { + &self.messages + } + + /// The number of accumulated turns. + pub fn len(&self) -> usize { + self.messages.len() + } + + /// Whether no turns have been accumulated yet. + pub fn is_empty(&self) -> bool { + self.messages.is_empty() + } +} + +/// A single user prompt to send to the provider. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Prompt { + /// The prompt text. + pub text: String, +} + +impl Prompt { + /// Creates a prompt from anything string-like. + pub fn new(text: impl Into) -> Self { + Self { text: text.into() } + } +} + +/// Provider-reported token usage for a single call. +/// +/// Each count is optional: a `2xx` response may omit the `usage` block (or a +/// field within it) entirely, in which case that count is `None` (unknown) +/// rather than an error. This is the token-accounting surface the exchange +/// trail records for cost/observability and the future budget governor. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct TokenUsage { + /// Input (prompt) tokens the provider billed, if reported. + pub input_tokens: Option, + /// Output (completion) tokens the provider billed, if reported. + pub output_tokens: Option, +} + +/// A single assistant reply returned by the provider. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AssistantReply { + /// The reply text. + pub text: String, + /// Provider-reported token usage for the call, when available. + pub usage: TokenUsage, + /// Provider-reported terminal reason, when available. + pub stop_reason: Option, +} + +impl AssistantReply { + /// Creates a reply from anything string-like, with no usage recorded. + pub fn new(text: impl Into) -> Self { + Self { + text: text.into(), + usage: TokenUsage::default(), + stop_reason: None, + } + } + + /// Creates a reply carrying the provider's reported token usage. + pub fn with_usage(text: impl Into, usage: TokenUsage) -> Self { + Self::with_usage_and_stop_reason(text, usage, None) + } + + /// Creates a reply carrying provider usage and its terminal reason. + pub fn with_usage_and_stop_reason( + text: impl Into, + usage: TokenUsage, + stop_reason: Option, + ) -> Self { + Self { + text: text.into(), + usage, + stop_reason, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prompt_new_accepts_str_and_string() { + assert_eq!(Prompt::new("hi"), Prompt::new(String::from("hi"))); + assert_eq!(Prompt::new("hi").text, "hi"); + } + + #[test] + fn reply_new_stores_text() { + assert_eq!(AssistantReply::new("ok").text, "ok"); + assert_eq!(AssistantReply::new("ok").stop_reason, None); + } + + #[test] + fn role_wire_values() { + assert_eq!(Role::User.as_str(), "user"); + assert_eq!(Role::Assistant.as_str(), "assistant"); + } + + #[test] + fn message_constructors_tag_the_role() { + assert_eq!( + Message::user("hi"), + Message { + role: Role::User, + content: "hi".to_string(), + } + ); + assert_eq!( + Message::assistant("yo"), + Message { + role: Role::Assistant, + content: "yo".to_string(), + } + ); + } + + #[test] + fn conversation_starts_empty() { + let convo = Conversation::new(); + assert!(convo.is_empty()); + assert_eq!(convo.len(), 0); + assert_eq!(convo.messages(), &[]); + } + + #[test] + fn conversation_accumulates_turns_in_order() { + let mut convo = Conversation::new(); + convo.push_user("a"); + convo.push_assistant("b"); + convo.push_user("c"); + + assert_eq!(convo.len(), 3); + assert!(!convo.is_empty()); + assert_eq!( + convo.messages(), + &[ + Message::user("a"), + Message::assistant("b"), + Message::user("c"), + ] + ); + } + + #[test] + fn conversation_pop_removes_most_recent_turn() { + let mut convo = Conversation::new(); + convo.push_user("a"); + convo.push_assistant("b"); + + assert_eq!(convo.pop(), Some(Message::assistant("b"))); + assert_eq!(convo.messages(), &[Message::user("a")]); + assert_eq!(convo.pop(), Some(Message::user("a"))); + assert_eq!(convo.pop(), None); + assert!(convo.is_empty()); + } +} diff --git a/src/participant.rs b/src/participant.rs new file mode 100644 index 0000000..4bb6f85 --- /dev/null +++ b/src/participant.rs @@ -0,0 +1,202 @@ +//! The participant seam: an envelope-in / envelope-out boundary. +//! +//! [`Participant`] is the `ask` driver's infallible contract: a provider (or +//! delivery) failure is a *delivered* `kind: "error"` response, never a +//! propagated `Err`. [`LocalParticipant`] is the only implementation ported at +//! this slice — an in-process, LLM-backed participant that is a +//! [`crate::transport::Transport`] plus the metadata stamped on its nested +//! exchange record. Subprocess/mailbox/external-agent participants (baton's +//! `SubprocessParticipant`/`MailboxParticipant`/`ExternalAgentParticipant`) are +//! harness pieces (mailbox/service) out of scope for `leg`. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Instant; + +use crate::events::{Exchange, ExchangeMeta, Outcome, RequestRecord, now_ms}; +use crate::message::{MessageEnvelope, MessageKind, WrappedExchange}; +use crate::model::Prompt; +use crate::transport::Transport; + +/// Answers a `baton.message/v1` request envelope with a response envelope. +/// +/// Infallible by contract: a provider (or delivery) failure is a *delivered* +/// `kind: "error"` response, never a propagated `Err`. +pub trait Participant { + /// Consumes a `request` envelope and returns the correlated response. + fn respond(&self, request: &MessageEnvelope) -> MessageEnvelope; +} + +/// An in-process, LLM-backed participant: a [`Transport`] plus exchange +/// metadata. +/// +/// The response envelope preserves `conversation_id`, links `in_reply_to` to +/// the request, swaps addressing (`from`/`to`), and nests the +/// `baton.exchange/v1` record for the call it ran so the call — and its token +/// usage — is observable in-band. [`ExchangeMeta`] supplies the `model`/ +/// `base_url` stamped on that nested record. +pub struct LocalParticipant { + transport: T, + meta: ExchangeMeta, +} + +impl LocalParticipant { + /// Builds a participant over `transport`, stamping `meta` (`model` / + /// `base_url`) onto the nested `baton.exchange/v1` record of each reply. + pub fn new(transport: T, meta: ExchangeMeta) -> Self { + Self { transport, meta } + } +} + +impl Participant for LocalParticipant { + fn respond(&self, request: &MessageEnvelope) -> MessageEnvelope { + let request_ts = now_ms(); + let start = Instant::now(); + let result = self.transport.send(&Prompt::new(request.body.as_str())); + let duration_ms = start.elapsed().as_millis() as u64; + let outcome_ts = now_ms(); + + let request_record = RequestRecord { + ts_ms: request_ts, + model: self.meta.model.clone(), + base_url: self.meta.base_url.clone(), + prompt: request.body.clone(), + }; + + let (kind, body, outcome) = match result { + Ok(reply) => { + let outcome = Outcome::Ok { + ts_ms: outcome_ts, + duration_ms, + reply: reply.text.clone(), + input_tokens: reply.usage.input_tokens, + output_tokens: reply.usage.output_tokens, + stop_reason: reply.stop_reason.clone(), + }; + (MessageKind::Response, reply.text, outcome) + } + Err(err) => { + let outcome = Outcome::Error { + ts_ms: outcome_ts, + duration_ms, + kind: err.kind().to_string(), + message: err.to_string(), + }; + (MessageKind::Error, err.to_string(), outcome) + } + }; + + // Addressing swaps: the reply is from the request's recipient, to its + // sender. + let mut response = MessageEnvelope::new( + fresh_message_id(&request.conversation_id, outcome_ts), + request.conversation_id.clone(), + request.to.clone(), + request.from.clone(), + kind, + body, + outcome_ts, + ); + response.in_reply_to = Some(request.message_id.clone()); + response.exchange = Some(WrappedExchange::new(Exchange { + request: request_record, + outcome, + })); + response + } +} + +/// A process-lifetime counter making every synthesized response id distinct. +/// +/// Millisecond timestamps do not separate emissions in a tight loop, so the +/// counter — not the timestamp — carries uniqueness. +static RESPONSE_SEQ: AtomicU64 = AtomicU64::new(0); + +/// Builds a fresh `message_id` for a response without adding a dependency. +/// +/// Derived from the conversation id, the response timestamp, and a draw from +/// [`RESPONSE_SEQ`]: each call takes a value no other call in this process +/// takes, so two replies emitted within the same millisecond still differ. +fn fresh_message_id(conversation_id: &str, ts_ms: u64) -> String { + let seq = RESPONSE_SEQ.fetch_add(1, Ordering::Relaxed); + format!("{conversation_id}-r-{ts_ms}-{seq}") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::LegError; + use crate::model::AssistantReply; + + struct FakeTransport(Result); + + impl Transport for FakeTransport { + fn send_conversation( + &self, + _messages: &[crate::model::Message], + ) -> crate::error::Result { + match &self.0 { + Ok(reply) => Ok(reply.clone()), + Err(()) => Err(LegError::Auth("bad credentials".to_string())), + } + } + } + + fn request() -> MessageEnvelope { + let mut envelope = MessageEnvelope::new( + "m-1", + "c-1", + "user", + "assistant", + MessageKind::Request, + "hello", + 1_700_000_000_000, + ); + envelope.in_reply_to = None; + envelope + } + + fn meta() -> ExchangeMeta { + ExchangeMeta { + model: "claude-test-model".to_string(), + base_url: "https://api.anthropic.com".to_string(), + } + } + + #[test] + fn success_reply_swaps_addressing_and_nests_ok_outcome() { + let participant = + LocalParticipant::new(FakeTransport(Ok(AssistantReply::new("hi there"))), meta()); + let response = participant.respond(&request()); + + assert_eq!(response.kind, MessageKind::Response); + assert_eq!(response.body, "hi there"); + assert_eq!(response.from, "assistant"); + assert_eq!(response.to, "user"); + assert_eq!(response.conversation_id, "c-1"); + assert_eq!(response.in_reply_to.as_deref(), Some("m-1")); + match &response.exchange.expect("wrapped").exchange.outcome { + Outcome::Ok { reply, .. } => assert_eq!(reply, "hi there"), + other => panic!("expected Ok outcome, got {other:?}"), + } + } + + #[test] + fn provider_failure_is_delivered_as_error_envelope_not_a_panic_or_err() { + let participant = LocalParticipant::new(FakeTransport(Err(())), meta()); + let response = participant.respond(&request()); + + assert_eq!(response.kind, MessageKind::Error); + assert_eq!(response.body, "authentication error: bad credentials"); + match &response.exchange.expect("wrapped").exchange.outcome { + Outcome::Error { kind, .. } => assert_eq!(kind, "auth"), + other => panic!("expected Error outcome, got {other:?}"), + } + } + + #[test] + fn fresh_message_id_is_unique_across_calls() { + let a = fresh_message_id("c-1", 1_000); + let b = fresh_message_id("c-1", 1_000); + assert_ne!(a, b); + } +} diff --git a/src/transport/claude.rs b/src/transport/claude.rs new file mode 100644 index 0000000..969f915 --- /dev/null +++ b/src/transport/claude.rs @@ -0,0 +1,697 @@ +//! A non-streaming Claude-compatible Messages client. +//! +//! [`ClaudeClient`] implements [`Transport`] against `POST /v1/messages`. It +//! sends a full conversation history (one or more role-tagged turns) and decodes +//! one assistant reply — no streaming or tool use (those remain out of scope). +//! The request building and response parsing are pure functions so they can be +//! tested without a network via a fake [`HttpClient`]. + +use serde::{Deserialize, Serialize}; + +use crate::config::{Credential, LegConfig}; +use crate::error::{LegError, Result}; +use crate::model::{AssistantReply, Message, TokenUsage}; +use crate::transport::Transport; +use crate::transport::http::{HttpClient, UreqHttpClient}; + +/// The Messages API version pinned by this client. +pub const ANTHROPIC_VERSION: &str = "2023-06-01"; + +/// A Claude-compatible Messages client over an arbitrary [`HttpClient`]. +pub struct ClaudeClient { + config: LegConfig, + http: H, +} + +impl ClaudeClient { + /// Creates a client that talks to the provider over real HTTP, using the + /// timeout from `config`. + pub fn from_config(config: LegConfig) -> Self { + let http = UreqHttpClient::new(config.timeout); + Self { config, http } + } +} + +impl ClaudeClient { + /// Creates a client over a caller-supplied [`HttpClient`]. + /// + /// Used by tests to inject a fake transport; production code uses + /// [`ClaudeClient::from_config`]. + pub fn with_http(config: LegConfig, http: H) -> Self { + Self { config, http } + } + + /// The full Messages endpoint URL for the configured base URL. + fn endpoint(&self) -> String { + format!("{}/v1/messages", self.config.base_url.trim_end_matches('/')) + } +} + +impl Transport for ClaudeClient { + fn send_conversation(&self, messages: &[Message]) -> Result { + let body = build_request_body( + &self.config.model, + self.config.max_tokens, + messages, + self.config.system_prompt.as_deref(), + )?; + let url = self.endpoint(); + // `auth_value` is bound to this stack frame so the array of header + // refs below can borrow from it. The OAuth case formats the bearer + // token once per request; the API-key case clones the key (also + // once per request). No heap allocation for the headers themselves. + let (auth_name, auth_value) = auth_header(&self.config.credential); + let headers = [ + (auth_name, auth_value.as_str()), + ("anthropic-version", ANTHROPIC_VERSION), + ("content-type", "application/json"), + ]; + + let response = self.http.post_json(&url, &headers, &body)?; + parse_response(response.status, &response.body) + } +} + +/// Maps the resolved [`Credential`] onto the wire-level auth header pair. +/// +/// The credential is read from the already-resolved config (no env lookup +/// happens per request) and converted into the matching name/value pair: +/// `ApiKey` -> `x-api-key`, `OAuth` -> `Authorization: Bearer `. +/// +/// Returns an owned value for the auth header so it can live on the caller's +/// stack frame and be borrowed into the `&[(&str, &str)]` slice that +/// `HttpClient::post_json` requires. +fn auth_header(credential: &Credential) -> (&'static str, String) { + match credential { + Credential::ApiKey(key) => ("x-api-key", key.clone()), + Credential::OAuth(token) => ("Authorization", format!("Bearer {token}")), + } +} + +/// Serializes a Messages request body for `model` carrying `messages` in order. +/// +/// Each turn's [`Role`](crate::model::Role) is emitted as its wire `role` value, +/// preserving order so multi-turn history reaches the provider intact. When +/// `system_prompt` is `Some`, it is emitted as the request's `system` field; +/// `None` omits the field entirely. +fn build_request_body( + model: &str, + max_tokens: u32, + messages: &[Message], + system_prompt: Option<&str>, +) -> Result { + let request = MessagesRequest { + model, + max_tokens, + system: system_prompt, + messages: messages + .iter() + .map(|message| RequestMessage { + role: message.role.as_str(), + content: &message.content, + }) + .collect(), + }; + serde_json::to_string(&request) + .map_err(|err| LegError::Transport(format!("failed to serialize request: {err}"))) +} + +/// Maps an HTTP status and body onto an [`AssistantReply`] or [`LegError`]. +/// +/// 2xx responses are decoded into a reply; non-2xx statuses become the matching +/// explicit error variant, surfacing the provider's message rather than hiding +/// the failure. +fn parse_response(status: u16, body: &str) -> Result { + if (200..300).contains(&status) { + return parse_success(body); + } + + let message = extract_error_message(body); + Err(match status { + 401 => LegError::Auth(message), + 429 => LegError::RateLimited(message), + 500..=599 => LegError::Server { status, message }, + _ => LegError::Api { status, message }, + }) +} + +/// Decodes a successful Messages response into an [`AssistantReply`]. +/// +/// All `text` content blocks are concatenated in order and the provider's +/// optional terminal reason is retained on the reply. A body that fails to +/// decode, or that carries no assistant text, is a [`LegError::Decode`] — the +/// client never returns a silently empty reply. +fn parse_success(body: &str) -> Result { + let response: MessagesResponse = serde_json::from_str(body) + .map_err(|err| LegError::Decode(format!("malformed Messages response: {err}")))?; + + let text: String = response + .content + .iter() + .filter(|block| block.block_type == "text") + .filter_map(|block| block.text.as_deref()) + .collect(); + + if text.is_empty() { + return Err(LegError::Decode( + "response contained no assistant text".to_string(), + )); + } + + // A missing `usage` block (or a missing field within it) is recorded as + // `None`, not an error — usage is observability, never a decode failure. + let usage = response + .usage + .map_or_else(TokenUsage::default, |u| TokenUsage { + input_tokens: u.input_tokens, + output_tokens: u.output_tokens, + }); + + Ok(AssistantReply::with_usage_and_stop_reason( + text, + usage, + response.stop_reason, + )) +} + +/// Pulls `error.message` out of a Claude error body, falling back to the raw +/// body (trimmed) when it is absent or unparseable. +fn extract_error_message(body: &str) -> String { + if let Ok(parsed) = serde_json::from_str::(body) { + return parsed.error.message; + } + let trimmed = body.trim(); + if trimmed.is_empty() { + "no response body".to_string() + } else { + trimmed.to_string() + } +} + +#[derive(Serialize)] +struct MessagesRequest<'a> { + model: &'a str, + max_tokens: u32, + #[serde(skip_serializing_if = "Option::is_none")] + system: Option<&'a str>, + messages: Vec>, +} + +#[derive(Serialize)] +struct RequestMessage<'a> { + role: &'a str, + content: &'a str, +} + +#[derive(Deserialize)] +struct MessagesResponse { + #[serde(default)] + content: Vec, + #[serde(default)] + usage: Option, + #[serde(default)] + /// Provider terminal state, such as `end_turn` or `max_tokens`. + stop_reason: Option, +} + +/// The provider's `usage` object. Each count is optional so a partial or absent +/// block degrades to `None` per field rather than failing the decode. +#[derive(Deserialize)] +struct UsageBlock { + #[serde(default)] + input_tokens: Option, + #[serde(default)] + output_tokens: Option, +} + +#[derive(Deserialize)] +struct ContentBlock { + #[serde(rename = "type")] + block_type: String, + #[serde(default)] + text: Option, +} + +#[derive(Deserialize)] +struct ErrorResponse { + error: ErrorDetail, +} + +#[derive(Deserialize)] +struct ErrorDetail { + message: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{Credential, DEFAULT_MAX_TOKENS}; + use crate::model::Prompt; + use std::cell::RefCell; + use std::time::Duration; + + /// A fake transport that records the last request and returns a canned + /// response. + struct FakeHttp { + status: u16, + body: String, + last_url: RefCell>, + last_headers: RefCell>, + last_body: RefCell>, + } + + impl FakeHttp { + fn new(status: u16, body: &str) -> Self { + Self { + status, + body: body.to_string(), + last_url: RefCell::new(None), + last_headers: RefCell::new(Vec::new()), + last_body: RefCell::new(None), + } + } + } + + impl HttpClient for FakeHttp { + fn post_json( + &self, + url: &str, + headers: &[(&str, &str)], + body: &str, + ) -> Result { + *self.last_url.borrow_mut() = Some(url.to_string()); + *self.last_headers.borrow_mut() = headers + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + *self.last_body.borrow_mut() = Some(body.to_string()); + Ok(crate::transport::http::HttpResponse { + status: self.status, + body: self.body.clone(), + }) + } + } + + fn config_with(base_url: &str, model: &str) -> LegConfig { + config_with_credential( + base_url, + model, + Credential::ApiKey("secret-key".to_string()), + ) + } + + fn config_with_credential(base_url: &str, model: &str, credential: Credential) -> LegConfig { + LegConfig { + credential, + base_url: base_url.to_string(), + model: model.to_string(), + timeout: Duration::from_secs(60), + max_tokens: DEFAULT_MAX_TOKENS, + system_prompt: None, + } + } + + const SUCCESS_BODY: &str = r#"{ + "id": "msg_1", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "Hello there"}], + "stop_reason": "end_turn" + }"#; + + #[test] + fn extracts_assistant_text_from_valid_response() { + let client = ClaudeClient::with_http( + config_with("https://api.anthropic.com", "claude-sonnet-4-6"), + FakeHttp::new(200, SUCCESS_BODY), + ); + let reply = client.send(&Prompt::new("hi")).expect("should succeed"); + assert_eq!(reply.text, "Hello there"); + assert_eq!(reply.stop_reason.as_deref(), Some("end_turn")); + } + + #[test] + fn decodes_max_tokens_stop_reason_without_rejecting_reply() { + let body = r#"{ + "content": [{"type": "text", "text": "unfinished"}], + "stop_reason": "max_tokens" + }"#; + let client = ClaudeClient::with_http( + config_with("https://api.anthropic.com", "claude-sonnet-4-6"), + FakeHttp::new(200, body), + ); + + let reply = client.send(&Prompt::new("hi")).expect("should succeed"); + + assert_eq!(reply.text, "unfinished"); + assert_eq!(reply.stop_reason.as_deref(), Some("max_tokens")); + } + + #[test] + fn success_without_stop_reason_remains_backward_compatible() { + let body = r#"{ + "content": [{"type": "text", "text": "complete enough"}] + }"#; + let client = ClaudeClient::with_http( + config_with("https://api.anthropic.com", "claude-sonnet-4-6"), + FakeHttp::new(200, body), + ); + + let reply = client.send(&Prompt::new("hi")).expect("should succeed"); + + assert_eq!(reply.text, "complete enough"); + assert_eq!(reply.stop_reason, None); + } + + #[test] + fn decodes_token_usage_from_response() { + let body = r#"{ + "content": [{"type": "text", "text": "hi"}], + "usage": {"input_tokens": 12, "output_tokens": 34} + }"#; + let client = ClaudeClient::with_http( + config_with("https://api.anthropic.com", "claude-sonnet-4-6"), + FakeHttp::new(200, body), + ); + let reply = client.send(&Prompt::new("hi")).expect("should succeed"); + assert_eq!(reply.usage.input_tokens, Some(12)); + assert_eq!(reply.usage.output_tokens, Some(34)); + } + + #[test] + fn success_without_usage_block_records_absent_tokens() { + // SUCCESS_BODY carries no `usage`: the reply still succeeds and usage is + // recorded as unknown (None), never a decode error. + let client = ClaudeClient::with_http( + config_with("https://api.anthropic.com", "claude-sonnet-4-6"), + FakeHttp::new(200, SUCCESS_BODY), + ); + let reply = client.send(&Prompt::new("hi")).expect("should succeed"); + assert_eq!(reply.text, "Hello there"); + assert_eq!(reply.usage.input_tokens, None); + assert_eq!(reply.usage.output_tokens, None); + } + + #[test] + fn partial_usage_block_records_present_field_only() { + let body = r#"{ + "content": [{"type": "text", "text": "hi"}], + "usage": {"input_tokens": 7} + }"#; + let client = ClaudeClient::with_http( + config_with("https://api.anthropic.com", "claude-sonnet-4-6"), + FakeHttp::new(200, body), + ); + let reply = client.send(&Prompt::new("hi")).expect("should succeed"); + assert_eq!(reply.usage.input_tokens, Some(7)); + assert_eq!(reply.usage.output_tokens, None); + } + + #[test] + fn concatenates_multiple_text_blocks_and_ignores_non_text() { + let body = r#"{ + "content": [ + {"type": "text", "text": "part one "}, + {"type": "tool_use", "id": "t1", "name": "x", "input": {}}, + {"type": "text", "text": "part two"} + ] + }"#; + let client = ClaudeClient::with_http( + config_with("https://api.anthropic.com", "claude-opus-4-8"), + FakeHttp::new(200, body), + ); + let reply = client.send(&Prompt::new("hi")).expect("should succeed"); + assert_eq!(reply.text, "part one part two"); + } + + #[test] + fn request_uses_configured_endpoint_model_key_and_version() { + let http = FakeHttp::new(200, SUCCESS_BODY); + // Trailing slash on the base URL must not double up in the path. + let client = ClaudeClient::with_http( + config_with("https://proxy.example/", "claude-test-model"), + http, + ); + client + .send(&Prompt::new("hello world")) + .expect("should succeed"); + + let FakeHttp { + last_url, + last_headers, + last_body, + .. + } = &client.http; + assert_eq!( + last_url.borrow().as_deref(), + Some("https://proxy.example/v1/messages") + ); + + let headers = last_headers.borrow(); + assert!(headers.contains(&("x-api-key".to_string(), "secret-key".to_string()))); + assert!(headers.contains(&( + "anthropic-version".to_string(), + ANTHROPIC_VERSION.to_string() + ))); + + let sent = last_body.borrow(); + let value: serde_json::Value = + serde_json::from_str(sent.as_deref().unwrap()).expect("body is json"); + assert_eq!(value["model"], "claude-test-model"); + assert_eq!(value["max_tokens"], DEFAULT_MAX_TOKENS); + assert_eq!(value["messages"][0]["role"], "user"); + assert_eq!(value["messages"][0]["content"], "hello world"); + } + + #[test] + fn request_carries_configured_max_tokens() { + let mut config = config_with("https://api.anthropic.com", "claude-sonnet-4-6"); + config.max_tokens = 4096; + let client = ClaudeClient::with_http(config, FakeHttp::new(200, SUCCESS_BODY)); + client.send(&Prompt::new("hi")).expect("should succeed"); + + let sent = client.http.last_body.borrow(); + let value: serde_json::Value = + serde_json::from_str(sent.as_deref().unwrap()).expect("body is json"); + assert_eq!(value["max_tokens"], 4096); + } + + #[test] + fn send_conversation_serializes_full_history_in_order() { + let client = ClaudeClient::with_http( + config_with("https://api.anthropic.com", "claude-sonnet-4-6"), + FakeHttp::new(200, SUCCESS_BODY), + ); + let history = [ + Message::user("first"), + Message::assistant("reply one"), + Message::user("second"), + ]; + client.send_conversation(&history).expect("should succeed"); + + let sent = client.http.last_body.borrow(); + let value: serde_json::Value = + serde_json::from_str(sent.as_deref().unwrap()).expect("body is json"); + let messages = value["messages"].as_array().expect("messages is an array"); + assert_eq!(messages.len(), 3, "the full history is sent, got: {value}"); + assert_eq!(messages[0]["role"], "user"); + assert_eq!(messages[0]["content"], "first"); + assert_eq!(messages[1]["role"], "assistant"); + assert_eq!(messages[1]["content"], "reply one"); + assert_eq!(messages[2]["role"], "user"); + assert_eq!(messages[2]["content"], "second"); + } + + #[test] + fn request_omits_system_field_when_system_prompt_is_none() { + let http = FakeHttp::new(200, SUCCESS_BODY); + let client = ClaudeClient::with_http( + config_with("https://api.anthropic.com", "claude-sonnet-4-6"), + http, + ); + client.send(&Prompt::new("hi")).expect("should succeed"); + + let sent = client.http.last_body.borrow(); + let value: serde_json::Value = + serde_json::from_str(sent.as_deref().unwrap()).expect("body is json"); + assert!( + value.get("system").is_none(), + "system field must be absent when system_prompt is None, got: {value}" + ); + } + + #[test] + fn request_includes_system_field_when_system_prompt_is_some() { + let mut config = config_with("https://api.anthropic.com", "claude-sonnet-4-6"); + config.system_prompt = Some("You are a terse agent.".to_string()); + let client = ClaudeClient::with_http(config, FakeHttp::new(200, SUCCESS_BODY)); + client.send(&Prompt::new("hi")).expect("should succeed"); + + let sent = client.http.last_body.borrow(); + let value: serde_json::Value = + serde_json::from_str(sent.as_deref().unwrap()).expect("body is json"); + assert_eq!(value["system"], "You are a terse agent."); + } + + #[test] + fn request_oauth_credential_emits_bearer_header_and_no_api_key() { + let http = FakeHttp::new(200, SUCCESS_BODY); + let client = ClaudeClient::with_http( + config_with_credential( + "https://api.anthropic.com", + "claude-sonnet-4-6", + Credential::OAuth("tok-123".to_string()), + ), + http, + ); + client + .send(&Prompt::new("hello world")) + .expect("should succeed"); + + let FakeHttp { last_headers, .. } = &client.http; + let headers = last_headers.borrow(); + assert!( + headers + .iter() + .any(|(k, v)| k == "Authorization" && v == "Bearer tok-123"), + "expected `Authorization: Bearer tok-123` header, got: {headers:?}" + ); + assert!( + !headers + .iter() + .any(|(k, _)| k.eq_ignore_ascii_case("x-api-key")), + "OAuth credential must not emit an `x-api-key` header, got: {headers:?}" + ); + // The other pinned headers still ride along. + assert!(headers.contains(&( + "anthropic-version".to_string(), + ANTHROPIC_VERSION.to_string() + ))); + assert!(headers.contains(&("content-type".to_string(), "application/json".to_string()))); + } + + #[test] + fn unauthorized_maps_to_auth_error() { + let body = r#"{"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"}}"#; + let client = ClaudeClient::with_http( + config_with("https://api.anthropic.com", "claude-sonnet-4-6"), + FakeHttp::new(401, body), + ); + match client.send(&Prompt::new("hi")).unwrap_err() { + LegError::Auth(msg) => assert_eq!(msg, "invalid x-api-key"), + other => panic!("expected Auth, got {other:?}"), + } + } + + #[test] + fn too_many_requests_maps_to_rate_limited() { + let body = r#"{"type":"error","error":{"type":"rate_limit_error","message":"slow down"}}"#; + let client = ClaudeClient::with_http( + config_with("https://api.anthropic.com", "claude-sonnet-4-6"), + FakeHttp::new(429, body), + ); + match client.send(&Prompt::new("hi")).unwrap_err() { + LegError::RateLimited(msg) => assert_eq!(msg, "slow down"), + other => panic!("expected RateLimited, got {other:?}"), + } + } + + #[test] + fn server_error_maps_to_server_variant_with_status() { + let body = r#"{"type":"error","error":{"type":"api_error","message":"overloaded"}}"#; + let client = ClaudeClient::with_http( + config_with("https://api.anthropic.com", "claude-sonnet-4-6"), + FakeHttp::new(503, body), + ); + match client.send(&Prompt::new("hi")).unwrap_err() { + LegError::Server { status, message } => { + assert_eq!(status, 503); + assert_eq!(message, "overloaded"); + } + other => panic!("expected Server, got {other:?}"), + } + } + + #[test] + fn other_status_maps_to_api_variant() { + let body = + r#"{"type":"error","error":{"type":"invalid_request_error","message":"bad model"}}"#; + let client = ClaudeClient::with_http( + config_with("https://api.anthropic.com", "claude-sonnet-4-6"), + FakeHttp::new(400, body), + ); + match client.send(&Prompt::new("hi")).unwrap_err() { + LegError::Api { status, message } => { + assert_eq!(status, 400); + assert_eq!(message, "bad model"); + } + other => panic!("expected Api, got {other:?}"), + } + } + + #[test] + fn error_body_without_json_falls_back_to_raw_text() { + let client = ClaudeClient::with_http( + config_with("https://api.anthropic.com", "claude-sonnet-4-6"), + FakeHttp::new(502, " upstream timeout "), + ); + match client.send(&Prompt::new("hi")).unwrap_err() { + LegError::Server { status, message } => { + assert_eq!(status, 502); + assert_eq!(message, "upstream timeout"); + } + other => panic!("expected Server, got {other:?}"), + } + } + + #[test] + fn malformed_success_body_is_decode_error() { + let client = ClaudeClient::with_http( + config_with("https://api.anthropic.com", "claude-sonnet-4-6"), + FakeHttp::new(200, "not json"), + ); + assert!(matches!( + client.send(&Prompt::new("hi")).unwrap_err(), + LegError::Decode(_) + )); + } + + #[test] + fn success_with_no_text_blocks_is_decode_error() { + let body = r#"{"content": [{"type": "tool_use", "id": "t1", "name": "x", "input": {}}]}"#; + let client = ClaudeClient::with_http( + config_with("https://api.anthropic.com", "claude-sonnet-4-6"), + FakeHttp::new(200, body), + ); + assert!(matches!( + client.send(&Prompt::new("hi")).unwrap_err(), + LegError::Decode(_) + )); + } + + /// A fake transport that always returns a transport-level error. + struct FailingHttp; + + impl HttpClient for FailingHttp { + fn post_json( + &self, + _url: &str, + _headers: &[(&str, &str)], + _body: &str, + ) -> Result { + Err(LegError::Transport("connection timed out".to_string())) + } + } + + #[test] + fn timeout_transport_error() { + let client = ClaudeClient::with_http( + config_with("https://api.anthropic.com", "claude-sonnet-4-6"), + FailingHttp, + ); + match client.send(&Prompt::new("hi")).unwrap_err() { + LegError::Transport(msg) => assert!(msg.contains("timed out")), + other => panic!("expected Transport, got {other:?}"), + } + } +} diff --git a/src/transport/http.rs b/src/transport/http.rs new file mode 100644 index 0000000..50fca15 --- /dev/null +++ b/src/transport/http.rs @@ -0,0 +1,81 @@ +//! HTTP execution boundary for provider clients. +//! +//! [`HttpClient`] is the seam the Claude client depends on so its request +//! building and response parsing can be unit-tested with a fake client, without +//! touching the network — mirroring the testable split in +//! [`LegConfig::from_lookup`](crate::config::LegConfig::from_lookup). +//! +//! A non-2xx status is *not* an error at this layer: it is returned as an +//! ordinary [`HttpResponse`] carrying the status and body so the caller can map +//! it onto the appropriate [`LegError`] variant. Only failures with no HTTP +//! response (connection refused, DNS, TLS, timeout) become +//! [`LegError::Transport`]. + +use std::time::Duration; + +use crate::error::{LegError, Result}; + +/// A completed HTTP response: the status code and the raw body text. +#[derive(Debug, Clone)] +pub struct HttpResponse { + /// The HTTP status code. + pub status: u16, + /// The response body, read as a UTF-8 string. + pub body: String, +} + +/// Sends a single JSON POST request and returns the raw response. +/// +/// Implementations must return `Ok` for any completed HTTP exchange, including +/// non-2xx statuses, and reserve `Err(LegError::Transport(..))` for failures +/// where no response was received. +pub trait HttpClient { + /// POSTs `body` to `url` with the given `headers` (name, value pairs). + fn post_json(&self, url: &str, headers: &[(&str, &str)], body: &str) -> Result; +} + +/// A [`HttpClient`] backed by [`ureq`], with a per-request global timeout. +/// +/// Blocking by design: it matches the synchronous [`Transport`] trait, so there +/// is no async runtime to manage for the single-turn first-reply path. +/// +/// [`Transport`]: crate::transport::Transport +pub struct UreqHttpClient { + agent: ureq::Agent, +} + +impl UreqHttpClient { + /// Creates a client whose requests time out after `timeout`. + pub fn new(timeout: Duration) -> Self { + // `http_status_as_error(false)` makes ureq return non-2xx responses as + // `Ok` instead of an error, so the caller sees the status and body and + // maps them onto leg's error variants. + let agent: ureq::Agent = ureq::Agent::config_builder() + .http_status_as_error(false) + .timeout_global(Some(timeout)) + .build() + .into(); + Self { agent } + } +} + +impl HttpClient for UreqHttpClient { + fn post_json(&self, url: &str, headers: &[(&str, &str)], body: &str) -> Result { + let mut request = self.agent.post(url); + for (name, value) in headers { + request = request.header(*name, *value); + } + + let mut response = request + .send(body) + .map_err(|err| LegError::Transport(err.to_string()))?; + + let status = response.status().as_u16(); + let body = response + .body_mut() + .read_to_string() + .map_err(|err| LegError::Transport(format!("failed to read response body: {err}")))?; + + Ok(HttpResponse { status, body }) + } +} diff --git a/src/transport/mod.rs b/src/transport/mod.rs new file mode 100644 index 0000000..589fcf2 --- /dev/null +++ b/src/transport/mod.rs @@ -0,0 +1,36 @@ +//! Provider transport boundary. +//! +//! This module defines the seam between leg's typed model and a concrete +//! provider client. [`Transport`] is the stable boundary the CLI and tests +//! depend on; the [`claude`] submodule provides the first concrete +//! implementation (a non-streaming Claude-compatible Messages client), and +//! [`http`] isolates the underlying HTTP execution so the request/response +//! logic can be tested without a network. + +pub mod claude; +pub mod http; + +use crate::error::Result; +use crate::model::{AssistantReply, Message, Prompt}; + +/// Sends a conversation and returns a single reply. +/// +/// Intentionally synchronous: no streaming or tool calling. The primitive is +/// [`Transport::send_conversation`], which maps the full message history onto a +/// provider request — so a multi-turn session resends its accumulated turns on +/// every call. [`Transport::send`] is a single-turn convenience wrapping one +/// user prompt, provided so the `ask` path needs no separate implementation. +pub trait Transport { + /// Sends `messages` (the full conversation history, oldest first) and + /// returns the assistant's reply to the latest turn. + fn send_conversation(&self, messages: &[Message]) -> Result; + + /// Sends a single user `prompt` and returns the assistant's reply. + /// + /// Wraps the prompt as a one-message user conversation and delegates to + /// [`Transport::send_conversation`], so single-turn callers and tests are + /// unchanged by the multi-turn primitive. + fn send(&self, prompt: &Prompt) -> Result { + self.send_conversation(std::slice::from_ref(&Message::user(prompt.text.as_str()))) + } +}