harn-cli 0.10.147

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

use crate::net;

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("MLX_BASE_URL").is_ok() || std::env::var("MLX_MODEL_ID").is_ok() {
                Some("mlx".to_string())
            } 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("MLX_MODEL_ID")
                .ok()
                .filter(|value| !value.is_empty())
        })
        .or_else(|| {
            if std::env::var("LOCAL_LLM_BASE_URL").is_ok() {
                std::env::var("LOCAL_LLM_MODEL")
                    .ok()
                    .filter(|value| !value.is_empty())
            } else {
                None
            }
        });

    let mut providers = Vec::new();
    let catalog_models = llm_config::model_catalog_entries();
    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 = llm_config::auth_env_names(&def.auth_env);
        let auth_configured = harn_vm::llm::provider_auth_status(&name).available;
        let local = is_local_provider(&name, &base_url);
        let aliases = config
            .aliases
            .iter()
            .filter(|(_, alias)| alias.provider == name)
            .map(|(alias_name, _)| alias_name.clone())
            .collect::<Vec<_>>();
        let discovered = if local {
            Some(discover_provider_models(&name, &base_url, &def).await)
        } else {
            None
        };
        let viable = auth_configured
            && discovered
                .as_ref()
                .is_none_or(|result| result.as_ref().is_ok_and(|models| !models.is_empty()));
        let mut models = discovered.and_then(Result::ok).unwrap_or_default();
        if !local {
            models.extend(
                catalog_models
                    .iter()
                    .filter(|(_, model)| model.provider == name && !model.deprecated)
                    .map(|(id, model)| model.wire_model.clone().unwrap_or_else(|| id.clone())),
            );
        }
        let default_model = llm_config::portal_default_model_for_provider(&name);
        if let Some(default_model) = &default_model {
            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)
                    && llm_config::model_catalog_entry_for_route(&name, &resolved)
                        .is_some_and(|model| !model.deprecated)
                {
                    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.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 is_local_provider(provider: &str, base_url: &str) -> bool {
    matches!(provider, "local" | "mlx" | "ollama")
        || url::Url::parse(base_url)
            .ok()
            .and_then(|url| {
                url.host().map(|host| match host {
                    url::Host::Domain(domain) => domain.eq_ignore_ascii_case("localhost"),
                    url::Host::Ipv4(address) => address.is_loopback(),
                    url::Host::Ipv6(address) => address.is_loopback(),
                })
            })
            .unwrap_or(false)
}

async fn discover_provider_models(
    provider: &str,
    base_url: &str,
    def: &llm_config::ProviderDef,
) -> Result<Vec<String>, String> {
    let client = net::http_client_builder("cli.portal.llm")
        .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: {}",
                net::reqwest_error(&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}: {}", net::reqwest_error(&error))
            })?
    } else {
        client
            .get(format!("{base_url}/v1/models"))
            .send()
            .await
            .map_err(|error| {
                format!("failed to reach {provider}: {}", net::reqwest_error(&error))
            })?
    };
    if !response.status().is_success() {
        return Err(format!(
            "failed to discover {provider} models: HTTP {}",
            response.status()
        ));
    }
    let payload = response
        .json::<serde_json::Value>()
        .await
        .map_err(|error| format!("failed to parse model list: {error}"))?;
    let (entries, field) = if provider == "ollama" || def.chat_endpoint.contains("/api/chat") {
        (
            payload.get("models").and_then(|value| value.as_array()),
            "name",
        )
    } else {
        (payload.get("data").and_then(|value| value.as_array()), "id")
    };
    let entries = entries.ok_or_else(|| format!("invalid {provider} model list response"))?;
    let mut models = entries
        .iter()
        .filter_map(|entry| entry.get(field).and_then(|value| value.as_str()))
        .map(str::to_string)
        .collect::<Vec<_>>();
    models.sort();
    models.dedup();
    Ok(models)
}

#[cfg(test)]
mod tests {
    use super::is_local_provider;

    #[test]
    fn local_model_discovery_uses_provider_identity_or_loopback_host() {
        assert!(is_local_provider("local", "http://192.168.1.40:8000"));
        assert!(is_local_provider("custom", "http://[::1]:8000"));
        assert!(!is_local_provider(
            "openai",
            "https://localhost.example.com/v1"
        ));
    }
}