Skip to main content

bankr_agent_api/
client.rs

1//! Bankr Agent API client built on `hpx-transport`.
2
3use std::time::Duration;
4
5use hpx_transport::{
6    ExchangeClient, TypedResponse,
7    auth::ApiKeyAuth,
8    exchange::{RestClient, RestConfig},
9};
10use tracing::{debug, info, warn};
11
12use crate::{
13    error::BankrError,
14    types::{
15        CancelJobResponse, JobResponse, JobStatus, PromptRequest, PromptResponse, SignRequest,
16        SignResponse, SubmitRequest, SubmitResponse, UserInfoResponse,
17    },
18};
19
20/// Default base URL for the Bankr Agent API.
21const DEFAULT_BASE_URL: &str = "https://api.bankr.bot";
22
23/// Default request timeout.
24const DEFAULT_TIMEOUT: Duration = Duration::from_mins(1);
25
26/// Default polling interval when waiting for a job to complete.
27const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(2);
28
29/// Default maximum number of poll attempts.
30const DEFAULT_MAX_POLL_ATTEMPTS: u32 = 60;
31
32/// Client for the Bankr Agent API.
33///
34/// Uses `hpx-transport`'s [`RestClient`] with [`ApiKeyAuth`] to communicate
35/// with `https://api.bankr.bot`.
36#[derive(Debug)]
37pub struct BankrAgentClient {
38    rest: RestClient<ApiKeyAuth>,
39}
40
41impl BankrAgentClient {
42    /// Create a new client with the given API key and the default base URL.
43    ///
44    /// # Errors
45    ///
46    /// Returns [`BankrError::Config`] if the underlying HTTP client cannot be
47    /// created.
48    pub fn new(api_key: &str) -> Result<Self, BankrError> {
49        Self::with_base_url(api_key, DEFAULT_BASE_URL)
50    }
51
52    /// Create a new client with a custom base URL.
53    ///
54    /// # Errors
55    ///
56    /// Returns [`BankrError::Config`] if the underlying HTTP client cannot be
57    /// created.
58    pub fn with_base_url(api_key: &str, base_url: &str) -> Result<Self, BankrError> {
59        let config =
60            RestConfig::new(base_url).timeout(DEFAULT_TIMEOUT).user_agent("bankr-sdk-rs/0.1.0");
61
62        let auth = ApiKeyAuth::header("X-API-Key", api_key);
63
64        let rest = RestClient::new(config, auth).map_err(|e| BankrError::Config(e.to_string()))?;
65
66        Ok(Self { rest })
67    }
68
69    // -----------------------------------------------------------------------
70    // User Info
71    // -----------------------------------------------------------------------
72
73    /// Retrieve the authenticated user's profile.
74    ///
75    /// `GET /agent/me`
76    pub async fn get_me(&self) -> Result<UserInfoResponse, BankrError> {
77        debug!("GET /agent/me");
78        let resp: TypedResponse<UserInfoResponse> =
79            self.rest.get("/agent/me").await.map_err(transport_err)?;
80        Ok(resp.data)
81    }
82
83    // -----------------------------------------------------------------------
84    // Prompt
85    // -----------------------------------------------------------------------
86
87    /// Submit a natural language prompt to the Bankr AI agent.
88    ///
89    /// `POST /agent/prompt`
90    pub async fn submit_prompt(&self, req: &PromptRequest) -> Result<PromptResponse, BankrError> {
91        debug!(prompt = %req.prompt, "POST /agent/prompt");
92        let resp: TypedResponse<PromptResponse> =
93            self.rest.post("/agent/prompt", req).await.map_err(transport_err)?;
94        Ok(resp.data)
95    }
96
97    // -----------------------------------------------------------------------
98    // Job Management
99    // -----------------------------------------------------------------------
100
101    /// Get the status of a previously submitted job.
102    ///
103    /// `GET /agent/job/{jobId}`
104    pub async fn get_job(&self, job_id: &str) -> Result<JobResponse, BankrError> {
105        debug!(job_id, "GET /agent/job/{job_id}");
106        let path = format!("/agent/job/{job_id}");
107        let resp: TypedResponse<JobResponse> = self.rest.get(&path).await.map_err(transport_err)?;
108        Ok(resp.data)
109    }
110
111    /// Cancel a pending or processing job.
112    ///
113    /// `POST /agent/job/{jobId}/cancel`
114    pub async fn cancel_job(&self, job_id: &str) -> Result<CancelJobResponse, BankrError> {
115        debug!(job_id, "POST /agent/job/{job_id}/cancel");
116        let path = format!("/agent/job/{job_id}/cancel");
117        // The cancel endpoint expects an empty POST body.
118        let empty = serde_json::json!({});
119        let resp: TypedResponse<CancelJobResponse> =
120            self.rest.post(&path, &empty).await.map_err(transport_err)?;
121        Ok(resp.data)
122    }
123
124    // -----------------------------------------------------------------------
125    // Sign
126    // -----------------------------------------------------------------------
127
128    /// Sign a message, typed data, or transaction without broadcasting.
129    ///
130    /// `POST /agent/sign`
131    pub async fn sign(&self, req: &SignRequest) -> Result<SignResponse, BankrError> {
132        debug!(sig_type = %req.signature_type, "POST /agent/sign");
133        let resp: TypedResponse<SignResponse> =
134            self.rest.post("/agent/sign", req).await.map_err(transport_err)?;
135        Ok(resp.data)
136    }
137
138    // -----------------------------------------------------------------------
139    // Submit
140    // -----------------------------------------------------------------------
141
142    /// Submit a raw EVM transaction to the blockchain.
143    ///
144    /// `POST /agent/submit`
145    pub async fn submit_transaction(
146        &self,
147        req: &SubmitRequest,
148    ) -> Result<SubmitResponse, BankrError> {
149        debug!(chain_id = req.transaction.chain_id, "POST /agent/submit");
150        let resp: TypedResponse<SubmitResponse> =
151            self.rest.post("/agent/submit", req).await.map_err(transport_err)?;
152        Ok(resp.data)
153    }
154
155    // -----------------------------------------------------------------------
156    // Polling helper
157    // -----------------------------------------------------------------------
158
159    /// Submit a prompt and poll until the job completes (or fails / is
160    /// cancelled).
161    ///
162    /// Uses the default polling interval (2 s) and max attempts (60).
163    pub async fn prompt_and_wait(&self, req: &PromptRequest) -> Result<JobResponse, BankrError> {
164        self.prompt_and_wait_with(req, DEFAULT_POLL_INTERVAL, DEFAULT_MAX_POLL_ATTEMPTS).await
165    }
166
167    /// Submit a prompt and poll with custom interval and attempt count.
168    pub async fn prompt_and_wait_with(
169        &self,
170        req: &PromptRequest,
171        interval: Duration,
172        max_attempts: u32,
173    ) -> Result<JobResponse, BankrError> {
174        let prompt_resp = self.submit_prompt(req).await?;
175        info!(job_id = %prompt_resp.job_id, "Job submitted, polling…");
176        self.poll_job(&prompt_resp.job_id, interval, max_attempts).await
177    }
178
179    /// Poll a job until it reaches a terminal state.
180    pub async fn poll_job(
181        &self,
182        job_id: &str,
183        interval: Duration,
184        max_attempts: u32,
185    ) -> Result<JobResponse, BankrError> {
186        for attempt in 1..=max_attempts {
187            let job = self.get_job(job_id).await?;
188            debug!(attempt, status = %job.status, "Poll attempt");
189
190            match job.status {
191                JobStatus::Completed => return Ok(job),
192                JobStatus::Failed => {
193                    return Err(BankrError::JobFailed {
194                        message: job.error.unwrap_or_else(|| "unknown error".to_owned()),
195                    });
196                }
197                JobStatus::Cancelled => return Err(BankrError::JobCancelled),
198                JobStatus::Pending | JobStatus::Processing => {
199                    if attempt < max_attempts {
200                        tokio::time::sleep(interval).await;
201                    }
202                }
203            }
204        }
205
206        warn!(job_id, "Poll timeout reached");
207        Err(BankrError::PollTimeout { attempts: max_attempts })
208    }
209}
210
211/// Convert an `hpx_transport` error into a [`BankrError`].
212fn transport_err(err: impl std::fmt::Display) -> BankrError {
213    BankrError::Transport(err.to_string())
214}