use std::time::Duration;
use reqwest::{Client, Method};
use serde::de::DeserializeOwned;
use serde_json::Value;
use crate::error::Result;
use crate::http::{BasicAuth, HttpTransport, Scope};
use crate::protocol_generated::types::{
MessageWithParts, PermissionReplyParams, PromptAsyncParams, Session, SessionCreateParams,
};
use crate::sse::{EventStream, RetryConfig};
pub const DEFAULT_BASE_URL: &str = "http://127.0.0.1:4096";
#[derive(Clone, Debug)]
pub struct OpencodeClient {
transport: HttpTransport,
}
impl OpencodeClient {
pub fn builder() -> OpencodeClientBuilder {
OpencodeClientBuilder::new()
}
pub fn transport(&self) -> &HttpTransport {
&self.transport
}
pub fn event_stream(&self, retry: RetryConfig) -> Result<EventStream> {
EventStream::from_request(self.transport.event_request(), retry)
}
pub async fn create_session(&self, params: &SessionCreateParams) -> Result<Session> {
let body = serde_json::to_value(params)?;
self.transport
.request_json(
Method::POST,
&self.transport.session_create_url(),
Some(body),
)
.await
}
pub async fn prompt_async(&self, session_id: &str, params: &PromptAsyncParams) -> Result<()> {
let body = serde_json::to_value(params)?;
self.transport
.request_unit(
Method::POST,
&self.transport.prompt_async_url(session_id),
Some(body),
)
.await
}
pub async fn list_messages(&self, session_id: &str) -> Result<Vec<MessageWithParts>> {
self.list_messages_page(session_id, None, None).await
}
pub async fn list_messages_page(
&self,
session_id: &str,
limit: Option<u64>,
before: Option<&str>,
) -> Result<Vec<MessageWithParts>> {
self.transport
.request_json(
Method::GET,
&self.transport.messages_url(session_id, limit, before),
None,
)
.await
}
pub async fn abort(&self, session_id: &str) -> Result<bool> {
self.transport
.request_json(Method::POST, &self.transport.abort_url(session_id), None)
.await
}
pub async fn respond_permission(
&self,
session_id: &str,
permission_id: &str,
reply: &PermissionReplyParams,
) -> Result<bool> {
let body = serde_json::to_value(reply)?;
self.transport
.request_json(
Method::POST,
&self.transport.permission_url(session_id, permission_id),
Some(body),
)
.await
}
pub async fn request<T: DeserializeOwned>(
&self,
method: Method,
path: &str,
body: Option<Value>,
) -> Result<T> {
self.transport
.request_json(method, &self.transport.join(path), body)
.await
}
pub async fn request_unit(
&self,
method: Method,
path: &str,
body: Option<Value>,
) -> Result<()> {
self.transport
.request_unit(method, &self.transport.join(path), body)
.await
}
}
#[derive(Clone, Debug)]
pub struct OpencodeClientBuilder {
base_url: String,
auth: Option<BasicAuth>,
timeout: Option<Duration>,
client: Option<Client>,
scope: Scope,
}
impl Default for OpencodeClientBuilder {
fn default() -> Self {
Self {
base_url: DEFAULT_BASE_URL.to_string(),
auth: None,
timeout: None,
client: None,
scope: Scope::default(),
}
}
}
impl OpencodeClientBuilder {
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = base_url.into();
self
}
#[must_use]
pub fn auth(mut self, auth: BasicAuth) -> Self {
self.auth = Some(auth);
self
}
#[must_use]
pub fn auth_from_env(mut self) -> Self {
if let Some(auth) = BasicAuth::from_env() {
self.auth = Some(auth);
}
self
}
#[must_use]
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
#[must_use]
pub fn reqwest_client(mut self, client: Client) -> Self {
self.client = Some(client);
self
}
#[must_use]
pub fn directory(mut self, directory: impl Into<String>) -> Self {
self.scope.directory = Some(directory.into());
self
}
#[must_use]
pub fn workspace(mut self, workspace: impl Into<String>) -> Self {
self.scope.workspace = Some(workspace.into());
self
}
#[must_use]
pub fn scope(mut self, scope: Scope) -> Self {
self.scope = scope;
self
}
pub fn build(self) -> Result<OpencodeClient> {
let client = match self.client {
Some(client) => client,
None => Client::builder().build()?,
};
let transport =
HttpTransport::new(client, self.base_url, self.timeout, self.auth, self.scope);
Ok(OpencodeClient { transport })
}
}
#[cfg(all(test, feature = "integration-tests"))]
mod tests {
use super::*;
use crate::protocol_generated::types::{PromptAsyncParamsPartsItem, TextPartInput};
fn client() -> OpencodeClient {
let base_url = std::env::var("OPENCODE_BASE_URL")
.unwrap_or_else(|_| "http://127.0.0.1:41999".to_string());
OpencodeClient::builder()
.base_url(base_url)
.auth_from_env()
.timeout(Duration::from_secs(30))
.build()
.expect("client builds")
}
#[tokio::test]
async fn create_list_abort_roundtrip() {
let client = client();
let session = client
.create_session(&SessionCreateParams {
title: Some("opencode-codes integration probe".into()),
agent: None,
metadata: None,
model: None,
parent_id: None,
permission: None,
workspace_id: None,
})
.await
.expect("create session");
assert!(session.id.starts_with("ses"));
let messages = client
.list_messages(&session.id)
.await
.expect("list messages");
assert!(messages.is_empty());
let aborted = client.abort(&session.id).await.expect("abort");
let _ = aborted;
}
#[tokio::test]
async fn respond_to_unknown_permission_is_404() {
let client = client();
let session = client
.create_session(&SessionCreateParams {
title: Some("opencode-codes permission probe".into()),
agent: None,
metadata: None,
model: None,
parent_id: None,
permission: None,
workspace_id: None,
})
.await
.expect("create session");
let err = client
.respond_permission(
&session.id,
"per_does_not_exist",
&PermissionReplyParams {
response: "reject".into(),
},
)
.await
.expect_err("stale permission id must fail");
match err {
crate::Error::Http { status, .. } => assert_eq!(status, 404),
other => panic!("expected HTTP 404, got {other:?}"),
}
}
#[tokio::test]
async fn raw_request_escape_hatch() {
let client = client();
let config: Value = client
.request(Method::GET, "/config", None)
.await
.expect("raw GET /config");
assert!(config.is_object());
}
#[test]
fn prompt_parts_serialize_shape() {
let params = PromptAsyncParams {
agent: None,
format: None,
message_id: None,
model: None,
no_reply: None,
parts: vec![PromptAsyncParamsPartsItem::Text(TextPartInput {
id: None,
ignored: None,
metadata: None,
synthetic: None,
text: "hello".into(),
time: None,
type_: String::new(),
})],
system: None,
tools: None,
variant: None,
};
let value = serde_json::to_value(¶ms).expect("serialize");
assert_eq!(value["parts"][0]["type"], "text");
assert_eq!(value["parts"][0]["text"], "hello");
}
}