1use 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#[derive(Clone)]
15pub struct Client {
16 #[cfg(feature = "http")]
17 http: HttpClient,
18 #[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#[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), }
40 }
41}
42
43impl ClientBuilder {
44 pub fn new() -> Self {
50 Self::default()
51 }
52
53 pub fn base_url(mut self, url: impl Into<String>) -> Self {
55 self.base_url = url.into();
56 self
57 }
58
59 pub fn directory(mut self, dir: impl Into<String>) -> Self {
63 self.directory = Some(dir.into());
64 self
65 }
66
67 pub fn timeout_secs(mut self, secs: u64) -> Self {
69 self.timeout = Duration::from_secs(secs);
70 self
71 }
72
73 #[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 #[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 pub fn builder() -> ClientBuilder {
111 ClientBuilder::new()
112 }
113
114 #[cfg(feature = "http")]
116 pub fn sessions(&self) -> crate::http::sessions::SessionsApi {
117 crate::http::sessions::SessionsApi::new(self.http.clone())
118 }
119
120 #[cfg(feature = "http")]
122 pub fn messages(&self) -> crate::http::messages::MessagesApi {
123 crate::http::messages::MessagesApi::new(self.http.clone())
124 }
125
126 #[cfg(feature = "http")]
128 pub fn parts(&self) -> crate::http::parts::PartsApi {
129 crate::http::parts::PartsApi::new(self.http.clone())
130 }
131
132 #[cfg(feature = "http")]
134 pub fn permissions(&self) -> crate::http::permissions::PermissionsApi {
135 crate::http::permissions::PermissionsApi::new(self.http.clone())
136 }
137
138 #[cfg(feature = "http")]
140 pub fn questions(&self) -> crate::http::questions::QuestionsApi {
141 crate::http::questions::QuestionsApi::new(self.http.clone())
142 }
143
144 #[cfg(feature = "http")]
146 pub fn files(&self) -> crate::http::files::FilesApi {
147 crate::http::files::FilesApi::new(self.http.clone())
148 }
149
150 #[cfg(feature = "http")]
152 pub fn find(&self) -> crate::http::find::FindApi {
153 crate::http::find::FindApi::new(self.http.clone())
154 }
155
156 #[cfg(feature = "http")]
158 pub fn providers(&self) -> crate::http::providers::ProvidersApi {
159 crate::http::providers::ProvidersApi::new(self.http.clone())
160 }
161
162 #[cfg(feature = "http")]
164 pub fn mcp(&self) -> crate::http::mcp::McpApi {
165 crate::http::mcp::McpApi::new(self.http.clone())
166 }
167
168 #[cfg(feature = "http")]
170 pub fn pty(&self) -> crate::http::pty::PtyApi {
171 crate::http::pty::PtyApi::new(self.http.clone())
172 }
173
174 #[cfg(feature = "http")]
176 pub fn config(&self) -> crate::http::config::ConfigApi {
177 crate::http::config::ConfigApi::new(self.http.clone())
178 }
179
180 #[cfg(feature = "http")]
182 pub fn tools(&self) -> crate::http::tools::ToolsApi {
183 crate::http::tools::ToolsApi::new(self.http.clone())
184 }
185
186 #[cfg(feature = "http")]
188 pub fn project(&self) -> crate::http::project::ProjectApi {
189 crate::http::project::ProjectApi::new(self.http.clone())
190 }
191
192 #[cfg(feature = "http")]
194 pub fn worktree(&self) -> crate::http::worktree::WorktreeApi {
195 crate::http::worktree::WorktreeApi::new(self.http.clone())
196 }
197
198 #[cfg(feature = "http")]
200 pub fn misc(&self) -> crate::http::misc::MiscApi {
201 crate::http::misc::MiscApi::new(self.http.clone())
202 }
203
204 #[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 #[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 #[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 #[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 #[cfg(feature = "sse")]
305 #[allow(dead_code)] pub(crate) async fn set_last_event_id(&self, id: Option<String>) {
307 *self.last_event_id.write().await = id;
308 }
309
310 #[cfg(feature = "sse")]
312 #[allow(dead_code)] pub(crate) async fn last_event_id(&self) -> Option<String> {
314 self.last_event_id.read().await.clone()
315 }
316
317 #[cfg(feature = "http")]
319 #[allow(dead_code)] pub(crate) fn http(&self) -> &HttpClient {
321 &self.http
322 }
323
324 #[cfg(feature = "sse")]
326 #[allow(dead_code)] 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 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 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 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 pub async fn subscribe(&self) -> Result<crate::sse::SseSubscription> {
460 self.subscribe_typed().await
461 }
462
463 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 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 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 pub async fn session_event_router(&self) -> Result<crate::sse::SessionEventRouter> {
502 self.default_session_event_router().await
503 }
504
505 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 pub async fn subscribe_global(&self) -> Result<crate::sse::SseSubscription> {
522 self.subscribe_typed_global().await
523 }
524
525 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 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}