use std::collections::HashMap;
use bon::Builder;
use chimera_core::{
BackendCapabilities, CapabilitySupport, McpConfigMode, McpSupport, ResumeSemantics,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
#[derive(Default)]
pub enum OpenCodeProvider {
#[default]
Zen,
Go,
Direct { base_url: String },
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum OpenCodeExecutionMode {
#[default]
Http,
Agent,
}
impl OpenCodeProvider {
pub fn base_url(&self) -> &str {
match self {
Self::Zen => "https://opencode.ai/zen/v1",
Self::Go => "https://opencode.ai/zen/go/v1",
Self::Direct { base_url } => base_url.as_str(),
}
}
}
#[derive(Debug, Clone, Builder, Serialize, Deserialize)]
pub struct OpenCodeMcpServer {
#[builder(into)]
pub name: String,
#[builder(default)]
pub command: Vec<String>,
#[builder(default)]
pub env: HashMap<String, String>,
}
#[derive(Debug, Clone, Builder, Serialize, Deserialize)]
pub struct OpenCodeConfig {
#[builder(default)]
pub execution_mode: OpenCodeExecutionMode,
#[builder(into)]
pub api_key: Option<String>,
#[builder(default)]
pub provider: OpenCodeProvider,
pub timeout_secs: Option<u64>,
pub startup_timeout_secs: Option<u64>,
pub max_retries: Option<u32>,
pub max_tokens: Option<u32>,
pub temperature: Option<f32>,
#[builder(default = true)]
pub stream: bool,
#[builder(default)]
pub mcp_servers: Vec<OpenCodeMcpServer>,
#[builder(default)]
pub mcp_config_mode: McpConfigMode,
#[builder(into)]
pub agent_base_url: Option<String>,
#[builder(into)]
pub session_title: Option<String>,
#[builder(into)]
pub system_prompt: Option<String>,
#[builder(into)]
pub variant: Option<String>,
}
impl Default for OpenCodeConfig {
fn default() -> Self {
Self {
execution_mode: OpenCodeExecutionMode::Http,
api_key: None,
provider: OpenCodeProvider::default(),
timeout_secs: None,
startup_timeout_secs: None,
max_retries: None,
max_tokens: None,
temperature: None,
stream: true,
mcp_servers: Vec::new(),
mcp_config_mode: McpConfigMode::Merge,
agent_base_url: None,
session_title: None,
system_prompt: None,
variant: None,
}
}
}
impl OpenCodeConfig {
pub(crate) fn startup_timeout(&self) -> std::time::Duration {
std::time::Duration::from_secs(self.startup_timeout_secs.unwrap_or(30))
}
pub fn capabilities(&self) -> BackendCapabilities {
let agent_mode = self.execution_mode == OpenCodeExecutionMode::Agent;
BackendCapabilities {
input_images: CapabilitySupport::Unsupported,
output_schema: if agent_mode {
CapabilitySupport::Unsupported
} else {
CapabilitySupport::Supported
},
interrupt: if agent_mode {
CapabilitySupport::Unsupported
} else {
CapabilitySupport::Supported
},
resume: ResumeSemantics::Stateful,
mcp: McpSupport::MergeAndExplicitOnly,
tool_call_events: CapabilitySupport::Supported,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_defaults() {
let config = OpenCodeConfig::default();
assert_eq!(config.execution_mode, OpenCodeExecutionMode::Http);
assert!(config.api_key.is_none());
assert!(matches!(config.provider, OpenCodeProvider::Zen));
assert!(config.timeout_secs.is_none());
assert!(config.startup_timeout_secs.is_none());
assert!(config.max_retries.is_none());
assert!(config.max_tokens.is_none());
assert!(config.temperature.is_none());
assert!(config.stream);
assert_eq!(config.mcp_config_mode, McpConfigMode::Merge);
assert!(config.agent_base_url.is_none());
assert!(config.session_title.is_none());
assert!(config.variant.is_none());
}
#[test]
fn config_builder() {
let config = OpenCodeConfig::builder()
.api_key("sk-test")
.provider(OpenCodeProvider::Direct {
base_url: "http://localhost:8080".into(),
})
.timeout_secs(60)
.startup_timeout_secs(90)
.max_retries(3)
.variant("medium")
.stream(false)
.build();
assert_eq!(config.api_key.as_deref(), Some("sk-test"));
assert_eq!(config.timeout_secs, Some(60));
assert_eq!(config.startup_timeout_secs, Some(90));
assert_eq!(config.max_retries, Some(3));
assert_eq!(config.variant.as_deref(), Some("medium"));
assert!(!config.stream);
}
#[test]
fn provider_base_url_zen() {
let p = OpenCodeProvider::Zen;
assert_eq!(p.base_url(), "https://opencode.ai/zen/v1");
}
#[test]
fn provider_base_url_go() {
let p = OpenCodeProvider::Go;
assert_eq!(p.base_url(), "https://opencode.ai/zen/go/v1");
}
#[test]
fn provider_base_url_direct() {
let p = OpenCodeProvider::Direct {
base_url: "http://localhost:3000/v1".into(),
};
assert_eq!(p.base_url(), "http://localhost:3000/v1");
}
#[test]
fn config_serde_roundtrip() {
let config = OpenCodeConfig::builder()
.api_key("key")
.max_tokens(4096)
.startup_timeout_secs(75)
.build();
let json = serde_json::to_string(&config).unwrap();
let parsed: OpenCodeConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.api_key.as_deref(), Some("key"));
assert_eq!(parsed.max_tokens, Some(4096));
assert_eq!(parsed.startup_timeout_secs, Some(75));
}
#[test]
fn startup_timeout_defaults_to_30_seconds() {
assert_eq!(
OpenCodeConfig::default().startup_timeout(),
std::time::Duration::from_secs(30)
);
}
#[test]
fn startup_timeout_uses_explicit_override() {
let config = OpenCodeConfig::builder().startup_timeout_secs(120).build();
assert_eq!(
config.startup_timeout(),
std::time::Duration::from_secs(120)
);
}
#[test]
fn http_mode_capabilities_report_schema_and_interrupt_support() {
let capabilities = OpenCodeConfig::default().capabilities();
assert_eq!(capabilities.input_images, CapabilitySupport::Unsupported);
assert_eq!(capabilities.output_schema, CapabilitySupport::Supported);
assert_eq!(capabilities.interrupt, CapabilitySupport::Supported);
assert_eq!(capabilities.resume, ResumeSemantics::Stateful);
}
#[test]
fn agent_mode_capabilities_disable_schema_and_interrupt() {
let capabilities = OpenCodeConfig::builder()
.execution_mode(OpenCodeExecutionMode::Agent)
.build()
.capabilities();
assert_eq!(capabilities.output_schema, CapabilitySupport::Unsupported);
assert_eq!(capabilities.interrupt, CapabilitySupport::Unsupported);
assert_eq!(capabilities.mcp, McpSupport::MergeAndExplicitOnly);
}
}