Skip to main content

opencode_codes/
client_async.rs

1//! High-level async client for the opencode server.
2//!
3//! [`OpencodeClient`] wraps the low-level [`crate::http::HttpTransport`] with
4//! typed methods for the six hand-wrapped endpoints. Construct one with
5//! [`OpencodeClient::builder`]:
6//!
7//! ```rust,ignore
8//! use opencode_codes::client_async::OpencodeClient;
9//! use opencode_codes::protocol_generated::types::SessionCreateParams;
10//!
11//! # async fn demo() -> opencode_codes::Result<()> {
12//! let client = OpencodeClient::builder()
13//!     .base_url("http://127.0.0.1:4096")
14//!     .build()?;
15//! let session = client.create_session(&SessionCreateParams {
16//!     title: Some("demo".into()),
17//!     ..Default::default()
18//! }).await?;
19//! # Ok(())
20//! # }
21//! ```
22//!
23//! # Reconciliation
24//!
25//! The `GET /event` SSE stream (see [`crate::sse`]) is best-effort and must not
26//! be trusted as the sole source of truth. After observing activity on the
27//! stream, poll [`OpencodeClient::list_messages`] to reconcile against the
28//! server's authoritative message state.
29
30use std::time::Duration;
31
32use reqwest::{Client, Method};
33use serde::de::DeserializeOwned;
34use serde_json::Value;
35
36use crate::error::Result;
37use crate::http::{BasicAuth, HttpTransport, Scope};
38use crate::protocol_generated::types::{
39    MessageWithParts, PermissionReplyParams, PromptAsyncParams, Session, SessionCreateParams,
40};
41use crate::sse::{EventStream, RetryConfig};
42
43/// Base URL of a default local `opencode serve` instance.
44pub const DEFAULT_BASE_URL: &str = "http://127.0.0.1:4096";
45
46/// Async client for the opencode HTTP/SSE server.
47///
48/// Cloning is cheap; the underlying [`reqwest::Client`] is shared.
49#[derive(Clone, Debug)]
50pub struct OpencodeClient {
51    transport: HttpTransport,
52}
53
54impl OpencodeClient {
55    /// Start building a client. Equivalent to [`OpencodeClientBuilder::new`].
56    pub fn builder() -> OpencodeClientBuilder {
57        OpencodeClientBuilder::new()
58    }
59
60    /// The underlying transport, exposing base-URL, auth, and the `GET /event`
61    /// URL for an SSE subscriber.
62    pub fn transport(&self) -> &HttpTransport {
63        &self.transport
64    }
65
66    /// Open the `GET /event` SSE stream, reusing this client's base URL, auth,
67    /// directory/workspace scope, and `reqwest` connection pool.
68    ///
69    /// This is the ergonomic counterpart to [`OpencodeClient::list_messages`]:
70    /// the same client drives both the low-latency event stream and the
71    /// authoritative reconciliation poll, so credentials configured with
72    /// [`OpencodeClientBuilder::auth`] flow to the stream without a detour
73    /// through the `OPENCODE_SERVER_PASSWORD` environment variable.
74    ///
75    /// # Errors
76    ///
77    /// Returns [`crate::Error`] if the request cannot be prepared for streaming.
78    pub fn event_stream(&self, retry: RetryConfig) -> Result<EventStream> {
79        EventStream::from_request(self.transport.event_request(), retry)
80    }
81
82    /// Create a new session — `POST /session`.
83    ///
84    /// The response is the freshly created [`Session`]; its `id` (a `ses…`
85    /// string) is used to address every subsequent per-session call.
86    pub async fn create_session(&self, params: &SessionCreateParams) -> Result<Session> {
87        let body = serde_json::to_value(params)?;
88        self.transport
89            .request_json(
90                Method::POST,
91                &self.transport.session_create_url(),
92                Some(body),
93            )
94            .await
95    }
96
97    /// Submit a prompt — `POST /session/{sessionID}/prompt_async`.
98    ///
99    /// Returns as soon as the server accepts the work (HTTP 204); the agent's
100    /// output is observed on the `GET /event` SSE stream and reconciled via
101    /// [`OpencodeClient::list_messages`].
102    pub async fn prompt_async(&self, session_id: &str, params: &PromptAsyncParams) -> Result<()> {
103        let body = serde_json::to_value(params)?;
104        self.transport
105            .request_unit(
106                Method::POST,
107                &self.transport.prompt_async_url(session_id),
108                Some(body),
109            )
110            .await
111    }
112
113    /// List a session's messages — `GET /session/{sessionID}/message`.
114    ///
115    /// Returns every message with its parts. This is the authoritative
116    /// reconciliation path for the best-effort SSE stream. For pagination use
117    /// [`OpencodeClient::list_messages_page`].
118    pub async fn list_messages(&self, session_id: &str) -> Result<Vec<MessageWithParts>> {
119        self.list_messages_page(session_id, None, None).await
120    }
121
122    /// Paginated variant of [`OpencodeClient::list_messages`].
123    ///
124    /// `limit` caps the number of messages returned; `before` is a message id
125    /// cursor (results strictly older than it) for walking history backwards.
126    pub async fn list_messages_page(
127        &self,
128        session_id: &str,
129        limit: Option<u64>,
130        before: Option<&str>,
131    ) -> Result<Vec<MessageWithParts>> {
132        self.transport
133            .request_json(
134                Method::GET,
135                &self.transport.messages_url(session_id, limit, before),
136                None,
137            )
138            .await
139    }
140
141    /// Abort in-flight work — `POST /session/{sessionID}/abort`.
142    ///
143    /// Returns `true` when the session had work that was aborted.
144    pub async fn abort(&self, session_id: &str) -> Result<bool> {
145        self.transport
146            .request_json(Method::POST, &self.transport.abort_url(session_id), None)
147            .await
148    }
149
150    /// Reply to a permission request —
151    /// `POST /session/{sessionID}/permissions/{permissionID}`.
152    ///
153    /// # Deprecation
154    ///
155    /// In the 1.18.5 spec this route (operation `permission.respond`) is marked
156    /// **deprecated** in favor of the newer reply endpoints
157    /// `POST /permission/{requestID}/reply` (operation `permission.reply`) and
158    /// `POST /api/session/{sessionID}/permission/{requestID}/reply` (operation
159    /// `v2.session.permission.reply`), neither of which this crate wraps yet.
160    /// Reach either through the raw [`OpencodeClient::request`] escape hatch using
161    /// those exact paths — note the session-scoped one requires the `/api/`
162    /// prefix. This deprecated route remains the reply channel for the
163    /// `permission.asked` event and works on 1.18.5; a future opencode release
164    /// may remove it.
165    ///
166    /// # Correlation contract
167    ///
168    /// Permission handling is deliberately split across two channels and
169    /// correlating them is the **consumer's** responsibility:
170    ///
171    /// 1. A permission *request* arrives on the `GET /event` SSE stream as an
172    ///    [`Event::PermissionAsked`](crate::protocol_generated::types::Event::PermissionAsked)
173    ///    event (wire type `permission.asked`), whose `properties` carry a `ses…`
174    ///    session id and a `per…` permission id. This is the *only* ask event
175    ///    that pairs with this call: the coexisting
176    ///    [`Event::PermissionV2Asked`](crate::protocol_generated::types::Event::PermissionV2Asked)
177    ///    (`permission.v2.asked`) belongs to the unwrapped v2 reply endpoints, so
178    ///    do **not** feed its id here.
179    /// 2. The *reply* is this separate REST call, addressed by exactly those two
180    ///    ids. There is no server-side callback and no implicit pairing: the
181    ///    caller must remember which pending `(session_id, permission_id)` a reply
182    ///    answers.
183    ///
184    /// [`PermissionReplyParams::response`] is one of
185    /// [`PermissionReplyResponse::Once`](crate::protocol_generated::types::PermissionReplyResponse::Once),
186    /// [`Always`](crate::protocol_generated::types::PermissionReplyResponse::Always),
187    /// or [`Reject`](crate::protocol_generated::types::PermissionReplyResponse::Reject).
188    /// Returns `true` when the reply was accepted; a stale or unknown permission
189    /// id yields [`crate::Error::Http`] with status 404.
190    pub async fn respond_permission(
191        &self,
192        session_id: &str,
193        permission_id: &str,
194        reply: &PermissionReplyParams,
195    ) -> Result<bool> {
196        let body = serde_json::to_value(reply)?;
197        self.transport
198            .request_json(
199                Method::POST,
200                &self.transport.permission_url(session_id, permission_id),
201                Some(body),
202            )
203            .await
204    }
205
206    /// Raw escape hatch for endpoints this crate does not hand-wrap.
207    ///
208    /// `path` is joined onto the configured base URL (leading slash optional) and
209    /// used verbatim; `body`, when present, is sent as a JSON request body. The
210    /// 2xx response is deserialized into `T`; non-2xx becomes
211    /// [`crate::Error::Http`].
212    pub async fn request<T: DeserializeOwned>(
213        &self,
214        method: Method,
215        path: &str,
216        body: Option<Value>,
217    ) -> Result<T> {
218        self.transport
219            .request_json(method, &self.transport.join(path), body)
220            .await
221    }
222
223    /// Raw escape hatch for endpoints that answer with an empty body.
224    ///
225    /// Identical to [`OpencodeClient::request`] but discards the response body
226    /// instead of deserializing it. Many opencode `POST` endpoints reply `204 No
227    /// Content` (e.g. `prompt_async` and several unwrapped routes); calling those
228    /// through [`OpencodeClient::request`] would fail deserializing the empty
229    /// body, so use this variant for them.
230    pub async fn request_unit(
231        &self,
232        method: Method,
233        path: &str,
234        body: Option<Value>,
235    ) -> Result<()> {
236        self.transport
237            .request_unit(method, &self.transport.join(path), body)
238            .await
239    }
240}
241
242/// Builder for [`OpencodeClient`].
243#[derive(Clone, Debug)]
244pub struct OpencodeClientBuilder {
245    base_url: String,
246    auth: Option<BasicAuth>,
247    timeout: Option<Duration>,
248    client: Option<Client>,
249    scope: Scope,
250}
251
252impl Default for OpencodeClientBuilder {
253    fn default() -> Self {
254        Self {
255            base_url: DEFAULT_BASE_URL.to_string(),
256            auth: None,
257            timeout: None,
258            client: None,
259            scope: Scope::default(),
260        }
261    }
262}
263
264impl OpencodeClientBuilder {
265    /// A builder defaulting to [`DEFAULT_BASE_URL`] with no auth or timeout.
266    pub fn new() -> Self {
267        Self::default()
268    }
269
270    /// Set the opencode server base URL (e.g. `http://127.0.0.1:4096`).
271    #[must_use]
272    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
273        self.base_url = base_url.into();
274        self
275    }
276
277    /// Attach explicit HTTP Basic credentials.
278    #[must_use]
279    pub fn auth(mut self, auth: BasicAuth) -> Self {
280        self.auth = Some(auth);
281        self
282    }
283
284    /// Attach HTTP Basic credentials derived from `OPENCODE_SERVER_PASSWORD`
285    /// (username `"opencode"`). A no-op when the variable is unset or empty.
286    ///
287    /// This is the explicit opt-in for reading the environment; the request path
288    /// never consults it implicitly.
289    #[must_use]
290    pub fn auth_from_env(mut self) -> Self {
291        if let Some(auth) = BasicAuth::from_env() {
292            self.auth = Some(auth);
293        }
294        self
295    }
296
297    /// Apply a per-request timeout to every call the client makes.
298    ///
299    /// Applied per request, so it also constrains an injected
300    /// [`reqwest::Client`] that was built without a timeout.
301    #[must_use]
302    pub fn timeout(mut self, timeout: Duration) -> Self {
303        self.timeout = Some(timeout);
304        self
305    }
306
307    /// Inject a pre-configured [`reqwest::Client`] (connection pools, proxies,
308    /// custom TLS). When omitted, a default client is built.
309    #[must_use]
310    pub fn reqwest_client(mut self, client: Client) -> Self {
311        self.client = Some(client);
312        self
313    }
314
315    /// Scope every session endpoint (and the `GET /event` stream) to a project
316    /// `directory`.
317    ///
318    /// `opencode serve` can manage several directories at once; without this the
319    /// server uses its own working directory. Set it to target a specific
320    /// project on a multi-directory server. Build one client per directory (the
321    /// clone is cheap) to drive several concurrently.
322    #[must_use]
323    pub fn directory(mut self, directory: impl Into<String>) -> Self {
324        self.scope.directory = Some(directory.into());
325        self
326    }
327
328    /// Scope every session endpoint (and the `GET /event` stream) to a
329    /// `workspace` identifier. See [`directory`](Self::directory).
330    #[must_use]
331    pub fn workspace(mut self, workspace: impl Into<String>) -> Self {
332        self.scope.workspace = Some(workspace.into());
333        self
334    }
335
336    /// Set the full directory/workspace [`Scope`] at once.
337    #[must_use]
338    pub fn scope(mut self, scope: Scope) -> Self {
339        self.scope = scope;
340        self
341    }
342
343    /// Build the [`OpencodeClient`].
344    ///
345    /// # Errors
346    ///
347    /// Returns [`crate::Error::Transport`] if a default [`reqwest::Client`] must
348    /// be constructed and its builder fails.
349    pub fn build(self) -> Result<OpencodeClient> {
350        let client = match self.client {
351            Some(client) => client,
352            None => Client::builder().build()?,
353        };
354        let transport =
355            HttpTransport::new(client, self.base_url, self.timeout, self.auth, self.scope);
356        Ok(OpencodeClient { transport })
357    }
358}
359
360#[cfg(all(test, feature = "integration-tests"))]
361mod tests {
362    use super::*;
363    use crate::protocol_generated::types::{PromptAsyncParamsPartsItem, TextPartInput};
364
365    fn client() -> OpencodeClient {
366        let base_url = std::env::var("OPENCODE_BASE_URL")
367            .unwrap_or_else(|_| "http://127.0.0.1:41999".to_string());
368        OpencodeClient::builder()
369            .base_url(base_url)
370            .auth_from_env()
371            .timeout(Duration::from_secs(30))
372            .build()
373            .expect("client builds")
374    }
375
376    #[tokio::test]
377    async fn create_list_abort_roundtrip() {
378        let client = client();
379        let session = client
380            .create_session(&SessionCreateParams {
381                title: Some("opencode-codes integration probe".into()),
382                agent: None,
383                metadata: None,
384                model: None,
385                parent_id: None,
386                permission: None,
387                workspace_id: None,
388            })
389            .await
390            .expect("create session");
391        assert!(session.id.starts_with("ses"));
392
393        let messages = client
394            .list_messages(&session.id)
395            .await
396            .expect("list messages");
397        assert!(messages.is_empty());
398
399        let aborted = client.abort(&session.id).await.expect("abort");
400        // No work was running, but the endpoint still answers with a boolean.
401        let _ = aborted;
402    }
403
404    #[tokio::test]
405    async fn respond_to_unknown_permission_is_404() {
406        let client = client();
407        let session = client
408            .create_session(&SessionCreateParams {
409                title: Some("opencode-codes permission probe".into()),
410                agent: None,
411                metadata: None,
412                model: None,
413                parent_id: None,
414                permission: None,
415                workspace_id: None,
416            })
417            .await
418            .expect("create session");
419
420        let err = client
421            .respond_permission(
422                &session.id,
423                "per_does_not_exist",
424                &PermissionReplyParams {
425                    response: "reject".into(),
426                },
427            )
428            .await
429            .expect_err("stale permission id must fail");
430        match err {
431            crate::Error::Http { status, .. } => assert_eq!(status, 404),
432            other => panic!("expected HTTP 404, got {other:?}"),
433        }
434    }
435
436    #[tokio::test]
437    async fn raw_request_escape_hatch() {
438        let client = client();
439        let config: Value = client
440            .request(Method::GET, "/config", None)
441            .await
442            .expect("raw GET /config");
443        assert!(config.is_object());
444    }
445
446    #[test]
447    fn prompt_parts_serialize_shape() {
448        let params = PromptAsyncParams {
449            agent: None,
450            format: None,
451            message_id: None,
452            model: None,
453            no_reply: None,
454            parts: vec![PromptAsyncParamsPartsItem::Text(TextPartInput {
455                id: None,
456                ignored: None,
457                metadata: None,
458                synthetic: None,
459                text: "hello".into(),
460                time: None,
461                type_: String::new(),
462            })],
463            system: None,
464            tools: None,
465            variant: None,
466        };
467        let value = serde_json::to_value(&params).expect("serialize");
468        assert_eq!(value["parts"][0]["type"], "text");
469        assert_eq!(value["parts"][0]["text"], "hello");
470    }
471}