scud/opencode/
client.rs

1//! HTTP client for OpenCode Server
2//!
3//! Provides async HTTP operations for creating sessions, sending messages,
4//! and managing agent lifecycle via the OpenCode Server REST API.
5
6use anyhow::{Context, Result};
7use reqwest::Client;
8use std::time::Duration;
9
10use super::types::*;
11
12/// HTTP client for OpenCode Server API
13pub struct OpenCodeClient {
14    base_url: String,
15    client: Client,
16}
17
18impl OpenCodeClient {
19    /// Create a new client connecting to the given base URL
20    pub fn new(base_url: &str) -> Self {
21        let client = Client::builder()
22            .timeout(Duration::from_secs(300)) // 5 min timeout for long operations
23            .build()
24            .expect("Failed to create HTTP client");
25
26        Self {
27            base_url: base_url.trim_end_matches('/').to_string(),
28            client,
29        }
30    }
31
32    /// Create with default localhost URL
33    pub fn localhost(port: u16) -> Self {
34        Self::new(&format!("http://127.0.0.1:{}", port))
35    }
36
37    /// Check if server is ready
38    pub async fn health_check(&self) -> Result<bool> {
39        let response = self
40            .client
41            .get(format!("{}/health", self.base_url))
42            .timeout(Duration::from_secs(2))
43            .send()
44            .await;
45
46        match response {
47            Ok(r) => Ok(r.status().is_success()),
48            Err(_) => Ok(false),
49        }
50    }
51
52    /// Get server info
53    pub async fn server_info(&self) -> Result<ServerInfo> {
54        let response = self
55            .client
56            .get(format!("{}/", self.base_url))
57            .send()
58            .await
59            .context("Failed to get server info")?;
60
61        if !response.status().is_success() {
62            let error: ErrorResponse = response.json().await.unwrap_or(ErrorResponse {
63                error: "Unknown error".to_string(),
64                details: None,
65            });
66            anyhow::bail!("Server error: {}", error.error);
67        }
68
69        response
70            .json()
71            .await
72            .context("Failed to parse server info")
73    }
74
75    /// Create a new session
76    pub async fn create_session(&self, title: &str) -> Result<Session> {
77        let request = CreateSessionRequest {
78            title: title.to_string(),
79            system_prompt: None,
80        };
81
82        let response = self
83            .client
84            .post(format!("{}/session", self.base_url))
85            .json(&request)
86            .send()
87            .await
88            .context("Failed to create session")?;
89
90        if !response.status().is_success() {
91            let status = response.status();
92            let error_text = response.text().await.unwrap_or_default();
93            anyhow::bail!("Failed to create session ({}): {}", status, error_text);
94        }
95
96        response
97            .json()
98            .await
99            .context("Failed to parse session response")
100    }
101
102    /// Send a message/prompt to a session
103    pub async fn send_message(
104        &self,
105        session_id: &str,
106        text: &str,
107        model: Option<(&str, &str)>, // (provider_id, model_id)
108    ) -> Result<()> {
109        let request = MessageRequest {
110            parts: vec![MessagePart::Text {
111                text: text.to_string(),
112            }],
113            model: model.map(|(provider, model_id)| ModelSpec {
114                provider_id: provider.to_string(),
115                model_id: model_id.to_string(),
116            }),
117        };
118
119        let response = self
120            .client
121            .post(format!("{}/session/{}/message", self.base_url, session_id))
122            .json(&request)
123            .send()
124            .await
125            .context("Failed to send message")?;
126
127        if !response.status().is_success() {
128            let status = response.status();
129            let error_text = response.text().await.unwrap_or_default();
130            anyhow::bail!("Failed to send message ({}): {}", status, error_text);
131        }
132
133        Ok(())
134    }
135
136    /// Get session status
137    pub async fn get_session_status(&self, session_id: &str) -> Result<SessionStatus> {
138        let response = self
139            .client
140            .get(format!("{}/session/{}", self.base_url, session_id))
141            .send()
142            .await
143            .context("Failed to get session status")?;
144
145        if !response.status().is_success() {
146            let status = response.status();
147            let error_text = response.text().await.unwrap_or_default();
148            anyhow::bail!("Failed to get session ({}): {}", status, error_text);
149        }
150
151        response
152            .json()
153            .await
154            .context("Failed to parse session status")
155    }
156
157    /// Abort/cancel a running session
158    pub async fn abort_session(&self, session_id: &str) -> Result<()> {
159        let response = self
160            .client
161            .post(format!("{}/session/{}/abort", self.base_url, session_id))
162            .send()
163            .await
164            .context("Failed to abort session")?;
165
166        if !response.status().is_success() {
167            let status = response.status();
168            let error_text = response.text().await.unwrap_or_default();
169            anyhow::bail!("Failed to abort session ({}): {}", status, error_text);
170        }
171
172        Ok(())
173    }
174
175    /// Delete a session
176    pub async fn delete_session(&self, session_id: &str) -> Result<()> {
177        let response = self
178            .client
179            .delete(format!("{}/session/{}", self.base_url, session_id))
180            .send()
181            .await
182            .context("Failed to delete session")?;
183
184        if !response.status().is_success() {
185            let status = response.status();
186            let error_text = response.text().await.unwrap_or_default();
187            anyhow::bail!("Failed to delete session ({}): {}", status, error_text);
188        }
189
190        Ok(())
191    }
192
193    /// List all sessions
194    pub async fn list_sessions(&self) -> Result<Vec<Session>> {
195        let response = self
196            .client
197            .get(format!("{}/session", self.base_url))
198            .send()
199            .await
200            .context("Failed to list sessions")?;
201
202        if !response.status().is_success() {
203            let status = response.status();
204            let error_text = response.text().await.unwrap_or_default();
205            anyhow::bail!("Failed to list sessions ({}): {}", status, error_text);
206        }
207
208        response
209            .json()
210            .await
211            .context("Failed to parse sessions list")
212    }
213
214    /// Get the SSE event stream URL
215    pub fn event_stream_url(&self) -> String {
216        format!("{}/event", self.base_url)
217    }
218
219    /// Get base URL
220    pub fn base_url(&self) -> &str {
221        &self.base_url
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn test_client_creation() {
231        let client = OpenCodeClient::localhost(4096);
232        assert_eq!(client.base_url(), "http://127.0.0.1:4096");
233    }
234
235    #[test]
236    fn test_client_creation_with_trailing_slash() {
237        let client = OpenCodeClient::new("http://localhost:4096/");
238        assert_eq!(client.base_url(), "http://localhost:4096");
239    }
240
241    #[test]
242    fn test_event_stream_url() {
243        let client = OpenCodeClient::new("http://localhost:4096");
244        assert_eq!(client.event_stream_url(), "http://localhost:4096/event");
245    }
246
247    #[test]
248    fn test_localhost_constructor() {
249        let client = OpenCodeClient::localhost(8080);
250        assert_eq!(client.base_url(), "http://127.0.0.1:8080");
251    }
252}