smart-tree 8.0.1

Smart Tree - An intelligent, AI-friendly directory visualization tool
Documentation
//! LLM Client - OpenAI-compatible API client for Ollama, LM Studio, vLLM, etc.
//!
//! Saved from liquid-rust for use in smart-tree's subconscious layer.
//!
//! Usage:
//! ```rust
//! let messages = vec![json!({"role": "user", "content": "Hello"})];
//!
//! // Ollama
//! let response = call_ollama("http://localhost:11434", "llama3.2", &messages).await?;
//!
//! // OpenAI-compatible (LM Studio, vLLM, etc.)
//! let response = call_openai_compatible("http://localhost:8080/v1", None, &messages).await?;
//! ```

use anyhow::Result;
use tracing::info;

/// Call Ollama API (OpenAI-compatible endpoint)
/// Ollama serves at http://localhost:11434/v1/chat/completions
pub async fn call_ollama(
    base_url: &str,
    model: &str,
    messages: &[serde_json::Value],
) -> Result<String> {
    let client = reqwest::Client::new();

    // Ollama's OpenAI-compatible endpoint
    let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));

    info!("Calling Ollama at {} with model {}", url, model);

    let response = client
        .post(&url)
        .header("content-type", "application/json")
        .json(&serde_json::json!({
            "model": model,
            "max_tokens": 1024,
            "messages": messages,
            "stream": false
        }))
        .send()
        .await?;

    let status = response.status();
    let json: serde_json::Value = response.json().await?;

    if !status.is_success() {
        if let Some(error) = json["error"]["message"].as_str() {
            anyhow::bail!("Ollama error: {}", error);
        }
        anyhow::bail!("Ollama error: HTTP {}", status);
    }

    if let Some(choice) = json["choices"].get(0) {
        if let Some(text) = choice["message"]["content"].as_str() {
            return Ok(text.to_string());
        }
    }

    anyhow::bail!("Unexpected Ollama response format: {:?}", json)
}

/// Call any OpenAI-compatible API (LM Studio, vLLM, text-generation-webui, LocalAI, etc.)
pub async fn call_openai_compatible(
    base_url: &str,
    api_key: Option<&str>,
    messages: &[serde_json::Value],
) -> Result<String> {
    call_openai_compatible_with_model(base_url, api_key, "gpt-4o-mini", messages).await
}

/// Call OpenAI-compatible API with specific model
pub async fn call_openai_compatible_with_model(
    base_url: &str,
    api_key: Option<&str>,
    model: &str,
    messages: &[serde_json::Value],
) -> Result<String> {
    let client = reqwest::Client::new();

    let url = format!("{}/chat/completions", base_url.trim_end_matches('/'));

    info!("Calling OpenAI-compatible API at {}", url);

    let mut request = client.post(&url).header("content-type", "application/json");

    // Add auth header if API key provided
    if let Some(key) = api_key {
        request = request.header("Authorization", format!("Bearer {}", key));
    }

    let response = request
        .json(&serde_json::json!({
            "model": model,
            "max_tokens": 1024,
            "messages": messages
        }))
        .send()
        .await?;

    let status = response.status();
    let json: serde_json::Value = response.json().await?;

    if !status.is_success() {
        if let Some(error) = json["error"]["message"].as_str() {
            anyhow::bail!("API error: {}", error);
        }
        anyhow::bail!("API error: HTTP {}", status);
    }

    if let Some(choice) = json["choices"].get(0) {
        if let Some(text) = choice["message"]["content"].as_str() {
            return Ok(text.to_string());
        }
    }

    anyhow::bail!("Unexpected API response format: {:?}", json)
}

/// Call OpenAI API directly
pub async fn call_openai(api_key: &str, messages: &[serde_json::Value]) -> Result<String> {
    call_openai_compatible("https://api.openai.com/v1", Some(api_key), messages).await
}

/// Call Claude API (Anthropic)
pub async fn call_claude(
    api_key: &str,
    messages: &[serde_json::Value],
    system: Option<&str>,
) -> Result<String> {
    let client = reqwest::Client::new();

    let mut body = serde_json::json!({
        "model": "claude-sonnet-4-6",
        "max_tokens": 1024,
        "messages": messages
    });

    if let Some(sys) = system {
        body["system"] = serde_json::json!(sys);
    }

    let response = client
        .post("https://api.anthropic.com/v1/messages")
        .header("x-api-key", api_key)
        .header("anthropic-version", "2023-06-01")
        .header("content-type", "application/json")
        .json(&body)
        .send()
        .await?;

    let status = response.status();
    let json: serde_json::Value = response.json().await?;

    if !status.is_success() {
        if let Some(error) = json["error"]["message"].as_str() {
            anyhow::bail!("Claude error: {}", error);
        }
        anyhow::bail!("Claude error: HTTP {}", status);
    }

    // Claude API returns content as array
    if let Some(content) = json["content"].as_array() {
        if let Some(first) = content.first() {
            if let Some(text) = first["text"].as_str() {
                return Ok(text.to_string());
            }
        }
    }

    anyhow::bail!("Unexpected Claude response format: {:?}", json)
}

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

    #[tokio::test]
    #[ignore] // Requires running Ollama
    async fn test_ollama_call() {
        let messages = vec![serde_json::json!({
            "role": "user",
            "content": "Say 'hello' and nothing else."
        })];

        let result = call_ollama("http://localhost:11434", "llama3.2", &messages).await;
        assert!(result.is_ok());
        println!("Ollama response: {}", result.unwrap());
    }
}