use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::session::storage;
#[cfg(feature = "mcp")]
use crate::extras::mcp::config::McpServerConfig;
#[cfg(feature = "acp")]
use crate::extras::acp::config::AcpServerConfig;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProviderAuth {
#[serde(alias = "api-key")]
ApiKey,
#[serde(
alias = "chatgpt",
alias = "chat-gpt",
alias = "chatgpt_auth_tokens",
alias = "codex"
)]
ChatGpt,
#[serde(alias = "claude-code", alias = "claude_code", alias = "claude")]
Anthropic,
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(default)]
pub struct ProviderEntry {
pub provider_type: Option<String>,
pub base_url: Option<String>,
pub model: Option<String>,
pub auth: Option<ProviderAuth>,
pub api_key_env: Option<String>,
#[serde(alias = "apiKey")]
pub api_key: Option<String>,
pub allow_insecure: bool,
pub stream_chunk_timeout_secs: Option<u64>,
pub options: Option<serde_json::Map<String, serde_json::Value>>,
}
impl ProviderEntry {
pub fn resolved_api_key(&self) -> Option<Result<String, String>> {
let raw = self.api_key.as_deref()?;
if let Some(name) = raw.strip_prefix("${").and_then(|s| s.strip_suffix('}')) {
match std::env::var(name) {
Ok(v) if !v.is_empty() => Some(Ok(v)),
_ => Some(Err(name.to_string())),
}
} else {
Some(Ok(raw.to_string()))
}
}
pub fn options_temperature(&self) -> Option<f64> {
self.options.as_ref()?.get("temperature")?.as_f64()
}
}
#[derive(Debug, Clone, Copy)]
#[allow(dead_code)]
pub enum ConfigRole {
Default,
Review,
Escalation,
Summarization,
Subagent,
Critic,
Approval,
}
#[derive(Debug, Clone, Deserialize)]
pub struct KeybindingConfig {
pub key: String,
pub command: String,
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(default)]
pub struct MemoryConfig {
pub hybrid_retrieval: Option<bool>,
pub embed_url: Option<String>,
pub embed_model: Option<String>,
pub embed_api_key_env: Option<String>,
pub verbatim_pre_recall: Option<bool>,
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(default)]
pub struct ToolsConfig {
pub websearch: Option<bool>,
pub webfetch: Option<bool>,
pub bash_output_inline_max_bytes: Option<usize>,
pub webfetch_output_inline_max_bytes: Option<usize>,
pub task_output_inline_max_bytes: Option<usize>,
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(default)]
pub struct TimeoutsConfig {
pub stream_chunk_secs: Option<u64>,
pub tool_call_gap_secs: Option<u64>,
pub mcp_call_secs: Option<u64>,
pub mcp_init_secs: Option<u64>,
pub lsp_request_secs: Option<u64>,
pub lsp_initialize_secs: Option<u64>,
pub bash_secs: Option<u64>,
}
#[cfg(feature = "lsp")]
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(default)]
pub struct LspServerConfig {
pub command: Option<Vec<String>>,
pub extensions: Option<Vec<String>>,
#[serde(alias = "extendExtensions")]
pub extend_extensions: Option<Vec<String>>,
pub env: Option<HashMap<String, String>>,
pub initialization: Option<serde_json::Value>,
pub disabled: Option<bool>,
}
#[cfg(feature = "lsp")]
impl crate::lsp::server::AsExtensionOverride for LspServerConfig {
fn extensions(&self) -> Option<&[String]> {
self.extensions.as_deref()
}
fn extend_extensions(&self) -> Option<&[String]> {
self.extend_extensions.as_deref()
}
fn disabled(&self) -> bool {
self.disabled.unwrap_or(false)
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct PluginSettings {
pub enabled: Option<bool>,
pub auto_start: Option<bool>,
}
#[cfg(feature = "lsp")]
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum LspConfig {
Enabled(bool),
Servers(HashMap<String, LspServerConfig>),
}
#[cfg(feature = "lsp")]
impl LspConfig {
pub fn is_enabled(&self) -> bool {
match self {
LspConfig::Enabled(b) => *b,
LspConfig::Servers(_) => true,
}
}
pub fn server_overrides(&self) -> &HashMap<String, LspServerConfig> {
match self {
LspConfig::Enabled(_) => {
static EMPTY: std::sync::OnceLock<HashMap<String, LspServerConfig>> =
std::sync::OnceLock::new();
EMPTY.get_or_init(HashMap::new)
}
LspConfig::Servers(map) => map,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct SandboxConfig {
pub mode: Option<String>,
pub image: Option<String>,
pub cpus: Option<u8>,
pub memory_mib: Option<u32>,
}
impl SandboxConfig {
pub fn to_mode(&self) -> crate::sandbox::SandboxMode {
#[cfg(feature = "sandbox-microvm")]
{
match self.mode.as_deref() {
Some("microvm") => crate::sandbox::SandboxMode::Microvm,
Some("off") => crate::sandbox::SandboxMode::Off,
_ => crate::sandbox::SandboxMode::Bwrap,
}
}
#[cfg(not(feature = "sandbox-microvm"))]
{
match self.mode.as_deref() {
Some("microvm") => {
eprintln!(
"warning: sandbox=microvm in config but dirge was built without the sandbox-microvm feature. Using bwrap instead."
);
crate::sandbox::SandboxMode::Bwrap
}
Some("off") => crate::sandbox::SandboxMode::Off,
_ => crate::sandbox::SandboxMode::Bwrap,
}
}
}
}
fn checked_u64<T, E>(v: &serde_json::Value, field: &str) -> Result<T, E>
where
T: TryFrom<u64>,
E: serde::de::Error,
{
let n = v
.as_u64()
.ok_or_else(|| E::custom(format!("microvm.{field} must be a non-negative integer")))?;
T::try_from(n).map_err(|_| E::custom(format!("microvm.{field} value {n} out of range")))
}
impl<'de> Deserialize<'de> for SandboxConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de;
struct SandboxConfigVisitor;
impl<'de> de::Visitor<'de> for SandboxConfigVisitor {
type Value = SandboxConfig;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str(
"a sandbox mode string, bool, or {mode, image, cpus, memory_mib} object",
)
}
fn visit_bool<E: de::Error>(self, v: bool) -> Result<Self::Value, E> {
Ok(SandboxConfig {
mode: Some(if v { "bwrap" } else { "off" }.to_string()),
..Default::default()
})
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
Ok(SandboxConfig {
mode: Some(v.to_string()),
..Default::default()
})
}
fn visit_map<M: de::MapAccess<'de>>(self, mut map: M) -> Result<Self::Value, M::Error> {
let mut mode: Option<String> = None;
let mut image: Option<String> = None;
let mut cpus: Option<u8> = None;
let mut memory_mib: Option<u32> = None;
while let Some(key) = map.next_key::<String>()? {
match key.as_str() {
"mode" => mode = Some(map.next_value()?),
"image" => image = Some(map.next_value()?),
"cpus" => cpus = Some(map.next_value()?),
"memory_mib" => memory_mib = Some(map.next_value()?),
"microvm" => {
let sub: serde_json::Value = map.next_value()?;
if let Some(obj) = sub.as_object() {
if image.is_none() {
image =
obj.get("image").and_then(|v| v.as_str().map(String::from));
}
if cpus.is_none()
&& let Some(v) = obj.get("cpus")
{
cpus = Some(checked_u64::<u8, M::Error>(v, "cpus")?);
}
if memory_mib.is_none()
&& let Some(v) = obj.get("memory_mib")
{
memory_mib =
Some(checked_u64::<u32, M::Error>(v, "memory_mib")?);
}
}
}
_ => {
let _: de::IgnoredAny = map.next_value()?;
}
}
}
Ok(SandboxConfig {
mode,
image,
cpus,
memory_mib,
})
}
}
deserializer.deserialize_any(SandboxConfigVisitor)
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct Config {
pub provider: Option<String>,
pub auth: Option<ProviderAuth>,
pub max_tokens: Option<u64>,
pub temperature: Option<f64>,
pub no_tools: Option<bool>,
pub no_context_files: Option<bool>,
pub context_window: Option<u64>,
pub reserve_tokens: Option<u64>,
pub keep_recent_tokens: Option<u64>,
pub max_agent_turns: Option<usize>,
pub compact_enabled: Option<bool>,
pub providers: Option<HashMap<String, ProviderEntry>>,
pub agents: Option<HashMap<String, crate::context::agent_defs::AgentConfig>>,
pub plugins: Option<HashMap<String, PluginSettings>>,
pub permission: Option<serde_json::Value>,
pub restrictive: Option<bool>,
pub accept_all: Option<bool>,
pub yolo: Option<bool>,
pub sandbox: Option<SandboxConfig>,
pub microvm_image: Option<String>,
pub default_permission_mode: Option<String>,
pub show_tool_details: Option<bool>,
pub show_edit_diff: Option<bool>,
pub show_reasoning: Option<bool>,
pub display: Option<String>,
pub tool_result_max_chars: Option<usize>,
pub tool_result_max_lines: Option<usize>,
pub stream_chunk_timeout_secs: Option<u64>,
pub default_prompt: Option<String>,
pub review_provider: Option<String>,
pub escalation_provider: Option<String>,
pub summarization_provider: Option<String>,
pub compaction_fold_threshold: Option<f64>,
pub context_target: Option<u64>,
pub incremental_checkpoint: Option<bool>,
pub subagent_provider: Option<String>,
pub critic_provider: Option<String>,
pub critic_preamble: Option<String>,
pub approval_provider: Option<String>,
pub theme: Option<String>,
pub keybindings: Option<Vec<KeybindingConfig>>,
pub chord_timeout_ms: Option<u64>,
pub max_sessions: Option<usize>,
pub slash_aliases: Option<HashMap<String, String>>,
pub tools: Option<ToolsConfig>,
pub memory: Option<MemoryConfig>,
pub dynamic_tool_search: Option<bool>,
pub context_depth_reminder_threshold: Option<usize>,
pub phased_workflow_enabled: Option<bool>,
pub phased_workflow_max_review_cycles: Option<usize>,
pub timeouts: Option<TimeoutsConfig>,
#[cfg(feature = "lsp")]
pub lsp: Option<LspConfig>,
#[cfg(feature = "mcp")]
pub mcp_servers: Option<HashMap<String, McpServerConfig>>,
#[cfg(feature = "acp")]
pub acp_servers: Option<HashMap<String, AcpServerConfig>>,
}
impl Config {
pub fn providers_map(&self) -> HashMap<String, ProviderEntry> {
self.providers.clone().unwrap_or_default()
}
#[allow(dead_code)]
pub fn plugin_enabled(&self, name: &str) -> bool {
self.plugins
.as_ref()
.and_then(|m| m.get(name))
.and_then(|s| s.enabled)
.unwrap_or(true)
}
#[allow(dead_code)] pub fn plugin_auto_start(&self, name: &str) -> bool {
self.plugins
.as_ref()
.and_then(|m| m.get(name))
.and_then(|s| s.auto_start)
.unwrap_or(false)
}
pub fn resolve_context_depth_threshold(&self) -> Option<usize> {
self.context_depth_reminder_threshold.map(|t| t.max(2))
}
pub fn resolve_role(&self, role: ConfigRole) -> Option<(String, ProviderEntry)> {
let providers = self.providers.as_ref();
let role_name: Option<&str> = match role {
ConfigRole::Default => self.provider.as_deref(),
ConfigRole::Review => self.review_provider.as_deref().or(self.provider.as_deref()),
ConfigRole::Escalation => self
.escalation_provider
.as_deref()
.or(self.provider.as_deref()),
ConfigRole::Summarization => self
.summarization_provider
.as_deref()
.or(self.provider.as_deref()),
ConfigRole::Subagent => self
.subagent_provider
.as_deref()
.or(self.provider.as_deref()),
ConfigRole::Critic => self.critic_provider.as_deref(),
ConfigRole::Approval => self.approval_provider.as_deref(),
};
let alias = role_name?.to_string();
if let Some(map) = providers
&& let Some(entry) = map
.get(&alias)
.or_else(|| map.get(&alias.to_ascii_lowercase()))
{
return Some((alias, entry.clone()));
}
if crate::provider::parse_provider(&alias).is_some() {
return Some((alias, ProviderEntry::default()));
}
None
}
pub fn resolve_critic_preamble(&self) -> &str {
self.critic_preamble
.as_deref()
.unwrap_or(crate::agent::agent_loop::critic::CRITIC_PREAMBLE)
}
pub fn provider_type_of(name: &str, entry: &ProviderEntry) -> String {
entry
.provider_type
.clone()
.unwrap_or_else(|| name.to_ascii_lowercase())
}
pub fn resolve_context_window(&self, model: &str) -> u64 {
if let Some(v) = self.context_window {
return v;
}
context_window_for_model(model).unwrap_or(128_000)
}
pub fn resolve_reserve_tokens(&self) -> u64 {
self.reserve_tokens.unwrap_or(16_384)
}
pub fn resolve_keep_recent_tokens(&self) -> u64 {
self.keep_recent_tokens.unwrap_or(20_000)
}
pub fn resolve_compact_enabled(&self) -> bool {
self.compact_enabled.unwrap_or(true)
}
pub fn resolve_dynamic_tool_search(&self) -> bool {
self.dynamic_tool_search.unwrap_or(false)
}
pub fn resolve_phased_workflow_enabled(&self) -> bool {
self.phased_workflow_enabled.unwrap_or(false)
}
pub fn resolve_phased_workflow_max_review_cycles(&self) -> usize {
self.phased_workflow_max_review_cycles.unwrap_or(2)
}
pub fn resolve_tool_result_max_chars(&self) -> usize {
self.tool_result_max_chars.unwrap_or(500)
}
pub fn resolve_tool_result_max_lines(&self) -> usize {
self.tool_result_max_lines.unwrap_or(4)
}
pub fn resolve_stream_chunk_timeout(&self, provider: &str) -> std::time::Duration {
let lower = provider.to_ascii_lowercase();
let from_provider = self
.providers
.as_ref()
.and_then(|m| m.get(provider).or_else(|| m.get(&lower)))
.and_then(|p| p.stream_chunk_timeout_secs);
match from_provider.or(self.stream_chunk_timeout_secs) {
Some(secs) => std::time::Duration::from_secs(secs),
None => self.resolve_timeouts().stream_chunk,
}
}
pub fn resolve_timeouts(&self) -> crate::timeout::Timeouts {
let d = crate::timeout::Timeouts::DEFAULT;
let c = self.timeouts.clone().unwrap_or_default();
let or_default = |o: Option<u64>, default: std::time::Duration| {
o.map(std::time::Duration::from_secs).unwrap_or(default)
};
crate::timeout::Timeouts {
stream_chunk: or_default(c.stream_chunk_secs, d.stream_chunk),
tool_call_gap: or_default(c.tool_call_gap_secs, d.tool_call_gap),
mcp_call: or_default(c.mcp_call_secs, d.mcp_call),
mcp_init: or_default(c.mcp_init_secs, d.mcp_init),
lsp_request: or_default(c.lsp_request_secs, d.lsp_request),
lsp_initialize: or_default(c.lsp_initialize_secs, d.lsp_initialize),
bash: or_default(c.bash_secs, d.bash),
}
}
pub fn resolve_show_edit_diff(&self) -> bool {
self.show_edit_diff.unwrap_or(true)
}
pub fn resolve_show_reasoning(&self) -> bool {
self.show_reasoning.unwrap_or(false)
}
pub fn resolve_max_sessions(&self) -> usize {
self.max_sessions.unwrap_or(3)
}
pub fn resolve_sandbox_mode(&self) -> crate::sandbox::SandboxMode {
self.sandbox
.as_ref()
.map(|s| s.to_mode())
.unwrap_or(crate::sandbox::SandboxMode::Off)
}
pub fn resolve_microvm_image(&self) -> Option<String> {
self.sandbox
.as_ref()
.and_then(|s| s.image.clone())
.or_else(|| self.microvm_image.clone())
}
pub fn resolve_microvm_cpus(&self) -> u8 {
self.sandbox.as_ref().and_then(|s| s.cpus).unwrap_or(1)
}
pub fn resolve_microvm_memory_mib(&self) -> u32 {
self.sandbox
.as_ref()
.and_then(|s| s.memory_mib)
.unwrap_or(512)
}
}
pub fn exa_api_key() -> Option<String> {
std::env::var("EXA_API_KEY")
.ok()
.map(|k| k.trim().to_string())
.filter(|k| !k.is_empty())
}
fn web_env_true(k: &str) -> bool {
std::env::var(k)
.map(|v| v == "true" || v == "1")
.unwrap_or(false)
}
pub fn websearch_enabled(cfg: &Config) -> bool {
cfg.tools.as_ref().and_then(|t| t.websearch).unwrap_or(true)
|| web_env_true("WEBSEARCH_ENABLED")
}
pub fn webfetch_enabled(cfg: &Config) -> bool {
cfg.tools.as_ref().and_then(|t| t.webfetch).unwrap_or(true) || web_env_true("WEBFETCH_ENABLED")
}
pub fn context_window_for_model(model: &str) -> Option<u64> {
let m = model.to_lowercase();
const TABLE: &[(&str, u64)] = &[
("deepseek-v4", 1_000_000),
("deepseek-r1", 128_000),
("deepseek", 128_000),
("glm-4.6", 200_000),
("glm-4.5", 128_000),
("glm-4", 128_000),
("claude-opus-4-5", 1_000_000),
("claude-opus-4-7", 1_000_000),
("claude-sonnet-4-5", 1_000_000),
("claude-sonnet-4-6", 1_000_000),
("claude-opus", 200_000),
("claude-sonnet", 200_000),
("claude-haiku", 200_000),
("claude-3-7", 200_000),
("claude-3.5", 200_000),
("claude-3", 200_000),
("claude", 200_000),
("gpt-5", 400_000),
("gpt-4.1", 1_000_000),
("gpt-4o", 128_000),
("gpt-4-turbo", 128_000),
("gpt-4", 128_000),
("o3", 200_000),
("o1", 200_000),
("gemini-2.0-flash-thinking", 32_000),
("gemini-2.5-pro", 2_000_000),
("gemini-2.5-flash", 1_000_000),
("gemini-2.0-pro", 2_000_000),
("gemini-2.0-flash", 1_000_000),
("gemini-1.5-pro", 2_000_000),
("gemini-1.5-flash", 1_000_000),
("gemini-pro", 128_000),
("gemini", 128_000),
("llama-4", 1_000_000),
("llama-3.3", 128_000),
("llama-3.1", 128_000),
("llama-3", 8_000),
("mistral-large", 128_000),
("mistral", 32_000),
("qwen2.5", 128_000),
("qwen", 32_000),
];
for (key, window) in TABLE {
if m.contains(key) {
return Some(*window);
}
}
None
}
pub fn config_file_path() -> PathBuf {
storage::config_path().join("config.json")
}
pub fn project_config_file_path() -> PathBuf {
PathBuf::from(".dirge").join("config.json")
}
fn read_config_value(path: &Path) -> Option<serde_json::Value> {
if !path.exists() {
return None;
}
let content = std::fs::read_to_string(path).unwrap_or_else(|e| {
eprintln!(
"error: failed to read config file ({}): {}\n\
Fix the file or remove it to use defaults.",
path.display(),
e,
);
std::process::exit(1);
});
Some(serde_json::from_str(&content).unwrap_or_else(|e| {
eprintln!(
"error: {} is not a valid config: {}\n\
Fix the file or remove it to use defaults.",
path.display(),
e,
);
std::process::exit(1);
}))
}
fn merge_json(base: &mut serde_json::Value, overlay: serde_json::Value) {
match (base, overlay) {
(serde_json::Value::Object(base_map), serde_json::Value::Object(overlay_map)) => {
for (k, v) in overlay_map {
match base_map.get_mut(&k) {
Some(existing) => merge_json(existing, v),
None => {
base_map.insert(k, v);
}
}
}
}
(slot, overlay) => *slot = overlay,
}
}
pub fn load() -> Config {
let global_path = config_file_path();
let project_path = project_config_file_path();
let mut value: serde_json::Value = read_config_value(&global_path)
.unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
if let Some(project) = read_config_value(&project_path) {
merge_json(&mut value, project);
}
if let Some(obj) = value.as_object() {
const LEGACY: &[&str] = &["custom_providers", "model", "review_model"];
let found: Vec<&str> = LEGACY
.iter()
.copied()
.filter(|k| obj.contains_key(*k))
.collect();
if !found.is_empty() {
eprintln!(
"error: legacy config keys found ({:?}) after merging {} and {}",
found,
global_path.display(),
project_path.display(),
);
eprintln!("Migrate to the unified `providers` map:");
eprintln!(" - top-level `model` -> `providers.<active-provider>.model`");
eprintln!(" - `custom_providers.X` -> `providers.X`");
eprintln!(" - top-level `review_model` -> `providers.<review-provider>.model`");
eprintln!(
"Then optionally set `review_provider`, `escalation_provider`, \
`summarization_provider`, `subagent_provider`."
);
std::process::exit(2);
}
}
#[allow(unused_mut)]
let mut cfg: Config = serde_json::from_value(value).unwrap_or_else(|e| {
eprintln!(
"error: merged config ({} + {}) is not valid: {}\n\
Fix the offending file or remove it to use defaults.",
global_path.display(),
project_path.display(),
e,
);
std::process::exit(1);
});
if let Some(providers) = cfg.providers.as_ref() {
for (name, p) in providers {
let ptype = Config::provider_type_of(name, p);
if crate::provider::parse_provider(&ptype).is_none() {
eprintln!(
"error: provider {:?} has invalid provider_type {:?}.\n\
Either the alias must match a built-in (openrouter, openai,\n\
anthropic, gemini, deepseek, glm, ollama, custom) or set\n\
`provider_type` explicitly to one of those.",
name, ptype,
);
std::process::exit(1);
}
}
}
#[cfg(feature = "mcp")]
if cfg.mcp_servers.is_none() {
match exa_api_key() {
Some(key) => {
let mut headers = HashMap::new();
headers.insert("x-api-key".to_string(), key);
let mut defaults = HashMap::new();
defaults.insert(
"Exa Web Search".to_string(),
McpServerConfig::Url {
url: "https://mcp.exa.ai/mcp".to_string(),
headers,
allow_external_paths: false,
},
);
cfg.mcp_servers = Some(defaults);
}
_ => {
}
}
}
cfg
}
#[cfg(feature = "sandbox-microvm")]
pub fn update_config_file(updates: &serde_json::Value) -> anyhow::Result<()> {
let path = config_file_path();
let mut existing: serde_json::Map<String, serde_json::Value> = if path.exists() {
let content = std::fs::read_to_string(&path)?;
serde_json::from_str(&content).unwrap_or_default()
} else {
serde_json::Map::new()
};
if let Some(obj) = updates.as_object() {
for (k, v) in obj {
existing.insert(k.clone(), v.clone());
}
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(&existing)?;
std::fs::write(&path, json)?;
Ok(())
}
#[cfg(all(test, feature = "lsp"))]
mod tests {
use super::*;
#[test]
fn sandbox_legacy_nested_rejects_out_of_range_cpus() {
let ok: SandboxConfig =
serde_json::from_str(r#"{ "mode": "microvm", "microvm": { "cpus": 4 } }"#).unwrap();
assert_eq!(ok.cpus, Some(4));
let err = serde_json::from_str::<SandboxConfig>(
r#"{ "mode": "microvm", "microvm": { "cpus": 256 } }"#,
);
assert!(err.is_err(), "256 CPUs must error, not wrap to 0");
let err = serde_json::from_str::<SandboxConfig>(
r#"{ "mode": "microvm", "microvm": { "memory_mib": 5000000000 } }"#,
);
assert!(err.is_err(), "out-of-range memory_mib must error");
}
#[test]
fn phased_workflow_defaults_off_with_two_cycles() {
let cfg: Config = serde_json::from_str(r#"{}"#).unwrap();
assert!(!cfg.resolve_phased_workflow_enabled());
assert_eq!(cfg.resolve_phased_workflow_max_review_cycles(), 2);
let cfg: Config = serde_json::from_str(
r#"{ "phased_workflow_enabled": true, "phased_workflow_max_review_cycles": 4 }"#,
)
.unwrap();
assert!(cfg.resolve_phased_workflow_enabled());
assert_eq!(cfg.resolve_phased_workflow_max_review_cycles(), 4);
}
#[test]
fn chord_timeout_ms_absent_and_parses() {
let cfg: Config = serde_json::from_str(r#"{}"#).unwrap();
assert!(cfg.chord_timeout_ms.is_none());
let cfg: Config = serde_json::from_str(r#"{ "chord_timeout_ms": 1500 }"#).unwrap();
assert_eq!(cfg.chord_timeout_ms, Some(1500));
}
#[test]
fn critic_preamble_absent_and_parses() {
let cfg: Config = serde_json::from_str(r#"{}"#).unwrap();
assert!(cfg.critic_preamble.is_none());
let cfg: Config =
serde_json::from_str(r#"{ "critic_preamble": "Be extra strict." }"#).unwrap();
assert_eq!(cfg.critic_preamble.as_deref(), Some("Be extra strict."));
let cfg: Config = serde_json::from_str(r#"{}"#).unwrap();
assert_eq!(
cfg.resolve_critic_preamble(),
crate::agent::agent_loop::critic::CRITIC_PREAMBLE,
);
}
#[test]
fn max_sessions_absent_and_parses() {
let cfg: Config = serde_json::from_str(r#"{}"#).unwrap();
assert!(cfg.max_sessions.is_none());
assert_eq!(cfg.resolve_max_sessions(), 3, "default depth is 3");
let cfg: Config = serde_json::from_str(r#"{ "max_sessions": 5 }"#).unwrap();
assert_eq!(cfg.max_sessions, Some(5));
assert_eq!(cfg.resolve_max_sessions(), 5);
let cfg: Config = serde_json::from_str(r#"{ "max_sessions": 0 }"#).unwrap();
assert_eq!(cfg.resolve_max_sessions(), 0);
}
#[test]
fn memory_config_defaults_absent_and_parses() {
let cfg: Config = serde_json::from_str(r#"{}"#).unwrap();
assert!(cfg.memory.is_none(), "no memory block by default");
let cfg: Config = serde_json::from_str(
r#"{ "memory": { "hybrid_retrieval": true, "embed_url": "http://localhost:11434/v1/embeddings", "embed_api_key_env": "OPENAI_API_KEY", "verbatim_pre_recall": true } }"#,
)
.unwrap();
let m = cfg.memory.expect("memory block present");
assert_eq!(m.hybrid_retrieval, Some(true));
assert_eq!(
m.embed_url.as_deref(),
Some("http://localhost:11434/v1/embeddings")
);
assert_eq!(
m.embed_model, None,
"model is optional (falls back to default)"
);
assert_eq!(m.embed_api_key_env.as_deref(), Some("OPENAI_API_KEY"));
assert_eq!(m.verbatim_pre_recall, Some(true));
}
#[test]
fn show_reasoning_defaults_off_and_parses() {
let cfg: Config = serde_json::from_str("{}").unwrap();
assert_eq!(cfg.show_reasoning, None);
assert!(!cfg.resolve_show_reasoning(), "off by default");
let cfg: Config = serde_json::from_str(r#"{"show_reasoning": true}"#).unwrap();
assert!(cfg.resolve_show_reasoning());
let cfg: Config = serde_json::from_str(r#"{"show_reasoning": false}"#).unwrap();
assert!(!cfg.resolve_show_reasoning());
}
#[test]
fn timeouts_override_merges_onto_defaults() {
let d = crate::timeout::Timeouts::DEFAULT;
let cfg: Config = serde_json::from_str(r#"{}"#).unwrap();
let t = cfg.resolve_timeouts();
assert_eq!(t.mcp_call, d.mcp_call);
assert_eq!(t.lsp_request, d.lsp_request);
let cfg: Config =
serde_json::from_str(r#"{ "timeouts": { "mcp_call_secs": 45, "bash_secs": 300 } }"#)
.unwrap();
let t = cfg.resolve_timeouts();
assert_eq!(t.mcp_call, std::time::Duration::from_secs(45));
assert_eq!(t.bash, std::time::Duration::from_secs(300));
assert_eq!(t.lsp_request, d.lsp_request);
assert_eq!(t.mcp_init, d.mcp_init);
}
#[test]
fn lsp_config_parses_as_bool() {
let cfg: Config = serde_json::from_str(r#"{"lsp": true}"#).unwrap();
assert!(cfg.lsp.unwrap().is_enabled());
let cfg: Config = serde_json::from_str(r#"{"lsp": false}"#).unwrap();
assert!(!cfg.lsp.unwrap().is_enabled());
}
#[test]
fn plugin_toggles_parse_with_enabled_default_true() {
let cfg: Config = serde_json::from_str(
r#"{
"plugins": {
"backpressured": {"enabled": true, "auto_start": true},
"nrepl": {"enabled": false},
"noisy": {"auto_start": true}
}
}"#,
)
.unwrap();
assert!(cfg.plugin_enabled("backpressured"));
assert!(cfg.plugin_auto_start("backpressured"));
assert!(!cfg.plugin_enabled("nrepl"));
assert!(!cfg.plugin_auto_start("nrepl"));
assert!(cfg.plugin_enabled("noisy"));
assert!(cfg.plugin_auto_start("noisy"));
assert!(cfg.plugin_enabled("unlisted"));
assert!(!cfg.plugin_auto_start("unlisted"));
let empty: Config = serde_json::from_str("{}").unwrap();
assert!(empty.plugin_enabled("anything"));
assert!(!empty.plugin_auto_start("anything"));
}
#[test]
fn provider_auth_mode_parses_chatgpt_aliases() {
let cfg: Config = serde_json::from_str(
r#"{
"auth": "chatgpt",
"providers": {
"openai": { "auth": "chatgpt" },
"codex": { "auth": "chatgpt_auth_tokens" }
}
}"#,
)
.unwrap();
assert_eq!(cfg.auth, Some(ProviderAuth::ChatGpt));
let providers = cfg.providers.unwrap();
assert_eq!(providers["openai"].auth, Some(ProviderAuth::ChatGpt));
assert_eq!(providers["codex"].auth, Some(ProviderAuth::ChatGpt));
let cfg: Config =
serde_json::from_str(r#"{ "providers": { "openai": { "auth": "api-key" } } }"#)
.unwrap();
assert_eq!(
cfg.providers.unwrap()["openai"].auth,
Some(ProviderAuth::ApiKey)
);
}
#[test]
fn provider_auth_mode_parses_anthropic_aliases() {
let cfg: Config = serde_json::from_str(
r#"{
"providers": {
"anthropic": { "auth": "anthropic" },
"claude": { "auth": "claude-code" }
}
}"#,
)
.unwrap();
let providers = cfg.providers.unwrap();
assert_eq!(providers["anthropic"].auth, Some(ProviderAuth::Anthropic));
assert_eq!(providers["claude"].auth, Some(ProviderAuth::Anthropic));
}
#[test]
fn lsp_config_parses_as_per_server_map() {
let raw = r#"{
"lsp": {
"rust": { "command": ["my-rust-analyzer", "--my-arg"] },
"typescript": { "disabled": true }
}
}"#;
let cfg: Config = serde_json::from_str(raw).unwrap();
let overrides = cfg.lsp.as_ref().unwrap().server_overrides();
assert_eq!(overrides.len(), 2);
assert_eq!(
overrides["rust"].command.as_ref().unwrap(),
&vec!["my-rust-analyzer".to_string(), "--my-arg".to_string()]
);
assert_eq!(overrides["typescript"].disabled, Some(true));
}
#[test]
fn absent_lsp_config_is_none() {
let cfg: Config = serde_json::from_str(r#"{"provider": "deepseek"}"#).unwrap();
assert!(cfg.lsp.is_none());
}
#[test]
fn lsp_config_mixes_command_and_disabled_entries() {
let raw = r#"{
"lsp": {
"rust": { "command": ["rust-analyzer"], "env": {"RUST_LOG": "info"} },
"typescript": { "disabled": true }
}
}"#;
let cfg: Config = serde_json::from_str(raw).unwrap();
let overrides = cfg.lsp.as_ref().unwrap().server_overrides();
assert!(overrides["rust"].command.is_some());
assert_eq!(
overrides["rust"]
.env
.as_ref()
.unwrap()
.get("RUST_LOG")
.unwrap(),
"info"
);
assert_eq!(overrides["typescript"].disabled, Some(true));
}
}
#[cfg(test)]
mod model_context_tests {
use super::*;
#[test]
fn known_models_resolve_to_published_windows() {
for (model, want) in &[
("deepseek-v4-pro", 1_000_000),
("deepseek/deepseek-v4-flash", 1_000_000),
("claude-opus-4-7", 1_000_000),
("claude-sonnet-4-6", 1_000_000),
("claude-3.5-sonnet-20241022", 200_000),
("openai/gpt-4o", 128_000),
("gpt-5", 400_000),
("gemini-2.5-pro", 2_000_000),
("gemini-1.5-flash-002", 1_000_000),
("glm-4.6", 200_000),
] {
let got = context_window_for_model(model);
assert_eq!(
got,
Some(*want),
"model {model} expected {want}, got {got:?}",
);
}
}
#[test]
fn unknown_model_returns_none() {
assert!(context_window_for_model("totally-fictional-model").is_none());
assert!(context_window_for_model("").is_none());
}
#[test]
fn model_match_is_case_insensitive() {
assert_eq!(context_window_for_model("Claude-Opus-4-7"), Some(1_000_000));
assert_eq!(context_window_for_model("DEEPSEEK-V4-PRO"), Some(1_000_000));
}
#[test]
fn explicit_config_overrides_model_table() {
let cfg = Config {
context_window: Some(50_000),
..Default::default()
};
assert_eq!(cfg.resolve_context_window("deepseek-v4-pro"), 50_000);
}
#[test]
fn fallback_default_is_128k() {
let cfg = Config::default();
assert_eq!(cfg.resolve_context_window("unknown-model-9000"), 128_000);
}
}
#[cfg(test)]
mod provider_role_tests {
use super::*;
fn cfg_with_providers(json: &str) -> Config {
serde_json::from_str(json).expect("parses")
}
#[test]
fn resolve_role_default_returns_provider_entry() {
let cfg = cfg_with_providers(
r#"{
"provider": "deepseek",
"providers": { "deepseek": { "model": "deepseek-v4-pro" } }
}"#,
);
let (name, entry) = cfg.resolve_role(ConfigRole::Default).unwrap();
assert_eq!(name, "deepseek");
assert_eq!(entry.model.as_deref(), Some("deepseek-v4-pro"));
}
#[test]
fn resolve_role_review_falls_back_to_default_provider() {
let cfg = cfg_with_providers(
r#"{
"provider": "deepseek",
"providers": { "deepseek": { "model": "deepseek-v4-pro" } }
}"#,
);
let (name, entry) = cfg.resolve_role(ConfigRole::Review).unwrap();
assert_eq!(name, "deepseek");
assert_eq!(entry.model.as_deref(), Some("deepseek-v4-pro"));
}
#[test]
fn resolve_role_review_uses_explicit_assignment() {
let cfg = cfg_with_providers(
r#"{
"provider": "deepseek",
"review_provider": "glm",
"providers": {
"deepseek": { "model": "deepseek-v4-pro" },
"glm": { "model": "glm-4.6" }
}
}"#,
);
let (name, entry) = cfg.resolve_role(ConfigRole::Review).unwrap();
assert_eq!(name, "glm");
assert_eq!(entry.model.as_deref(), Some("glm-4.6"));
}
#[test]
fn provider_type_of_returns_explicit_value_when_set() {
let entry = ProviderEntry {
provider_type: Some("openai".to_string()),
..Default::default()
};
assert_eq!(Config::provider_type_of("ollama", &entry), "openai");
}
#[test]
fn provider_type_of_falls_back_to_alias_when_unset() {
let entry = ProviderEntry::default();
assert_eq!(Config::provider_type_of("deepseek", &entry), "deepseek");
assert_eq!(Config::provider_type_of("Anthropic", &entry), "anthropic");
}
#[test]
fn providers_map_returns_clone() {
let cfg = cfg_with_providers(
r#"{
"providers": { "deepseek": { "model": "x" } }
}"#,
);
let map = cfg.providers_map();
assert_eq!(map.len(), 1);
assert!(map.contains_key("deepseek"));
}
#[test]
fn providers_map_empty_when_unset() {
let cfg = Config::default();
assert!(cfg.providers_map().is_empty());
}
#[test]
fn new_shape_with_aliased_ollama_parses() {
let cfg = cfg_with_providers(
r#"{
"provider": "deepseek",
"providers": {
"deepseek": { "model": "deepseek-v4-pro" },
"ollama": {
"provider_type": "openai",
"base_url": "http://127.0.0.1:11434/v1"
}
}
}"#,
);
let (name, entry) = cfg.resolve_role(ConfigRole::Default).unwrap();
assert_eq!(name, "deepseek");
assert_eq!(entry.model.as_deref(), Some("deepseek-v4-pro"));
assert_eq!(Config::provider_type_of("deepseek", &entry), "deepseek");
let ollama = cfg.providers_map().get("ollama").cloned().unwrap();
assert_eq!(Config::provider_type_of("ollama", &ollama), "openai");
assert_eq!(
ollama.base_url.as_deref(),
Some("http://127.0.0.1:11434/v1")
);
}
#[test]
fn api_key_literal_passes_through() {
let cfg = cfg_with_providers(
r#"{
"providers": { "glm": { "api_key": "sk-literal" } }
}"#,
);
let entry = cfg.providers_map().get("glm").cloned().unwrap();
assert_eq!(
entry.resolved_api_key().and_then(|r| r.ok()),
Some("sk-literal".to_string())
);
}
#[test]
fn api_key_camel_case_alias_parses() {
let cfg = cfg_with_providers(
r#"{
"providers": { "glm": { "apiKey": "sk-camel" } }
}"#,
);
let entry = cfg.providers_map().get("glm").cloned().unwrap();
assert_eq!(entry.api_key.as_deref(), Some("sk-camel"));
}
#[test]
fn api_key_env_interpolation_expands() {
let var = "DIRGE_TEST_API_KEY_EXPAND";
unsafe { std::env::set_var(var, "sk-from-env") };
let cfg = cfg_with_providers(&format!(
r#"{{
"providers": {{ "glm": {{ "api_key": "${{{var}}}" }} }}
}}"#
));
let entry = cfg.providers_map().get("glm").cloned().unwrap();
assert_eq!(
entry.resolved_api_key().and_then(|r| r.ok()),
Some("sk-from-env".to_string())
);
unsafe { std::env::remove_var(var) };
}
#[test]
fn api_key_env_interpolation_reports_missing_var() {
let cfg = cfg_with_providers(
r#"{
"providers": { "glm": { "api_key": "${DIRGE_TEST_MISSING_VAR_NEVER_SET}" } }
}"#,
);
let entry = cfg.providers_map().get("glm").cloned().unwrap();
let err = entry.resolved_api_key().unwrap().unwrap_err();
assert_eq!(err, "DIRGE_TEST_MISSING_VAR_NEVER_SET");
}
#[test]
fn api_key_none_when_unset() {
let entry = ProviderEntry::default();
assert!(entry.resolved_api_key().is_none());
}
#[test]
fn options_temperature_f64() {
let cfg = cfg_with_providers(
r#"{
"providers": { "glm": { "options": { "temperature": 0.2 } } }
}"#,
);
let entry = cfg.providers_map().get("glm").cloned().unwrap();
assert_eq!(entry.options_temperature(), Some(0.2));
}
#[test]
fn options_temperature_missing_or_wrong_shape() {
let cfg = cfg_with_providers(
r#"{
"providers": {
"no-options": {},
"wrong-shape": { "options": { "temperature": "hot" } }
}
}"#,
);
assert_eq!(
cfg.providers_map()
.get("no-options")
.unwrap()
.options_temperature(),
None
);
assert_eq!(
cfg.providers_map()
.get("wrong-shape")
.unwrap()
.options_temperature(),
None
);
}
#[test]
fn legacy_model_key_detected() {
let raw: serde_json::Value =
serde_json::from_str(r#"{"model": "deepseek-v4-pro"}"#).unwrap();
let obj = raw.as_object().unwrap();
let legacy = ["custom_providers", "model", "review_model"];
let found: Vec<&str> = legacy
.iter()
.copied()
.filter(|k| obj.contains_key(*k))
.collect();
assert_eq!(found, vec!["model"]);
}
#[test]
fn legacy_custom_providers_key_detected() {
let raw: serde_json::Value =
serde_json::from_str(r#"{"custom_providers": {"x": {}}}"#).unwrap();
let obj = raw.as_object().unwrap();
let legacy = ["custom_providers", "model", "review_model"];
let found: Vec<&str> = legacy
.iter()
.copied()
.filter(|k| obj.contains_key(*k))
.collect();
assert_eq!(found, vec!["custom_providers"]);
}
}
#[cfg(test)]
mod config_merge_tests {
use super::*;
use serde_json::json;
#[test]
fn merge_overrides_scalar_but_keeps_absent_keys() {
let mut base = json!({ "provider": "deepseek", "max_tokens": 4096 });
merge_json(&mut base, json!({ "max_tokens": 8192 }));
assert_eq!(base["provider"], "deepseek");
assert_eq!(base["max_tokens"], 8192);
}
#[test]
fn merge_unions_map_values_key_by_key() {
let mut base = json!({
"providers": { "deepseek": { "model": "v3" }, "glm": { "model": "glm-4.6" } }
});
merge_json(
&mut base,
json!({ "providers": { "deepseek": { "model": "v4-pro" } } }),
);
assert_eq!(base["providers"]["deepseek"]["model"], "v4-pro");
assert_eq!(base["providers"]["glm"]["model"], "glm-4.6");
}
#[test]
fn merge_recurses_into_nested_objects() {
let mut base = json!({ "providers": { "ollama": { "base_url": "x", "model": "qwen" } } });
merge_json(
&mut base,
json!({ "providers": { "ollama": { "model": "llama" } } }),
);
assert_eq!(base["providers"]["ollama"]["base_url"], "x");
assert_eq!(base["providers"]["ollama"]["model"], "llama");
}
#[test]
fn merge_empty_map_overlay_is_a_noop_union() {
let mut base = json!({ "mcp_servers": { "exa": {} } });
merge_json(&mut base, json!({ "mcp_servers": {} }));
assert!(base["mcp_servers"]["exa"].as_object().is_some());
}
}