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(¶ms).expect("serialize");
468 assert_eq!(value["parts"][0]["type"], "text");
469 assert_eq!(value["parts"][0]["text"], "hello");
470 }
471}