Skip to main content

chimera_opencode/
config.rs

1use std::collections::HashMap;
2
3use bon::Builder;
4use chimera_core::{
5    BackendCapabilities, CapabilitySupport, McpConfigMode, McpSupport, ResumeSemantics,
6};
7use serde::{Deserialize, Serialize};
8
9/// OpenCode API provider.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12#[non_exhaustive]
13#[derive(Default)]
14pub enum OpenCodeProvider {
15    /// Official Zen gateway at <https://opencode.ai/zen/v1>.
16    /// Default for pay-as-you-go credits.
17    #[default]
18    Zen,
19    /// Go subscription gateway at <https://opencode.ai/zen/go/v1>.
20    /// For Go/Lite subscription users with limited model access.
21    Go,
22    /// Custom endpoint (self-hosted or alternative OpenAI-compatible API).
23    Direct { base_url: String },
24}
25
26/// Selects whether Chimera calls an OpenAI-compatible endpoint directly or
27/// drives the installed OpenCode agent.
28#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30#[non_exhaustive]
31pub enum OpenCodeExecutionMode {
32    #[default]
33    Http,
34    Agent,
35}
36
37impl OpenCodeProvider {
38    pub fn base_url(&self) -> &str {
39        match self {
40            Self::Zen => "https://opencode.ai/zen/v1",
41            Self::Go => "https://opencode.ai/zen/go/v1",
42            Self::Direct { base_url } => base_url.as_str(),
43        }
44    }
45}
46
47/// A single MCP server to register with the OpenCode agent.
48///
49/// Uses OpenCode's native format: `command` is the full argv (binary + args in one vec).
50#[derive(Debug, Clone, Builder, Serialize, Deserialize)]
51pub struct OpenCodeMcpServer {
52    /// Server name (key in the `mcp` config object).
53    #[builder(into)]
54    pub name: String,
55
56    /// Full command-line: `["/path/to/binary", "arg1", "arg2", ...]`.
57    #[builder(default)]
58    pub command: Vec<String>,
59
60    /// Environment variables for the MCP server subprocess.
61    #[builder(default)]
62    pub env: HashMap<String, String>,
63}
64
65#[derive(Debug, Clone, Builder, Serialize, Deserialize)]
66pub struct OpenCodeConfig {
67    /// Execution path. Agent mode launches `opencode serve`.
68    #[builder(default)]
69    pub execution_mode: OpenCodeExecutionMode,
70
71    /// API key for bearer auth. Falls back to OPENCODE_ZEN_KEY / OPENCODE_API_KEY env vars.
72    #[builder(into)]
73    pub api_key: Option<String>,
74
75    /// Provider selection.
76    #[builder(default)]
77    pub provider: OpenCodeProvider,
78
79    /// HTTP request timeout in seconds.
80    pub timeout_secs: Option<u64>,
81
82    /// Maximum time to wait for `opencode serve` to become ready in agent mode.
83    pub startup_timeout_secs: Option<u64>,
84
85    /// Maximum retry attempts for transient errors (429, 5xx).
86    pub max_retries: Option<u32>,
87
88    /// Maximum tokens in the completion response.
89    pub max_tokens: Option<u32>,
90
91    /// Sampling temperature.
92    pub temperature: Option<f32>,
93
94    /// Enable SSE streaming responses.
95    #[builder(default = true)]
96    pub stream: bool,
97
98    /// MCP servers to register with the OpenCode agent.
99    #[builder(default)]
100    pub mcp_servers: Vec<OpenCodeMcpServer>,
101
102    /// Controls whether Chimera MCP servers are merged with ambient OpenCode
103    /// config or launched in an isolated explicit-only config environment.
104    #[builder(default)]
105    pub mcp_config_mode: McpConfigMode,
106
107    /// Override the selected OpenCode provider's base URL in agent mode.
108    #[builder(into)]
109    pub agent_base_url: Option<String>,
110
111    /// Initial title for sessions created in agent mode.
112    ///
113    /// A fixed title suppresses OpenCode's asynchronous title-generation turn.
114    #[builder(into)]
115    pub session_title: Option<String>,
116
117    /// System prompt override passed to the agent session.
118    #[builder(into)]
119    pub system_prompt: Option<String>,
120
121    /// Provider/model variant name (for example reasoning presets like `medium` or `high`).
122    #[builder(into)]
123    pub variant: Option<String>,
124}
125
126impl Default for OpenCodeConfig {
127    fn default() -> Self {
128        Self {
129            execution_mode: OpenCodeExecutionMode::Http,
130            api_key: None,
131            provider: OpenCodeProvider::default(),
132            timeout_secs: None,
133            startup_timeout_secs: None,
134            max_retries: None,
135            max_tokens: None,
136            temperature: None,
137            stream: true,
138            mcp_servers: Vec::new(),
139            mcp_config_mode: McpConfigMode::Merge,
140            agent_base_url: None,
141            session_title: None,
142            system_prompt: None,
143            variant: None,
144        }
145    }
146}
147
148impl OpenCodeConfig {
149    pub(crate) fn startup_timeout(&self) -> std::time::Duration {
150        std::time::Duration::from_secs(self.startup_timeout_secs.unwrap_or(30))
151    }
152
153    pub fn capabilities(&self) -> BackendCapabilities {
154        let agent_mode = self.execution_mode == OpenCodeExecutionMode::Agent;
155
156        BackendCapabilities {
157            input_images: CapabilitySupport::Unsupported,
158            output_schema: if agent_mode {
159                CapabilitySupport::Unsupported
160            } else {
161                CapabilitySupport::Supported
162            },
163            interrupt: if agent_mode {
164                CapabilitySupport::Unsupported
165            } else {
166                CapabilitySupport::Supported
167            },
168            resume: ResumeSemantics::Stateful,
169            mcp: McpSupport::MergeAndExplicitOnly,
170            tool_call_events: CapabilitySupport::Supported,
171        }
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn config_defaults() {
181        let config = OpenCodeConfig::default();
182        assert_eq!(config.execution_mode, OpenCodeExecutionMode::Http);
183        assert!(config.api_key.is_none());
184        assert!(matches!(config.provider, OpenCodeProvider::Zen));
185        assert!(config.timeout_secs.is_none());
186        assert!(config.startup_timeout_secs.is_none());
187        assert!(config.max_retries.is_none());
188        assert!(config.max_tokens.is_none());
189        assert!(config.temperature.is_none());
190        assert!(config.stream);
191        assert_eq!(config.mcp_config_mode, McpConfigMode::Merge);
192        assert!(config.agent_base_url.is_none());
193        assert!(config.session_title.is_none());
194        assert!(config.variant.is_none());
195    }
196
197    #[test]
198    fn config_builder() {
199        let config = OpenCodeConfig::builder()
200            .api_key("sk-test")
201            .provider(OpenCodeProvider::Direct {
202                base_url: "http://localhost:8080".into(),
203            })
204            .timeout_secs(60)
205            .startup_timeout_secs(90)
206            .max_retries(3)
207            .variant("medium")
208            .stream(false)
209            .build();
210
211        assert_eq!(config.api_key.as_deref(), Some("sk-test"));
212        assert_eq!(config.timeout_secs, Some(60));
213        assert_eq!(config.startup_timeout_secs, Some(90));
214        assert_eq!(config.max_retries, Some(3));
215        assert_eq!(config.variant.as_deref(), Some("medium"));
216        assert!(!config.stream);
217    }
218
219    #[test]
220    fn provider_base_url_zen() {
221        let p = OpenCodeProvider::Zen;
222        assert_eq!(p.base_url(), "https://opencode.ai/zen/v1");
223    }
224
225    #[test]
226    fn provider_base_url_go() {
227        let p = OpenCodeProvider::Go;
228        assert_eq!(p.base_url(), "https://opencode.ai/zen/go/v1");
229    }
230
231    #[test]
232    fn provider_base_url_direct() {
233        let p = OpenCodeProvider::Direct {
234            base_url: "http://localhost:3000/v1".into(),
235        };
236        assert_eq!(p.base_url(), "http://localhost:3000/v1");
237    }
238
239    #[test]
240    fn config_serde_roundtrip() {
241        let config = OpenCodeConfig::builder()
242            .api_key("key")
243            .max_tokens(4096)
244            .startup_timeout_secs(75)
245            .build();
246
247        let json = serde_json::to_string(&config).unwrap();
248        let parsed: OpenCodeConfig = serde_json::from_str(&json).unwrap();
249        assert_eq!(parsed.api_key.as_deref(), Some("key"));
250        assert_eq!(parsed.max_tokens, Some(4096));
251        assert_eq!(parsed.startup_timeout_secs, Some(75));
252    }
253
254    #[test]
255    fn startup_timeout_defaults_to_30_seconds() {
256        assert_eq!(
257            OpenCodeConfig::default().startup_timeout(),
258            std::time::Duration::from_secs(30)
259        );
260    }
261
262    #[test]
263    fn startup_timeout_uses_explicit_override() {
264        let config = OpenCodeConfig::builder().startup_timeout_secs(120).build();
265
266        assert_eq!(
267            config.startup_timeout(),
268            std::time::Duration::from_secs(120)
269        );
270    }
271
272    #[test]
273    fn http_mode_capabilities_report_schema_and_interrupt_support() {
274        let capabilities = OpenCodeConfig::default().capabilities();
275        assert_eq!(capabilities.input_images, CapabilitySupport::Unsupported);
276        assert_eq!(capabilities.output_schema, CapabilitySupport::Supported);
277        assert_eq!(capabilities.interrupt, CapabilitySupport::Supported);
278        assert_eq!(capabilities.resume, ResumeSemantics::Stateful);
279    }
280
281    #[test]
282    fn agent_mode_capabilities_disable_schema_and_interrupt() {
283        let capabilities = OpenCodeConfig::builder()
284            .execution_mode(OpenCodeExecutionMode::Agent)
285            .build()
286            .capabilities();
287
288        assert_eq!(capabilities.output_schema, CapabilitySupport::Unsupported);
289        assert_eq!(capabilities.interrupt, CapabilitySupport::Unsupported);
290        assert_eq!(capabilities.mcp, McpSupport::MergeAndExplicitOnly);
291    }
292}