pub const API_KEY_ENV: &str = "ROTEIRO_REMOTE_API_KEY";
const TIMEOUT_SECS: u64 = 120;
const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(TIMEOUT_SECS);
const MAX_RESPONSE_BYTES: u64 = 4 * 1024 * 1024;
pub fn call(endpoint: &rto_remote::Endpoint, body: &str) -> Result<String, String> {
let agent: ureq::Agent = ureq::config::Config::builder()
.max_redirects(0)
.timeout_global(Some(TIMEOUT))
.http_status_as_error(false)
.build()
.into();
let mut request = agent
.post(endpoint.url())
.header("content-type", "application/json");
if let Some(key) = api_key() {
request = request.header("authorization", format!("Bearer {key}"));
}
let mut response = request.send(body).map_err(|e| send_failure(&e))?;
let status = response.status().as_u16();
let text = response
.body_mut()
.with_config()
.limit(MAX_RESPONSE_BYTES)
.read_to_string()
.map_err(|e| format!("the response body did not arrive whole: {e}"))?;
if let Some(failure) = status_failure(status, &text) {
return Err(failure);
}
Ok(text)
}
pub fn api_key() -> Option<String> {
std::env::var(API_KEY_ENV)
.ok()
.map(|key| key.trim().to_owned())
.filter(|key| !key.is_empty())
}
pub fn api_key_is_set() -> bool {
api_key().is_some()
}
fn send_failure(error: &ureq::Error) -> String {
match error {
ureq::Error::Timeout(_) => format!(
"no response within {TIMEOUT_SECS}s, so the call was abandoned. \
The request had already been sent and is already in the ledger — \
a timeout does not un-send it"
),
ureq::Error::HostNotFound => format!(
"the host named by `[remote] endpoint` could not be resolved ({error}). \
That is a configuration error rather than an outage"
),
ureq::Error::RedirectFailed => format!(
"the endpoint answered with a redirect, which is not followed ({error}): \
the ledger records the endpoint that was consented to, and following the \
redirect would make that record wrong about where the bytes went. \
Point `[remote] endpoint` at the new URL deliberately"
),
other => other.to_string(),
}
}
fn status_failure(status: u16, body: &str) -> Option<String> {
if (200..300).contains(&status) {
return None;
}
let said = body.trim();
let said = if said.is_empty() {
"and sent no explanation".to_owned()
} else {
format!("and said: {}", excerpt(said))
};
let hint = match status {
401 | 403 => format!(
". The credential comes from `${API_KEY_ENV}` — it is not a config key, because \
`roteiro.toml` is committed by design"
),
300..=399 => ". Redirects are not followed: the ledger records the endpoint that was \
consented to, and following a redirect would make that record wrong about where \
the bytes went. Point `[remote] endpoint` at the new URL deliberately"
.to_owned(),
_ => String::new(),
};
Some(format!("the endpoint answered HTTP {status} {said}{hint}"))
}
fn excerpt(text: &str) -> String {
match text.char_indices().nth(200) {
None => text.to_owned(),
Some((cut, _)) => format!("{}…[truncated]", &text[..cut]),
}
}
pub fn may_prompt(reason: rto_remote::Reason) -> bool {
matches!(reason, rto_remote::Reason::InvocationUnset)
}
pub fn prompt_text(endpoint: &rto_remote::Endpoint, body: &str, fields: &[&'static str]) -> String {
format!(
"\nroteiro is about to send repository content off this machine.\n\
\n\
to: {url}\n\
as model: {model} (trust: {trust})\n\
carrying: {fields} ({bytes} bytes)\n\
\n\
--- the exact body ---\n{body}\n--- end of body ---\n\
\n{disclosure}\n",
url = endpoint.url(),
model = endpoint.model(),
trust = endpoint.trust().as_str(),
fields = fields.join(", "),
bytes = body.len(),
disclosure = rto_remote::Payload::disclosure(),
)
}
#[cfg(test)]
mod tests {
use super::{API_KEY_ENV, TIMEOUT_SECS, may_prompt, prompt_text, send_failure, status_failure};
use rto_remote::{Endpoint, ProducerTrust, Reason};
fn endpoint() -> Endpoint {
Endpoint::new(
"https://models.example/v1/chat/completions",
"a-vendor-model",
ProducerTrust::VendorAsserted,
)
.expect("a valid endpoint")
}
#[test]
fn a_send_failure_says_the_true_thing_about_why_nothing_came_back() {
let timed_out = send_failure(&ureq::Error::Timeout(ureq::Timeout::Global));
assert!(timed_out.contains(&TIMEOUT_SECS.to_string()), "{timed_out}");
assert!(
timed_out.contains("does not un-send it"),
"the bytes are gone and the ledger already says so: {timed_out}"
);
let refused = send_failure(&ureq::Error::Io(std::io::Error::new(
std::io::ErrorKind::ConnectionRefused,
"Connection refused",
)));
assert!(refused.contains("Connection refused"), "{refused}");
assert!(
!refused.contains(&TIMEOUT_SECS.to_string()),
"an instant refusal did not wait for anything: {refused}"
);
let unresolved = send_failure(&ureq::Error::HostNotFound);
assert!(unresolved.contains("[remote] endpoint"), "{unresolved}");
assert!(unresolved.contains("rather than an outage"), "{unresolved}");
let redirected = send_failure(&ureq::Error::RedirectFailed);
assert!(redirected.contains("consented to"), "{redirected}");
assert!(redirected.contains("not followed"), "{redirected}");
}
#[test]
fn a_success_status_is_not_a_failure() {
for status in [200, 201, 204, 299] {
assert_eq!(status_failure(status, "{}"), None, "HTTP {status}");
}
for status in [199, 300] {
assert!(
status_failure(status, "{}").is_some(),
"HTTP {status} is not a success"
);
}
}
#[test]
fn a_failure_status_quotes_what_the_endpoint_said() {
let detail = status_failure(400, r#"{"error":{"message":"model `x` does not exist"}}"#)
.expect("a failure");
assert!(detail.contains("HTTP 400"), "{detail}");
assert!(detail.contains("model `x` does not exist"), "{detail}");
let silent = status_failure(500, " ").expect("a failure");
assert!(silent.contains("sent no explanation"), "{silent}");
let huge = status_failure(502, &"x".repeat(5_000)).expect("a failure");
assert!(huge.contains("…[truncated]"), "bounded");
assert!(
huge.len() < 600,
"{} chars is not an error message",
huge.len()
);
}
#[test]
fn an_auth_failure_names_the_environment_variable() {
for status in [401, 403] {
let detail = status_failure(status, "unauthorized").expect("a failure");
assert!(detail.contains(API_KEY_ENV), "HTTP {status}: {detail}");
assert!(detail.contains("committed by design"), "{detail}");
}
let other = status_failure(500, "boom").expect("a failure");
assert!(!other.contains(API_KEY_ENV), "{other}");
}
#[test]
fn a_redirect_is_reported_as_a_refusal_with_its_reason() {
let detail = status_failure(302, "").expect("a failure");
assert!(detail.contains("Redirects are not followed"), "{detail}");
assert!(detail.contains("consented to"), "{detail}");
}
#[test]
fn a_prompt_may_only_supply_the_invocation_half_of_consent() {
assert!(
may_prompt(Reason::InvocationUnset),
"the run, not the human"
);
for reason in [
Reason::Granted,
Reason::ProjectDenied,
Reason::InvocationDenied,
Reason::PromptDeclined,
Reason::UserLayerDenied,
Reason::UserLayerUnset,
] {
assert!(
!may_prompt(reason),
"{reason:?} must not be resolvable at a prompt"
);
}
}
#[test]
fn the_prompt_shows_the_exact_body_and_the_full_disclosure() {
let endpoint = endpoint();
let payload = rto_remote::Payload::new(
"what changed?",
&[rto_graph::Node::new(
"adr:0019",
rto_graph::NodeKind::Adr,
"Remote tier",
)],
)
.expect("assembles");
let body = rto_remote::dry_run(&endpoint, &payload);
let text = prompt_text(&endpoint, &body, &payload.fields_present());
assert!(text.contains(&body), "the exact bytes, verbatim");
assert!(text.contains("https://models.example/v1/chat/completions"));
assert!(text.contains("vendor_asserted"), "{text}");
assert!(text.contains("no redaction chokepoint"), "{text}");
assert!(text.contains("DATABASE_URL"), "{text}");
}
}