Skip to main content

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.json().await.context("Failed to parse server info")
70    }
71
72    /// Create a new session
73    pub async fn create_session(&self, title: &str) -> Result<Session> {
74        let request = CreateSessionRequest {
75            title: title.to_string(),
76            system_prompt: None,
77        };
78
79        let response = self
80            .client
81            .post(format!("{}/session", self.base_url))
82            .json(&request)
83            .send()
84            .await
85            .context("Failed to create session")?;
86
87        if !response.status().is_success() {
88            let status = response.status();
89            let error_text = response.text().await.unwrap_or_default();
90            anyhow::bail!("Failed to create session ({}): {}", status, error_text);
91        }
92
93        response
94            .json()
95            .await
96            .context("Failed to parse session response")
97    }
98
99    /// Send a message/prompt to a session
100    pub async fn send_message(
101        &self,
102        session_id: &str,
103        text: &str,
104        model: Option<(&str, &str)>, // (provider_id, model_id)
105    ) -> Result<()> {
106        let request = MessageRequest {
107            parts: vec![MessagePart::Text {
108                text: text.to_string(),
109            }],
110            model: model.map(|(provider, model_id)| ModelSpec {
111                provider_id: provider.to_string(),
112                model_id: model_id.to_string(),
113            }),
114        };
115
116        let response = self
117            .client
118            .post(format!("{}/session/{}/message", self.base_url, session_id))
119            .json(&request)
120            .send()
121            .await
122            .context("Failed to send message")?;
123
124        if !response.status().is_success() {
125            let status = response.status();
126            let error_text = response.text().await.unwrap_or_default();
127            anyhow::bail!("Failed to send message ({}): {}", status, error_text);
128        }
129
130        Ok(())
131    }
132
133    /// Get session status
134    pub async fn get_session_status(&self, session_id: &str) -> Result<SessionStatus> {
135        let response = self
136            .client
137            .get(format!("{}/session/{}", self.base_url, session_id))
138            .send()
139            .await
140            .context("Failed to get session status")?;
141
142        if !response.status().is_success() {
143            let status = response.status();
144            let error_text = response.text().await.unwrap_or_default();
145            anyhow::bail!("Failed to get session ({}): {}", status, error_text);
146        }
147
148        response
149            .json()
150            .await
151            .context("Failed to parse session status")
152    }
153
154    /// Abort/cancel a running session
155    pub async fn abort_session(&self, session_id: &str) -> Result<()> {
156        let response = self
157            .client
158            .post(format!("{}/session/{}/abort", self.base_url, session_id))
159            .send()
160            .await
161            .context("Failed to abort session")?;
162
163        if !response.status().is_success() {
164            let status = response.status();
165            let error_text = response.text().await.unwrap_or_default();
166            anyhow::bail!("Failed to abort session ({}): {}", status, error_text);
167        }
168
169        Ok(())
170    }
171
172    /// Delete a session
173    pub async fn delete_session(&self, session_id: &str) -> Result<()> {
174        let response = self
175            .client
176            .delete(format!("{}/session/{}", self.base_url, session_id))
177            .send()
178            .await
179            .context("Failed to delete session")?;
180
181        if !response.status().is_success() {
182            let status = response.status();
183            let error_text = response.text().await.unwrap_or_default();
184            anyhow::bail!("Failed to delete session ({}): {}", status, error_text);
185        }
186
187        Ok(())
188    }
189
190    /// List all sessions
191    pub async fn list_sessions(&self) -> Result<Vec<Session>> {
192        let response = self
193            .client
194            .get(format!("{}/session", self.base_url))
195            .send()
196            .await
197            .context("Failed to list sessions")?;
198
199        if !response.status().is_success() {
200            let status = response.status();
201            let error_text = response.text().await.unwrap_or_default();
202            anyhow::bail!("Failed to list sessions ({}): {}", status, error_text);
203        }
204
205        response
206            .json()
207            .await
208            .context("Failed to parse sessions list")
209    }
210
211    /// Get the SSE event stream URL
212    pub fn event_stream_url(&self) -> String {
213        format!("{}/event", self.base_url)
214    }
215
216    /// Get base URL
217    pub fn base_url(&self) -> &str {
218        &self.base_url
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn test_client_creation() {
228        let client = OpenCodeClient::localhost(4096);
229        assert_eq!(client.base_url(), "http://127.0.0.1:4096");
230    }
231
232    #[test]
233    fn test_client_creation_with_trailing_slash() {
234        let client = OpenCodeClient::new("http://localhost:4096/");
235        assert_eq!(client.base_url(), "http://localhost:4096");
236    }
237
238    #[test]
239    fn test_event_stream_url() {
240        let client = OpenCodeClient::new("http://localhost:4096");
241        assert_eq!(client.event_stream_url(), "http://localhost:4096/event");
242    }
243
244    #[test]
245    fn test_localhost_constructor() {
246        let client = OpenCodeClient::localhost(8080);
247        assert_eq!(client.base_url(), "http://127.0.0.1:8080");
248    }
249}