mhome-core-api 1.10.0

Shared wire types and service contracts for the mHome core
Documentation
use serde::{Deserialize, Serialize};
use serde_json::Value;

pub const COMPLETE_TARGET: &str = "/llm/complete";

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub struct Image {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub url: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub base64: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub mime_type: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub created_at: Option<i64>,
}

impl Image {
    #[must_use]
    pub fn from_base64(base64: impl Into<String>, mime_type: impl Into<String>) -> Self {
        Self {
            base64: Some(base64.into()),
            mime_type: Some(mime_type.into()),
            ..Self::default()
        }
    }

    #[must_use]
    pub fn from_url(url: impl Into<String>) -> Self {
        Self {
            url: Some(url.into()),
            ..Self::default()
        }
    }

    #[must_use]
    pub fn resolved_mime_type(&self) -> Option<String> {
        let mime_type = self
            .mime_type
            .as_ref()
            .map(|value| value.trim())
            .filter(|value| !value.is_empty())?;
        let normalized = mime_type.to_ascii_lowercase();
        Some(match normalized.as_str() {
            "image/jpg" => "image/jpeg".to_string(),
            _ => normalized,
        })
    }

    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.url
            .as_ref()
            .is_none_or(|value| value.trim().is_empty())
            && self
                .base64
                .as_ref()
                .is_none_or(|value| value.trim().is_empty())
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ChatToolCallFunction {
    pub name: String,
    pub arguments: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ChatToolCall {
    pub id: String,
    #[serde(rename = "type")]
    pub call_type: String,
    pub function: ChatToolCallFunction,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct ChatMessage {
    pub role: String,
    pub content: String,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub images: Vec<Image>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub tool_calls: Vec<ChatToolCall>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tool_call_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct LlmGenerationOptions {
    #[serde(default)]
    pub temperature: Option<f32>,
    #[serde(default)]
    pub max_tokens: Option<u32>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct LlmCompleteRequest {
    #[serde(default)]
    pub use_case: Option<String>,
    #[serde(default)]
    pub mode: Option<String>,
    pub messages: Vec<ChatMessage>,
    #[serde(default)]
    pub tools: Option<Value>,
    #[serde(default)]
    pub provider: Option<Value>,
    #[serde(default)]
    pub response_format: Option<Value>,
    #[serde(default)]
    pub options: LlmGenerationOptions,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderToolCall {
    pub id: String,
    pub name: String,
    pub arguments_json: String,
}

#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct LlmTokenUsage {
    pub input_tokens: u64,
    pub output_tokens: u64,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cached_input_tokens: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub reasoning_output_tokens: Option<u64>,
}

impl LlmTokenUsage {
    #[must_use]
    pub fn total_tokens(self) -> u64 {
        self.input_tokens.saturating_add(self.output_tokens)
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct LlmRouteInfo {
    pub provider: String,
    pub model: String,
    pub mode: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct LlmCompleteResponse {
    pub content: String,
    #[serde(default)]
    pub tool_calls: Vec<ProviderToolCall>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub usage: Option<LlmTokenUsage>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub route: Option<LlmRouteInfo>,
}

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

    #[test]
    fn request_rejects_removed_top_level_generation_options() {
        assert!(
            serde_json::from_value::<LlmCompleteRequest>(serde_json::json!({
                "messages": [],
                "options": {},
                "temperature": 0.2
            }))
            .is_err()
        );
    }

    #[test]
    fn response_includes_normalized_usage() {
        let response = LlmCompleteResponse {
            usage: Some(LlmTokenUsage {
                input_tokens: 2,
                output_tokens: 3,
                ..LlmTokenUsage::default()
            }),
            ..LlmCompleteResponse::default()
        };
        assert_eq!(response.usage.unwrap().total_tokens(), 5);
    }
}