Skip to main content

opencode_sdk/
client.rs

1//! High-level client API for OpenCode.
2//!
3//! This module provides the ergonomic `Client` and `ClientBuilder` types.
4
5use crate::error::{OpencodeError, Result};
6use std::sync::Arc;
7use std::time::Duration;
8use tokio::sync::RwLock;
9
10#[cfg(feature = "http")]
11use crate::http::{HttpClient, HttpConfig};
12
13/// OpenCode client for interacting with the server.
14#[derive(Clone)]
15pub struct Client {
16    #[cfg(feature = "http")]
17    http: HttpClient,
18    // Used by SSE subscriber for reconnection (Phase 5)
19    #[allow(dead_code)]
20    last_event_id: Arc<RwLock<Option<String>>>,
21    #[cfg(all(feature = "http", feature = "sse"))]
22    session_event_router: Arc<RwLock<Option<crate::sse::SessionEventRouter>>>,
23}
24
25/// Builder for creating a [`Client`].
26#[derive(Clone)]
27pub struct ClientBuilder {
28    base_url: String,
29    directory: Option<String>,
30    timeout: Duration,
31}
32
33impl Default for ClientBuilder {
34    fn default() -> Self {
35        Self {
36            base_url: "http://127.0.0.1:4096".to_string(),
37            directory: None,
38            timeout: Duration::from_secs(300), // 5 min for long AI requests
39        }
40    }
41}
42
43impl ClientBuilder {
44    /// Create a new client builder with default settings.
45    ///
46    /// Default settings:
47    /// - Base URL: `http://127.0.0.1:4096`
48    /// - Timeout: 300 seconds (5 minutes)
49    pub fn new() -> Self {
50        Self::default()
51    }
52
53    /// Set the base URL for the OpenCode server.
54    pub fn base_url(mut self, url: impl Into<String>) -> Self {
55        self.base_url = url.into();
56        self
57    }
58
59    /// Set the directory context for requests.
60    ///
61    /// This sets the `x-opencode-directory` header on all requests.
62    pub fn directory(mut self, dir: impl Into<String>) -> Self {
63        self.directory = Some(dir.into());
64        self
65    }
66
67    /// Set the request timeout in seconds.
68    pub fn timeout_secs(mut self, secs: u64) -> Self {
69        self.timeout = Duration::from_secs(secs);
70        self
71    }
72
73    /// Build the client.
74    ///
75    /// # Errors
76    ///
77    /// Returns an error if the HTTP client cannot be built or if the
78    /// `http` feature is not enabled.
79    #[cfg(feature = "http")]
80    pub fn build(self) -> Result<Client> {
81        let http = HttpClient::new(HttpConfig {
82            base_url: self.base_url.trim_end_matches('/').to_string(),
83            directory: self.directory,
84            timeout: self.timeout,
85        })?;
86
87        Ok(Client {
88            http,
89            last_event_id: Arc::new(RwLock::new(None)),
90            #[cfg(all(feature = "http", feature = "sse"))]
91            session_event_router: Arc::new(RwLock::new(None)),
92        })
93    }
94
95    /// Build the client.
96    ///
97    /// # Errors
98    ///
99    /// Returns an error because the `http` feature is required.
100    #[cfg(not(feature = "http"))]
101    pub fn build(self) -> Result<Client> {
102        Err(OpencodeError::InvalidConfig(
103            "http feature required to build client".into(),
104        ))
105    }
106}
107
108impl Client {
109    /// Create a new client builder.
110    pub fn builder() -> ClientBuilder {
111        ClientBuilder::new()
112    }
113
114    /// Get the sessions API.
115    #[cfg(feature = "http")]
116    pub fn sessions(&self) -> crate::http::sessions::SessionsApi {
117        crate::http::sessions::SessionsApi::new(self.http.clone())
118    }
119
120    /// Get the messages API.
121    #[cfg(feature = "http")]
122    pub fn messages(&self) -> crate::http::messages::MessagesApi {
123        crate::http::messages::MessagesApi::new(self.http.clone())
124    }
125
126    /// Get the parts API.
127    #[cfg(feature = "http")]
128    pub fn parts(&self) -> crate::http::parts::PartsApi {
129        crate::http::parts::PartsApi::new(self.http.clone())
130    }
131
132    /// Get the permissions API.
133    #[cfg(feature = "http")]
134    pub fn permissions(&self) -> crate::http::permissions::PermissionsApi {
135        crate::http::permissions::PermissionsApi::new(self.http.clone())
136    }
137
138    /// Get the questions API.
139    #[cfg(feature = "http")]
140    pub fn questions(&self) -> crate::http::questions::QuestionsApi {
141        crate::http::questions::QuestionsApi::new(self.http.clone())
142    }
143
144    /// Get the files API.
145    #[cfg(feature = "http")]
146    pub fn files(&self) -> crate::http::files::FilesApi {
147        crate::http::files::FilesApi::new(self.http.clone())
148    }
149
150    /// Get the find API.
151    #[cfg(feature = "http")]
152    pub fn find(&self) -> crate::http::find::FindApi {
153        crate::http::find::FindApi::new(self.http.clone())
154    }
155
156    /// Get the providers API.
157    #[cfg(feature = "http")]
158    pub fn providers(&self) -> crate::http::providers::ProvidersApi {
159        crate::http::providers::ProvidersApi::new(self.http.clone())
160    }
161
162    /// Get the MCP API.
163    #[cfg(feature = "http")]
164    pub fn mcp(&self) -> crate::http::mcp::McpApi {
165        crate::http::mcp::McpApi::new(self.http.clone())
166    }
167
168    /// Get the PTY API.
169    #[cfg(feature = "http")]
170    pub fn pty(&self) -> crate::http::pty::PtyApi {
171        crate::http::pty::PtyApi::new(self.http.clone())
172    }
173
174    /// Get the config API.
175    #[cfg(feature = "http")]
176    pub fn config(&self) -> crate::http::config::ConfigApi {
177        crate::http::config::ConfigApi::new(self.http.clone())
178    }
179
180    /// Get the tools API.
181    #[cfg(feature = "http")]
182    pub fn tools(&self) -> crate::http::tools::ToolsApi {
183        crate::http::tools::ToolsApi::new(self.http.clone())
184    }
185
186    /// Get the project API.
187    #[cfg(feature = "http")]
188    pub fn project(&self) -> crate::http::project::ProjectApi {
189        crate::http::project::ProjectApi::new(self.http.clone())
190    }
191
192    /// Get the worktree API.
193    #[cfg(feature = "http")]
194    pub fn worktree(&self) -> crate::http::worktree::WorktreeApi {
195        crate::http::worktree::WorktreeApi::new(self.http.clone())
196    }
197
198    /// Get the misc API.
199    #[cfg(feature = "http")]
200    pub fn misc(&self) -> crate::http::misc::MiscApi {
201        crate::http::misc::MiscApi::new(self.http.clone())
202    }
203
204    /// Simple helper to create session and send a text prompt.
205    ///
206    /// Note: This method returns immediately after sending the prompt.
207    /// The AI response will arrive asynchronously via SSE events.
208    /// Use [`subscribe_session`] to receive the response.
209    ///
210    /// # Errors
211    ///
212    /// Returns an error if session creation or prompt fails.
213    #[cfg(feature = "http")]
214    pub async fn run_simple_text(
215        &self,
216        text: impl Into<String>,
217    ) -> Result<crate::types::session::Session> {
218        use crate::types::message::{PromptPart, PromptRequest};
219        use crate::types::session::CreateSessionRequest;
220
221        let session = self
222            .sessions()
223            .create(&CreateSessionRequest::default())
224            .await?;
225
226        self.messages()
227            .prompt(
228                &session.id,
229                &PromptRequest {
230                    parts: vec![PromptPart::Text {
231                        text: text.into(),
232                        synthetic: None,
233                        ignored: None,
234                        metadata: None,
235                    }],
236                    message_id: None,
237                    model: None,
238                    agent: None,
239                    no_reply: None,
240                    system: None,
241                    variant: None,
242                },
243            )
244            .await?;
245
246        Ok(session)
247    }
248
249    /// Create a session with a title.
250    ///
251    /// This convenience helper wraps [`crate::http::sessions::SessionsApi::create_with`].
252    ///
253    /// # Errors
254    ///
255    /// Returns an error if session creation fails.
256    #[cfg(feature = "http")]
257    pub async fn create_session_with_title(
258        &self,
259        title: impl Into<String>,
260    ) -> Result<crate::types::session::Session> {
261        self.sessions()
262            .create_with(crate::types::session::SessionCreateOptions::new().with_title(title))
263            .await
264    }
265
266    /// Send plain text asynchronously to a session.
267    ///
268    /// This convenience helper wraps [`crate::http::messages::MessagesApi::send_text_async`].
269    /// The server returns an empty body on success.
270    ///
271    /// # Errors
272    ///
273    /// Returns an error if the request fails.
274    #[cfg(feature = "http")]
275    pub async fn send_text_async(
276        &self,
277        session_id: &str,
278        text: impl Into<String>,
279        model: Option<crate::types::project::ModelRef>,
280    ) -> Result<()> {
281        self.messages()
282            .send_text_async(session_id, text, model)
283            .await
284    }
285
286    /// Send plain text asynchronously to a session object.
287    ///
288    /// The server returns an empty body on success.
289    ///
290    /// # Errors
291    ///
292    /// Returns an error if the request fails.
293    #[cfg(feature = "http")]
294    pub async fn send_text_async_for_session(
295        &self,
296        session: &crate::types::session::Session,
297        text: impl Into<String>,
298        model: Option<crate::types::project::ModelRef>,
299    ) -> Result<()> {
300        self.send_text_async(&session.id, text, model).await
301    }
302
303    /// Set the last event ID (for SSE reconnection).
304    #[cfg(feature = "sse")]
305    #[allow(dead_code)] // Used by SSE subscriber in Phase 5
306    pub(crate) async fn set_last_event_id(&self, id: Option<String>) {
307        *self.last_event_id.write().await = id;
308    }
309
310    /// Get the last event ID.
311    #[cfg(feature = "sse")]
312    #[allow(dead_code)] // Used by SSE subscriber in Phase 5
313    pub(crate) async fn last_event_id(&self) -> Option<String> {
314        self.last_event_id.read().await.clone()
315    }
316
317    /// Get the HTTP client.
318    #[cfg(feature = "http")]
319    #[allow(dead_code)] // May be used by external crates
320    pub(crate) fn http(&self) -> &HttpClient {
321        &self.http
322    }
323
324    /// Get the last event ID handle for SSE.
325    #[cfg(feature = "sse")]
326    #[allow(dead_code)] // May be used by external crates
327    pub(crate) fn last_event_id_handle(&self) -> Arc<RwLock<Option<String>>> {
328        self.last_event_id.clone()
329    }
330
331    #[cfg(all(feature = "http", feature = "sse"))]
332    async fn default_session_event_router(&self) -> Result<crate::sse::SessionEventRouter> {
333        if let Some(existing) = self.session_event_router.read().await.clone() {
334            return Ok(existing);
335        }
336
337        let router = self
338            .sse_subscriber()
339            .session_event_router(crate::sse::SessionEventRouterOptions::default())
340            .await?;
341
342        let mut guard = self.session_event_router.write().await;
343        if let Some(existing) = guard.clone() {
344            return Ok(existing);
345        }
346
347        *guard = Some(router.clone());
348        Ok(router)
349    }
350}
351
352#[cfg(all(feature = "http", feature = "sse"))]
353impl Client {
354    /// Wait until a session reaches idle state and collect streamed assistant text.
355    ///
356    /// This helper listens to `message.part.updated` and idle/status events for the session.
357    ///
358    /// # Errors
359    ///
360    /// Returns an error if the stream closes, times out, or the session emits `session.error`.
361    pub async fn wait_for_idle_text(&self, session_id: &str, timeout: Duration) -> Result<String> {
362        let subscription = self.subscribe_session(session_id).await?;
363        self.collect_idle_text(subscription, timeout).await
364    }
365
366    /// Send text asynchronously and wait until session idle while collecting text output.
367    ///
368    /// This subscribes before sending to avoid missing early stream events.
369    ///
370    /// # Errors
371    ///
372    /// Returns an error if sending fails, stream closes, times out, or session emits `session.error`.
373    pub async fn send_text_async_and_wait_for_idle(
374        &self,
375        session_id: &str,
376        text: impl Into<String>,
377        model: Option<crate::types::project::ModelRef>,
378        timeout: Duration,
379    ) -> Result<String> {
380        let subscription = self.subscribe_session(session_id).await?;
381        self.send_text_async(session_id, text, model).await?;
382        self.collect_idle_text(subscription, timeout).await
383    }
384
385    async fn collect_idle_text(
386        &self,
387        mut subscription: crate::sse::SseSubscription,
388        timeout: Duration,
389    ) -> Result<String> {
390        let timeout_ms = u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX);
391        let deadline = tokio::time::Instant::now() + timeout;
392        let mut output = String::new();
393
394        loop {
395            let now = tokio::time::Instant::now();
396            if now >= deadline {
397                return Err(OpencodeError::ServerTimeout { timeout_ms });
398            }
399
400            let remaining = deadline.saturating_duration_since(now);
401            let event = match tokio::time::timeout(remaining, subscription.recv()).await {
402                Ok(Some(event)) => event,
403                Ok(None) => return Err(OpencodeError::StreamClosed),
404                Err(_) => return Err(OpencodeError::ServerTimeout { timeout_ms }),
405            };
406
407            match event {
408                crate::types::event::Event::MessagePartUpdated { properties } => {
409                    if let Some(delta) = properties.delta.as_deref()
410                        && matches!(
411                            properties.part.as_ref(),
412                            Some(crate::types::message::Part::Text { .. })
413                        )
414                    {
415                        output.push_str(delta);
416                    }
417                }
418                crate::types::event::Event::SessionStatus { properties } => {
419                    let is_idle = properties
420                        .get("status")
421                        .and_then(|status| status.get("type"))
422                        .and_then(serde_json::Value::as_str)
423                        == Some("idle");
424                    if is_idle {
425                        break;
426                    }
427                }
428                crate::types::event::Event::SessionIdle { .. } => break,
429                crate::types::event::Event::SessionError { properties } => {
430                    return Err(OpencodeError::State(format!(
431                        "session.error: {:?}",
432                        properties.error
433                    )));
434                }
435                _ => {}
436            }
437        }
438
439        Ok(output.trim().to_string())
440    }
441
442    /// Get an SSE subscriber for streaming events.
443    pub fn sse_subscriber(&self) -> crate::sse::SseSubscriber {
444        crate::sse::SseSubscriber::new(
445            self.http.base().to_string(),
446            self.http.directory().map(|s| s.to_string()),
447            self.last_event_id.clone(),
448        )
449    }
450
451    /// Subscribe to all events for the configured directory with default options.
452    ///
453    /// This subscribes to the `/event` endpoint which streams all events
454    /// for the directory specified in the client configuration.
455    ///
456    /// # Errors
457    ///
458    /// Returns an error if the subscription cannot be created.
459    pub async fn subscribe(&self) -> Result<crate::sse::SseSubscription> {
460        self.subscribe_typed().await
461    }
462
463    /// Subscribe to all events for the configured directory as typed events.
464    ///
465    /// This is equivalent to [`Self::subscribe`], but explicitly named to
466    /// distinguish it from [`Self::subscribe_raw`].
467    pub async fn subscribe_typed(&self) -> Result<crate::sse::SseSubscription> {
468        self.sse_subscriber()
469            .subscribe_typed(crate::sse::SseOptions::default())
470            .await
471    }
472
473    /// Subscribe to events filtered by session ID with default options.
474    ///
475    /// Events are filtered client-side to only include events matching
476    /// the specified session ID.
477    ///
478    /// # Errors
479    ///
480    /// Returns an error if the subscription cannot be created.
481    pub async fn subscribe_session(&self, session_id: &str) -> Result<crate::sse::SseSubscription> {
482        let router = self.default_session_event_router().await?;
483        Ok(router.subscribe(session_id).await)
484    }
485
486    /// Create a new session event router with one upstream `/event` subscription.
487    ///
488    /// This returns a dedicated router instance and does not modify the default
489    /// cached router used by [`Self::subscribe_session`].
490    pub async fn session_event_router_with_options(
491        &self,
492        opts: crate::sse::SessionEventRouterOptions,
493    ) -> Result<crate::sse::SessionEventRouter> {
494        self.sse_subscriber().session_event_router(opts).await
495    }
496
497    /// Get the default session event router.
498    ///
499    /// The first call lazily creates the router; subsequent calls return the
500    /// same router instance.
501    pub async fn session_event_router(&self) -> Result<crate::sse::SessionEventRouter> {
502        self.default_session_event_router().await
503    }
504
505    /// Subscribe to raw JSON frames from `/event` for debugging.
506    ///
507    /// # Errors
508    ///
509    /// Returns an error if the subscription cannot be created.
510    pub async fn subscribe_raw(&self) -> Result<crate::sse::RawSseSubscription> {
511        self.sse_subscriber()
512            .subscribe_raw(crate::sse::SseOptions::default())
513            .await
514    }
515
516    /// Subscribe to global events with default options (all directories).
517    ///
518    /// # Errors
519    ///
520    /// Returns an error if the subscription cannot be created.
521    pub async fn subscribe_global(&self) -> Result<crate::sse::SseSubscription> {
522        self.subscribe_typed_global().await
523    }
524
525    /// Subscribe to global events as typed events (all directories).
526    ///
527    /// # Errors
528    ///
529    /// Returns an error if the subscription cannot be created.
530    pub async fn subscribe_typed_global(&self) -> Result<crate::sse::SseSubscription> {
531        self.sse_subscriber()
532            .subscribe_typed_global(crate::sse::SseOptions::default())
533            .await
534    }
535}
536
537#[cfg(test)]
538mod tests {
539    // TODO(3): Add integration tests with mocked HTTP/SSE backends for Client API methods
540    use super::*;
541
542    #[test]
543    fn test_client_builder_defaults() {
544        let builder = ClientBuilder::new();
545        assert_eq!(builder.base_url, "http://127.0.0.1:4096");
546        assert_eq!(builder.timeout, Duration::from_secs(300));
547        assert!(builder.directory.is_none());
548    }
549
550    #[test]
551    fn test_client_builder_customization() {
552        let builder = ClientBuilder::new()
553            .base_url("http://localhost:8080")
554            .directory("/my/project")
555            .timeout_secs(60);
556
557        assert_eq!(builder.base_url, "http://localhost:8080");
558        assert_eq!(builder.directory, Some("/my/project".to_string()));
559        assert_eq!(builder.timeout, Duration::from_secs(60));
560    }
561
562    #[cfg(feature = "http")]
563    #[test]
564    fn test_client_build() {
565        let client = ClientBuilder::new().build();
566        assert!(client.is_ok());
567    }
568}