mecha10-cli 0.6.5

Mecha10 CLI tool
Documentation
//! AI model availability checks before spawning nodes
//!
//! Used in standalone mode (where node-runner is not available) to make
//! sure any AI models a node's config declares are downloaded before the
//! node is spawned.

use crate::services::ModelService;
use anyhow::Result;
use serde::Deserialize;
use std::path::PathBuf;

/// Minimal config structure to extract model requirements from node configs
#[derive(Deserialize, Default)]
struct NodeModelConfig {
    model: Option<ModelInfo>,
    /// LLM provider (`"local"` means Ollama - see `ensure_ollama_models_available`).
    #[serde(default)]
    provider: Option<String>,
    /// Ollama model name (e.g. "llama3.1") when `provider == "local"`.
    #[serde(default)]
    llm_model: Option<String>,
}

#[derive(Deserialize)]
struct ModelInfo {
    name: Option<String>,
}

/// Environment-aware node config wrapper
#[derive(Deserialize)]
struct EnvironmentNodeConfig {
    dev: Option<NodeModelConfig>,
    production: Option<NodeModelConfig>,
}

/// Parse node config from environment-aware format
fn parse_node_config(content: &str) -> Option<NodeModelConfig> {
    let profile = std::env::var("MECHA10_ENVIRONMENT").unwrap_or_else(|_| "dev".to_string());

    if let Ok(env_config) = serde_json::from_str::<EnvironmentNodeConfig>(content) {
        let config = match profile.as_str() {
            "production" | "prod" => env_config.production,
            _ => env_config.dev,
        };
        return config;
    }

    serde_json::from_str(content).ok()
}

/// Ensure all required AI models are available for nodes
///
/// Reads node configs and downloads missing models before spawning nodes.
/// This is called in standalone mode where node-runner is not available.
pub(super) async fn ensure_node_models_available(node_names: &[String]) -> Result<()> {
    let model_service = match ModelService::with_models_dir("models".into()) {
        Ok(service) => service,
        Err(e) => {
            eprintln!("⚠️  Warning: Could not initialize model service: {}", e);
            return Ok(());
        }
    };

    for node_name in node_names {
        let short_name = node_name.strip_prefix("@mecha10/").unwrap_or(node_name);

        let config_paths = crate::paths::config::node_config_candidates(short_name);

        for config_path in &config_paths {
            let path = PathBuf::from(config_path);
            if !path.exists() {
                continue;
            }

            let content = match std::fs::read_to_string(&path) {
                Ok(c) => c,
                Err(_) => continue,
            };

            let config = match parse_node_config(&content) {
                Some(c) => c,
                None => continue,
            };

            if let Some(model_info) = config.model {
                if let Some(model_name) = model_info.name {
                    let model_path = PathBuf::from(format!("models/{}/model.onnx", model_name));
                    if !model_path.exists() {
                        println!("📦 Downloading model '{}' for node '{}'...", model_name, node_name);
                        match model_service.pull(&model_name, None).await {
                            Ok(_) => println!("✅ Model '{}' ready", model_name),
                            Err(e) => {
                                eprintln!("⚠️  Warning: Failed to download model '{}': {}", model_name, e);
                                eprintln!("   You can manually download with: mecha10 model pull {}", model_name);
                            }
                        }
                    }
                }
            }
            break;
        }
    }

    Ok(())
}

/// Ollama's default host-mapped endpoint - see `${OLLAMA_PORT:-11434}` in the `ollama` Compose
/// service (`mecha10-templates`' `docker-compose.yml.template`/`docker-compose.remote.yml.template`).
/// This preflight always runs on the host (the CLI process itself, not inside any node's
/// container), so `localhost` is correct here even though the node process reaching Ollama from
/// inside a container needs `OLLAMA_ENDPOINT` pointed at the `ollama` service name instead - see
/// `llm_command::OpenAIReasoningNode::new`.
fn ollama_base_url() -> String {
    std::env::var("OLLAMA_ENDPOINT")
        .map(|endpoint| endpoint.trim_end_matches("/api/chat").to_string())
        .unwrap_or_else(|_| "http://localhost:11434".to_string())
}

/// Ensure every node declaring `"provider": "local"` (Ollama) has its `llm_model` pulled before
/// nodes are spawned - the same "check disk, download if missing" preflight
/// [`ensure_node_models_available`] already does for ONNX vision models, adapted to Ollama's own
/// model store. That store is bind-mounted at `<project_root>/models/catalog` - the same
/// directory the ONNX catalog above already uses - by the `ollama` Compose service, so nothing
/// here needs to know about that path directly; Ollama manages its own layout under it once a
/// model is pulled.
///
/// A no-op (with a warning, not an error) if Ollama isn't reachable at all - this mirrors
/// `ensure_node_models_available`'s "warn and continue" behavior for the same reason: a
/// preflight check should never be the thing that blocks `mecha10 dev` from starting.
pub(super) async fn ensure_ollama_models_available(node_names: &[String]) -> Result<()> {
    let client = reqwest::Client::new();
    let base_url = ollama_base_url();

    let mut wanted_models = Vec::new();
    for node_name in node_names {
        let short_name = node_name.strip_prefix("@mecha10/").unwrap_or(node_name);

        for config_path in crate::paths::config::node_config_candidates(short_name) {
            let path = PathBuf::from(&config_path);
            if !path.exists() {
                continue;
            }

            let Ok(content) = std::fs::read_to_string(&path) else {
                continue;
            };

            let Some(config) = parse_node_config(&content) else {
                continue;
            };

            if config.provider.as_deref() == Some("local") {
                if let Some(model) = config.llm_model {
                    wanted_models.push((node_name.clone(), model));
                }
            }
            break;
        }
    }

    if wanted_models.is_empty() {
        return Ok(());
    }

    let tags_url = format!("{base_url}/api/tags");
    let installed: Vec<String> = match client.get(&tags_url).send().await {
        Ok(response) => response
            .json::<serde_json::Value>()
            .await
            .ok()
            .map(|body| mecha10_core::ollama::parse_installed_model_names(&body))
            .unwrap_or_default(),
        Err(e) => {
            eprintln!("⚠️  Warning: Could not reach Ollama at {base_url}: {e}");
            eprintln!("   Local LLM nodes will fail until Ollama is running and the model is pulled.");
            return Ok(());
        }
    };

    for (node_name, model) in wanted_models {
        let already_installed = installed
            .iter()
            .any(|name| name == &model || name.starts_with(&format!("{model}:")));
        if already_installed {
            continue;
        }

        println!("📦 Pulling Ollama model '{}' for node '{}'...", model, node_name);
        let pull_result = client
            .post(format!("{base_url}/api/pull"))
            .json(&serde_json::json!({ "model": model, "stream": false }))
            .send()
            .await;

        match pull_result {
            Ok(response) if response.status().is_success() => println!("✅ Ollama model '{}' ready", model),
            Ok(response) => {
                eprintln!(
                    "⚠️  Warning: Ollama rejected pulling '{}': {}",
                    model,
                    response.status()
                );
                eprintln!(
                    "   You can manually pull with: docker compose exec ollama ollama pull {}",
                    model
                );
            }
            Err(e) => {
                eprintln!("⚠️  Warning: Failed to pull Ollama model '{}': {}", model, e);
                eprintln!(
                    "   You can manually pull with: docker compose exec ollama ollama pull {}",
                    model
                );
            }
        }
    }

    Ok(())
}