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 pub(crate) binary_path: Option<PathBuf>,
20}
21
22impl OpenCodeBackend {
23 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 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 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 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 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 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 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 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
158fn 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}