use crate::chat::message::{ContentBlock, Message, Role, StopReason};
use crate::chat::request::ChatRequest;
use crate::chat::response::ChatResponse;
use crate::cost::usage::Usage;
use crate::error::{Error, Result};
use crate::model::{ModelCapabilities, ModelId, Reach};
use crate::provider::{Access, Provider};
use async_trait::async_trait;
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::AsyncWriteExt;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ProcessOutput {
pub exit_code: Option<i32>,
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
}
impl ProcessOutput {
pub fn new(exit_code: Option<i32>, stdout: Vec<u8>) -> Self {
Self {
exit_code,
stdout,
stderr: Vec::new(),
}
}
#[must_use]
pub fn with_stderr(mut self, stderr: Vec<u8>) -> Self {
self.stderr = stderr;
self
}
}
#[async_trait]
pub trait ProcessRunner: Send + Sync {
async fn run(
&self,
program: &str,
args: &[String],
stdin: &str,
timeout: Duration,
) -> Result<ProcessOutput>;
}
pub struct Spawning;
#[async_trait]
impl ProcessRunner for Spawning {
async fn run(
&self,
program: &str,
args: &[String],
stdin: &str,
timeout: Duration,
) -> Result<ProcessOutput> {
let mut child = tokio::process::Command::new(program)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()
.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => Error::Unsupported(format!(
"{program} is not on the path, so nothing ran. This is not an empty answer"
)),
_ => Error::Transient(format!("starting {program}: {e}")),
})?;
if let Some(mut pipe) = child.stdin.take() {
pipe.write_all(stdin.as_bytes())
.await
.map_err(|e| Error::Transient(format!("writing the prompt: {e}")))?;
drop(pipe);
}
match tokio::time::timeout(timeout, child.wait_with_output()).await {
Err(_) => Err(Error::Timeout { elapsed: timeout }),
Ok(Err(e)) => Err(Error::Transient(format!("running {program}: {e}"))),
Ok(Ok(output)) => Ok(ProcessOutput {
exit_code: output.status.code(),
stdout: output.stdout,
stderr: output.stderr,
}),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Envelope {
answer: String,
usage: Option<String>,
names: UsageNames,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct UsageNames {
pub input: String,
pub cache_read: String,
pub cache_write: String,
pub output: String,
}
impl UsageNames {
pub fn new(
input: impl Into<String>,
cache_read: impl Into<String>,
cache_write: impl Into<String>,
output: impl Into<String>,
) -> Self {
Self {
input: input.into(),
cache_read: cache_read.into(),
cache_write: cache_write.into(),
output: output.into(),
}
}
pub fn anthropic() -> Self {
Self {
input: "input_tokens".into(),
cache_read: "cache_read_input_tokens".into(),
cache_write: "cache_creation_input_tokens".into(),
output: "output_tokens".into(),
}
}
}
impl Envelope {
pub fn at(answer: impl Into<String>) -> Self {
Self {
answer: answer.into(),
usage: None,
names: UsageNames::anthropic(),
}
}
#[must_use]
pub fn with_usage(mut self, pointer: impl Into<String>, names: UsageNames) -> Self {
self.usage = Some(pointer.into());
self.names = names;
self
}
}
pub struct LocalCli {
id: String,
program: String,
args: Vec<String>,
timeout: Duration,
model_flag: Option<String>,
serves: std::collections::BTreeSet<String>,
runner: Arc<dyn ProcessRunner>,
envelope: Option<Envelope>,
pub(crate) probe: Option<Vec<String>>,
}
const PROBE_TIMEOUT: Duration = Duration::from_secs(10);
impl LocalCli {
pub fn new(
id: impl Into<String>,
program: impl Into<String>,
args: impl IntoIterator<Item = impl Into<String>>,
timeout: Duration,
) -> Self {
Self {
id: id.into(),
program: program.into(),
args: args.into_iter().map(Into::into).collect(),
timeout,
model_flag: None,
serves: std::collections::BTreeSet::new(),
runner: Arc::new(Spawning),
envelope: None,
probe: None,
}
}
#[must_use]
pub fn reading(mut self, envelope: Envelope) -> Self {
self.envelope = Some(envelope);
self
}
#[must_use]
pub fn with_probe(mut self, args: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.probe = Some(args.into_iter().map(Into::into).collect());
self
}
#[must_use]
pub fn with_runner(mut self, runner: Arc<dyn ProcessRunner>) -> Self {
self.runner = runner;
self
}
#[must_use]
pub fn serving(mut self, models: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.serves = models.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn with_model_flag(mut self, flag: impl Into<String>) -> Self {
self.model_flag = Some(flag.into());
self
}
fn prompt(request: &ChatRequest) -> String {
let mut out = String::new();
if let Some(system) = &request.system {
out.push_str(system);
out.push_str("\n\n");
}
for message in &request.messages {
let who = match message.role {
Role::User => "User",
Role::Assistant => "Assistant",
};
let text = message.text();
if !text.is_empty() {
out.push_str(who);
out.push_str(": ");
out.push_str(&text);
out.push_str("\n\n");
}
}
out.trim_end().to_string()
}
}
#[async_trait]
impl Provider for LocalCli {
fn id(&self) -> &str {
&self.id
}
fn capabilities(&self, model: &ModelId) -> Option<ModelCapabilities> {
if !self.serves.contains(model.as_str()) {
return None;
}
Some(ModelCapabilities::none(Reach::LocalCli))
}
async fn chat(&self, request: ChatRequest) -> Result<ChatResponse> {
let unmet = request
.needs()
.unmet_by(&ModelCapabilities::none(Reach::LocalCli));
if !unmet.is_empty() {
return Err(Error::Unsupported(format!(
"{} cannot carry {} through a command line tool. `capabilities` says so \
before you send; this is the same answer at the point of sending, rather \
than a reply that quietly did without them",
self.id,
unmet.join(" or ")
)));
}
let mut args = self.args.clone();
if let Some(flag) = &self.model_flag {
args.push(flag.clone());
args.push(request.model.0.clone());
}
let output = self
.runner
.run(&self.program, &args, &Self::prompt(&request), self.timeout)
.await?;
if output.exit_code != Some(0) {
let said = String::from_utf8_lossy(&output.stderr);
return Err(Error::Transient(format!(
"{} exited with {}: {}",
self.program,
output
.exit_code
.map(|c| c.to_string())
.unwrap_or_else(|| "a signal".into()),
said.lines()
.find(|l| !l.trim().is_empty())
.unwrap_or("nothing")
)));
}
let printed = String::from_utf8_lossy(&output.stdout).trim().to_string();
if printed.is_empty() {
return Err(Error::Unreadable(format!(
"{} exited cleanly and printed nothing",
self.program
)));
}
let (text, usage) = match &self.envelope {
None => (printed, Usage::absent()),
Some(envelope) => {
let parsed: serde_json::Value = serde_json::from_str(&printed).map_err(|e| {
Error::Unreadable(format!(
"{} was configured to print JSON and printed something else: {e}",
self.program
))
})?;
let answer = parsed
.pointer(&envelope.answer)
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
Error::Unreadable(format!(
"{} printed an envelope with no answer at {}",
self.program, envelope.answer
))
})?
.trim()
.to_string();
let usage = match envelope.usage.as_deref().and_then(|p| parsed.pointer(p)) {
None => Usage::absent(),
Some(reported) => {
let field =
|name: &str| reported.get(name).and_then(serde_json::Value::as_u64);
let mut usage = Usage::absent();
if let Some(n) = field(&envelope.names.input) {
usage = usage.with_input(n);
}
if let Some(n) = field(&envelope.names.cache_read) {
usage = usage.with_cache_read(n);
}
if let Some(n) = field(&envelope.names.cache_write) {
usage = usage.with_cache_write(n);
}
if let Some(n) = field(&envelope.names.output) {
usage = usage.with_output(n);
}
usage
}
};
(answer, usage)
}
};
if text.is_empty() {
return Err(Error::Unreadable(format!(
"{} printed an envelope whose answer was empty",
self.program
)));
}
Ok(ChatResponse::new(
Message {
role: Role::Assistant,
content: vec![ContentBlock::Text(text)],
},
StopReason::Other,
usage,
request.model.clone(),
)
.with_stop_details("a command line tool does not report why it stopped"))
}
async fn validate(&self, model: &ModelId) -> Access {
let Some(probe) = &self.probe else {
return Access::unknown(format!(
"{} was given no probe, so nothing was asked. `with_probe` says how to ask",
self.id
));
};
let output = match self
.runner
.run(&self.program, probe, "", self.timeout.min(PROBE_TIMEOUT))
.await
{
Ok(output) => output,
Err(Error::Unsupported(said)) => {
return Access::denied(format!("{}: {said}", self.id));
}
Err(e) => {
return Access::unknown(format!("{} could not be probed: {e}", self.id));
}
};
if output.exit_code != Some(0) {
let said = String::from_utf8_lossy(&output.stderr);
return Access::denied(format!(
"{} exited with {}: {}",
self.program,
output
.exit_code
.map(|c| c.to_string())
.unwrap_or_else(|| "a signal".into()),
said.lines()
.find(|l| !l.trim().is_empty())
.unwrap_or("nothing")
));
}
if self.serves.is_empty() {
return Access::unknown(format!(
"{} runs, and a command line tool cannot be asked which models it serves. Name them with `serving` and this becomes an answer",
self.program
));
}
if !self.serves.contains(model.as_str()) {
return Access::denied(format!(
"{} was not told it serves {model}, so nothing here can reach it",
self.id
));
}
Access::Ready
}
}
#[cfg(test)]
mod tests {
use super::*;
struct Scripted(std::sync::Mutex<Result<ProcessOutput>>);
impl Scripted {
fn new(output: Result<ProcessOutput>) -> Arc<Self> {
Arc::new(Self(std::sync::Mutex::new(output)))
}
}
#[async_trait]
impl ProcessRunner for Scripted {
async fn run(
&self,
_program: &str,
_args: &[String],
_stdin: &str,
_timeout: Duration,
) -> Result<ProcessOutput> {
match &*self
.0
.lock()
.map_err(|_| Error::Transient("poisoned".into()))?
{
Ok(output) => Ok(output.clone()),
Err(e) => Err(Error::Transient(e.to_string())),
}
}
}
#[derive(Default)]
struct Recording(std::sync::Mutex<Vec<(String, Vec<String>, String)>>);
#[async_trait]
impl ProcessRunner for Recording {
async fn run(
&self,
program: &str,
args: &[String],
stdin: &str,
_timeout: Duration,
) -> Result<ProcessOutput> {
if let Ok(mut seen) = self.0.lock() {
seen.push((program.to_string(), args.to_vec(), stdin.to_string()));
}
Ok(ProcessOutput::new(Some(0), b"an answer".to_vec()))
}
}
fn cli(runner: Arc<dyn ProcessRunner>) -> LocalCli {
LocalCli::new(
"test-cli",
"a-tool",
[] as [&str; 0],
Duration::from_secs(5),
)
.with_runner(runner)
}
fn request() -> ChatRequest {
ChatRequest::new(
"any-model",
vec![Message::user("what is 2 + 2"), Message::assistant("4")],
)
.with_system("Answer briefly.")
}
#[test]
fn the_prompt_says_who_said_what() {
let prompt = LocalCli::prompt(&request());
assert!(prompt.starts_with("Answer briefly."));
assert!(prompt.contains("User: what is 2 + 2"));
assert!(prompt.contains("Assistant: 4"));
}
#[test]
fn a_tool_that_was_not_told_what_it_serves_knows_nothing() {
assert_eq!(
cli(Arc::new(Recording::default())).capabilities(&"anything".into()),
None
);
}
#[test]
fn a_named_model_answers_with_everything_off() {
let cli = cli(Arc::new(Recording::default())).serving(["claude-sonnet-5"]);
let caps = cli.capabilities(&"claude-sonnet-5".into());
assert_eq!(caps.map(|c| c.tools), Some(false));
assert_eq!(caps.map(|c| c.prompt_caching), Some(false));
assert_eq!(caps.map(|c| c.reach), Some(Reach::LocalCli));
assert_eq!(cli.capabilities(&"a-typo".into()), None);
}
#[tokio::test]
async fn the_model_reaches_the_command_line_when_a_flag_was_named() {
let runner = Arc::new(Recording::default());
let cli = cli(runner.clone()).with_model_flag("--model");
let _ = cli.chat(request()).await;
let seen = runner.0.lock().map(|s| s.clone()).unwrap_or_default();
let (program, args, stdin) = seen.first().cloned().unwrap_or_default();
assert_eq!(program, "a-tool");
assert_eq!(args, vec!["--model".to_string(), "any-model".to_string()]);
assert!(stdin.contains("what is 2 + 2"));
}
#[tokio::test]
async fn a_missing_tool_is_an_error_rather_than_an_empty_answer() {
let cli = cli(Scripted::new(Err(Error::Unsupported(
"a-tool is not on the path, so nothing ran. This is not an empty answer".into(),
))));
let message = cli
.chat(request())
.await
.err()
.map(|e| e.to_string())
.unwrap_or_default();
assert!(message.contains("not an empty answer"), "{message}");
}
#[tokio::test]
async fn a_tool_that_prints_nothing_is_unreadable_rather_than_silent_success() {
let cli = cli(Scripted::new(Ok(ProcessOutput::new(Some(0), Vec::new()))));
let refused = cli.chat(request()).await;
assert!(matches!(refused, Err(Error::Unreadable(_))), "{refused:?}");
}
#[tokio::test]
async fn a_tool_that_failed_says_what_it_complained_about() {
let cli = cli(Scripted::new(Ok(
ProcessOutput::new(Some(2), Vec::new()).with_stderr(b"not logged in\n".to_vec())
)));
let message = cli
.chat(request())
.await
.err()
.map(|e| e.to_string())
.unwrap_or_default();
assert!(message.contains("not logged in"), "{message}");
}
#[tokio::test]
async fn a_request_this_reach_cannot_carry_is_refused_rather_than_stripped() {
let cli = cli(Scripted::new(Ok(ProcessOutput::new(
Some(0),
b"an answer".to_vec(),
))));
let with_tools = request().with_tools(vec![crate::ToolSchema::new(
"read_file",
"read a file",
serde_json::json!({ "type": "object" }),
)]);
let refused = cli.chat(with_tools).await;
let message = refused.err().map(|e| e.to_string()).unwrap_or_default();
assert!(message.contains("tools"), "{message}");
assert!(message.contains("quietly did without"), "{message}");
}
#[tokio::test]
async fn an_image_through_a_reach_that_takes_text_is_refused_too() {
let cli = cli(Scripted::new(Ok(ProcessOutput::new(
Some(0),
b"an answer".to_vec(),
))));
let with_an_image = ChatRequest::new(
"m",
vec![Message {
role: Role::User,
content: vec![ContentBlock::Image {
media_type: "image/png".into(),
source: crate::chat::message::ImageSource::Bytes(vec![0x89, 0x50]),
}],
}],
);
let refused = cli.chat(with_an_image).await;
let message = refused.err().map(|e| e.to_string()).unwrap_or_default();
assert!(message.contains("images"), "{message}");
}
#[tokio::test]
async fn a_plain_request_still_goes_through() {
let cli = cli(Scripted::new(Ok(ProcessOutput::new(
Some(0),
b"an answer".to_vec(),
))));
assert!(cli.chat(request()).await.is_ok());
}
fn an_envelope(text: &str) -> Vec<u8> {
serde_json::to_vec(&serde_json::json!({
"type": "result",
"result": text,
"usage": {
"input_tokens": 10,
"cache_creation_input_tokens": 26_193,
"cache_read_input_tokens": 0,
"output_tokens": 7
}
}))
.unwrap_or_default()
}
fn an_envelope_reader() -> Envelope {
Envelope::at("/result").with_usage("/usage", UsageNames::anthropic())
}
#[tokio::test]
async fn an_envelope_yields_the_answer_rather_than_the_document() {
let cli = cli(Scripted::new(Ok(ProcessOutput::new(
Some(0),
an_envelope("Four."),
))))
.reading(an_envelope_reader());
let reply = cli.chat(request()).await.expect("a reply");
assert_eq!(reply.text(), "Four.");
}
#[tokio::test]
async fn usage_the_tool_reported_is_read_rather_than_thrown_away() {
let cli = cli(Scripted::new(Ok(ProcessOutput::new(
Some(0),
an_envelope("Four."),
))))
.reading(an_envelope_reader());
let usage = cli
.chat(request())
.await
.map(|r| r.usage)
.unwrap_or_default();
assert_eq!(usage.input_tokens, Some(10));
assert_eq!(usage.cache_write_tokens, Some(26_193));
assert_eq!(usage.output_tokens, Some(7));
assert_eq!(usage.coverage(), crate::UsageCoverage::Exact);
}
#[tokio::test]
async fn a_tool_that_promised_json_and_printed_prose_is_unreadable() {
let cli = cli(Scripted::new(Ok(ProcessOutput::new(
Some(0),
b"just some words".to_vec(),
))))
.reading(an_envelope_reader());
let refused = cli.chat(request()).await;
assert!(matches!(refused, Err(Error::Unreadable(_))), "{refused:?}");
}
#[tokio::test]
async fn an_envelope_with_no_usage_in_it_reports_absent() {
let bare =
serde_json::to_vec(&serde_json::json!({ "result": "Four." })).unwrap_or_default();
let cli =
cli(Scripted::new(Ok(ProcessOutput::new(Some(0), bare)))).reading(an_envelope_reader());
let usage = cli
.chat(request())
.await
.map(|r| r.usage)
.unwrap_or_default();
assert_eq!(usage.coverage(), crate::UsageCoverage::Absent);
}
#[tokio::test]
async fn a_reply_reports_no_usage_rather_than_zero() {
let cli = cli(Scripted::new(Ok(ProcessOutput::new(
Some(0),
b"four".to_vec(),
))));
let usage = cli
.chat(request())
.await
.map(|r| r.usage)
.unwrap_or_default();
assert_eq!(usage.coverage(), crate::UsageCoverage::Absent);
}
#[derive(Default)]
struct Probing(std::sync::Mutex<Vec<(Vec<String>, String, Duration)>>);
#[async_trait]
impl ProcessRunner for Probing {
async fn run(
&self,
_program: &str,
args: &[String],
stdin: &str,
timeout: Duration,
) -> Result<ProcessOutput> {
if let Ok(mut seen) = self.0.lock() {
seen.push((args.to_vec(), stdin.to_string(), timeout));
}
Ok(ProcessOutput::new(Some(0), b"1.2.3\n".to_vec()))
}
}
#[tokio::test]
async fn a_tool_with_no_probe_answers_unknown_rather_than_denied() {
let cli = cli(Arc::new(Recording::default())).serving(["any-model"]);
let access = cli.validate(&"any-model".into()).await;
assert!(access.is_unknown(), "{access:?}");
assert!(
access.detail().unwrap_or_default().contains("with_probe"),
"{access}"
);
}
#[tokio::test]
async fn a_probed_tool_that_runs_and_serves_the_model_is_ready() {
let cli = cli(Arc::new(Probing::default()))
.with_probe(["--version"])
.serving(["claude-sonnet-5"]);
assert_eq!(cli.validate(&"claude-sonnet-5".into()).await, Access::Ready);
}
#[tokio::test]
async fn a_tool_that_is_not_installed_is_denied() {
struct Missing;
#[async_trait]
impl ProcessRunner for Missing {
async fn run(
&self,
program: &str,
_args: &[String],
_stdin: &str,
_timeout: Duration,
) -> Result<ProcessOutput> {
Err(Error::Unsupported(format!(
"{program} is not on the path, so nothing ran. This is not an empty answer"
)))
}
}
let missing = cli(Arc::new(Missing))
.with_probe(["--version"])
.serving(["any-model"]);
let access = missing.validate(&"any-model".into()).await;
assert!(access.is_denied(), "{access:?}");
assert!(
access
.detail()
.unwrap_or_default()
.contains("not on the path"),
"{access}"
);
}
#[tokio::test]
async fn a_tool_that_is_installed_and_signed_out_is_denied_and_says_what_it_complained_about() {
let cli = cli(Scripted::new(Ok(ProcessOutput::new(Some(1), Vec::new())
.with_stderr(b"\nnot logged in: run `a-tool login`\n".to_vec()))))
.with_probe(["--version"])
.serving(["any-model"]);
let access = cli.validate(&"any-model".into()).await;
assert!(access.is_denied(), "{access:?}");
assert!(
access
.detail()
.unwrap_or_default()
.contains("not logged in"),
"{access}"
);
}
#[tokio::test]
async fn a_probe_that_could_not_be_run_at_all_is_unknown() {
let cli = cli(Scripted::new(Err(Error::Timeout {
elapsed: Duration::from_secs(10),
})))
.with_probe(["--version"])
.serving(["any-model"]);
assert!(cli.validate(&"any-model".into()).await.is_unknown());
}
#[tokio::test]
async fn a_running_tool_that_was_never_told_what_it_serves_is_unknown() {
let cli = cli(Arc::new(Probing::default())).with_probe(["--version"]);
let access = cli.validate(&"claude-sonnet-5".into()).await;
assert!(access.is_unknown(), "{access:?}");
assert!(
access.detail().unwrap_or_default().contains("serving"),
"{access}"
);
}
#[tokio::test]
async fn validate_and_capabilities_agree_about_a_model_nobody_named() {
let cli = cli(Arc::new(Probing::default()))
.with_probe(["--version"])
.serving(["claude-sonnet-5"]);
assert_eq!(cli.capabilities(&"a-typo".into()), None);
assert!(cli.validate(&"a-typo".into()).await.is_denied());
}
#[tokio::test]
async fn the_probe_runs_the_arguments_it_was_given_and_sends_no_prompt() {
let runner = Arc::new(Probing::default());
let cli = cli(runner.clone())
.with_probe(["auth", "status"])
.serving(["any-model"]);
let _ = cli.validate(&"any-model".into()).await;
let seen = runner.0.lock().map(|s| s.clone()).unwrap_or_default();
let (args, stdin, _) = seen.first().cloned().unwrap_or_default();
assert_eq!(args, vec!["auth".to_string(), "status".to_string()]);
assert!(stdin.is_empty(), "{stdin:?}");
}
#[tokio::test]
async fn a_probe_is_not_given_the_whole_chat_deadline() {
let runner = Arc::new(Probing::default());
let cli = LocalCli::new(
"test-cli",
"a-tool",
[] as [&str; 0],
Duration::from_secs(300),
)
.with_runner(runner.clone())
.with_probe(["--version"])
.serving(["any-model"]);
let _ = cli.validate(&"any-model".into()).await;
let seen = runner.0.lock().map(|s| s.clone()).unwrap_or_default();
let (_, _, deadline) = seen.first().cloned().unwrap_or_default();
assert_eq!(deadline, PROBE_TIMEOUT);
}
#[tokio::test]
async fn a_shorter_chat_deadline_is_not_stretched_to_the_probe_ceiling() {
let runner = Arc::new(Probing::default());
let cli = LocalCli::new(
"test-cli",
"a-tool",
[] as [&str; 0],
Duration::from_secs(2),
)
.with_runner(runner.clone())
.with_probe(["--version"])
.serving(["any-model"]);
let _ = cli.validate(&"any-model".into()).await;
let seen = runner.0.lock().map(|s| s.clone()).unwrap_or_default();
let (_, _, deadline) = seen.first().cloned().unwrap_or_default();
assert_eq!(deadline, Duration::from_secs(2));
}
}