use std::collections::HashMap;
use std::env;
use std::path::PathBuf;
pub use nexil::core::execution::{ApiBaseConfig, ApiKeyConfig};
pub use nexil::llm::ApiFormat;
pub const DEFAULT_MODEL: &str = "openrouter:qwen/qwen3-coder-next";
pub const DEFAULT_MAX_OUTPUT_TOKENS: usize = 65_536;
pub const DEFAULT_CONTEXT_WINDOW: usize = 128_000;
use super::model_specs::{infer_context_window, infer_max_output_tokens};
fn default_home() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".eli")
}
fn api_format_from_str_lossy(s: &str) -> ApiFormat {
match s.trim().to_lowercase().as_str() {
"auto" => ApiFormat::Auto,
"responses" => ApiFormat::Responses,
"messages" => ApiFormat::Messages,
"completion" => ApiFormat::Completion,
_ => ApiFormat::Auto,
}
}
fn env_parse<T: std::str::FromStr>(key: &str) -> Option<T> {
env::var(key).ok().and_then(|v| v.parse().ok())
}
fn parse_fallback_models() -> Option<Vec<String>> {
env::var("ELI_FALLBACK_MODELS").ok().map(|v| {
v.split(',')
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
.collect()
})
}
pub struct EnvConfig;
impl EnvConfig {
pub fn model(config: &crate::builtin::config::EliConfig) -> String {
env::var("ELI_MODEL")
.ok()
.or_else(|| config.resolve_model())
.unwrap_or_else(|| DEFAULT_MODEL.to_owned())
}
pub fn api_credentials() -> (ApiKeyConfig, ApiBaseConfig) {
resolve_api_credentials()
}
pub fn api_key(explicit: Option<&str>) -> Option<String> {
explicit
.map(String::from)
.or_else(|| env::var("ELI_API_KEY").ok())
}
pub fn model_override() -> Option<String> {
env::var("ELI_MODEL").ok()
}
}
fn resolve_api_credentials() -> (ApiKeyConfig, ApiBaseConfig) {
let single_key = env::var("ELI_API_KEY").ok();
let single_base = env::var("ELI_API_BASE").ok();
if let (Some(key), Some(base)) = (single_key.clone(), single_base.clone()) {
return (ApiKeyConfig::Single(key), ApiBaseConfig::Single(base));
}
let mut key_map: HashMap<String, String> = HashMap::new();
let mut base_map: HashMap<String, String> = HashMap::new();
if let Some(k) = single_key {
key_map.insert("default".to_owned(), k);
}
if let Some(b) = single_base {
base_map.insert("default".to_owned(), b);
}
for (key, value) in env::vars() {
if let Some(provider) = key
.strip_prefix("ELI_")
.and_then(|rest| rest.strip_suffix("_API_KEY"))
&& provider != "API"
{
key_map.insert(provider.to_lowercase(), value.clone());
}
if let Some(provider) = key
.strip_prefix("ELI_")
.and_then(|rest| rest.strip_suffix("_API_BASE"))
&& provider != "API"
{
base_map.insert(provider.to_lowercase(), value);
}
}
let api_key = collapse_config_map(
key_map,
ApiKeyConfig::None,
ApiKeyConfig::Single,
ApiKeyConfig::PerProvider,
);
let api_base = collapse_config_map(
base_map,
ApiBaseConfig::None,
ApiBaseConfig::Single,
ApiBaseConfig::PerProvider,
);
(api_key, api_base)
}
fn merge_profile_api_base(
current: ApiBaseConfig,
provider: Option<&str>,
profile_api_base: Option<&str>,
) -> ApiBaseConfig {
let (Some(provider), Some(profile_api_base)) = (provider, profile_api_base) else {
return current;
};
let provider_key =
nexil::core::provider_policies::normalized_provider_name(provider).to_lowercase();
match current {
ApiBaseConfig::Single(s) => ApiBaseConfig::Single(s),
ApiBaseConfig::None => {
let mut map = HashMap::new();
map.insert(provider_key, profile_api_base.to_owned());
ApiBaseConfig::PerProvider(map)
}
ApiBaseConfig::PerProvider(mut map) => {
map.entry(provider_key)
.or_insert_with(|| profile_api_base.to_owned());
ApiBaseConfig::PerProvider(map)
}
}
}
fn collapse_config_map<T>(
mut map: HashMap<String, String>,
none: T,
single: fn(String) -> T,
per_provider: fn(HashMap<String, String>) -> T,
) -> T {
if map.is_empty() {
none
} else if map.len() == 1 && map.contains_key("default") {
single(
map.remove("default")
.expect("SAFETY: key presence verified"),
)
} else {
per_provider(map)
}
}
#[derive(Debug, Clone)]
pub struct AgentSettings {
pub home: PathBuf,
pub model: String,
pub fallback_models: Option<Vec<String>>,
pub api_key: ApiKeyConfig,
pub api_base: ApiBaseConfig,
pub api_format: ApiFormat,
pub max_steps: usize,
pub max_tokens: usize,
pub model_timeout_seconds: Option<u64>,
pub verbose: u8,
pub context_window: usize,
}
impl AgentSettings {
pub fn from_env() -> Self {
let home = env::var("ELI_HOME")
.ok()
.map(PathBuf::from)
.unwrap_or_else(default_home);
let config = crate::builtin::config::EliConfig::load();
let model = EnvConfig::model(&config);
let (api_key, api_base) = EnvConfig::api_credentials();
let api_base = merge_profile_api_base(
api_base,
config.resolve_provider().as_deref(),
config.resolve_api_base().as_deref(),
);
const MIN_CONTEXT_WINDOW: usize = 1_000;
const MAX_CONTEXT_WINDOW: usize = 10_000_000;
let raw_context_window =
env_parse("ELI_CONTEXT_WINDOW").unwrap_or_else(|| infer_context_window(&model));
let context_window = raw_context_window.clamp(MIN_CONTEXT_WINDOW, MAX_CONTEXT_WINDOW);
if context_window != raw_context_window {
tracing::warn!(
value = raw_context_window,
clamped = context_window,
min = MIN_CONTEXT_WINDOW,
max = MAX_CONTEXT_WINDOW,
"context_window out of valid range [1000, 10_000_000], clamped"
);
}
Self {
home,
fallback_models: parse_fallback_models(),
api_format: api_format_from_str_lossy(&env::var("ELI_API_FORMAT").unwrap_or_default()),
max_steps: env_parse("ELI_MAX_STEPS").unwrap_or(50),
max_tokens: env_parse("ELI_MAX_TOKENS")
.unwrap_or_else(|| infer_max_output_tokens(&model)),
model_timeout_seconds: env_parse("ELI_MODEL_TIMEOUT_SECONDS"),
verbose: env_parse::<u8>("ELI_VERBOSE").unwrap_or(0).min(2),
context_window,
api_key,
api_base,
model,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_api_format_from_str_lossy() {
assert_eq!(
api_format_from_str_lossy("completion"),
ApiFormat::Completion
);
assert_eq!(api_format_from_str_lossy("auto"), ApiFormat::Auto);
assert_eq!(api_format_from_str_lossy("responses"), ApiFormat::Responses);
assert_eq!(api_format_from_str_lossy("messages"), ApiFormat::Messages);
assert_eq!(api_format_from_str_lossy("RESPONSES"), ApiFormat::Responses);
assert_eq!(api_format_from_str_lossy("unknown"), ApiFormat::Auto);
assert_eq!(api_format_from_str_lossy(""), ApiFormat::Auto);
}
#[test]
fn test_api_format_as_str() {
assert_eq!(ApiFormat::Auto.as_str(), "auto");
assert_eq!(ApiFormat::Completion.as_str(), "completion");
assert_eq!(ApiFormat::Responses.as_str(), "responses");
assert_eq!(ApiFormat::Messages.as_str(), "messages");
}
#[test]
fn test_api_key_config_single() {
let config = ApiKeyConfig::Single("sk-test".into());
match config {
ApiKeyConfig::Single(k) => assert_eq!(k, "sk-test"),
_ => panic!("expected Single"),
}
}
#[test]
fn test_api_key_config_per_provider() {
let mut map = HashMap::new();
map.insert("openai".into(), "sk-openai".into());
let config = ApiKeyConfig::PerProvider(map);
match config {
ApiKeyConfig::PerProvider(m) => assert_eq!(m["openai"], "sk-openai"),
_ => panic!("expected PerProvider"),
}
}
#[test]
fn test_merge_profile_api_base_into_none() {
let merged = merge_profile_api_base(
ApiBaseConfig::None,
Some("local"),
Some("http://127.0.0.1:8000/v1"),
);
match merged {
ApiBaseConfig::PerProvider(m) => {
assert_eq!(m["local"], "http://127.0.0.1:8000/v1");
}
_ => panic!("expected PerProvider"),
}
}
#[test]
fn test_merge_profile_api_base_preserves_env_per_provider() {
let mut map = HashMap::new();
map.insert("local".into(), "http://192.168.1.10:9000/v1".into());
let merged = merge_profile_api_base(
ApiBaseConfig::PerProvider(map),
Some("local"),
Some("http://127.0.0.1:8000/v1"),
);
match merged {
ApiBaseConfig::PerProvider(m) => {
assert_eq!(m["local"], "http://192.168.1.10:9000/v1");
}
_ => panic!("expected PerProvider"),
}
}
#[test]
fn test_merge_profile_api_base_single_is_pass_through() {
let merged = merge_profile_api_base(
ApiBaseConfig::Single("http://proxy.example.com/v1".into()),
Some("local"),
Some("http://127.0.0.1:8000/v1"),
);
match merged {
ApiBaseConfig::Single(s) => assert_eq!(s, "http://proxy.example.com/v1"),
_ => panic!("expected Single"),
}
}
#[test]
fn test_merge_profile_api_base_normalizes_alias() {
let merged = merge_profile_api_base(
ApiBaseConfig::None,
Some("ollama"),
Some("http://127.0.0.1:11434/v1"),
);
match merged {
ApiBaseConfig::PerProvider(m) => {
assert!(m.contains_key("local"));
assert_eq!(m["local"], "http://127.0.0.1:11434/v1");
}
_ => panic!("expected PerProvider"),
}
}
#[test]
fn test_default_model_constant() {
assert!(!DEFAULT_MODEL.is_empty());
}
#[test]
fn test_default_max_output_tokens_constant() {
assert!(DEFAULT_MAX_OUTPUT_TOKENS > 0);
}
#[test]
fn test_default_home_returns_path() {
let home = default_home();
assert!(home.ends_with(".eli"));
}
#[test]
fn test_agent_settings_clone() {
let settings = AgentSettings {
home: PathBuf::from("/tmp"),
model: "test-model".into(),
fallback_models: Some(vec!["fallback1".into()]),
api_key: ApiKeyConfig::Single("sk-test".into()),
api_base: ApiBaseConfig::None,
api_format: ApiFormat::Completion,
max_steps: 10,
max_tokens: 512,
model_timeout_seconds: Some(30),
verbose: 1,
context_window: 128_000,
};
let cloned = settings.clone();
assert_eq!(cloned.model, "test-model");
assert_eq!(cloned.max_steps, 10);
assert_eq!(cloned.max_tokens, 512);
assert_eq!(cloned.verbose, 1);
}
}