chimera-opencode 0.2.1

OpenCode/OpenAI-compatible REST backend for the chimera AI agent SDK
Documentation
use std::collections::HashMap;

use bon::Builder;
use chimera_core::{
    BackendCapabilities, CapabilitySupport, McpConfigMode, McpSupport, ResumeSemantics,
};
use serde::{Deserialize, Serialize};

/// OpenCode API provider.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
#[derive(Default)]
pub enum OpenCodeProvider {
    /// Official Zen gateway at <https://opencode.ai/zen/v1>.
    /// Default for pay-as-you-go credits.
    #[default]
    Zen,
    /// Go subscription gateway at <https://opencode.ai/zen/go/v1>.
    /// For Go/Lite subscription users with limited model access.
    Go,
    /// Custom endpoint (self-hosted or alternative OpenAI-compatible API).
    Direct { base_url: String },
}

/// Selects whether Chimera calls an OpenAI-compatible endpoint directly or
/// drives the installed OpenCode agent.
#[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(),
        }
    }
}

/// A single MCP server to register with the OpenCode agent.
///
/// Uses OpenCode's native format: `command` is the full argv (binary + args in one vec).
#[derive(Debug, Clone, Builder, Serialize, Deserialize)]
pub struct OpenCodeMcpServer {
    /// Server name (key in the `mcp` config object).
    #[builder(into)]
    pub name: String,

    /// Full command-line: `["/path/to/binary", "arg1", "arg2", ...]`.
    #[builder(default)]
    pub command: Vec<String>,

    /// Environment variables for the MCP server subprocess.
    #[builder(default)]
    pub env: HashMap<String, String>,
}

#[derive(Debug, Clone, Builder, Serialize, Deserialize)]
pub struct OpenCodeConfig {
    /// Execution path. Agent mode launches `opencode serve`.
    #[builder(default)]
    pub execution_mode: OpenCodeExecutionMode,

    /// API key for bearer auth. Falls back to OPENCODE_ZEN_KEY / OPENCODE_API_KEY env vars.
    #[builder(into)]
    pub api_key: Option<String>,

    /// Provider selection.
    #[builder(default)]
    pub provider: OpenCodeProvider,

    /// HTTP request timeout in seconds.
    pub timeout_secs: Option<u64>,

    /// Maximum time to wait for `opencode serve` to become ready in agent mode.
    pub startup_timeout_secs: Option<u64>,

    /// Maximum retry attempts for transient errors (429, 5xx).
    pub max_retries: Option<u32>,

    /// Maximum tokens in the completion response.
    pub max_tokens: Option<u32>,

    /// Sampling temperature.
    pub temperature: Option<f32>,

    /// Enable SSE streaming responses.
    #[builder(default = true)]
    pub stream: bool,

    /// MCP servers to register with the OpenCode agent.
    #[builder(default)]
    pub mcp_servers: Vec<OpenCodeMcpServer>,

    /// Controls whether Chimera MCP servers are merged with ambient OpenCode
    /// config or launched in an isolated explicit-only config environment.
    #[builder(default)]
    pub mcp_config_mode: McpConfigMode,

    /// Override the selected OpenCode provider's base URL in agent mode.
    #[builder(into)]
    pub agent_base_url: Option<String>,

    /// Initial title for sessions created in agent mode.
    ///
    /// A fixed title suppresses OpenCode's asynchronous title-generation turn.
    #[builder(into)]
    pub session_title: Option<String>,

    /// System prompt override passed to the agent session.
    #[builder(into)]
    pub system_prompt: Option<String>,

    /// Provider/model variant name (for example reasoning presets like `medium` or `high`).
    #[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);
    }
}