use std::path::{Path, PathBuf};
use bon::Builder;
use chimera_core::*;
use crate::agent_session::OpenCodeAgentSession;
use crate::config::{OpenCodeConfig, OpenCodeExecutionMode, OpenCodeProvider};
use crate::session::OpenCodeSession;
#[derive(Debug, Clone, Builder)]
pub struct OpenCodeBackend {
#[builder(into)]
pub(crate) api_key: Option<String>,
pub(crate) provider: Option<OpenCodeProvider>,
pub(crate) binary_path: Option<PathBuf>,
}
impl OpenCodeBackend {
pub fn from_env() -> Self {
from_env_with(|key| std::env::var(key).ok(), load_auth_json_api_key)
}
}
fn from_env_with(
get_env: impl Fn(&str) -> Option<String>,
load_auth_json_key: impl Fn() -> Option<(String, Option<OpenCodeProvider>)>,
) -> OpenCodeBackend {
if let Some(key) = get_env("OPENCODE_ZEN_KEY")
.filter(|s| !s.is_empty())
.or_else(|| get_env("OPENCODE_API_KEY").filter(|s| !s.is_empty()))
{
return OpenCodeBackend {
api_key: Some(key),
provider: None,
binary_path: None,
};
}
if let Some(key) = get_env("OPENCODE_GO_API_KEY").filter(|s| !s.is_empty()) {
return OpenCodeBackend {
api_key: Some(key),
provider: Some(crate::config::OpenCodeProvider::Go),
binary_path: None,
};
}
if let Some(key) = get_env("OPENROUTER_API_KEY").filter(|s| !s.is_empty()) {
return OpenCodeBackend {
api_key: Some(key),
provider: Some(crate::config::OpenCodeProvider::Direct {
base_url: "https://openrouter.ai/api/v1".into(),
}),
binary_path: None,
};
}
if let Some((key, provider)) = load_auth_json_key() {
return OpenCodeBackend {
api_key: Some(key),
provider,
binary_path: None,
};
}
OpenCodeBackend {
api_key: None,
provider: None,
binary_path: None,
}
}
impl OpenCodeBackend {
pub fn from_path() -> Result<Self> {
let binary_path = find_opencode_binary()?;
Ok(Self {
api_key: None,
provider: None,
binary_path: Some(binary_path),
})
}
}
fn find_opencode_binary() -> Result<PathBuf> {
if let Ok(path) = std::env::var("CHIMERA_OPENCODE_BINARY") {
let path = PathBuf::from(path);
if path.exists() {
return Ok(path);
}
}
let home = std::env::var("HOME").unwrap_or_default();
if let Ok(path) = which_opencode() {
return Ok(path);
}
let source_candidates: &[&str] = &[
"~/projects/opencode/packages/opencode",
"~/opencode/packages/opencode",
];
for raw in source_candidates {
let expanded = raw.replacen("~", &home, 1);
let path = PathBuf::from(&expanded);
if is_runnable_opencode_source(&path) {
return Ok(path);
}
}
let binary_candidates: &[&str] = &[
"~/.npm-global/bin/opencode",
"~/.local/bin/opencode",
"/usr/local/bin/opencode",
"~/.yarn/bin/opencode",
"~/.bun/bin/opencode",
];
for raw in binary_candidates {
let expanded = raw.replacen("~", &home, 1);
let path = PathBuf::from(&expanded);
if path.exists() {
return Ok(path);
}
}
Err(AgentError::BinaryNotFound {
binary: "opencode".into(),
searched: source_candidates
.iter()
.chain(binary_candidates.iter())
.map(PathBuf::from)
.collect(),
})
}
fn is_runnable_opencode_source(path: &Path) -> bool {
path.join("src/index.ts").exists() && find_ancestor_node_module(path, "@opencode-ai/core")
}
fn find_ancestor_node_module(path: &Path, module: &str) -> bool {
path.ancestors()
.any(|ancestor| ancestor.join("node_modules").join(module).exists())
}
fn load_auth_json_api_key() -> Option<(String, Option<OpenCodeProvider>)> {
let home = std::env::var("HOME").ok()?;
let path = std::path::Path::new(&home).join(".local/share/opencode/auth.json");
let text = std::fs::read_to_string(&path).ok()?;
let v: serde_json::Value = serde_json::from_str(&text).ok()?;
parse_auth_json_api_key(&v)
}
fn parse_auth_json_api_key(v: &serde_json::Value) -> Option<(String, Option<OpenCodeProvider>)> {
for (provider_key, provider) in [
("opencode", None),
("opencode-go", Some(crate::config::OpenCodeProvider::Go)),
] {
let entry = &v[provider_key];
let Some(key) = entry.get("key").and_then(|value| value.as_str()) else {
continue;
};
let key = key.trim();
let entry_type = entry.get("type").and_then(|value| value.as_str());
if key.is_empty() {
continue;
}
if entry_type.is_none() || entry_type == Some("api") {
return Some((key.to_string(), provider));
}
}
None
}
fn which_opencode() -> std::result::Result<PathBuf, ()> {
let path_var = std::env::var("PATH").map_err(|_| ())?;
for dir in std::env::split_paths(&path_var) {
let candidate = dir.join("opencode");
if candidate.exists() {
return Ok(candidate);
}
}
Err(())
}
impl Backend for OpenCodeBackend {
type Config = OpenCodeConfig;
type Session = OpenCodeSession;
fn name(&self) -> &'static str {
"opencode"
}
fn capabilities(&self) -> BackendCapabilities {
BackendCapabilities {
input_images: CapabilitySupport::Unsupported,
output_schema: CapabilitySupport::ConfigDependent,
interrupt: CapabilitySupport::ConfigDependent,
resume: ResumeSemantics::Stateful,
mcp: McpSupport::MergeAndExplicitOnly,
tool_call_events: CapabilitySupport::Supported,
}
}
async fn session(&self, config: SessionConfig<OpenCodeConfig>) -> Result<OpenCodeSession> {
if config.backend.execution_mode == OpenCodeExecutionMode::Agent {
let binary_path = match self.binary_path.clone() {
Some(p) => p,
None => find_opencode_binary()?,
};
let agent = OpenCodeAgentSession::new(binary_path, config).await?;
return Ok(OpenCodeSession::Agent(Box::new(agent)));
}
validate_http_config(&config.backend)?;
let api_key = config
.backend
.api_key
.clone()
.or_else(|| self.api_key.clone());
let provider = self
.provider
.clone()
.unwrap_or_else(|| config.backend.provider.clone());
Ok(OpenCodeSession::Http(Box::new(
crate::session::HttpSession::new(api_key, provider, config),
)))
}
async fn resume(
&self,
session_id: &str,
config: SessionConfig<OpenCodeConfig>,
) -> Result<OpenCodeSession> {
if config.backend.execution_mode == OpenCodeExecutionMode::Agent {
let binary_path = match self.binary_path.clone() {
Some(p) => p,
None => find_opencode_binary()?,
};
let agent = OpenCodeAgentSession::resume(binary_path, config, session_id).await?;
return Ok(OpenCodeSession::Agent(Box::new(agent)));
}
validate_http_config(&config.backend)?;
let api_key = config
.backend
.api_key
.clone()
.or_else(|| self.api_key.clone());
let provider = self
.provider
.clone()
.unwrap_or_else(|| config.backend.provider.clone());
Ok(OpenCodeSession::Http(Box::new(
crate::session::HttpSession::new(api_key, provider, config)
.with_session_id(session_id.to_owned()),
)))
}
}
fn validate_http_config(config: &OpenCodeConfig) -> Result<()> {
if !config.mcp_servers.is_empty()
|| config.variant.is_some()
|| config.agent_base_url.is_some()
|| config.session_title.is_some()
{
return Err(AgentError::Other {
message: "opencode agent-only options require execution_mode = agent".into(),
source: None,
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backend_name() {
let backend = OpenCodeBackend::from_env();
assert_eq!(backend.name(), "opencode");
assert_eq!(
backend.capabilities().output_schema,
CapabilitySupport::ConfigDependent
);
}
#[tokio::test]
async fn session_merges_api_key() {
let backend = OpenCodeBackend::builder().api_key("backend-key").build();
let config = SessionConfig::builder()
.backend(OpenCodeConfig::default())
.build();
let session = backend.session(config).await.unwrap();
let crate::session::OpenCodeSession::Http(http) = session else {
panic!("expected Http session");
};
assert_eq!(http.api_key(), Some("backend-key"));
}
#[tokio::test]
async fn session_config_overrides_backend_key() {
let backend = OpenCodeBackend::builder().api_key("backend-key").build();
let config = SessionConfig::builder()
.backend(OpenCodeConfig::builder().api_key("session-key").build())
.build();
let session = backend.session(config).await.unwrap();
let crate::session::OpenCodeSession::Http(http) = session else {
panic!("expected Http session");
};
assert_eq!(http.api_key(), Some("session-key"));
}
#[test]
fn from_env_prefers_openrouter_key_when_present() {
let key = "test-openrouter-key";
let backend = from_env_with(
|env_key| (env_key == "OPENROUTER_API_KEY").then(|| key.to_string()),
|| None,
);
assert_eq!(backend.api_key.as_deref(), Some(key));
assert!(matches!(
backend.provider,
Some(OpenCodeProvider::Direct { ref base_url })
if base_url == "https://openrouter.ai/api/v1"
));
}
#[tokio::test]
async fn session_rejects_variant_in_http_mode() {
let backend = OpenCodeBackend::builder().build();
let config = SessionConfig::builder()
.backend(OpenCodeConfig::builder().variant("medium").build())
.build();
let err = match backend.session(config).await {
Ok(_) => panic!("expected variant-in-http-mode session to fail"),
Err(err) => err,
};
assert!(
err.to_string()
.contains("agent-only options require execution_mode = agent")
);
}
#[tokio::test]
async fn resume_sets_session_id() {
let backend = OpenCodeBackend::builder().build();
let config = SessionConfig::builder()
.backend(OpenCodeConfig::default())
.build();
let session = backend.resume("sess-abc", config).await.unwrap();
assert_eq!(session.session_id(), Some("sess-abc"));
}
#[test]
fn auth_json_prefers_typed_opencode_record() {
let parsed = parse_auth_json_api_key(&serde_json::json!({
"opencode": { "type": "api", "key": "typed-key" },
"opencode-go": { "type": "api", "key": "go-key" }
}));
let (key, provider) = parsed.expect("expected auth fallback");
assert_eq!(key, "typed-key");
assert!(provider.is_none());
}
#[test]
fn auth_json_can_select_legacy_go_provider() {
let parsed = parse_auth_json_api_key(&serde_json::json!({
"opencode-go": { "key": "go-key" }
}));
let (key, provider) = parsed.expect("expected auth fallback");
assert_eq!(key, "go-key");
assert!(matches!(provider, Some(OpenCodeProvider::Go)));
}
#[test]
fn auth_json_ignores_non_api_records_for_fallback() {
let parsed = parse_auth_json_api_key(&serde_json::json!({
"opencode": { "type": "oauth", "access": "x", "refresh": "y", "expires": 1 }
}));
assert!(parsed.is_none());
}
#[test]
fn opencode_source_is_not_runnable_without_workspace_deps() {
let temp = tempfile::tempdir().unwrap();
let package = temp.path().join("packages/opencode");
std::fs::create_dir_all(package.join("src")).unwrap();
std::fs::write(package.join("src/index.ts"), "").unwrap();
assert!(!is_runnable_opencode_source(&package));
}
#[test]
fn opencode_source_is_runnable_when_workspace_deps_exist() {
let temp = tempfile::tempdir().unwrap();
let package = temp.path().join("packages/opencode");
std::fs::create_dir_all(package.join("src")).unwrap();
std::fs::write(package.join("src/index.ts"), "").unwrap();
std::fs::create_dir_all(temp.path().join("node_modules/@opencode-ai/core")).unwrap();
assert!(is_runnable_opencode_source(&package));
}
#[tokio::test]
async fn resume_rejects_variant_in_http_mode() {
let backend = OpenCodeBackend::builder().build();
let config = SessionConfig::builder()
.backend(OpenCodeConfig::builder().variant("medium").build())
.build();
let err = match backend.resume("sess-abc", config).await {
Ok(_) => panic!("expected variant-in-http-mode resume to fail"),
Err(err) => err,
};
assert!(
err.to_string()
.contains("agent-only options require execution_mode = agent")
);
}
}