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,
}
}
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 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").unwrap())
} 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 _ = dotenvy::dotenv();
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 fallback_models = env::var("ELI_FALLBACK_MODELS").ok().map(|v| {
v.split(',')
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
.collect()
});
let api_format = api_format_from_str_lossy(&env::var("ELI_API_FORMAT").unwrap_or_default());
let max_steps: usize = env::var("ELI_MAX_STEPS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(50);
let max_tokens: usize = env::var("ELI_MAX_TOKENS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or_else(|| infer_max_output_tokens(&model));
let model_timeout_seconds: Option<u64> = env::var("ELI_MODEL_TIMEOUT_SECONDS")
.ok()
.and_then(|v| v.parse().ok());
let verbose: u8 = env::var("ELI_VERBOSE")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0)
.min(2);
let context_window: usize = env::var("ELI_CONTEXT_WINDOW")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or_else(|| infer_context_window(&model));
let (api_key, api_base) = EnvConfig::api_credentials();
Self {
home,
model,
fallback_models,
api_key,
api_base,
api_format,
max_steps,
max_tokens,
model_timeout_seconds,
verbose,
context_window,
}
}
}
#[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_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);
}
}