eli 0.3.2

Ease Lives Instantly — hook-first AI agent framework with multi-channel support
Documentation
//! Agent configuration loaded from environment variables.

use std::collections::HashMap;
use std::env;
use std::path::PathBuf;

pub use nexil::core::execution::{ApiBaseConfig, ApiKeyConfig};
pub use nexil::llm::ApiFormat;

/// Default model identifier.
pub const DEFAULT_MODEL: &str = "openrouter:qwen/qwen3-coder-next";
/// Fallback maximum output tokens when we cannot infer from the model name.
pub const DEFAULT_MAX_OUTPUT_TOKENS: usize = 65_536;
/// Fallback context window when we cannot infer from the model name.
pub const DEFAULT_CONTEXT_WINDOW: usize = 128_000;

use super::model_specs::{infer_context_window, infer_max_output_tokens};

/// Return the default home directory (`~/.eli`).
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,
    }
}

// ---------------------------------------------------------------------------
// Centralized env-var resolution
// ---------------------------------------------------------------------------

/// Centralized environment variable resolution for eli configuration.
///
/// Provides a single source of truth for how each `ELI_*` env var is resolved
/// and what its precedence is.  Call sites should prefer these helpers over
/// raw `std::env::var` so that precedence rules are documented in one place.
///
/// # Recognized environment variables
///
/// | Variable                       | Purpose                                       |
/// |--------------------------------|-----------------------------------------------|
/// | `ELI_HOME`                     | Override the eli home directory (`~/.eli`)     |
/// | `ELI_MODEL`                    | Model identifier (`provider:model`)            |
/// | `ELI_FALLBACK_MODELS`          | Comma-separated fallback model list            |
/// | `ELI_API_KEY`                  | Single API key for all providers               |
/// | `ELI_API_BASE`                 | Single API base URL for all providers          |
/// | `ELI_<PROVIDER>_API_KEY`       | Per-provider API key                           |
/// | `ELI_<PROVIDER>_API_BASE`      | Per-provider API base URL                      |
/// | `ELI_API_FORMAT`               | API wire format (`auto`/`messages`/`responses`/`completion`) |
/// | `ELI_MAX_STEPS`                | Maximum agent steps per turn (default 50)      |
/// | `ELI_MAX_TOKENS`               | Max output tokens (auto-detected from model)   |
/// | `ELI_MODEL_TIMEOUT_SECONDS`    | HTTP timeout for model calls                   |
/// | `ELI_CONTEXT_WINDOW`           | Context window override (auto-detected)        |
/// | `ELI_VERBOSE`                  | Verbosity level (0–2)                          |
/// | `ELI_TELEGRAM_TOKEN`           | Telegram bot token (gateway mode)              |
pub struct EnvConfig;

impl EnvConfig {
    /// Resolve the model identifier.
    ///
    /// Precedence: `ELI_MODEL` env > config file active profile > [`DEFAULT_MODEL`].
    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())
    }

    /// Resolve the API key configuration (single or per-provider).
    ///
    /// Precedence: `ELI_API_KEY` (single) or `ELI_<PROVIDER>_API_KEY` (per-provider).
    /// See [`resolve_api_credentials`] for the full algorithm.
    pub fn api_credentials() -> (ApiKeyConfig, ApiBaseConfig) {
        resolve_api_credentials()
    }

    /// Resolve the API key as a single optional string.
    ///
    /// Precedence: explicit parameter > `ELI_API_KEY` env.
    pub fn api_key(explicit: Option<&str>) -> Option<String> {
        explicit
            .map(String::from)
            .or_else(|| env::var("ELI_API_KEY").ok())
    }

    /// Read `ELI_MODEL` from the environment, if set.
    ///
    /// Useful for CLI display (e.g. "ELI_MODEL overrides your profile").
    pub fn model_override() -> Option<String> {
        env::var("ELI_MODEL").ok()
    }
}

// ---------------------------------------------------------------------------
// API credential resolution
// ---------------------------------------------------------------------------

/// Resolve API key and base URL from environment variables.
///
/// Supports both single-value (`ELI_API_KEY` / `ELI_API_BASE`) and per-provider
/// (`ELI_<PROVIDER>_API_KEY` / `ELI_<PROVIDER>_API_BASE`) patterns.
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)
}

/// Collapse a `HashMap` into a config enum: empty → `none`, single "default"
/// entry → `single(value)`, otherwise → `per_provider(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").unwrap())
    } else {
        per_provider(map)
    }
}

// ---------------------------------------------------------------------------
// AgentSettings
// ---------------------------------------------------------------------------

/// Agent configuration, loaded from environment variables with the `ELI_` prefix
/// and optionally from a `.env` file via `dotenvy`.
#[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,
    /// Context window size in tokens. Auto-detected from model name if not set
    /// via `ELI_CONTEXT_WINDOW`.
    pub context_window: usize,
}

impl AgentSettings {
    /// Load settings from environment (and `.env` file).
    ///
    /// Per-provider API keys are detected via `ELI_<PROVIDER>_API_KEY` and
    /// `ELI_<PROVIDER>_API_BASE` patterns, matching the Python implementation.
    pub fn from_env() -> Self {
        // Best-effort load of a .env file in the current directory.
        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));

        // Resolve API key / base — single value or per-provider map.
        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::*;

    // -- ApiFormat tests ------------------------------------------------------

    #[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");
    }

    // -- ApiKeyConfig / ApiBaseConfig -----------------------------------------

    #[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"),
        }
    }

    // -- AgentSettings defaults -----------------------------------------------

    #[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);
    }
}