microclaw-engine 0.10.1

Channel-independent Agent Engine and default services for MicroClaw
Documentation
use std::sync::Arc;

use anyhow::{anyhow, Result};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};

use crate::config::Config;

#[async_trait]
pub trait EmbeddingProvider: Send + Sync {
    async fn embed(&self, text: &str) -> Result<Vec<f32>>;
    fn model(&self) -> &str;
    fn dimension(&self) -> usize;
}

pub struct OpenAIEmbeddingProvider {
    client: reqwest::Client,
    base_url: String,
    api_key: String,
    model: String,
    dim: usize,
}

pub struct OllamaEmbeddingProvider {
    client: reqwest::Client,
    base_url: String,
    model: String,
    dim: usize,
}

#[derive(Debug, Serialize)]
struct OpenAIEmbeddingRequest<'a> {
    model: &'a str,
    input: &'a str,
}

#[derive(Debug, Deserialize)]
struct OpenAIEmbeddingResponse {
    data: Vec<OpenAIEmbeddingData>,
}

#[derive(Debug, Deserialize)]
struct OpenAIEmbeddingData {
    embedding: Vec<f32>,
}

#[derive(Debug, Serialize)]
struct OllamaEmbeddingRequest<'a> {
    model: &'a str,
    prompt: &'a str,
}

#[derive(Debug, Deserialize)]
struct OllamaEmbeddingResponse {
    embedding: Vec<f32>,
}

#[cfg(feature = "sqlite-vec")]
fn infer_default_dim(provider: &str, model: &str) -> usize {
    match provider {
        "openai" if model.contains("3-large") => 3072,
        "openai" => 1536,
        "ollama" => 1024,
        _ => 1536,
    }
}

#[async_trait]
impl EmbeddingProvider for OpenAIEmbeddingProvider {
    async fn embed(&self, text: &str) -> Result<Vec<f32>> {
        let url = format!("{}/embeddings", self.base_url.trim_end_matches('/'));
        let response = self
            .client
            .post(url)
            .bearer_auth(&self.api_key)
            .json(&OpenAIEmbeddingRequest {
                model: &self.model,
                input: text,
            })
            .send()
            .await?;

        if !response.status().is_success() {
            let body = response.text().await.unwrap_or_default();
            return Err(anyhow!("embedding request failed: {}", body));
        }

        let body: OpenAIEmbeddingResponse = response.json().await?;
        let embedding = body
            .data
            .into_iter()
            .next()
            .ok_or_else(|| anyhow!("empty embedding response"))?
            .embedding;
        Ok(embedding)
    }

    fn model(&self) -> &str {
        &self.model
    }

    fn dimension(&self) -> usize {
        self.dim
    }
}

#[async_trait]
impl EmbeddingProvider for OllamaEmbeddingProvider {
    async fn embed(&self, text: &str) -> Result<Vec<f32>> {
        let url = format!("{}/api/embeddings", self.base_url.trim_end_matches('/'));
        let response = self
            .client
            .post(url)
            .json(&OllamaEmbeddingRequest {
                model: &self.model,
                prompt: text,
            })
            .send()
            .await?;

        if !response.status().is_success() {
            let body = response.text().await.unwrap_or_default();
            return Err(anyhow!("embedding request failed: {}", body));
        }

        let body: OllamaEmbeddingResponse = response.json().await?;
        Ok(body.embedding)
    }

    fn model(&self) -> &str {
        &self.model
    }

    fn dimension(&self) -> usize {
        self.dim
    }
}

pub fn create_provider(config: &Config) -> Option<Arc<dyn EmbeddingProvider>> {
    #[cfg(not(feature = "sqlite-vec"))]
    {
        let _ = config;
        None
    }

    #[cfg(feature = "sqlite-vec")]
    {
        let provider = config
            .embedding_provider
            .as_deref()
            .unwrap_or("")
            .trim()
            .to_lowercase();
        if provider.is_empty() {
            return None;
        }

        let model = config
            .embedding_model
            .clone()
            .unwrap_or_else(|| match provider.as_str() {
                "openai" => "text-embedding-3-small".to_string(),
                "ollama" => "nomic-embed-text".to_string(),
                _ => "text-embedding-3-small".to_string(),
            });
        let dim = config
            .embedding_dim
            .unwrap_or_else(|| infer_default_dim(&provider, &model));
        let client = reqwest::Client::new();

        match provider.as_str() {
            "openai" => {
                let api_key = config.embedding_api_key.clone().unwrap_or_default();
                if api_key.trim().is_empty() {
                    return None;
                }
                let base_url = config
                    .embedding_base_url
                    .clone()
                    .unwrap_or_else(|| "https://api.openai.com/v1".to_string());
                Some(Arc::new(OpenAIEmbeddingProvider {
                    client,
                    base_url,
                    api_key,
                    model,
                    dim,
                }))
            }
            "ollama" => {
                let base_url = config
                    .embedding_base_url
                    .clone()
                    .unwrap_or_else(|| "http://127.0.0.1:11434".to_string());
                Some(Arc::new(OllamaEmbeddingProvider {
                    client,
                    base_url,
                    model,
                    dim,
                }))
            }
            _ => None,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::{Config, WorkingDirIsolation};

    fn base_config() -> Config {
        let mut cfg = Config::test_defaults();
        cfg.working_dir_isolation = WorkingDirIsolation::Chat;
        cfg
    }

    #[test]
    fn test_create_provider_without_config_returns_none() {
        let cfg = base_config();
        assert!(create_provider(&cfg).is_none());
    }

    #[cfg(feature = "sqlite-vec")]
    #[test]
    fn test_create_provider_openai_when_configured() {
        let mut cfg = base_config();
        cfg.embedding_provider = Some("openai".into());
        cfg.embedding_api_key = Some("sk-test".into());
        cfg.embedding_model = Some("text-embedding-3-small".into());
        cfg.embedding_dim = Some(1536);

        let provider = create_provider(&cfg);
        assert!(provider.is_some());
        assert_eq!(
            provider.as_ref().map(|p| p.model()),
            Some("text-embedding-3-small")
        );
    }
}