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(¶ms).expect("serialize");
481 assert_eq!(value["parts"][0]["type"], "text");
482 assert_eq!(value["parts"][0]["text"], "hello");
483 }
484}