Skip to main content

systemprompt_client/client/
mod.rs

1use crate::error::{ClientError, ClientResult};
2
3mod http;
4use chrono::Utc;
5use reqwest::Client;
6use std::time::Duration;
7use systemprompt_identifiers::{ContextId, JwtToken, TaskId};
8use systemprompt_models::a2a::{Task, methods};
9use systemprompt_models::admin::{AnalyticsData, LogEntry, UserInfo};
10use systemprompt_models::net::{
11    HTTP_AUTH_VERIFY_TIMEOUT, HTTP_DEFAULT_TIMEOUT, HTTP_HEALTH_CHECK_TIMEOUT,
12};
13use systemprompt_models::{
14    AgentCard, ApiPaths, CollectionResponse, CreateContextRequest, SingleResponse, UserContext,
15    UserContextWithStats,
16};
17
18#[derive(Debug, Clone)]
19pub struct SystempromptClient {
20    base_url: String,
21    token: Option<JwtToken>,
22    client: Client,
23}
24
25impl SystempromptClient {
26    pub fn new(base_url: &str) -> ClientResult<Self> {
27        let client = Client::builder().timeout(HTTP_DEFAULT_TIMEOUT).build()?;
28
29        Ok(Self {
30            base_url: base_url.trim_end_matches('/').to_owned(),
31            token: None,
32            client,
33        })
34    }
35
36    pub fn with_timeout(base_url: &str, timeout_secs: u64) -> ClientResult<Self> {
37        let client = Client::builder()
38            .timeout(Duration::from_secs(timeout_secs))
39            .build()?;
40
41        Ok(Self {
42            base_url: base_url.trim_end_matches('/').to_owned(),
43            token: None,
44            client,
45        })
46    }
47
48    #[must_use]
49    pub fn with_token(mut self, token: JwtToken) -> Self {
50        self.token = Some(token);
51        self
52    }
53
54    pub fn set_token(&mut self, token: JwtToken) {
55        self.token = Some(token);
56    }
57
58    #[must_use]
59    pub const fn token(&self) -> Option<&JwtToken> {
60        self.token.as_ref()
61    }
62
63    #[must_use]
64    pub fn base_url(&self) -> &str {
65        &self.base_url
66    }
67
68    pub async fn list_agents(&self) -> ClientResult<Vec<AgentCard>> {
69        let url = format!("{}{}", self.base_url, ApiPaths::AGENTS_REGISTRY);
70        let response: CollectionResponse<AgentCard> =
71            http::get(&self.client, &url, self.token.as_ref()).await?;
72        Ok(response.data)
73    }
74
75    pub async fn get_agent_card(&self, agent_name: &str) -> ClientResult<AgentCard> {
76        let url = format!(
77            "{}{}",
78            self.base_url,
79            ApiPaths::wellknown_agent_card_named(agent_name)
80        );
81        http::get(&self.client, &url, self.token.as_ref()).await
82    }
83
84    pub async fn list_contexts(&self) -> ClientResult<Vec<UserContextWithStats>> {
85        let url = format!(
86            "{}{}?sort=updated_at:desc",
87            self.base_url,
88            ApiPaths::CORE_CONTEXTS
89        );
90        let response: CollectionResponse<UserContextWithStats> =
91            http::get(&self.client, &url, self.token.as_ref()).await?;
92        Ok(response.data)
93    }
94
95    pub async fn get_context(&self, context_id: &ContextId) -> ClientResult<UserContext> {
96        let url = format!(
97            "{}{}/{}",
98            self.base_url,
99            ApiPaths::CORE_CONTEXTS,
100            context_id.as_ref()
101        );
102        let response: SingleResponse<UserContext> =
103            http::get(&self.client, &url, self.token.as_ref()).await?;
104        Ok(response.data)
105    }
106
107    pub async fn create_context(&self, name: Option<&str>) -> ClientResult<UserContext> {
108        let url = format!("{}{}", self.base_url, ApiPaths::CORE_CONTEXTS);
109        let request = CreateContextRequest {
110            name: name.map(String::from),
111        };
112        let response: SingleResponse<UserContext> =
113            http::post(&self.client, &url, &request, self.token.as_ref()).await?;
114        Ok(response.data)
115    }
116
117    pub async fn create_context_auto_name(&self) -> ClientResult<UserContext> {
118        let name = format!("Session {}", Utc::now().format("%Y-%m-%d %H:%M"));
119        self.create_context(Some(&name)).await
120    }
121
122    pub async fn fetch_or_create_context(&self) -> ClientResult<ContextId> {
123        let contexts = self.list_contexts().await?;
124        if let Some(ctx) = contexts.first() {
125            return Ok(ctx.context_id.clone());
126        }
127        let context = self.create_context_auto_name().await?;
128        Ok(context.context_id)
129    }
130
131    pub async fn update_context_name(
132        &self,
133        context_id: &ContextId,
134        name: &str,
135    ) -> ClientResult<()> {
136        let url = format!(
137            "{}{}/{}",
138            self.base_url,
139            ApiPaths::CORE_CONTEXTS,
140            context_id.as_str()
141        );
142        let body = serde_json::json!({ "name": name });
143        http::put(&self.client, &url, &body, self.token.as_ref()).await
144    }
145
146    pub async fn delete_context(&self, context_id: &ContextId) -> ClientResult<()> {
147        let url = format!(
148            "{}{}/{}",
149            self.base_url,
150            ApiPaths::CORE_CONTEXTS,
151            context_id.as_str()
152        );
153        http::delete(&self.client, &url, self.token.as_ref()).await
154    }
155
156    pub async fn list_tasks(&self, context_id: &ContextId) -> ClientResult<Vec<Task>> {
157        let url = format!(
158            "{}{}/{}/tasks",
159            self.base_url,
160            ApiPaths::CORE_CONTEXTS,
161            context_id.as_str()
162        );
163        http::get(&self.client, &url, self.token.as_ref()).await
164    }
165
166    pub async fn delete_task(&self, task_id: &TaskId) -> ClientResult<()> {
167        let url = format!(
168            "{}{}/{}",
169            self.base_url,
170            ApiPaths::CORE_TASKS,
171            task_id.as_str()
172        );
173        http::delete(&self.client, &url, self.token.as_ref()).await
174    }
175
176    // JSON: HTTP boundary. The shared client does not depend on the agent
177    // crate, so artifact rows are surfaced as raw JSON; callers that need
178    // typed access deserialize into `systemprompt_models::a2a::Artifact`.
179    pub async fn list_artifacts(
180        &self,
181        context_id: &ContextId,
182    ) -> ClientResult<Vec<serde_json::Value>> {
183        let url = format!(
184            "{}{}/{}/artifacts",
185            self.base_url,
186            ApiPaths::CORE_CONTEXTS,
187            context_id.as_str()
188        );
189        http::get(&self.client, &url, self.token.as_ref()).await
190    }
191
192    pub async fn check_health(&self) -> bool {
193        let url = format!("{}{}", self.base_url, ApiPaths::HEALTH);
194        self.client
195            .get(&url)
196            .timeout(HTTP_HEALTH_CHECK_TIMEOUT)
197            .send()
198            .await
199            .is_ok()
200    }
201
202    pub async fn verify_token(&self) -> ClientResult<bool> {
203        let url = format!("{}{}", self.base_url, ApiPaths::AUTH_ME);
204        let auth = self.auth_header()?;
205        let response = self
206            .client
207            .get(&url)
208            .timeout(HTTP_AUTH_VERIFY_TIMEOUT)
209            .header("Authorization", auth)
210            .send()
211            .await?;
212
213        Ok(response.status().is_success())
214    }
215
216    // JSON: A2A JSON-RPC 2.0 envelope. Both the inbound `message` and the
217    // returned response object are passed through as raw JSON so the shared
218    // client stays free of the agent-domain dependency.
219    pub async fn send_message(
220        &self,
221        agent_name: &str,
222        context_id: &ContextId,
223        message: serde_json::Value,
224    ) -> ClientResult<serde_json::Value> {
225        let url = format!("{}{}/{}/", self.base_url, ApiPaths::AGENTS_BASE, agent_name);
226        let request = serde_json::json!({
227            "jsonrpc": "2.0",
228            "method": methods::SEND_MESSAGE,
229            "params": { "message": message },
230            "id": context_id.as_ref()
231        });
232        http::post(&self.client, &url, &request, self.token.as_ref()).await
233    }
234
235    fn limited_url(&self, path: &str, limit: Option<u32>) -> String {
236        limit.map_or_else(
237            || format!("{}{}", self.base_url, path),
238            |l| format!("{}{}?limit={}", self.base_url, path, l),
239        )
240    }
241
242    pub async fn list_logs(&self, limit: Option<u32>) -> ClientResult<Vec<LogEntry>> {
243        let url = self.limited_url(ApiPaths::ADMIN_LOGS, limit);
244        http::get(&self.client, &url, self.token.as_ref()).await
245    }
246
247    pub async fn list_users(&self, limit: Option<u32>) -> ClientResult<Vec<UserInfo>> {
248        let url = self.limited_url(ApiPaths::ADMIN_USERS, limit);
249        http::get(&self.client, &url, self.token.as_ref()).await
250    }
251
252    pub async fn get_analytics(&self) -> ClientResult<AnalyticsData> {
253        let url = format!("{}{}", self.base_url, ApiPaths::ADMIN_ANALYTICS);
254        http::get(&self.client, &url, self.token.as_ref()).await
255    }
256
257    // JSON: HTTP boundary, see `list_artifacts`.
258    pub async fn list_all_artifacts(
259        &self,
260        limit: Option<u32>,
261    ) -> ClientResult<Vec<serde_json::Value>> {
262        let url = self.limited_url(ApiPaths::CORE_ARTIFACTS, limit);
263        http::get(&self.client, &url, self.token.as_ref()).await
264    }
265
266    fn auth_header(&self) -> ClientResult<String> {
267        self.token.as_ref().map_or_else(
268            || Err(ClientError::AuthError("No token configured".to_owned())),
269            |token| Ok(format!("Bearer {}", token.as_str())),
270        )
271    }
272}