oxinbox-backend 0.1.4

HTTP/WS API server for the oxinbox GTD productivity system
Documentation
use std::sync::Arc;

use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use tracing::instrument;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
    pub role: ChatRole,
    pub content: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ChatRole {
    System,
    User,
    Assistant,
}

#[derive(Debug, thiserror::Error)]
pub enum AiError {
    #[error("HTTP request failed: {0}")]
    Http(#[from] reqwest::Error),

    #[error("serialization error: {0}")]
    Serde(#[from] serde_json::Error),

    #[error("API returned error: {status} {body}")]
    Api { status: u16, body: String },

    #[error("not configured: {0}")]
    NotConfigured(String),
}

#[async_trait]
pub trait AiProvider: Send + Sync {
    async fn chat(&self, system: &str, messages: &[ChatMessage]) -> Result<String, AiError>;
    async fn embed(&self, text: &str) -> Result<Vec<f32>, AiError>;
    async fn transcribe(&self, audio_data: &[u8], mime_type: &str) -> Result<String, AiError>;
}

pub fn create_provider() -> Result<Arc<dyn AiProvider>, AiError> {
    let provider = std::env::var("AI_PROVIDER").unwrap_or_else(|_| "openrouter".into());
    match provider.as_str() {
        "openai" | "openrouter" => {
            let base_url = std::env::var("AI_BASE_URL")
                .unwrap_or_else(|_| "https://openrouter.ai/api/v1".into());
            let api_key = std::env::var("AI_API_KEY")
                .or_else(|_| std::env::var("OPENAI_API_KEY"))
                .or_else(|_| std::env::var("OPENROUTER_API_KEY"))
                .map_err(|_| {
                    AiError::NotConfigured(
                        "set AI_API_KEY, OPENAI_API_KEY, or OPENROUTER_API_KEY".into(),
                    )
                })?;
            let model =
                std::env::var("AI_MODEL").unwrap_or_else(|_| "deepseek/deepseek-v4-flash".into());
            let embedding_model =
                std::env::var("AI_EMBEDDING_MODEL").unwrap_or_else(|_| "baai/bge-m3".into());
            let whisper_model = std::env::var("AI_WHISPER_MODEL")
                .unwrap_or_else(|_| "qwen/qwen3-asr-flash-2026-02-10".into());

            let client =
                OpenAiProvider::new(base_url, api_key, model, embedding_model, whisper_model);
            tracing::info!(provider = %provider, model = %client.model, "AI provider configured");
            Ok(Arc::new(client))
        }
        other => Err(AiError::NotConfigured(format!(
            "unknown AI provider: {other}"
        ))),
    }
}

pub struct OpenAiProvider {
    http: reqwest::Client,
    base_url: String,
    api_key: String,
    model: String,
    embedding_model: String,
    whisper_model: String,
}

impl OpenAiProvider {
    pub fn new(
        base_url: String,
        api_key: String,
        model: String,
        embedding_model: String,
        whisper_model: String,
    ) -> Self {
        Self {
            http: reqwest::Client::builder()
                .timeout(std::time::Duration::from_mins(2))
                .build()
                .expect("failed to create HTTP client"),
            base_url,
            api_key,
            model,
            embedding_model,
            whisper_model,
        }
    }
}

#[async_trait]
impl AiProvider for OpenAiProvider {
    #[instrument(skip(self, system, messages), fields(model = %self.model))]
    async fn chat(&self, system: &str, messages: &[ChatMessage]) -> Result<String, AiError> {
        let mut api_messages: Vec<serde_json::Value> =
            vec![serde_json::json!({"role": "system", "content": system})];
        for msg in messages {
            api_messages.push(serde_json::json!({"role": msg.role, "content": msg.content}));
        }

        let body = serde_json::json!({
            "model": self.model,
            "messages": api_messages,
        });

        let resp = self
            .http
            .post(format!("{}/chat/completions", self.base_url))
            .header("Authorization", format!("Bearer {}", self.api_key))
            .json(&body)
            .send()
            .await?;

        let status = resp.status();
        let text = resp.text().await?;

        if !status.is_success() {
            return Err(AiError::Api {
                status: status.as_u16(),
                body: text,
            });
        }

        let json: serde_json::Value = serde_json::from_str(&text)?;
        let content = json["choices"][0]["message"]["content"]
            .as_str()
            .ok_or_else(|| AiError::Api {
                status: status.as_u16(),
                body: "unexpected response format".into(),
            })?;

        Ok(content.to_string())
    }

    #[instrument(skip(self), fields(model = %self.embedding_model))]
    async fn embed(&self, text: &str) -> Result<Vec<f32>, AiError> {
        let body = serde_json::json!({
            "model": self.embedding_model,
            "input": text,
        });

        let resp = self
            .http
            .post(format!("{}/embeddings", self.base_url))
            .header("Authorization", format!("Bearer {}", self.api_key))
            .json(&body)
            .send()
            .await?;

        let status = resp.status();
        let resp_text = resp.text().await?;

        if !status.is_success() {
            return Err(AiError::Api {
                status: status.as_u16(),
                body: resp_text,
            });
        }

        let json: serde_json::Value = serde_json::from_str(&resp_text)?;
        #[expect(clippy::cast_possible_truncation)]
        let embedding: Vec<f32> = json["data"][0]["embedding"]
            .as_array()
            .ok_or_else(|| AiError::Api {
                status: status.as_u16(),
                body: "no embedding in response".into(),
            })?
            .iter()
            .map(|v| v.as_f64().unwrap_or(0.0) as f32)
            .collect();

        Ok(embedding)
    }

    #[instrument(skip(self, audio_data), fields(model = %self.whisper_model))]
    async fn transcribe(&self, audio_data: &[u8], mime_type: &str) -> Result<String, AiError> {
        let ext = mime_to_ext(mime_type);
        let part = reqwest::multipart::Part::bytes(audio_data.to_vec())
            .file_name(format!("audio.{ext}"))
            .mime_str(mime_type)
            .map_err(|e| AiError::Api {
                status: 0,
                body: e.to_string(),
            })?;

        let form = reqwest::multipart::Form::new()
            .part("file", part)
            .text("model", self.whisper_model.clone());

        let resp = self
            .http
            .post(format!("{}/audio/transcriptions", self.base_url))
            .header("Authorization", format!("Bearer {}", self.api_key))
            .multipart(form)
            .send()
            .await?;

        let status = resp.status();
        let text = resp.text().await?;

        if !status.is_success() {
            return Err(AiError::Api {
                status: status.as_u16(),
                body: text,
            });
        }

        let json: serde_json::Value = serde_json::from_str(&text)?;
        let transcription = json["text"].as_str().unwrap_or_default().to_string();
        Ok(transcription)
    }
}

fn mime_to_ext(mime: &str) -> &'static str {
    match mime {
        "audio/wav" | "audio/wave" => "wav",
        "audio/mp3" | "audio/mpeg" => "mp3",
        "audio/mp4" => "mp4",
        "audio/x-m4a" => "m4a",
        "audio/flac" => "flac",
        _ => "webm",
    }
}