Skip to main content

agents_kit/config/
opencode.rs

1use std::ffi::OsString;
2use std::net::SocketAddr;
3use std::path::PathBuf;
4use std::time::Duration;
5
6use crate::KitError;
7
8const MIN_IDLE_STREAM_TIMEOUT: Duration = Duration::from_secs(30);
9
10/// Selects Kit-owned spawning or exclusive attachment for OpenCode.
11///
12/// # Examples
13///
14/// ```
15/// use std::time::Duration;
16/// use agents_kit::{
17///     config::{BackendConfig, OpenCodeConfig},
18///     KitError,
19/// };
20///
21/// let external_opencode = || -> Result<BackendConfig, KitError> {
22///     let opencode = OpenCodeConfig::external(
23///         "http://127.0.0.1:4096",
24///         Duration::from_secs(10),
25///         Duration::from_secs(60),
26///         Duration::from_secs(30),
27///     )?;
28///     Ok(opencode.into())
29/// };
30/// let backend = external_opencode()?;
31/// assert!(matches!(backend, BackendConfig::OpenCode(_)));
32/// # Ok::<(), KitError>(())
33/// ```
34#[derive(Clone, Debug)]
35pub struct OpenCodeConfig {
36    mode: OpenCodeMode,
37}
38
39#[derive(Clone, Debug)]
40pub(crate) enum OpenCodeMode {
41    Spawn {
42        executable: PathBuf,
43        arguments: Vec<OsString>,
44        address: SocketAddr,
45        startup_timeout: Duration,
46        request_timeout: Duration,
47        idle_stream_timeout: Duration,
48    },
49    External {
50        base_url: reqwest::Url,
51        connect_timeout: Duration,
52        request_timeout: Duration,
53        idle_stream_timeout: Duration,
54    },
55}
56
57impl OpenCodeConfig {
58    /// Configures a Kit-owned OpenCode process on a loopback address.
59    ///
60    /// Extra arguments may not replace the Kit-owned serve, hostname, or port routing.
61    ///
62    /// This creates passive configuration; it does not start the executable.
63    ///
64    /// # Errors
65    /// Returns [`crate::error::ErrorKind::InvalidConfig`] when:
66    /// The executable is empty, the address is not loopback with a nonzero port, arguments replace owned routing, startup/request timeout is zero, or stream silence is below thirty seconds.
67    ///
68    /// ```
69    /// use agents_kit::config::OpenCodeConfig;
70    /// use std::time::Duration;
71    /// let config = OpenCodeConfig::spawn("opencode".into(), vec![],
72    ///     "127.0.0.1:4096".parse().unwrap(), Duration::from_secs(15),
73    ///     Duration::from_secs(60), Duration::from_secs(30))?;
74    /// let backend: agents_kit::config::BackendConfig = config.into();
75    /// assert!(matches!(backend, agents_kit::config::BackendConfig::OpenCode(_)));
76    /// # Ok::<(), agents_kit::KitError>(())
77    /// ```
78    pub fn spawn(
79        executable: PathBuf,
80        arguments: Vec<OsString>,
81        address: SocketAddr,
82        startup_timeout: Duration,
83        request_timeout: Duration,
84        idle_stream_timeout: Duration,
85    ) -> Result<Self, KitError> {
86        if executable.as_os_str().is_empty() {
87            return Err(KitError::invalid_config(
88                "OpenCode executable path must not be empty",
89            ));
90        }
91        if !address.ip().is_loopback() || address.port() == 0 {
92            return Err(KitError::invalid_config(
93                "OpenCode spawn address must be loopback with a nonzero port",
94            ));
95        }
96        for argument in &arguments {
97            if Self::replaces_owned_routing(argument) {
98                return Err(KitError::invalid_config(
99                    "OpenCode extra arguments must not replace serve, hostname, or port",
100                ));
101            }
102        }
103        Self::validate_timeout("OpenCode startup timeout", startup_timeout)?;
104        Self::validate_timeout("OpenCode request timeout", request_timeout)?;
105        Self::validate_idle_timeout(idle_stream_timeout)?;
106        Ok(Self {
107            mode: OpenCodeMode::Spawn {
108                executable,
109                arguments,
110                address,
111                startup_timeout,
112                request_timeout,
113                idle_stream_timeout,
114            },
115        })
116    }
117
118    /// Configures exclusive attachment to a caller-managed OpenCode server.
119    ///
120    /// The base URL must be credential-free HTTP or HTTPS. Caller-owned server
121    /// configuration is not mutated by this attachment.
122    ///
123    /// This creates passive configuration without contacting the server. A path prefix is retained with a trailing slash.
124    ///
125    /// # Errors
126    /// Returns [`crate::error::ErrorKind::InvalidConfig`] when:
127    /// The URL is malformed or not credential-free HTTP(S), has query/fragment components, a connect/request timeout is zero, or stream silence is below thirty seconds.
128    ///
129    /// ```
130    /// use agents_kit::config::OpenCodeConfig;
131    /// use std::time::Duration;
132    /// let config = OpenCodeConfig::external("http://127.0.0.1:4096",
133    ///     Duration::from_secs(10), Duration::from_secs(60), Duration::from_secs(30))?;
134    /// assert!(OpenCodeConfig::external("http://127.0.0.1:4096?token=example",
135    ///     Duration::from_secs(10), Duration::from_secs(60), Duration::from_secs(30)).is_err());
136    /// # Ok::<(), agents_kit::KitError>(())
137    /// ```
138    pub fn external(
139        base_url: impl AsRef<str>,
140        connect_timeout: Duration,
141        request_timeout: Duration,
142        idle_stream_timeout: Duration,
143    ) -> Result<Self, KitError> {
144        let mut base_url = reqwest::Url::parse(base_url.as_ref())
145            .map_err(|_| KitError::invalid_config("OpenCode base URL is invalid"))?;
146        if !matches!(base_url.scheme(), "http" | "https")
147            || !base_url.username().is_empty()
148            || base_url.password().is_some()
149            || base_url.query().is_some()
150            || base_url.fragment().is_some()
151        {
152            return Err(KitError::invalid_config(
153                "OpenCode base URL must be credential-free canonical HTTP or HTTPS",
154            ));
155        }
156        base_url.set_query(None);
157        base_url.set_fragment(None);
158        if !base_url.path().ends_with('/') {
159            let path = format!("{}/", base_url.path());
160            base_url.set_path(&path);
161        }
162        Self::validate_timeout("OpenCode connect timeout", connect_timeout)?;
163        Self::validate_timeout("OpenCode request timeout", request_timeout)?;
164        Self::validate_idle_timeout(idle_stream_timeout)?;
165        Ok(Self {
166            mode: OpenCodeMode::External {
167                base_url,
168                connect_timeout,
169                request_timeout,
170                idle_stream_timeout,
171            },
172        })
173    }
174
175    pub(crate) fn mode(&self) -> &OpenCodeMode {
176        &self.mode
177    }
178
179    pub(crate) fn validate(&self) -> Result<(), KitError> {
180        match &self.mode {
181            OpenCodeMode::Spawn {
182                executable,
183                arguments,
184                address,
185                startup_timeout,
186                request_timeout,
187                idle_stream_timeout,
188            } => {
189                if executable.as_os_str().is_empty()
190                    || !address.ip().is_loopback()
191                    || address.port() == 0
192                    || arguments.iter().any(Self::replaces_owned_routing)
193                {
194                    return Err(KitError::invalid_config(
195                        "OpenCode spawn configuration is invalid",
196                    ));
197                }
198                Self::validate_timeout("OpenCode startup timeout", *startup_timeout)?;
199                Self::validate_timeout("OpenCode request timeout", *request_timeout)?;
200                Self::validate_idle_timeout(*idle_stream_timeout)
201            }
202            OpenCodeMode::External {
203                base_url,
204                connect_timeout,
205                request_timeout,
206                idle_stream_timeout,
207            } => {
208                if !matches!(base_url.scheme(), "http" | "https")
209                    || !base_url.username().is_empty()
210                    || base_url.password().is_some()
211                {
212                    return Err(KitError::invalid_config(
213                        "OpenCode external configuration is invalid",
214                    ));
215                }
216                Self::validate_timeout("OpenCode connect timeout", *connect_timeout)?;
217                Self::validate_timeout("OpenCode request timeout", *request_timeout)?;
218                Self::validate_idle_timeout(*idle_stream_timeout)
219            }
220        }
221    }
222
223    fn validate_timeout(label: &str, timeout: Duration) -> Result<(), KitError> {
224        if timeout.is_zero() {
225            return Err(KitError::invalid_config(format!(
226                "{label} must be greater than zero"
227            )));
228        }
229        Ok(())
230    }
231
232    fn validate_idle_timeout(timeout: Duration) -> Result<(), KitError> {
233        if timeout < MIN_IDLE_STREAM_TIMEOUT {
234            return Err(KitError::invalid_config(
235                "OpenCode idle stream timeout must be at least thirty seconds",
236            ));
237        }
238        Ok(())
239    }
240
241    fn replaces_owned_routing(argument: &OsString) -> bool {
242        let argument = argument.to_string_lossy();
243        matches!(argument.as_ref(), "serve" | "--" | "--hostname" | "--port")
244            || argument.starts_with("--hostname=")
245            || argument.starts_with("--port=")
246    }
247}
248
249impl OpenCodeMode {
250    pub(crate) fn base_url(&self) -> reqwest::Url {
251        match self {
252            Self::Spawn { address, .. } => reqwest::Url::parse(&format!("http://{address}/"))
253                .expect("validated SocketAddr always forms an HTTP URL"),
254            Self::External { base_url, .. } => base_url.clone(),
255        }
256    }
257
258    pub(crate) fn request_timeout(&self) -> Duration {
259        match self {
260            Self::Spawn {
261                request_timeout, ..
262            }
263            | Self::External {
264                request_timeout, ..
265            } => *request_timeout,
266        }
267    }
268
269    pub(crate) fn idle_stream_timeout(&self) -> Duration {
270        match self {
271            Self::Spawn {
272                idle_stream_timeout,
273                ..
274            }
275            | Self::External {
276                idle_stream_timeout,
277                ..
278            } => *idle_stream_timeout,
279        }
280    }
281
282    pub(crate) fn connect_timeout(&self) -> Option<Duration> {
283        match self {
284            Self::External {
285                connect_timeout, ..
286            } => Some(*connect_timeout),
287            Self::Spawn { .. } => None,
288        }
289    }
290}