harn-cli 0.7.25

CLI for the Harn programming language — run, test, REPL, format, and lint
use harn_vm::llm_config;

use super::dto::{PortalLlmOptions, PortalLlmProviderOption};

pub(super) async fn build_llm_options() -> PortalLlmOptions {
    let config = llm_config::load_config();
    let preferred_provider = std::env::var("HARN_LLM_PROVIDER")
        .ok()
        .filter(|value| !value.is_empty())
        .or_else(|| {
            if std::env::var("LOCAL_LLM_BASE_URL").is_ok() {
                Some("local".to_string())
            } else {
                None
            }
        });
    let preferred_model = std::env::var("HARN_LLM_MODEL")
        .ok()
        .filter(|value| !value.is_empty())
        .or_else(|| {
            std::env::var("LOCAL_LLM_MODEL")
                .ok()
                .filter(|value| !value.is_empty())
        });

    let mut providers = Vec::new();
    for name in llm_config::provider_names() {
        let Some(def) = llm_config::provider_config(&name) else {
            continue;
        };
        let base_url = llm_config::resolve_base_url(&def);
        let auth_envs = auth_env_names(&def.auth_env);
        let auth_configured = auth_envs.iter().any(|env_name| {
            std::env::var(env_name)
                .ok()
                .is_some_and(|value| !value.is_empty())
        });
        let viable = def.auth_style == "none" || auth_configured;
        let local = is_local_provider(&base_url);
        let aliases = config
            .aliases
            .iter()
            .filter(|(_, alias)| alias.provider == name)
            .map(|(alias_name, _)| alias_name.clone())
            .collect::<Vec<_>>();
        let mut models = if local {
            discover_provider_models(&name, &base_url, &def)
                .await
                .unwrap_or_default()
        } else {
            Vec::new()
        };
        if let Some(default_model) = default_model_for_provider(&name) {
            if !models.contains(&default_model) {
                models.insert(0, default_model.clone());
            }
        }
        for alias_name in &aliases {
            if let Some((resolved, _)) = llm_config::resolve_tier_model(alias_name, Some(&name)) {
                if !models.contains(&resolved) {
                    models.push(resolved);
                }
            }
        }
        models.sort();
        models.dedup();
        providers.push(PortalLlmProviderOption {
            name: name.clone(),
            base_url,
            base_url_env: def.base_url_env.clone(),
            auth_style: def.auth_style.clone(),
            auth_envs,
            auth_configured,
            viable,
            local,
            models,
            aliases,
            default_model: default_model_for_provider(&name).unwrap_or_default(),
        });
    }

    providers.sort_by(|left, right| {
        right
            .viable
            .cmp(&left.viable)
            .then_with(|| right.local.cmp(&left.local))
            .then_with(|| left.name.cmp(&right.name))
    });

    PortalLlmOptions {
        preferred_provider,
        preferred_model,
        providers,
    }
}

fn auth_env_names(auth_env: &llm_config::AuthEnv) -> Vec<String> {
    match auth_env {
        llm_config::AuthEnv::None => Vec::new(),
        llm_config::AuthEnv::Single(name) => vec![name.clone()],
        llm_config::AuthEnv::Multiple(names) => names.clone(),
    }
}

fn is_local_provider(base_url: &str) -> bool {
    base_url.contains("127.0.0.1") || base_url.contains("localhost")
}

fn default_model_for_provider(provider: &str) -> Option<String> {
    match provider {
        "local" => std::env::var("LOCAL_LLM_MODEL")
            .ok()
            .filter(|value| !value.is_empty())
            .or_else(|| {
                std::env::var("HARN_LLM_MODEL")
                    .ok()
                    .filter(|value| !value.is_empty())
            })
            .or_else(|| Some("gpt-4o".to_string())),
        "openai" => Some("gpt-4o".to_string()),
        "ollama" => Some("llama3.2".to_string()),
        "openrouter" => Some("Qwen/Qwen3.5-9B".to_string()),
        "anthropic" => Some("claude-sonnet-4-20250514".to_string()),
        _ => None,
    }
}

async fn discover_provider_models(
    provider: &str,
    base_url: &str,
    def: &llm_config::ProviderDef,
) -> Result<Vec<String>, String> {
    let client = reqwest::Client::builder()
        .connect_timeout(std::time::Duration::from_secs(2))
        .timeout(std::time::Duration::from_secs(3))
        .build()
        .map_err(|error| format!("failed to build model discovery client: {error}"))?;

    let response = if provider == "ollama" || def.chat_endpoint.contains("/api/chat") {
        client
            .get(format!("{base_url}/api/tags"))
            .send()
            .await
            .map_err(|error| format!("failed to reach {provider}: {error}"))?
    } else {
        client
            .get(format!("{base_url}/v1/models"))
            .send()
            .await
            .map_err(|error| format!("failed to reach {provider}: {error}"))?
    };
    if !response.status().is_success() {
        return Ok(Vec::new());
    }
    let payload = response
        .json::<serde_json::Value>()
        .await
        .map_err(|error| format!("failed to parse model list: {error}"))?;
    let mut models = Vec::new();
    if provider == "ollama" || def.chat_endpoint.contains("/api/chat") {
        if let Some(entries) = payload.get("models").and_then(|value| value.as_array()) {
            for entry in entries {
                if let Some(name) = entry.get("name").and_then(|value| value.as_str()) {
                    models.push(name.to_string());
                }
            }
        }
    } else if let Some(entries) = payload.get("data").and_then(|value| value.as_array()) {
        for entry in entries {
            if let Some(id) = entry.get("id").and_then(|value| value.as_str()) {
                models.push(id.to_string());
            }
        }
    }
    models.sort();
    models.dedup();
    Ok(models)
}