malvin 0.2.7

Non-interactive research and coding agent
use std::process::{Child, Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};

use serde::Deserialize;

use super::local_context::LOCAL_LLM_BASE_URL_ENV;
use super::local_endpoint::keyless_local_provider_is_listening;
use super::local_llm_paths::ollama_bin;

pub(crate) const SERVE_WAIT: Duration = Duration::from_secs(20);
const SERVE_POLL: Duration = Duration::from_millis(200);
const SHOW_TIMEOUT: Duration = Duration::from_secs(2);

#[derive(Debug, Deserialize)]
struct OllamaShowResponse {
    #[serde(default)]
    capabilities: Vec<String>,
}

pub(crate) fn process_alive(pid: u32) -> bool {
    Command::new("kill")
        .args(["-0", &pid.to_string()])
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .is_ok_and(|s| s.success())
}

pub(crate) fn stop_pid(pid: u32) {
    let _ = Command::new("kill")
        .args(["-TERM", &pid.to_string()])
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status();
}

pub(crate) fn run_ollama_stop(model: &str) {
    let _ = Command::new(ollama_bin())
        .args(["stop", model])
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status();
}

fn spawn_ollama_serve() -> Result<Child, String> {
    Command::new(ollama_bin())
        .arg("serve")
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .spawn()
        .map_err(|e| format!("failed to start `ollama serve`: {e}"))
}

fn wait_until_listening(provider: &str, child: &mut Child) -> Result<(), String> {
    let deadline = Instant::now() + SERVE_WAIT;
    while Instant::now() < deadline {
        if keyless_local_provider_is_listening(provider) {
            return Ok(());
        }
        if let Ok(Some(status)) = child.try_wait() {
            return Err(format!("`ollama serve` exited early ({status})"));
        }
        thread::sleep(SERVE_POLL);
    }
    let _ = child.kill();
    let _ = child.wait();
    Err("`ollama serve` did not become reachable in time".into())
}

pub(crate) fn ensure_provider_running(provider: &str) -> Result<Option<u32>, String> {
    if keyless_local_provider_is_listening(provider) {
        return Ok(None);
    }
    if !provider.eq_ignore_ascii_case("ollama") {
        return Err(format!(
            "local provider `{provider}` is not running; malvin can auto-start only `ollama`"
        ));
    }
    let mut child = spawn_ollama_serve()?;
    wait_until_listening(provider, &mut child)?;
    let pid = child.id();
    std::mem::forget(child);
    Ok(Some(pid))
}

#[must_use]
pub(crate) fn capabilities_include_tools(capabilities: &[String]) -> bool {
    capabilities
        .iter()
        .any(|c| c.eq_ignore_ascii_case("tools"))
}

fn trim_openai_v1_suffix(base: &str) -> String {
    let trimmed = base.trim().trim_end_matches('/');
    trimmed
        .strip_suffix("/v1")
        .unwrap_or(trimmed)
        .to_string()
}

fn ollama_api_root() -> String {
    if let Ok(value) = std::env::var(LOCAL_LLM_BASE_URL_ENV) {
        let trimmed = value.trim();
        if !trimmed.is_empty() {
            return trim_openai_v1_suffix(trimmed);
        }
    }
    if let Some(defaults) = pi::provider_metadata::provider_routing_defaults("ollama") {
        return trim_openai_v1_suffix(defaults.base_url);
    }
    "http://127.0.0.1:11434".to_string()
}

fn parse_ollama_show_capabilities(body: &str) -> Option<Vec<String>> {
    serde_json::from_str::<OllamaShowResponse>(body)
        .ok()
        .map(|parsed| parsed.capabilities)
}

async fn fetch_ollama_show_capabilities_async(model: &str) -> Option<Vec<String>> {
    let url = format!("{}/api/show", ollama_api_root());
    let client = pi::http::client::Client::new();
    let response = client
        .post(&url)
        .header("Content-Type", "application/json")
        .header("Accept", "application/json")
        .timeout(SHOW_TIMEOUT)
        .json(&serde_json::json!({ "name": model }))
        .ok()?
        .send()
        .await
        .ok()?;
    if !(200..300).contains(&response.status()) {
        return None;
    }
    let body = response.text().await.ok()?;
    parse_ollama_show_capabilities(&body)
}

#[must_use]
pub(crate) fn ollama_model_supports_tools(model: &str) -> Option<bool> {
    let Ok(runtime) = asupersync::runtime::RuntimeBuilder::current_thread().build() else {
        return None;
    };
    let caps = runtime.block_on(fetch_ollama_show_capabilities_async(model))?;
    Some(capabilities_include_tools(&caps))
}

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

    #[test]
    fn ensure_non_ollama_errors_when_down() {
        let err = ensure_provider_running("llamacpp").expect_err("down");
        assert!(err.contains("auto-start only `ollama`"), "{err}");
    }

    #[test]
    fn capabilities_include_tools_is_case_insensitive() {
        assert!(!capabilities_include_tools(&[]));
        assert!(!capabilities_include_tools(&[
            "completion".into(),
            "insert".into()
        ]));
        assert!(capabilities_include_tools(&[
            "completion".into(),
            "Tools".into()
        ]));
    }

    #[test]
    fn parse_show_capabilities_reads_tools_flag() {
        let phi = r#"{"capabilities":["completion"]}"#;
        let qwen = r#"{"capabilities":["completion","tools","insert"]}"#;
        assert!(!capabilities_include_tools(
            &parse_ollama_show_capabilities(phi).expect("phi")
        ));
        assert!(capabilities_include_tools(
            &parse_ollama_show_capabilities(qwen).expect("qwen")
        ));
        assert!(parse_ollama_show_capabilities("not-json").is_none());
    }

    #[test]
    fn trim_openai_v1_suffix_strips_trailing_v1() {
        assert_eq!(
            trim_openai_v1_suffix("http://127.0.0.1:11434/v1/"),
            "http://127.0.0.1:11434"
        );
        assert_eq!(
            trim_openai_v1_suffix("http://host:11434"),
            "http://host:11434"
        );
    }
}