use std::collections::BTreeMap;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use crate::generative_model::{
AnthropicBackendConfig, BackendConfig, CatalogModel, ModelCatalog, ModelSpec,
OpenAIResponsesBackendConfig, Protocol, ThinkingMode,
};
use crate::harness::{
AuthEntry, FileConfig, HarnessConfig, load_file_config, load_ssh_host_aliases,
};
pub const DEFAULT_MAX_OUTPUT_TOKENS: usize = 8192;
pub fn read_auth_file(path: &Path) -> Result<String, String> {
let expanded: PathBuf = match path.strip_prefix("~") {
Ok(rest) => dirs::home_dir()
.ok_or_else(|| "could not resolve home directory".to_string())?
.join(rest),
Err(_) => path.to_path_buf(),
};
let text =
std::fs::read_to_string(&expanded).map_err(|e| format!("{}: {e}", expanded.display()))?;
let token = text.trim();
if token.is_empty() {
return Err(format!("{}: file is empty", expanded.display()));
}
Ok(token.to_string())
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ColorMode {
#[default]
Auto,
Always,
Never,
}
impl std::fmt::Display for ColorMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
ColorMode::Auto => "auto",
ColorMode::Always => "always",
ColorMode::Never => "never",
})
}
}
impl std::str::FromStr for ColorMode {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_ascii_lowercase().as_str() {
"auto" => Ok(ColorMode::Auto),
"always" | "on" => Ok(ColorMode::Always),
"never" | "off" => Ok(ColorMode::Never),
other => Err(format!(
"unknown color mode {other:?}; expected auto|always|never"
)),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ConfigUserSettings {
pub color: ColorMode,
pub stdout_is_tty: Option<bool>,
pub harness_config_path: Option<PathBuf>,
pub model: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Config {
pub stdout_is_tty: bool,
pub colors_enabled: bool,
pub harness_config_path: PathBuf,
pub harness: HarnessConfig,
pub models: ModelCatalog,
pub model: String,
}
impl Config {
pub fn resolve(settings: ConfigUserSettings) -> Result<Self, String> {
let stdout_is_tty = std::io::stdout().is_terminal();
Self::resolve_with(
settings,
|k| std::env::var(k).ok(),
stdout_is_tty,
load_file_config,
load_ssh_host_aliases,
read_auth_file,
)
}
pub fn resolve_with(
settings: ConfigUserSettings,
env: impl Fn(&str) -> Option<String>,
stdout_is_tty: bool,
load_file: impl FnOnce(&Path) -> Result<FileConfig, String>,
ssh_aliases: impl FnOnce() -> Result<Vec<String>, String>,
read_auth_file: impl Fn(&Path) -> Result<String, String>,
) -> Result<Self, String> {
let env = |key: &str| env(key).filter(|v| !v.is_empty());
let ConfigUserSettings {
color,
stdout_is_tty: tty_override,
harness_config_path,
model: model_override,
} = settings;
let stdout_is_tty = tty_override.unwrap_or(stdout_is_tty);
let harness_config_path = resolve_harness_config_path(harness_config_path, &env)?;
let file = load_file(&harness_config_path)?;
let models = resolve_catalog(&file, &env, &read_auth_file)?;
let model = resolve_default_model(model_override, file.model.clone(), &models)?;
let mut harness = file.into_harness_config(ssh_aliases()?);
harness.models = models.clone();
let colors_enabled = resolve_colors(color, &env, stdout_is_tty);
Ok(Self {
stdout_is_tty,
colors_enabled,
harness_config_path,
harness,
models,
model,
})
}
}
fn resolve_harness_config_path(
override_path: Option<PathBuf>,
env: &impl Fn(&str) -> Option<String>,
) -> Result<PathBuf, String> {
if let Some(p) = override_path {
return Ok(p);
}
if let Some(p) = env("MYCO_CONFIG") {
return Ok(PathBuf::from(p));
}
let home = dirs::home_dir().ok_or_else(|| "could not resolve home directory".to_string())?;
Ok(home.join(".myco").join("config.toml"))
}
fn resolve_catalog(
file: &FileConfig,
env: &impl Fn(&str) -> Option<String>,
read_auth_file: &impl Fn(&Path) -> Result<String, String>,
) -> Result<ModelCatalog, String> {
let mut entries = BTreeMap::new();
for (key, entry) in &file.models {
let gateway = match &entry.gateway {
Some(name) => Some(file.gateways.get(name).ok_or_else(|| {
format!(
"model `{key}`: unknown gateway `{name}` (configured: [{}])",
file.gateways.keys().cloned().collect::<Vec<_>>().join(", ")
)
})?),
None => None,
};
let protocol = entry
.protocol
.or(gateway.map(|g| g.protocol))
.ok_or_else(|| {
format!("model `{key}`: no protocol — set `protocol` or reference a `gateway`")
})?;
let base_url = entry
.base_url
.clone()
.or_else(|| gateway.map(|g| g.base_url.clone()))
.ok_or_else(|| {
format!("model `{key}`: no base_url — set `base_url` or reference a `gateway`")
})?;
let auth = entry
.auth
.clone()
.or_else(|| gateway.and_then(|g| g.auth.clone()));
let thinking = entry
.thinking
.unwrap_or_else(|| ThinkingMode::default_for(protocol));
if !thinking.compatible_with(protocol) {
return Err(format!(
"model `{key}`: thinking `{thinking}` is not valid for protocol `{protocol}` \
(anthropic-messages: adaptive|budget|none; openai-responses: effort|none)"
));
}
let (token, auth_error) = match auth {
None | Some(AuthEntry::None) => (String::new(), None),
Some(AuthEntry::Token(token)) => (token, None),
Some(AuthEntry::Env { var_name }) => match env(&var_name) {
Some(v) => (v, None),
None => (
String::new(),
Some(format!(
"model `{key}`: auth env var `{var_name}` is unset or empty"
)),
),
},
Some(AuthEntry::File { path }) => match read_auth_file(Path::new(&path)) {
Ok(v) => (v, None),
Err(e) => (String::new(), Some(format!("model `{key}`: auth file {e}"))),
},
};
let spec = ModelSpec {
key: key.clone(),
api_id: entry.api_id.clone().unwrap_or_else(|| key.clone()),
protocol,
thinking,
context_window_tokens: entry.context_window,
};
let max_output = entry.max_output_tokens.unwrap_or(DEFAULT_MAX_OUTPUT_TOKENS);
let backend = match protocol {
Protocol::AnthropicMessages => BackendConfig::Anthropic(AnthropicBackendConfig {
anthropic_base_url: base_url,
anthropic_auth_token: token,
max_tokens_per_generate: max_output,
..Default::default()
}),
Protocol::OpenAIResponses => {
BackendConfig::OpenAIResponses(OpenAIResponsesBackendConfig {
base_url,
auth_token: token,
max_output_tokens: Some(max_output),
..Default::default()
})
}
};
entries.insert(
key.clone(),
CatalogModel {
spec,
backend,
auth_error,
},
);
}
Ok(ModelCatalog::new(entries))
}
fn resolve_default_model(
override_key: Option<String>,
file_key: Option<String>,
catalog: &ModelCatalog,
) -> Result<String, String> {
if let Some(key) = override_key.or(file_key) {
if !catalog.contains(&key) {
if catalog.is_empty() {
return Err(format!(
"model {key:?} selected but no models are configured — define \
[models] (and [gateways]) in config.toml"
));
}
return Err(format!(
"unknown model {key:?}; configured models: [{}]",
catalog.keys().join(", ")
));
}
return Ok(key);
}
match catalog.keys().as_slice() {
[] => Err(
"no models configured — define [models] (and [gateways]) in config.toml; \
see `myco --help overview` for the format"
.into(),
),
[only] => Ok(only.to_string()),
keys => Err(format!(
"no model selected — set `model = \"<key>\"` in config.toml or pass --model \
(configured: [{}])",
keys.join(", ")
)),
}
}
fn resolve_colors(
mode: ColorMode,
env: &impl Fn(&str) -> Option<String>,
stdout_is_tty: bool,
) -> bool {
match mode {
ColorMode::Always => true,
ColorMode::Never => false,
ColorMode::Auto => {
if env("NO_COLOR").is_some() {
return false;
}
if env("CLICOLOR_FORCE").is_some_and(|v| v != "0") {
return true;
}
if env("TERM").as_deref() == Some("dumb") {
return false;
}
stdout_is_tty
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::harness::parse_file_config_str;
fn env_of<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
move |key| {
pairs
.iter()
.find(|(name, _)| *name == key)
.map(|(_, value)| value.to_string())
}
}
const CATALOG: &str = r#"
model = "kimi-k3"
[gateways.openrouter]
protocol = "openai-responses"
base_url = "https://openrouter.ai/api/v1"
auth = { source = "env", var_name = "OPENROUTER_API_KEY" }
[gateways.anthropic]
protocol = "anthropic-messages"
base_url = "https://api.anthropic.com"
auth = { source = "env", var_name = "ANTHROPIC_API_KEY" }
[models.kimi-k3]
gateway = "openrouter"
api_id = "moonshotai/kimi-k3"
context_window = 1_000_000
[models.opus]
gateway = "anthropic"
api_id = "claude-opus-4-8"
context_window = 1_000_000
[models.haiku-local]
protocol = "anthropic-messages"
base_url = "http://localhost:8080"
thinking = "budget"
api_id = "claude-haiku-4-5"
context_window = 200_000
"#;
fn resolve_toml(
toml_text: &'static str,
settings: ConfigUserSettings,
env: impl Fn(&str) -> Option<String>,
) -> Result<Config, String> {
resolve_toml_with_files(toml_text, settings, env, |p| {
Err(format!("{}: no such file", p.display()))
})
}
fn resolve_toml_with_files(
toml_text: &'static str,
settings: ConfigUserSettings,
env: impl Fn(&str) -> Option<String>,
read_auth_file: impl Fn(&Path) -> Result<String, String>,
) -> Result<Config, String> {
Config::resolve_with(
settings,
env,
false,
move |_| parse_file_config_str(toml_text),
|| Ok(Vec::new()),
read_auth_file,
)
}
fn resolve_catalog_cfg(env_pairs: &[(&str, &str)]) -> Config {
resolve_toml(CATALOG, ConfigUserSettings::default(), env_of(env_pairs)).unwrap()
}
#[test]
fn gateway_ref_supplies_protocol_base_url_and_auth() {
let cfg = resolve_catalog_cfg(&[("OPENROUTER_API_KEY", "or-key")]);
let kimi = cfg.models.get("kimi-k3").unwrap();
assert_eq!(kimi.spec.key, "kimi-k3");
assert_eq!(kimi.spec.api_id, "moonshotai/kimi-k3");
assert_eq!(kimi.spec.protocol, Protocol::OpenAIResponses);
assert_eq!(kimi.spec.thinking, ThinkingMode::Effort);
assert_eq!(kimi.spec.context_window_tokens, 1_000_000);
match &kimi.backend {
BackendConfig::OpenAIResponses(b) => {
assert_eq!(b.base_url, "https://openrouter.ai/api/v1");
assert_eq!(b.auth_token, "or-key");
assert_eq!(b.max_output_tokens, Some(DEFAULT_MAX_OUTPUT_TOKENS));
}
other => panic!("expected OpenAI Responses backend, got {other:?}"),
}
}
#[test]
fn inline_model_needs_no_gateway_and_auth_none_is_usable() {
let cfg = resolve_catalog_cfg(&[]);
let local = cfg.models.get("haiku-local").unwrap();
assert_eq!(local.spec.protocol, Protocol::AnthropicMessages);
assert_eq!(local.spec.thinking, ThinkingMode::Budget);
match &local.backend {
BackendConfig::Anthropic(b) => {
assert_eq!(b.anthropic_base_url, "http://localhost:8080");
assert_eq!(b.anthropic_auth_token, "");
}
other => panic!("expected Anthropic backend, got {other:?}"),
}
}
#[test]
fn missing_env_credential_defers_until_use() {
let cfg = resolve_catalog_cfg(&[]);
assert_eq!(cfg.model, "kimi-k3");
let err = cfg.models.get("kimi-k3").unwrap_err();
assert!(err.contains("OPENROUTER_API_KEY"), "{err}");
assert!(err.contains("kimi-k3"), "{err}");
}
#[test]
fn literal_auth_string_is_the_token() {
let toml_text = r#"
[models.proxy]
protocol = "openai-responses"
base_url = "https://proxy.corp/v1"
auth = "sk-inline-secret"
context_window = 100_000
"#;
let cfg = resolve_toml(toml_text, ConfigUserSettings::default(), env_of(&[])).unwrap();
match &cfg.models.get("proxy").unwrap().backend {
BackendConfig::OpenAIResponses(b) => assert_eq!(b.auth_token, "sk-inline-secret"),
other => panic!("unexpected backend {other:?}"),
}
}
#[test]
fn file_auth_reads_trimmed_contents_and_read_failure_defers() {
let toml_text = r#"
[models.proxy]
protocol = "openai-responses"
base_url = "https://proxy.corp/v1"
auth = { source = "file", path = "~/.secrets/corp.token" }
context_window = 100_000
"#;
let cfg =
resolve_toml_with_files(toml_text, ConfigUserSettings::default(), env_of(&[]), |p| {
assert_eq!(p, Path::new("~/.secrets/corp.token"));
Ok("sekrit".into())
})
.unwrap();
match &cfg.models.get("proxy").unwrap().backend {
BackendConfig::OpenAIResponses(b) => assert_eq!(b.auth_token, "sekrit"),
other => panic!("unexpected backend {other:?}"),
}
let cfg = resolve_toml(toml_text, ConfigUserSettings::default(), env_of(&[])).unwrap();
let err = cfg.models.get("proxy").unwrap_err();
assert!(err.contains("auth file"), "{err}");
assert!(err.contains("corp.token"), "{err}");
}
#[test]
fn absent_auth_and_source_none_send_no_credential() {
let toml_text = r#"
model = "inherits"
[gateways.g]
protocol = "openai-responses"
base_url = "https://h"
auth = "sk-gateway-token"
[models.inherits]
gateway = "g"
context_window = 1000
[models.opts-out]
gateway = "g"
auth = { source = "none" }
context_window = 1000
"#;
let cfg = resolve_toml(toml_text, ConfigUserSettings::default(), env_of(&[])).unwrap();
match &cfg.models.get("inherits").unwrap().backend {
BackendConfig::OpenAIResponses(b) => assert_eq!(b.auth_token, "sk-gateway-token"),
other => panic!("unexpected backend {other:?}"),
}
match &cfg.models.get("opts-out").unwrap().backend {
BackendConfig::OpenAIResponses(b) => assert_eq!(b.auth_token, ""),
other => panic!("unexpected backend {other:?}"),
}
}
#[test]
fn config_shape_errors_name_the_model() {
let unknown_gateway = r#"
[models.x]
gateway = "nope"
context_window = 1000
"#;
let err =
resolve_toml(unknown_gateway, ConfigUserSettings::default(), env_of(&[])).unwrap_err();
assert!(err.contains("model `x`"), "{err}");
assert!(err.contains("unknown gateway `nope`"), "{err}");
let no_protocol = r#"
[models.x]
base_url = "https://h"
auth = "none"
context_window = 1000
"#;
let err =
resolve_toml(no_protocol, ConfigUserSettings::default(), env_of(&[])).unwrap_err();
assert!(err.contains("no protocol"), "{err}");
let bad_auth = r#"
[models.x]
protocol = "openai-responses"
base_url = "https://h"
auth = { source = "keychain" }
context_window = 1000
"#;
let err = resolve_toml(bad_auth, ConfigUserSettings::default(), env_of(&[])).unwrap_err();
assert!(err.contains("unknown source \"keychain\""), "{err}");
}
#[test]
fn incompatible_thinking_mode_is_a_resolve_error() {
let toml_text = r#"
[models.x]
protocol = "anthropic-messages"
base_url = "https://h"
auth = "none"
thinking = "effort"
context_window = 1000
"#;
let err = resolve_toml(toml_text, ConfigUserSettings::default(), env_of(&[])).unwrap_err();
assert!(err.contains("thinking `effort`"), "{err}");
assert!(err.contains("anthropic-messages"), "{err}");
}
#[test]
fn default_model_precedence_override_file_sole_entry() {
let cfg = resolve_toml(
CATALOG,
ConfigUserSettings {
model: Some("opus".into()),
..Default::default()
},
env_of(&[]),
)
.unwrap();
assert_eq!(cfg.model, "opus");
assert_eq!(resolve_catalog_cfg(&[]).model, "kimi-k3");
let sole = r#"
[models.only]
protocol = "openai-responses"
base_url = "https://h"
context_window = 1000
"#;
let cfg = resolve_toml(sole, ConfigUserSettings::default(), env_of(&[])).unwrap();
assert_eq!(cfg.model, "only");
}
#[test]
fn missing_model_selection_errors_are_actionable() {
let err = resolve_toml("", ConfigUserSettings::default(), env_of(&[])).unwrap_err();
assert!(err.contains("no models configured"), "{err}");
assert!(err.contains("[models]"), "{err}");
let two = r#"
[models.a]
protocol = "openai-responses"
base_url = "https://h"
context_window = 1000
[models.b]
protocol = "openai-responses"
base_url = "https://h"
context_window = 1000
"#;
let err = resolve_toml(two, ConfigUserSettings::default(), env_of(&[])).unwrap_err();
assert!(err.contains("no model selected"), "{err}");
assert!(err.contains("[a, b]"), "{err}");
let err = resolve_toml(
two,
ConfigUserSettings {
model: Some("c".into()),
..Default::default()
},
env_of(&[]),
)
.unwrap_err();
assert!(err.contains("unknown model \"c\""), "{err}");
assert!(err.contains("[a, b]"), "{err}");
}
#[test]
fn example_config_resolves_end_to_end() {
let cfg = Config::resolve_with(
ConfigUserSettings::default(),
env_of(&[("XAI_API_KEY", "xai")]),
false,
|_| parse_file_config_str(&crate::harness::example_config_toml()),
|| Ok(Vec::new()),
|_| Err("no files".into()),
)
.unwrap();
assert_eq!(cfg.model, "grok-4.5-build");
assert!(cfg.models.get("grok-4.5-build").is_ok());
let err = cfg.models.get("claude-opus-4-8").unwrap_err();
assert!(err.contains("ANTHROPIC_API_KEY"), "{err}");
}
#[test]
fn color_mode_always_and_never_override_everything() {
let cfg = resolve_toml(
CATALOG,
ConfigUserSettings {
color: ColorMode::Always,
..Default::default()
},
env_of(&[("NO_COLOR", "1")]),
)
.unwrap();
assert!(cfg.colors_enabled);
let cfg = resolve_toml(
CATALOG,
ConfigUserSettings {
color: ColorMode::Never,
stdout_is_tty: Some(true),
..Default::default()
},
env_of(&[("CLICOLOR_FORCE", "1")]),
)
.unwrap();
assert!(!cfg.colors_enabled);
}
#[test]
fn auto_colors_follow_tty_and_env_overrides() {
let auto = |env_pairs: &[(&str, &str)], tty: bool| {
let env = env_of(env_pairs);
let env = |k: &str| env(k).filter(|v| !v.is_empty());
resolve_colors(ColorMode::Auto, &env, tty)
};
assert!(auto(&[], true));
assert!(!auto(&[], false));
assert!(!auto(&[("NO_COLOR", "1"), ("CLICOLOR_FORCE", "1")], true));
assert!(auto(&[("NO_COLOR", "")], true));
assert!(auto(&[("CLICOLOR_FORCE", "1")], false));
assert!(!auto(&[("CLICOLOR_FORCE", "0")], false));
assert!(!auto(&[("TERM", "dumb")], true));
}
#[test]
fn color_mode_parses() {
assert_eq!("auto".parse::<ColorMode>().unwrap(), ColorMode::Auto);
assert_eq!("ALWAYS".parse::<ColorMode>().unwrap(), ColorMode::Always);
assert_eq!("off".parse::<ColorMode>().unwrap(), ColorMode::Never);
assert!("rainbow".parse::<ColorMode>().is_err());
}
#[test]
fn harness_config_path_override_beats_env_beats_home_default() {
let path_for = |override_path: Option<PathBuf>, env_pairs: &[(&str, &str)]| {
let env = env_of(env_pairs);
let env = move |k: &str| env(k).filter(|v: &String| !v.is_empty());
resolve_harness_config_path(override_path, &env).unwrap()
};
assert_eq!(
path_for(
Some(PathBuf::from("/tmp/x.toml")),
&[("MYCO_CONFIG", "/env/y.toml")]
),
PathBuf::from("/tmp/x.toml")
);
assert_eq!(
path_for(None, &[("MYCO_CONFIG", "/env/y.toml")]),
PathBuf::from("/env/y.toml")
);
assert!(path_for(None, &[]).ends_with(".myco/config.toml"));
}
#[test]
fn harness_loader_gets_resolved_path_and_result_is_stored() {
let cfg = Config::resolve_with(
ConfigUserSettings {
harness_config_path: Some(PathBuf::from("/tmp/h.toml")),
..Default::default()
},
env_of(&[]),
false,
|p| {
assert_eq!(p, Path::new("/tmp/h.toml"));
let mut file = parse_file_config_str(
"[models.m]\nprotocol = \"openai-responses\"\n\
base_url = \"https://h\"\ncontext_window = 1000\n",
)?;
file.attach_timeout_secs = 42;
Ok(file)
},
|| Ok(vec!["devbox".into()]),
|_| Err("no files".into()),
)
.unwrap();
assert_eq!(cfg.harness.attach_timeout_secs, 42);
assert_eq!(cfg.harness.remote_hosts.len(), 1);
assert_eq!(cfg.harness.remote_hosts[0].name, "devbox");
}
#[test]
fn load_errors_propagate() {
let err = Config::resolve_with(
ConfigUserSettings::default(),
env_of(&[]),
false,
|_| Err("invalid config TOML".into()),
|| Ok(Vec::new()),
|_| Err("no files".into()),
)
.unwrap_err();
assert!(err.contains("invalid config TOML"));
}
}