Skip to main content

chimera_opencode/
backend.rs

1use std::path::{Path, PathBuf};
2
3use bon::Builder;
4
5use chimera_core::*;
6
7use crate::agent_session::OpenCodeAgentSession;
8use crate::config::{OpenCodeConfig, OpenCodeExecutionMode, OpenCodeProvider};
9use crate::session::OpenCodeSession;
10
11#[derive(Debug, Clone, Builder)]
12pub struct OpenCodeBackend {
13    #[builder(into)]
14    pub(crate) api_key: Option<String>,
15
16    pub(crate) provider: Option<OpenCodeProvider>,
17
18    /// Path to the `opencode` CLI binary used by agent mode.
19    pub(crate) binary_path: Option<PathBuf>,
20}
21
22impl OpenCodeBackend {
23    /// Create a backend, reading API key from env vars or ~/.local/share/opencode/auth.json.
24    ///
25    /// Priority:
26    /// 1. `OPENCODE_ZEN_KEY` or `OPENCODE_API_KEY` env vars (Zen provider)
27    /// 2. `OPENCODE_GO_API_KEY` env var (Go provider)
28    /// 3. `OPENROUTER_API_KEY` env var (OpenRouter direct provider)
29    /// 4. `auth.json` provider records (`opencode` or legacy `opencode-go`)
30    pub fn from_env() -> Self {
31        from_env_with(|key| std::env::var(key).ok(), load_auth_json_api_key)
32    }
33}
34
35fn from_env_with(
36    get_env: impl Fn(&str) -> Option<String>,
37    load_auth_json_key: impl Fn() -> Option<(String, Option<OpenCodeProvider>)>,
38) -> OpenCodeBackend {
39    // Zen env vars
40    if let Some(key) = get_env("OPENCODE_ZEN_KEY")
41        .filter(|s| !s.is_empty())
42        .or_else(|| get_env("OPENCODE_API_KEY").filter(|s| !s.is_empty()))
43    {
44        return OpenCodeBackend {
45            api_key: Some(key),
46            provider: None,
47            binary_path: None,
48        };
49    }
50    // Go env var
51    if let Some(key) = get_env("OPENCODE_GO_API_KEY").filter(|s| !s.is_empty()) {
52        return OpenCodeBackend {
53            api_key: Some(key),
54            provider: Some(crate::config::OpenCodeProvider::Go),
55            binary_path: None,
56        };
57    }
58    // OpenRouter env var
59    if let Some(key) = get_env("OPENROUTER_API_KEY").filter(|s| !s.is_empty()) {
60        return OpenCodeBackend {
61            api_key: Some(key),
62            provider: Some(crate::config::OpenCodeProvider::Direct {
63                base_url: "https://openrouter.ai/api/v1".into(),
64            }),
65            binary_path: None,
66        };
67    }
68    // auth.json fallback
69    if let Some((key, provider)) = load_auth_json_key() {
70        return OpenCodeBackend {
71            api_key: Some(key),
72            provider,
73            binary_path: None,
74        };
75    }
76    OpenCodeBackend {
77        api_key: None,
78        provider: None,
79        binary_path: None,
80    }
81}
82
83impl OpenCodeBackend {
84    /// Locate the `opencode` binary on PATH or common install locations.
85    pub fn from_path() -> Result<Self> {
86        let binary_path = find_opencode_binary()?;
87        Ok(Self {
88            api_key: None,
89            provider: None,
90            binary_path: Some(binary_path),
91        })
92    }
93}
94
95fn find_opencode_binary() -> Result<PathBuf> {
96    if let Ok(path) = std::env::var("CHIMERA_OPENCODE_BINARY") {
97        let path = PathBuf::from(path);
98        if path.exists() {
99            return Ok(path);
100        }
101    }
102
103    let home = std::env::var("HOME").unwrap_or_default();
104
105    // Prefer the installed binary. A local source checkout is only safe when
106    // its workspace dependencies are installed; otherwise `bun run` exits at
107    // startup before the server is ready.
108    if let Ok(path) = which_opencode() {
109        return Ok(path);
110    }
111
112    let source_candidates: &[&str] = &[
113        "~/projects/opencode/packages/opencode",
114        "~/opencode/packages/opencode",
115    ];
116    for raw in source_candidates {
117        let expanded = raw.replacen("~", &home, 1);
118        let path = PathBuf::from(&expanded);
119        if is_runnable_opencode_source(&path) {
120            return Ok(path);
121        }
122    }
123
124    // Common install locations.
125    let binary_candidates: &[&str] = &[
126        "~/.npm-global/bin/opencode",
127        "~/.local/bin/opencode",
128        "/usr/local/bin/opencode",
129        "~/.yarn/bin/opencode",
130        "~/.bun/bin/opencode",
131    ];
132    for raw in binary_candidates {
133        let expanded = raw.replacen("~", &home, 1);
134        let path = PathBuf::from(&expanded);
135        if path.exists() {
136            return Ok(path);
137        }
138    }
139    Err(AgentError::BinaryNotFound {
140        binary: "opencode".into(),
141        searched: source_candidates
142            .iter()
143            .chain(binary_candidates.iter())
144            .map(PathBuf::from)
145            .collect(),
146    })
147}
148
149fn is_runnable_opencode_source(path: &Path) -> bool {
150    path.join("src/index.ts").exists() && find_ancestor_node_module(path, "@opencode-ai/core")
151}
152
153fn find_ancestor_node_module(path: &Path, module: &str) -> bool {
154    path.ancestors()
155        .any(|ancestor| ancestor.join("node_modules").join(module).exists())
156}
157
158/// Load an OpenCode API key from ~/.local/share/opencode/auth.json.
159///
160/// Current installs write typed provider records such as:
161/// `{ "opencode": { "type": "api", "key": "sk-..." } }`
162/// Older installs may have used `opencode-go` or omitted the `type` field.
163fn load_auth_json_api_key() -> Option<(String, Option<OpenCodeProvider>)> {
164    let home = std::env::var("HOME").ok()?;
165    let path = std::path::Path::new(&home).join(".local/share/opencode/auth.json");
166    let text = std::fs::read_to_string(&path).ok()?;
167    let v: serde_json::Value = serde_json::from_str(&text).ok()?;
168
169    parse_auth_json_api_key(&v)
170}
171
172fn parse_auth_json_api_key(v: &serde_json::Value) -> Option<(String, Option<OpenCodeProvider>)> {
173    for (provider_key, provider) in [
174        ("opencode", None),
175        ("opencode-go", Some(crate::config::OpenCodeProvider::Go)),
176    ] {
177        let entry = &v[provider_key];
178        let Some(key) = entry.get("key").and_then(|value| value.as_str()) else {
179            continue;
180        };
181        let key = key.trim();
182        let entry_type = entry.get("type").and_then(|value| value.as_str());
183        if key.is_empty() {
184            continue;
185        }
186        if entry_type.is_none() || entry_type == Some("api") {
187            return Some((key.to_string(), provider));
188        }
189    }
190
191    None
192}
193
194fn which_opencode() -> std::result::Result<PathBuf, ()> {
195    let path_var = std::env::var("PATH").map_err(|_| ())?;
196    for dir in std::env::split_paths(&path_var) {
197        let candidate = dir.join("opencode");
198        if candidate.exists() {
199            return Ok(candidate);
200        }
201    }
202    Err(())
203}
204
205impl Backend for OpenCodeBackend {
206    type Config = OpenCodeConfig;
207    type Session = OpenCodeSession;
208
209    fn name(&self) -> &'static str {
210        "opencode"
211    }
212
213    fn capabilities(&self) -> BackendCapabilities {
214        BackendCapabilities {
215            input_images: CapabilitySupport::Unsupported,
216            output_schema: CapabilitySupport::ConfigDependent,
217            interrupt: CapabilitySupport::ConfigDependent,
218            resume: ResumeSemantics::Stateful,
219            mcp: McpSupport::MergeAndExplicitOnly,
220            tool_call_events: CapabilitySupport::Supported,
221        }
222    }
223
224    async fn session(&self, config: SessionConfig<OpenCodeConfig>) -> Result<OpenCodeSession> {
225        if config.backend.execution_mode == OpenCodeExecutionMode::Agent {
226            let binary_path = match self.binary_path.clone() {
227                Some(p) => p,
228                None => find_opencode_binary()?,
229            };
230            let agent = OpenCodeAgentSession::new(binary_path, config).await?;
231            return Ok(OpenCodeSession::Agent(Box::new(agent)));
232        }
233
234        validate_http_config(&config.backend)?;
235
236        let api_key = config
237            .backend
238            .api_key
239            .clone()
240            .or_else(|| self.api_key.clone());
241        let provider = self
242            .provider
243            .clone()
244            .unwrap_or_else(|| config.backend.provider.clone());
245
246        Ok(OpenCodeSession::Http(Box::new(
247            crate::session::HttpSession::new(api_key, provider, config),
248        )))
249    }
250
251    async fn resume(
252        &self,
253        session_id: &str,
254        config: SessionConfig<OpenCodeConfig>,
255    ) -> Result<OpenCodeSession> {
256        if config.backend.execution_mode == OpenCodeExecutionMode::Agent {
257            let binary_path = match self.binary_path.clone() {
258                Some(p) => p,
259                None => find_opencode_binary()?,
260            };
261            let agent = OpenCodeAgentSession::resume(binary_path, config, session_id).await?;
262            return Ok(OpenCodeSession::Agent(Box::new(agent)));
263        }
264
265        validate_http_config(&config.backend)?;
266
267        let api_key = config
268            .backend
269            .api_key
270            .clone()
271            .or_else(|| self.api_key.clone());
272        let provider = self
273            .provider
274            .clone()
275            .unwrap_or_else(|| config.backend.provider.clone());
276
277        Ok(OpenCodeSession::Http(Box::new(
278            crate::session::HttpSession::new(api_key, provider, config)
279                .with_session_id(session_id.to_owned()),
280        )))
281    }
282}
283
284fn validate_http_config(config: &OpenCodeConfig) -> Result<()> {
285    if !config.mcp_servers.is_empty()
286        || config.variant.is_some()
287        || config.agent_base_url.is_some()
288        || config.session_title.is_some()
289    {
290        return Err(AgentError::Other {
291            message: "opencode agent-only options require execution_mode = agent".into(),
292            source: None,
293        });
294    }
295    Ok(())
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn backend_name() {
304        let backend = OpenCodeBackend::from_env();
305        assert_eq!(backend.name(), "opencode");
306        assert_eq!(
307            backend.capabilities().output_schema,
308            CapabilitySupport::ConfigDependent
309        );
310    }
311
312    #[tokio::test]
313    async fn session_merges_api_key() {
314        let backend = OpenCodeBackend::builder().api_key("backend-key").build();
315        let config = SessionConfig::builder()
316            .backend(OpenCodeConfig::default())
317            .build();
318
319        let session = backend.session(config).await.unwrap();
320        let crate::session::OpenCodeSession::Http(http) = session else {
321            panic!("expected Http session");
322        };
323        assert_eq!(http.api_key(), Some("backend-key"));
324    }
325
326    #[tokio::test]
327    async fn session_config_overrides_backend_key() {
328        let backend = OpenCodeBackend::builder().api_key("backend-key").build();
329        let config = SessionConfig::builder()
330            .backend(OpenCodeConfig::builder().api_key("session-key").build())
331            .build();
332
333        let session = backend.session(config).await.unwrap();
334        let crate::session::OpenCodeSession::Http(http) = session else {
335            panic!("expected Http session");
336        };
337        assert_eq!(http.api_key(), Some("session-key"));
338    }
339
340    #[test]
341    fn from_env_prefers_openrouter_key_when_present() {
342        let key = "test-openrouter-key";
343        let backend = from_env_with(
344            |env_key| (env_key == "OPENROUTER_API_KEY").then(|| key.to_string()),
345            || None,
346        );
347        assert_eq!(backend.api_key.as_deref(), Some(key));
348        assert!(matches!(
349            backend.provider,
350            Some(OpenCodeProvider::Direct { ref base_url })
351                if base_url == "https://openrouter.ai/api/v1"
352        ));
353    }
354
355    #[tokio::test]
356    async fn session_rejects_variant_in_http_mode() {
357        let backend = OpenCodeBackend::builder().build();
358        let config = SessionConfig::builder()
359            .backend(OpenCodeConfig::builder().variant("medium").build())
360            .build();
361
362        let err = match backend.session(config).await {
363            Ok(_) => panic!("expected variant-in-http-mode session to fail"),
364            Err(err) => err,
365        };
366        assert!(
367            err.to_string()
368                .contains("agent-only options require execution_mode = agent")
369        );
370    }
371
372    #[tokio::test]
373    async fn resume_sets_session_id() {
374        let backend = OpenCodeBackend::builder().build();
375        let config = SessionConfig::builder()
376            .backend(OpenCodeConfig::default())
377            .build();
378
379        let session = backend.resume("sess-abc", config).await.unwrap();
380        assert_eq!(session.session_id(), Some("sess-abc"));
381    }
382
383    #[test]
384    fn auth_json_prefers_typed_opencode_record() {
385        let parsed = parse_auth_json_api_key(&serde_json::json!({
386            "opencode": { "type": "api", "key": "typed-key" },
387            "opencode-go": { "type": "api", "key": "go-key" }
388        }));
389
390        let (key, provider) = parsed.expect("expected auth fallback");
391        assert_eq!(key, "typed-key");
392        assert!(provider.is_none());
393    }
394
395    #[test]
396    fn auth_json_can_select_legacy_go_provider() {
397        let parsed = parse_auth_json_api_key(&serde_json::json!({
398            "opencode-go": { "key": "go-key" }
399        }));
400
401        let (key, provider) = parsed.expect("expected auth fallback");
402        assert_eq!(key, "go-key");
403        assert!(matches!(provider, Some(OpenCodeProvider::Go)));
404    }
405
406    #[test]
407    fn auth_json_ignores_non_api_records_for_fallback() {
408        let parsed = parse_auth_json_api_key(&serde_json::json!({
409            "opencode": { "type": "oauth", "access": "x", "refresh": "y", "expires": 1 }
410        }));
411
412        assert!(parsed.is_none());
413    }
414
415    #[test]
416    fn opencode_source_is_not_runnable_without_workspace_deps() {
417        let temp = tempfile::tempdir().unwrap();
418        let package = temp.path().join("packages/opencode");
419        std::fs::create_dir_all(package.join("src")).unwrap();
420        std::fs::write(package.join("src/index.ts"), "").unwrap();
421
422        assert!(!is_runnable_opencode_source(&package));
423    }
424
425    #[test]
426    fn opencode_source_is_runnable_when_workspace_deps_exist() {
427        let temp = tempfile::tempdir().unwrap();
428        let package = temp.path().join("packages/opencode");
429        std::fs::create_dir_all(package.join("src")).unwrap();
430        std::fs::write(package.join("src/index.ts"), "").unwrap();
431        std::fs::create_dir_all(temp.path().join("node_modules/@opencode-ai/core")).unwrap();
432
433        assert!(is_runnable_opencode_source(&package));
434    }
435
436    #[tokio::test]
437    async fn resume_rejects_variant_in_http_mode() {
438        let backend = OpenCodeBackend::builder().build();
439        let config = SessionConfig::builder()
440            .backend(OpenCodeConfig::builder().variant("medium").build())
441            .build();
442
443        let err = match backend.resume("sess-abc", config).await {
444            Ok(_) => panic!("expected variant-in-http-mode resume to fail"),
445            Err(err) => err,
446        };
447        assert!(
448            err.to_string()
449                .contains("agent-only options require execution_mode = agent")
450        );
451    }
452}