use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context};
use orchestral_core::config::{load_config, OrchestralConfig};
use serde_yaml::{Mapping, Value as YamlValue};
use crate::google_auth::has_google_credentials;
use super::{
ModelOverrides, GENERATED_CONFIG_DIR, GENERATED_CONFIG_FILE, GENERATED_OVERRIDE_CONFIG_SUFFIX,
};
pub(crate) fn prepare_runtime_config_path(
explicit: Option<PathBuf>,
model_overrides: &ModelOverrides,
credential_file: Option<&Path>,
) -> anyhow::Result<PathBuf> {
let explicit_config = explicit.is_some();
let base_path = resolve_runtime_config_path(explicit)?;
let automatic = if explicit_config
|| model_overrides.backend.is_some()
|| model_overrides.model_profile.is_some()
|| model_overrides.base_url.is_some()
|| model_overrides.no_auth
|| model_overrides.api_key_env.is_some()
{
ModelOverrides::default()
} else {
auto_override_model_if_needed(&base_path, credential_file).unwrap_or_default()
};
let effective = merge_model_overrides(model_overrides, &automatic);
if effective.is_empty() {
return Ok(base_path);
}
write_overridden_runtime_config(&base_path, &effective)
}
pub(crate) fn inspect_runtime_config(
explicit: Option<PathBuf>,
overrides: &ModelOverrides,
credential_file: Option<&Path>,
) -> anyhow::Result<(OrchestralConfig, Option<PathBuf>)> {
let explicit_config = explicit.is_some();
let path = explicit.or_else(discover_config_path);
let raw = match &path {
Some(path) => {
fs::read_to_string(path).with_context(|| format!("read config '{}'", path.display()))?
}
None => embedded_default_config(),
};
let config: OrchestralConfig = serde_yaml::from_str(&raw).context("parse configuration")?;
let automatic = if !explicit_config
&& overrides.backend.is_none()
&& overrides.model_profile.is_none()
&& overrides.base_url.is_none()
&& !overrides.no_auth
&& overrides.api_key_env.is_none()
{
path.as_ref()
.and_then(|path| auto_override_model_if_needed(path, credential_file))
.unwrap_or_default()
} else {
ModelOverrides::default()
};
let mut yaml = serde_yaml::from_str(&raw)?;
apply_model_overrides_to_yaml(
&mut yaml,
&config,
&merge_model_overrides(overrides, &automatic),
)?;
Ok((serde_yaml::from_value(yaml)?, path))
}
fn auto_override_model_if_needed(
config_path: &Path,
credential_file: Option<&Path>,
) -> Option<ModelOverrides> {
let config = load_config(config_path).ok()?;
let backend_name = config
.agent
.backend
.as_deref()
.or(config.providers.default_backend.as_deref())?;
let backend = config.providers.get_backend(backend_name)?;
let is_google = matches!(
backend.kind.trim().to_ascii_lowercase().as_str(),
"google" | "gemini"
);
if backend.get_config::<String>("auth").as_deref() == Some("none")
|| backend.resolve_api_key().is_ok()
|| (is_google && has_google_credentials(credential_file))
{
return None;
}
let (detected_backend, detected_profile) = detect_default_model_profile(credential_file);
(detected_backend != backend_name).then(|| ModelOverrides {
backend: Some(detected_backend.to_owned()),
model_profile: Some(detected_profile.to_owned()),
model: None,
temperature: None,
..ModelOverrides::default()
})
}
fn merge_model_overrides(requested: &ModelOverrides, automatic: &ModelOverrides) -> ModelOverrides {
ModelOverrides {
backend: requested
.backend
.clone()
.or_else(|| automatic.backend.clone()),
model_profile: requested
.model_profile
.clone()
.or_else(|| automatic.model_profile.clone()),
model: requested.model.clone().or_else(|| automatic.model.clone()),
temperature: requested.temperature.or(automatic.temperature),
reasoning: requested
.reasoning
.clone()
.or_else(|| automatic.reasoning.clone()),
base_url: requested.base_url.clone(),
api_key_env: requested.api_key_env.clone(),
no_auth: requested.no_auth,
}
}
pub(crate) fn resolve_runtime_config_path(explicit: Option<PathBuf>) -> anyhow::Result<PathBuf> {
if let Some(path) = explicit {
if !path.exists() {
bail!("config file not found: {}", path.display());
}
return Ok(path);
}
discover_config_path().map_or_else(generate_default_config, Ok)
}
fn discover_config_path() -> Option<PathBuf> {
[
PathBuf::from(".orchestral/config.yaml"),
PathBuf::from(".orchestral/config.yml"),
PathBuf::from("configs/orchestral.cli.yaml"),
PathBuf::from("orchestral.yaml"),
]
.into_iter()
.find(|path| path.exists())
}
fn generate_default_config() -> anyhow::Result<PathBuf> {
let root = std::env::current_dir().context("resolve current directory")?;
let directory = root.join(GENERATED_CONFIG_DIR);
let desired = embedded_default_config();
super::config_storage::publish(&directory, &format!(".{GENERATED_CONFIG_FILE}"), &desired)
}
fn write_overridden_runtime_config(
base_path: &Path,
overrides: &ModelOverrides,
) -> anyhow::Result<PathBuf> {
let config =
load_config(base_path).with_context(|| format!("load config '{}'", base_path.display()))?;
let raw = fs::read_to_string(base_path)
.with_context(|| format!("read config '{}'", base_path.display()))?;
let mut yaml: YamlValue = serde_yaml::from_str(&raw)
.with_context(|| format!("parse config '{}'", base_path.display()))?;
apply_model_overrides_to_yaml(&mut yaml, &config, overrides)?;
let output = serde_yaml::to_string(&yaml).context("serialize model overrides")?;
let parent = base_path.parent().unwrap_or_else(|| Path::new("."));
super::config_storage::publish(parent, GENERATED_OVERRIDE_CONFIG_SUFFIX, &output)
}
fn apply_model_overrides_to_yaml(
yaml: &mut YamlValue,
config: &OrchestralConfig,
overrides: &ModelOverrides,
) -> anyhow::Result<()> {
let root = yaml
.as_mapping_mut()
.context("config root must be a YAML mapping")?;
let agent = ensure_mapping_entry(root, "agent");
if let Some(profile_name) = &overrides.model_profile {
let profile = config
.providers
.get_model(profile_name)
.with_context(|| format!("Model profile not found: {profile_name}"))?;
set_yaml_key(
agent,
"model_profile",
YamlValue::String(profile_name.clone()),
);
set_yaml_key(agent, "model", YamlValue::Null);
if overrides.backend.is_none() {
set_yaml_key(agent, "backend", YamlValue::String(profile.backend));
}
if overrides.temperature.is_none() {
set_yaml_key(agent, "temperature", YamlValue::Null);
}
}
if let Some(backend) = &overrides.backend {
if config.providers.get_backend(backend).is_none() {
bail!("Model backend not found: {backend}");
}
set_yaml_key(agent, "backend", YamlValue::String(backend.clone()));
}
if let Some(model) = &overrides.model {
set_yaml_key(agent, "model", YamlValue::String(model.clone()));
}
if let Some(temperature) = overrides.temperature {
set_yaml_key(
agent,
"temperature",
serde_yaml::to_value(temperature).context("serialize temperature")?,
);
}
if let Some(reasoning) = &overrides.reasoning {
set_yaml_key(agent, "reasoning", serde_yaml::to_value(reasoning)?);
}
if overrides.base_url.is_some() || overrides.api_key_env.is_some() || overrides.no_auth {
apply_connection_overrides(root, config, overrides)?;
}
Ok(())
}
fn apply_connection_overrides(
root: &mut Mapping,
config: &OrchestralConfig,
overrides: &ModelOverrides,
) -> anyhow::Result<()> {
let selected = overrides
.backend
.as_deref()
.or_else(|| {
overrides.model_profile.as_deref().and_then(|name| {
config
.providers
.models
.iter()
.find(|p| p.name == name)
.map(|p| p.backend.as_str())
})
})
.or_else(|| {
overrides
.base_url
.is_none()
.then_some(
config
.agent
.backend
.as_deref()
.or(config.providers.default_backend.as_deref()),
)
.flatten()
});
let kind = selected
.and_then(|name| {
config
.providers
.backends
.iter()
.find(|backend| backend.name == name)
})
.map(|backend| backend.kind.as_str())
.unwrap_or("openai");
if !matches!(
kind.to_ascii_lowercase().as_str(),
"openai" | "openrouter" | "deepseek" | "groq" | "xai" | "mistral"
) {
bail!("--base-url, --api-key-env and --no-auth require an OpenAI-compatible backend");
}
let backend_name = if overrides.base_url.is_some() {
"cli-openai"
} else {
selected.context("no model backend selected")?
};
let endpoint = overrides
.base_url
.as_deref()
.map(orchestral_model_openai::OpenAiEndpoint::parse)
.transpose()?;
let providers = ensure_mapping_entry(root, "providers");
let backends = providers
.get_mut(YamlValue::String("backends".to_owned()))
.and_then(YamlValue::as_sequence_mut)
.context("providers.backends must be a sequence")?;
let mut connection = if endpoint.is_some() {
let mut mapping = Mapping::new();
set_yaml_key(
&mut mapping,
"name",
YamlValue::String(backend_name.to_owned()),
);
set_yaml_key(&mut mapping, "kind", YamlValue::String("openai".to_owned()));
mapping
} else {
backends
.iter()
.find(|backend| backend["name"].as_str() == Some(backend_name))
.and_then(YamlValue::as_mapping)
.cloned()
.context("model backend not found")?
};
if let Some(endpoint) = endpoint {
set_yaml_key(
&mut connection,
"endpoint",
YamlValue::String(endpoint.base_url().to_owned()),
);
}
let auth =
if overrides.no_auth || (overrides.base_url.is_some() && overrides.api_key_env.is_none()) {
"none"
} else {
"api_key"
};
set_yaml_key(
ensure_mapping_entry(&mut connection, "config"),
"auth",
YamlValue::String(auth.to_owned()),
);
if let Some(name) = &overrides.api_key_env {
if name.trim().is_empty() {
bail!("--api-key-env requires a nonempty environment variable name");
}
set_yaml_key(
&mut connection,
"api_key_env",
YamlValue::String(name.clone()),
);
}
backends.retain(|backend| backend["name"].as_str() != Some(backend_name));
backends.push(YamlValue::Mapping(connection));
let agent = ensure_mapping_entry(root, "agent");
set_yaml_key(agent, "backend", YamlValue::String(backend_name.to_owned()));
if overrides.base_url.is_some() && overrides.model_profile.is_none() {
set_yaml_key(agent, "model_profile", YamlValue::Null);
if overrides.model.is_none() {
set_yaml_key(agent, "model", YamlValue::Null);
}
}
Ok(())
}
fn ensure_mapping_entry<'a>(mapping: &'a mut Mapping, key: &str) -> &'a mut Mapping {
let entry = mapping
.entry(YamlValue::String(key.to_owned()))
.or_insert_with(|| YamlValue::Mapping(Mapping::new()));
if !entry.is_mapping() {
*entry = YamlValue::Mapping(Mapping::new());
}
entry.as_mapping_mut().expect("entry was normalized")
}
fn set_yaml_key(mapping: &mut Mapping, key: &str, value: YamlValue) {
mapping.insert(YamlValue::String(key.to_owned()), value);
}
fn embedded_default_config() -> String {
let (backend, profile) = detect_default_model_profile(None);
format!(
r#"version: 1
app:
name: orchestral-cli
environment: development
agent:
backend: {backend}
model_profile: {profile}
stream_buffer: 128
history_limit: 128
max_context_tokens: 131072
reserved_output_tokens: 4096
project_instructions:
enabled: true
max_bytes: 65536
fallback_filenames: [CLAUDE.md]
model_retry:
max_retries: 3
base_delay_ms: 500
max_delay_ms: 8000
compaction:
enabled: true
minimum_source_records: 32
keep_recent_records: 16
summary_max_chars: 16384
providers:
default_backend: {backend}
default_model: {profile}
backends:
- name: openai
kind: openai
api_key_env: OPENAI_API_KEY
config: {{ stream_idle_timeout_secs: 300 }}
- name: google
kind: gemini
api_key_env: GOOGLE_API_KEY
config: {{ stream_idle_timeout_secs: 300 }}
- name: openrouter
kind: openrouter
endpoint: https://openrouter.ai/api/v1
api_key_env: OPENROUTER_API_KEY
config: {{ stream_idle_timeout_secs: 300 }}
- name: deepseek
kind: deepseek
endpoint: https://api.deepseek.com
api_key_env: DEEPSEEK_API_KEY
config: {{ stream_idle_timeout_secs: 300 }}
models:
- name: gpt-4o-mini
backend: openai
model: gpt-4o-mini
temperature: 0.2
max_tokens: 8192
- name: gemini-2.5-flash
backend: google
model: gemini-2.5-flash
temperature: 0.2
max_tokens: 8192
- name: openrouter-auto
backend: openrouter
model: openrouter/auto
temperature: 0.2
max_tokens: 8192
- name: deepseek-chat
backend: deepseek
model: deepseek-chat
temperature: 0.2
max_tokens: 8192
tools:
max_timeout_ms: 130000
max_output_bytes: 1048576
# null: one quarter of the model input window, in bytes, per inline result.
# Complete larger results remain available through artifact_read.
max_inline_output_bytes: null
exec:
enabled: true
allow_host_execution: true
network_targets: []
mcp:
enabled: true
import_files: []
servers: []
skills:
enabled: true
auto_discover: true
directories: []
journal:
backend: filesystem
root_dir: .orchestral/agent-journal
artifacts:
backend: filesystem
root_dir: .orchestral/artifacts
max_bytes: 67108864
summary_max_chars: 512
observability:
log_level: info
traces_enabled: false
"#
)
}
fn detect_default_model_profile(credential_file: Option<&Path>) -> (&'static str, &'static str) {
if has_any_env(&["GOOGLE_API_KEY", "GEMINI_API_KEY"]) || has_google_credentials(credential_file)
{
("google", "gemini-2.5-flash")
} else if has_env("OPENAI_API_KEY") {
("openai", "gpt-4o-mini")
} else if has_env("DEEPSEEK_API_KEY") {
("deepseek", "deepseek-chat")
} else if has_env("OPENROUTER_API_KEY") {
("openrouter", "openrouter-auto")
} else {
("openai", "gpt-4o-mini")
}
}
fn has_any_env(names: &[&str]) -> bool {
names.iter().any(|name| has_env(name))
}
fn has_env(name: &str) -> bool {
std::env::var(name).is_ok_and(|value| !value.trim().is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn concurrent_generated_configs_preserve_each_process_connection() {
let directory = std::env::temp_dir().join(format!("orch-config-{}", uuid::Uuid::new_v4()));
let raw = embedded_default_config();
let ready = std::sync::Arc::new(std::sync::Barrier::new(3));
std::thread::scope(|scope| {
let workers = (0..3)
.map(|index| {
let directory = &directory;
let raw = &raw;
let ready = ready.clone();
scope.spawn(move || {
let base = super::super::config_storage::publish(
directory,
".default.agent.yaml",
raw,
)
.unwrap();
let model = format!("model-{index}");
let endpoint = format!("http://127.0.0.1:{}/v1", 18000 + index);
let path = write_overridden_runtime_config(
&base,
&ModelOverrides {
model: Some(model.clone()),
base_url: Some(endpoint.clone()),
no_auth: true,
..Default::default()
},
)
.unwrap();
ready.wait();
let config = load_config(&path).unwrap();
assert_eq!(config.agent.model.as_deref(), Some(model.as_str()));
assert_eq!(
config
.providers
.get_backend("cli-openai")
.unwrap()
.endpoint
.as_deref(),
Some(endpoint.as_str())
);
assert_eq!(fs::read_to_string(base).unwrap(), *raw);
})
})
.collect::<Vec<_>>();
for worker in workers {
worker.join().unwrap();
}
});
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn reasoning_override_survives_config_roundtrip_and_explicit_default_is_not_absence() {
use orchestral_core::config::ReasoningPreference;
let raw = embedded_default_config();
let config: OrchestralConfig = serde_yaml::from_str(&raw).unwrap();
for reasoning in [
ReasoningPreference::Off,
ReasoningPreference::None,
ReasoningPreference::Default,
ReasoningPreference::Custom("future-next".into()),
ReasoningPreference::Custom("on".into()),
] {
let mut yaml: YamlValue = serde_yaml::from_str(&raw).unwrap();
apply_model_overrides_to_yaml(
&mut yaml,
&config,
&ModelOverrides {
model: Some("selected-api-model".into()),
reasoning: Some(reasoning.clone()),
..Default::default()
},
)
.unwrap();
let restored: OrchestralConfig = serde_yaml::from_value(yaml).unwrap();
assert_eq!(restored.agent.model.as_deref(), Some("selected-api-model"));
assert_eq!(restored.agent.reasoning, Some(reasoning));
}
assert!(ModelOverrides::default().is_empty());
assert!(!ModelOverrides {
reasoning: Some(ReasoningPreference::Default),
..Default::default()
}
.is_empty());
}
#[test]
fn generated_config_is_strict_agent_config() {
let raw = embedded_default_config();
let parsed: OrchestralConfig = serde_yaml::from_str(&raw).expect("strict config");
assert!(parsed.tools.exec.enabled);
assert_eq!(
parsed.tools.exec.sandboxed_execution_enabled,
!cfg!(windows)
);
assert_eq!(
orchestral_core::config::ExecToolConfig::default().sandboxed_execution_enabled,
!cfg!(windows)
);
assert!(!raw.contains("planner:"));
assert!(!raw.contains("actions:"));
assert!(!raw.contains("task:"));
}
#[test]
fn model_overrides_write_to_agent_section() {
let raw = embedded_default_config();
let config: OrchestralConfig = serde_yaml::from_str(&raw).expect("config");
let mut yaml: YamlValue = serde_yaml::from_str(&raw).expect("yaml");
apply_model_overrides_to_yaml(
&mut yaml,
&config,
&ModelOverrides {
backend: Some("google".to_owned()),
model_profile: Some("gemini-2.5-flash".to_owned()),
model: None,
temperature: Some(0.1),
..ModelOverrides::default()
},
)
.expect("override");
assert_eq!(yaml["agent"]["backend"].as_str(), Some("google"));
assert_eq!(
yaml["agent"]["model_profile"].as_str(),
Some("gemini-2.5-flash")
);
}
}