use crate::auth::ApiKey;
use crate::error::{Error, Result};
use crate::models::*;
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue};
use url::Url;
const DEFAULT_BASE_URL: &str = "https://custom.example.com/api";
#[derive(Clone)]
pub struct Everruns {
http: reqwest::Client,
base_url: Url,
api_key: ApiKey,
}
impl Everruns {
pub fn new(api_key: impl Into<String>) -> Result<Self> {
Self::with_base_url(api_key, DEFAULT_BASE_URL)
}
pub fn from_env() -> Result<Self> {
let api_key = ApiKey::from_env()?;
let base_url =
std::env::var("EVERRUNS_API_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string());
Self::with_api_key_and_url(api_key, &base_url)
}
pub fn with_base_url(api_key: impl Into<String>, base_url: &str) -> Result<Self> {
let api_key = ApiKey::new(api_key);
Self::with_api_key_and_url(api_key, base_url)
}
pub fn with_api_key(api_key: ApiKey) -> Result<Self> {
Self::with_api_key_and_url(api_key, DEFAULT_BASE_URL)
}
fn with_api_key_and_url(api_key: ApiKey, base_url: &str) -> Result<Self> {
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()?;
let normalized = if base_url.ends_with('/') {
base_url.to_string()
} else {
format!("{}/", base_url)
};
let base_url = Url::parse(&normalized)?;
Ok(Self {
http,
base_url,
api_key,
})
}
pub fn agents(&self) -> AgentsClient<'_> {
AgentsClient { client: self }
}
pub fn sessions(&self) -> SessionsClient<'_> {
SessionsClient { client: self }
}
pub fn messages(&self) -> MessagesClient<'_> {
MessagesClient { client: self }
}
pub fn events(&self) -> EventsClient<'_> {
EventsClient { client: self }
}
pub fn capabilities(&self) -> CapabilitiesClient<'_> {
CapabilitiesClient { client: self }
}
pub fn session_files(&self) -> SessionFilesClient<'_> {
SessionFilesClient { client: self }
}
pub fn connections(&self) -> ConnectionsClient<'_> {
ConnectionsClient { client: self }
}
pub fn budgets(&self) -> BudgetsClient<'_> {
BudgetsClient { client: self }
}
pub(crate) fn url(&self, path: &str) -> Url {
let path_without_slash = path.strip_prefix('/').unwrap_or(path);
let full_path = format!("v1/{}", path_without_slash);
self.base_url.join(&full_path).expect("valid URL")
}
fn headers(&self) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(self.api_key.expose()).expect("valid header"),
);
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
headers
}
pub(crate) async fn get<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T> {
let resp = self
.http
.get(self.url(path))
.headers(self.headers())
.send()
.await?;
self.handle_response(resp).await
}
pub(crate) async fn get_url<T: serde::de::DeserializeOwned>(&self, url: Url) -> Result<T> {
let resp = self.http.get(url).headers(self.headers()).send().await?;
self.handle_response(resp).await
}
pub(crate) async fn post<T: serde::de::DeserializeOwned, B: serde::Serialize>(
&self,
path: &str,
body: &B,
) -> Result<T> {
let resp = self
.http
.post(self.url(path))
.headers(self.headers())
.json(body)
.send()
.await?;
self.handle_response(resp).await
}
pub(crate) async fn patch<T: serde::de::DeserializeOwned, B: serde::Serialize>(
&self,
path: &str,
body: &B,
) -> Result<T> {
let resp = self
.http
.patch(self.url(path))
.headers(self.headers())
.json(body)
.send()
.await?;
self.handle_response(resp).await
}
pub(crate) async fn post_text<T: serde::de::DeserializeOwned>(
&self,
path: &str,
body: &str,
) -> Result<T> {
let mut headers = self.headers();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("text/plain"));
let resp = self
.http
.post(self.url(path))
.headers(headers)
.body(body.to_string())
.send()
.await?;
self.handle_response(resp).await
}
pub(crate) async fn get_text(&self, path: &str) -> Result<String> {
let resp = self
.http
.get(self.url(path))
.headers(self.headers())
.send()
.await?;
if resp.status().is_success() {
Ok(resp.text().await?)
} else {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
Err(Error::from_api_response(status, &body))
}
}
pub(crate) async fn put<T: serde::de::DeserializeOwned, B: serde::Serialize>(
&self,
path: &str,
body: &B,
) -> Result<T> {
let resp = self
.http
.put(self.url(path))
.headers(self.headers())
.json(body)
.send()
.await?;
self.handle_response(resp).await
}
pub(crate) async fn put_empty(&self, path: &str) -> Result<()> {
let resp = self
.http
.put(self.url(path))
.headers(self.headers())
.send()
.await?;
if resp.status().is_success() {
Ok(())
} else {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
Err(Error::from_api_response(status, &body))
}
}
pub(crate) async fn delete(&self, path: &str) -> Result<()> {
let resp = self
.http
.delete(self.url(path))
.headers(self.headers())
.send()
.await?;
if resp.status().is_success() {
Ok(())
} else {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
Err(Error::from_api_response(status, &body))
}
}
pub(crate) async fn delete_url<T: serde::de::DeserializeOwned>(&self, url: Url) -> Result<T> {
let resp = self.http.delete(url).headers(self.headers()).send().await?;
self.handle_response(resp).await
}
async fn handle_response<T: serde::de::DeserializeOwned>(
&self,
resp: reqwest::Response,
) -> Result<T> {
if resp.status().is_success() {
Ok(resp.json().await?)
} else {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
Err(Error::from_api_response(status, &body))
}
}
pub(crate) fn sse_url(
&self,
session_id: &str,
since_id: Option<&str>,
types: &[&str],
exclude: &[&str],
) -> Url {
let mut url = self.url(&format!("/sessions/{}/sse", session_id));
if let Some(id) = since_id {
url.query_pairs_mut().append_pair("since_id", id);
}
for t in types {
url.query_pairs_mut().append_pair("types", t);
}
for e in exclude {
url.query_pairs_mut().append_pair("exclude", e);
}
url
}
pub(crate) fn auth_header(&self) -> String {
self.api_key.expose().to_string()
}
}
pub struct AgentsClient<'a> {
client: &'a Everruns,
}
impl<'a> AgentsClient<'a> {
pub async fn list(&self) -> Result<ListResponse<Agent>> {
self.client.get("/agents").await
}
pub async fn search(&self, query: &str) -> Result<ListResponse<Agent>> {
let mut url = self.client.url("/agents");
url.query_pairs_mut().append_pair("search", query);
self.client.get_url(url).await
}
pub async fn get(&self, id: &str) -> Result<Agent> {
self.client.get(&format!("/agents/{}", id)).await
}
pub async fn create(&self, name: &str, system_prompt: &str) -> Result<Agent> {
let req = CreateAgentRequest::new(name, system_prompt);
self.client.post("/agents", &req).await
}
pub async fn create_with_options(&self, req: CreateAgentRequest) -> Result<Agent> {
self.client.post("/agents", &req).await
}
pub async fn apply(&self, id: &str, name: &str, system_prompt: &str) -> Result<Agent> {
let req = CreateAgentRequest::new(name, system_prompt).id(id);
self.client.post("/agents", &req).await
}
pub async fn apply_with_options(&self, id: &str, req: CreateAgentRequest) -> Result<Agent> {
let req = req.id(id);
self.client.post("/agents", &req).await
}
pub async fn copy(&self, id: &str) -> Result<Agent> {
self.client
.post::<Agent, _>(&format!("/agents/{}/copy", id), &())
.await
}
pub async fn delete(&self, id: &str) -> Result<()> {
self.client.delete(&format!("/agents/{}", id)).await
}
pub async fn import(&self, content: &str) -> Result<Agent> {
self.client.post_text("/agents/import", content).await
}
pub async fn export(&self, id: &str) -> Result<String> {
self.client
.get_text(&format!("/agents/{}/export", id))
.await
}
}
pub struct SessionsClient<'a> {
client: &'a Everruns,
}
impl<'a> SessionsClient<'a> {
pub async fn list(&self) -> Result<ListResponse<Session>> {
self.client.get("/sessions").await
}
pub async fn search(&self, query: &str) -> Result<ListResponse<Session>> {
let mut url = self.client.url("/sessions");
url.query_pairs_mut().append_pair("search", query);
self.client.get_url(url).await
}
pub async fn get(&self, id: &str) -> Result<Session> {
self.client.get(&format!("/sessions/{}", id)).await
}
pub async fn create(&self) -> Result<Session> {
let req = CreateSessionRequest::new();
self.client.post("/sessions", &req).await
}
pub async fn create_with_options(&self, req: CreateSessionRequest) -> Result<Session> {
self.client.post("/sessions", &req).await
}
pub async fn delete(&self, id: &str) -> Result<()> {
self.client.delete(&format!("/sessions/{}", id)).await
}
pub async fn cancel(&self, id: &str) -> Result<()> {
self.client
.post::<serde_json::Value, _>(&format!("/sessions/{}/cancel", id), &())
.await?;
Ok(())
}
pub async fn pin(&self, id: &str) -> Result<()> {
self.client
.put_empty(&format!("/sessions/{}/pin", id))
.await
}
pub async fn unpin(&self, id: &str) -> Result<()> {
self.client.delete(&format!("/sessions/{}/pin", id)).await
}
pub async fn budgets(&self, id: &str) -> Result<Vec<Budget>> {
self.client.get(&format!("/sessions/{}/budgets", id)).await
}
pub async fn budget_check(&self, id: &str) -> Result<BudgetCheckResult> {
self.client
.get(&format!("/sessions/{}/budget-check", id))
.await
}
pub async fn resume(&self, id: &str) -> Result<ResumeSessionResponse> {
self.client
.post::<ResumeSessionResponse, _>(&format!("/sessions/{}/resume", id), &())
.await
}
pub async fn set_secrets(
&self,
id: &str,
secrets: &std::collections::HashMap<String, String>,
) -> Result<()> {
let req = SetSecretsRequest::new(secrets.clone());
self.client
.put::<serde_json::Value, _>(&format!("/sessions/{}/storage/secrets", id), &req)
.await?;
Ok(())
}
}
pub struct MessagesClient<'a> {
client: &'a Everruns,
}
impl<'a> MessagesClient<'a> {
pub async fn list(&self, session_id: &str) -> Result<ListResponse<Message>> {
self.client
.get(&format!("/sessions/{}/messages", session_id))
.await
}
pub async fn create(&self, session_id: &str, text: &str) -> Result<Message> {
let req = CreateMessageRequest::user_text(text);
self.client
.post(&format!("/sessions/{}/messages", session_id), &req)
.await
}
pub async fn create_tool_results(
&self,
session_id: &str,
results: Vec<ContentPart>,
) -> Result<Message> {
let req = CreateMessageRequest::tool_results(results);
self.client
.post(&format!("/sessions/{}/messages", session_id), &req)
.await
}
pub async fn create_with_options(
&self,
session_id: &str,
req: CreateMessageRequest,
) -> Result<Message> {
self.client
.post(&format!("/sessions/{}/messages", session_id), &req)
.await
}
}
pub struct EventsClient<'a> {
client: &'a Everruns,
}
#[derive(Debug, Clone, Default)]
pub struct ListEventsOptions {
pub types: Vec<String>,
pub exclude: Vec<String>,
pub limit: Option<u32>,
pub before_sequence: Option<i32>,
}
impl<'a> EventsClient<'a> {
pub async fn list(&self, session_id: &str) -> Result<ListResponse<Event>> {
self.client
.get(&format!("/sessions/{}/events", session_id))
.await
}
pub async fn list_with_options(
&self,
session_id: &str,
options: &ListEventsOptions,
) -> Result<ListResponse<Event>> {
let mut url = self.client.url(&format!("/sessions/{}/events", session_id));
for t in &options.types {
url.query_pairs_mut().append_pair("types", t);
}
for e in &options.exclude {
url.query_pairs_mut().append_pair("exclude", e);
}
if let Some(limit) = options.limit {
url.query_pairs_mut()
.append_pair("limit", &limit.to_string());
}
if let Some(seq) = options.before_sequence {
url.query_pairs_mut()
.append_pair("before_sequence", &seq.to_string());
}
self.client.get_url(url).await
}
pub fn stream(&self, session_id: &str) -> crate::sse::EventStream {
crate::sse::EventStream::new(
self.client.clone(),
session_id.to_string(),
crate::sse::StreamOptions::default(),
)
}
pub fn stream_with_options(
&self,
session_id: &str,
options: crate::sse::StreamOptions,
) -> crate::sse::EventStream {
crate::sse::EventStream::new(self.client.clone(), session_id.to_string(), options)
}
}
pub struct CapabilitiesClient<'a> {
client: &'a Everruns,
}
impl<'a> CapabilitiesClient<'a> {
pub async fn list(&self) -> Result<ListResponse<CapabilityInfo>> {
self.client.get("/capabilities").await
}
pub async fn get(&self, id: &str) -> Result<CapabilityInfo> {
self.client.get(&format!("/capabilities/{}", id)).await
}
}
pub struct SessionFilesClient<'a> {
client: &'a Everruns,
}
impl<'a> SessionFilesClient<'a> {
pub async fn list(
&self,
session_id: &str,
path: Option<&str>,
recursive: Option<bool>,
) -> Result<ListResponse<FileInfo>> {
let api_path = match path {
Some(p) => format!("/sessions/{}/fs/{}", session_id, p.trim_start_matches('/')),
None => format!("/sessions/{}/fs", session_id),
};
let mut url = self.client.url(&api_path);
if let Some(true) = recursive {
url.query_pairs_mut().append_pair("recursive", "true");
}
self.client.get_url(url).await
}
pub async fn read(&self, session_id: &str, path: &str) -> Result<SessionFile> {
self.client
.get(&format!(
"/sessions/{}/fs/{}",
session_id,
path.trim_start_matches('/')
))
.await
}
pub async fn create(
&self,
session_id: &str,
path: &str,
content: &str,
encoding: Option<&str>,
) -> Result<SessionFile> {
let mut req = CreateFileRequest::file(content);
if let Some(enc) = encoding {
req = req.encoding(enc);
}
self.client
.post(
&format!(
"/sessions/{}/fs/{}",
session_id,
path.trim_start_matches('/')
),
&req,
)
.await
}
pub async fn create_with_options(
&self,
session_id: &str,
path: &str,
req: CreateFileRequest,
) -> Result<SessionFile> {
self.client
.post(
&format!(
"/sessions/{}/fs/{}",
session_id,
path.trim_start_matches('/')
),
&req,
)
.await
}
pub async fn create_dir(&self, session_id: &str, path: &str) -> Result<SessionFile> {
self.create_with_options(session_id, path, CreateFileRequest::directory())
.await
}
pub async fn update(
&self,
session_id: &str,
path: &str,
content: &str,
encoding: Option<&str>,
) -> Result<SessionFile> {
let mut req = UpdateFileRequest::content(content);
if let Some(enc) = encoding {
req = req.encoding(enc);
}
self.client
.put(
&format!(
"/sessions/{}/fs/{}",
session_id,
path.trim_start_matches('/')
),
&req,
)
.await
}
pub async fn update_with_options(
&self,
session_id: &str,
path: &str,
req: UpdateFileRequest,
) -> Result<SessionFile> {
self.client
.put(
&format!(
"/sessions/{}/fs/{}",
session_id,
path.trim_start_matches('/')
),
&req,
)
.await
}
pub async fn delete(
&self,
session_id: &str,
path: &str,
recursive: Option<bool>,
) -> Result<DeleteResponse> {
let mut url = self.client.url(&format!(
"/sessions/{}/fs/{}",
session_id,
path.trim_start_matches('/')
));
if let Some(true) = recursive {
url.query_pairs_mut().append_pair("recursive", "true");
}
self.client.delete_url(url).await
}
pub async fn move_file(
&self,
session_id: &str,
src_path: &str,
dst_path: &str,
) -> Result<SessionFile> {
let req = MoveFileRequest::new(src_path, dst_path);
self.client
.post(&format!("/sessions/{}/fs/_/move", session_id), &req)
.await
}
pub async fn copy_file(
&self,
session_id: &str,
src_path: &str,
dst_path: &str,
) -> Result<SessionFile> {
let req = CopyFileRequest::new(src_path, dst_path);
self.client
.post(&format!("/sessions/{}/fs/_/copy", session_id), &req)
.await
}
pub async fn grep(
&self,
session_id: &str,
pattern: &str,
path_pattern: Option<&str>,
) -> Result<ListResponse<GrepResult>> {
let mut req = GrepRequest::new(pattern);
if let Some(pp) = path_pattern {
req = req.path_pattern(pp);
}
self.client
.post(&format!("/sessions/{}/fs/_/grep", session_id), &req)
.await
}
pub async fn stat(&self, session_id: &str, path: &str) -> Result<FileStat> {
let req = StatRequest::new(path);
self.client
.post(&format!("/sessions/{}/fs/_/stat", session_id), &req)
.await
}
}
pub struct BudgetsClient<'a> {
client: &'a Everruns,
}
impl<'a> BudgetsClient<'a> {
pub async fn create(&self, req: CreateBudgetRequest) -> Result<Budget> {
self.client.post("/budgets", &req).await
}
pub async fn list(
&self,
subject_type: Option<&str>,
subject_id: Option<&str>,
) -> Result<Vec<Budget>> {
let mut url = self.client.url("/budgets");
if let Some(st) = subject_type {
url.query_pairs_mut().append_pair("subject_type", st);
}
if let Some(si) = subject_id {
url.query_pairs_mut().append_pair("subject_id", si);
}
self.client.get_url(url).await
}
pub async fn get(&self, id: &str) -> Result<Budget> {
self.client.get(&format!("/budgets/{}", id)).await
}
pub async fn update(&self, id: &str, req: UpdateBudgetRequest) -> Result<Budget> {
self.client.patch(&format!("/budgets/{}", id), &req).await
}
pub async fn delete(&self, id: &str) -> Result<()> {
self.client.delete(&format!("/budgets/{}", id)).await
}
pub async fn top_up(&self, id: &str, req: TopUpRequest) -> Result<Budget> {
self.client
.post(&format!("/budgets/{}/top-up", id), &req)
.await
}
pub async fn ledger(
&self,
id: &str,
limit: Option<i64>,
offset: Option<i64>,
) -> Result<Vec<LedgerEntry>> {
let mut url = self.client.url(&format!("/budgets/{}/ledger", id));
if let Some(l) = limit {
url.query_pairs_mut().append_pair("limit", &l.to_string());
}
if let Some(o) = offset {
url.query_pairs_mut().append_pair("offset", &o.to_string());
}
self.client.get_url(url).await
}
pub async fn check(&self, id: &str) -> Result<BudgetCheckResult> {
self.client.get(&format!("/budgets/{}/check", id)).await
}
}
pub struct ConnectionsClient<'a> {
client: &'a Everruns,
}
impl<'a> ConnectionsClient<'a> {
pub async fn set(&self, provider: &str, api_key: &str) -> Result<Connection> {
let req = SetConnectionRequest::new(api_key);
self.client
.post(&format!("/user/connections/{}", provider), &req)
.await
}
pub async fn list(&self) -> Result<ListResponse<Connection>> {
self.client.get("/user/connections").await
}
pub async fn remove(&self, provider: &str) -> Result<()> {
self.client
.delete(&format!("/user/connections/{}", provider))
.await
}
}
impl std::fmt::Debug for Everruns {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Everruns")
.field("base_url", &self.base_url.as_str())
.field("api_key", &self.api_key)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_client() -> Everruns {
Everruns::with_base_url("test_key", "https://api.example.com").unwrap()
}
#[test]
fn test_sse_url_no_params() {
let client = test_client();
let url = client.sse_url("session_123", None, &[], &[]);
assert_eq!(
url.as_str(),
"https://api.example.com/v1/sessions/session_123/sse"
);
}
#[test]
fn test_sse_url_with_since_id() {
let client = test_client();
let url = client.sse_url("session_123", Some("evt_001"), &[], &[]);
assert_eq!(
url.as_str(),
"https://api.example.com/v1/sessions/session_123/sse?since_id=evt_001"
);
}
#[test]
fn test_sse_url_exclude_expands_as_repeated_keys() {
let client = test_client();
let url = client.sse_url(
"session_123",
None,
&[],
&["output.message.delta", "reason.thinking.delta"],
);
let url_str = url.as_str();
assert!(
url_str.contains("exclude=output.message.delta"),
"URL missing first exclude: {}",
url_str
);
assert!(
url_str.contains("exclude=reason.thinking.delta"),
"URL missing second exclude: {}",
url_str
);
assert!(
!url_str.contains(','),
"URL must not use comma-separated excludes: {}",
url_str
);
assert_eq!(
url_str,
"https://api.example.com/v1/sessions/session_123/sse?exclude=output.message.delta&exclude=reason.thinking.delta"
);
}
#[test]
fn test_sse_url_single_exclude() {
let client = test_client();
let url = client.sse_url("session_123", None, &[], &["output.message.delta"]);
assert_eq!(
url.as_str(),
"https://api.example.com/v1/sessions/session_123/sse?exclude=output.message.delta"
);
}
#[test]
fn test_sse_url_combined_since_id_and_exclude() {
let client = test_client();
let url = client.sse_url(
"session_123",
Some("evt_001"),
&[],
&["output.message.delta", "reason.thinking.delta"],
);
assert_eq!(
url.as_str(),
"https://api.example.com/v1/sessions/session_123/sse?since_id=evt_001&exclude=output.message.delta&exclude=reason.thinking.delta"
);
}
#[test]
fn test_sse_url_three_exclude_values() {
let client = test_client();
let url = client.sse_url(
"session_123",
None,
&[],
&[
"output.message.delta",
"reason.thinking.delta",
"tool.started",
],
);
let url_str = url.as_str();
assert_eq!(url_str.matches("exclude=").count(), 3);
}
#[test]
fn test_sse_url_since_id_special_chars_encoded() {
let client = test_client();
let url = client.sse_url("session_123", Some("evt&id=1"), &[], &[]);
let url_str = url.as_str();
assert!(!url_str.contains("evt&id=1"));
assert!(url_str.contains("since_id=evt%26id%3D1"));
}
#[test]
fn test_sse_url_with_types() {
let client = test_client();
let url = client.sse_url(
"session_123",
None,
&["turn.started", "turn.completed"],
&[],
);
assert_eq!(
url.as_str(),
"https://api.example.com/v1/sessions/session_123/sse?types=turn.started&types=turn.completed"
);
}
#[test]
fn test_sse_url_with_types_and_exclude() {
let client = test_client();
let url = client.sse_url(
"session_123",
Some("evt_001"),
&["turn.started"],
&["output.message.delta"],
);
assert_eq!(
url.as_str(),
"https://api.example.com/v1/sessions/session_123/sse?since_id=evt_001&types=turn.started&exclude=output.message.delta"
);
}
}