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    /// Fork a session — `POST /session/{sessionID}/fork`.
142    ///
143    /// Branches the source session's **whole history** into a new session and
144    /// returns the freshly created [`Session`] (server-assigned id); the
145    /// source is left untouched. The 1.18.x spec exposes no at-point cut on
146    /// this route — for fork-at-a-turn semantics see `codex-codes`'
147    /// `thread_fork` with `last_turn_id`.
148    pub async fn fork_session(&self, session_id: &str) -> Result<Session> {
149        self.transport
150            .request_json(Method::POST, &self.transport.fork_url(session_id), None)
151            .await
152    }
153
154    /// Abort in-flight work — `POST /session/{sessionID}/abort`.
155    ///
156    /// Returns `true` when the session had work that was aborted.
157    pub async fn abort(&self, session_id: &str) -> Result<bool> {
158        self.transport
159            .request_json(Method::POST, &self.transport.abort_url(session_id), None)
160            .await
161    }
162
163    /// Reply to a permission request —
164    /// `POST /session/{sessionID}/permissions/{permissionID}`.
165    ///
166    /// # Deprecation
167    ///
168    /// In the 1.18.5 spec this route (operation `permission.respond`) is marked
169    /// **deprecated** in favor of the newer reply endpoints
170    /// `POST /permission/{requestID}/reply` (operation `permission.reply`) and
171    /// `POST /api/session/{sessionID}/permission/{requestID}/reply` (operation
172    /// `v2.session.permission.reply`), neither of which this crate wraps yet.
173    /// Reach either through the raw [`OpencodeClient::request`] escape hatch using
174    /// those exact paths — note the session-scoped one requires the `/api/`
175    /// prefix. This deprecated route remains the reply channel for the
176    /// `permission.asked` event and works on 1.18.5; a future opencode release
177    /// may remove it.
178    ///
179    /// # Correlation contract
180    ///
181    /// Permission handling is deliberately split across two channels and
182    /// correlating them is the **consumer's** responsibility:
183    ///
184    /// 1. A permission *request* arrives on the `GET /event` SSE stream as an
185    ///    [`Event::PermissionAsked`](crate::protocol_generated::types::Event::PermissionAsked)
186    ///    event (wire type `permission.asked`), whose `properties` carry a `ses…`
187    ///    session id and a `per…` permission id. This is the *only* ask event
188    ///    that pairs with this call: the coexisting
189    ///    [`Event::PermissionV2Asked`](crate::protocol_generated::types::Event::PermissionV2Asked)
190    ///    (`permission.v2.asked`) belongs to the unwrapped v2 reply endpoints, so
191    ///    do **not** feed its id here.
192    /// 2. The *reply* is this separate REST call, addressed by exactly those two
193    ///    ids. There is no server-side callback and no implicit pairing: the
194    ///    caller must remember which pending `(session_id, permission_id)` a reply
195    ///    answers.
196    ///
197    /// [`PermissionReplyParams::response`] is one of
198    /// [`PermissionReplyResponse::Once`](crate::protocol_generated::types::PermissionReplyResponse::Once),
199    /// [`Always`](crate::protocol_generated::types::PermissionReplyResponse::Always),
200    /// or [`Reject`](crate::protocol_generated::types::PermissionReplyResponse::Reject).
201    /// Returns `true` when the reply was accepted; a stale or unknown permission
202    /// id yields [`crate::Error::Http`] with status 404.
203    pub async fn respond_permission(
204        &self,
205        session_id: &str,
206        permission_id: &str,
207        reply: &PermissionReplyParams,
208    ) -> Result<bool> {
209        let body = serde_json::to_value(reply)?;
210        self.transport
211            .request_json(
212                Method::POST,
213                &self.transport.permission_url(session_id, permission_id),
214                Some(body),
215            )
216            .await
217    }
218
219    /// Raw escape hatch for endpoints this crate does not hand-wrap.
220    ///
221    /// `path` is joined onto the configured base URL (leading slash optional) and
222    /// used verbatim; `body`, when present, is sent as a JSON request body. The
223    /// 2xx response is deserialized into `T`; non-2xx becomes
224    /// [`crate::Error::Http`].
225    pub async fn request<T: DeserializeOwned>(
226        &self,
227        method: Method,
228        path: &str,
229        body: Option<Value>,
230    ) -> Result<T> {
231        self.transport
232            .request_json(method, &self.transport.join(path), body)
233            .await
234    }
235
236    /// Raw escape hatch for endpoints that answer with an empty body.
237    ///
238    /// Identical to [`OpencodeClient::request`] but discards the response body
239    /// instead of deserializing it. Many opencode `POST` endpoints reply `204 No
240    /// Content` (e.g. `prompt_async` and several unwrapped routes); calling those
241    /// through [`OpencodeClient::request`] would fail deserializing the empty
242    /// body, so use this variant for them.
243    pub async fn request_unit(
244        &self,
245        method: Method,
246        path: &str,
247        body: Option<Value>,
248    ) -> Result<()> {
249        self.transport
250            .request_unit(method, &self.transport.join(path), body)
251            .await
252    }
253}
254
255/// Builder for [`OpencodeClient`].
256#[derive(Clone, Debug)]
257pub struct OpencodeClientBuilder {
258    base_url: String,
259    auth: Option<BasicAuth>,
260    timeout: Option<Duration>,
261    client: Option<Client>,
262    scope: Scope,
263}
264
265impl Default for OpencodeClientBuilder {
266    fn default() -> Self {
267        Self {
268            base_url: DEFAULT_BASE_URL.to_string(),
269            auth: None,
270            timeout: None,
271            client: None,
272            scope: Scope::default(),
273        }
274    }
275}
276
277impl OpencodeClientBuilder {
278    /// A builder defaulting to [`DEFAULT_BASE_URL`] with no auth or timeout.
279    pub fn new() -> Self {
280        Self::default()
281    }
282
283    /// Set the opencode server base URL (e.g. `http://127.0.0.1:4096`).
284    #[must_use]
285    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
286        self.base_url = base_url.into();
287        self
288    }
289
290    /// Attach explicit HTTP Basic credentials.
291    #[must_use]
292    pub fn auth(mut self, auth: BasicAuth) -> Self {
293        self.auth = Some(auth);
294        self
295    }
296
297    /// Attach HTTP Basic credentials derived from `OPENCODE_SERVER_PASSWORD`
298    /// (username `"opencode"`). A no-op when the variable is unset or empty.
299    ///
300    /// This is the explicit opt-in for reading the environment; the request path
301    /// never consults it implicitly.
302    #[must_use]
303    pub fn auth_from_env(mut self) -> Self {
304        if let Some(auth) = BasicAuth::from_env() {
305            self.auth = Some(auth);
306        }
307        self
308    }
309
310    /// Apply a per-request timeout to every call the client makes.
311    ///
312    /// Applied per request, so it also constrains an injected
313    /// [`reqwest::Client`] that was built without a timeout.
314    #[must_use]
315    pub fn timeout(mut self, timeout: Duration) -> Self {
316        self.timeout = Some(timeout);
317        self
318    }
319
320    /// Inject a pre-configured [`reqwest::Client`] (connection pools, proxies,
321    /// custom TLS). When omitted, a default client is built.
322    #[must_use]
323    pub fn reqwest_client(mut self, client: Client) -> Self {
324        self.client = Some(client);
325        self
326    }
327
328    /// Scope every session endpoint (and the `GET /event` stream) to a project
329    /// `directory`.
330    ///
331    /// `opencode serve` can manage several directories at once; without this the
332    /// server uses its own working directory. Set it to target a specific
333    /// project on a multi-directory server. Build one client per directory (the
334    /// clone is cheap) to drive several concurrently.
335    #[must_use]
336    pub fn directory(mut self, directory: impl Into<String>) -> Self {
337        self.scope.directory = Some(directory.into());
338        self
339    }
340
341    /// Scope every session endpoint (and the `GET /event` stream) to a
342    /// `workspace` identifier. See [`directory`](Self::directory).
343    #[must_use]
344    pub fn workspace(mut self, workspace: impl Into<String>) -> Self {
345        self.scope.workspace = Some(workspace.into());
346        self
347    }
348
349    /// Set the full directory/workspace [`Scope`] at once.
350    #[must_use]
351    pub fn scope(mut self, scope: Scope) -> Self {
352        self.scope = scope;
353        self
354    }
355
356    /// Build the [`OpencodeClient`].
357    ///
358    /// # Errors
359    ///
360    /// Returns [`crate::Error::Transport`] if a default [`reqwest::Client`] must
361    /// be constructed and its builder fails.
362    pub fn build(self) -> Result<OpencodeClient> {
363        let client = match self.client {
364            Some(client) => client,
365            None => Client::builder().build()?,
366        };
367        let transport =
368            HttpTransport::new(client, self.base_url, self.timeout, self.auth, self.scope);
369        Ok(OpencodeClient { transport })
370    }
371}
372
373#[cfg(all(test, feature = "integration-tests"))]
374mod tests {
375    use super::*;
376    use crate::protocol_generated::types::{PromptAsyncParamsPartsItem, TextPartInput};
377
378    fn client() -> OpencodeClient {
379        let base_url = std::env::var("OPENCODE_BASE_URL")
380            .unwrap_or_else(|_| "http://127.0.0.1:41999".to_string());
381        OpencodeClient::builder()
382            .base_url(base_url)
383            .auth_from_env()
384            .timeout(Duration::from_secs(30))
385            .build()
386            .expect("client builds")
387    }
388
389    #[tokio::test]
390    async fn create_list_abort_roundtrip() {
391        let client = client();
392        let session = client
393            .create_session(&SessionCreateParams {
394                title: Some("opencode-codes integration probe".into()),
395                agent: None,
396                metadata: None,
397                model: None,
398                parent_id: None,
399                permission: None,
400                workspace_id: None,
401            })
402            .await
403            .expect("create session");
404        assert!(session.id.starts_with("ses"));
405
406        let messages = client
407            .list_messages(&session.id)
408            .await
409            .expect("list messages");
410        assert!(messages.is_empty());
411
412        let aborted = client.abort(&session.id).await.expect("abort");
413        // No work was running, but the endpoint still answers with a boolean.
414        let _ = aborted;
415    }
416
417    #[tokio::test]
418    async fn respond_to_unknown_permission_is_404() {
419        let client = client();
420        let session = client
421            .create_session(&SessionCreateParams {
422                title: Some("opencode-codes permission probe".into()),
423                agent: None,
424                metadata: None,
425                model: None,
426                parent_id: None,
427                permission: None,
428                workspace_id: None,
429            })
430            .await
431            .expect("create session");
432
433        let err = client
434            .respond_permission(
435                &session.id,
436                "per_does_not_exist",
437                &PermissionReplyParams {
438                    response: "reject".into(),
439                },
440            )
441            .await
442            .expect_err("stale permission id must fail");
443        match err {
444            crate::Error::Http { status, .. } => assert_eq!(status, 404),
445            other => panic!("expected HTTP 404, got {other:?}"),
446        }
447    }
448
449    #[tokio::test]
450    async fn raw_request_escape_hatch() {
451        let client = client();
452        let config: Value = client
453            .request(Method::GET, "/config", None)
454            .await
455            .expect("raw GET /config");
456        assert!(config.is_object());
457    }
458
459    #[test]
460    fn prompt_parts_serialize_shape() {
461        let params = PromptAsyncParams {
462            agent: None,
463            format: None,
464            message_id: None,
465            model: None,
466            no_reply: None,
467            parts: vec![PromptAsyncParamsPartsItem::Text(TextPartInput {
468                id: None,
469                ignored: None,
470                metadata: None,
471                synthetic: None,
472                text: "hello".into(),
473                time: None,
474                type_: String::new(),
475            })],
476            system: None,
477            tools: None,
478            variant: None,
479        };
480        let value = serde_json::to_value(&params).expect("serialize");
481        assert_eq!(value["parts"][0]["type"], "text");
482        assert_eq!(value["parts"][0]["text"], "hello");
483    }
484}