mhome-core-api 1.5.0

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

pub const VERSION: &str = crate::node::V1;
pub const NODE_TYPE: &str = "llm";
pub const RUNTIME_TARGET_PREFIX: &str = "/llm/app/";
pub const RUNTIME_STATUS: &str = "runtime/status";
pub const RUNTIME_RETRY: &str = "runtime/retry";
pub const MODEL_LIST: &str = "model/list";
pub const MODEL_DOWNLOAD: &str = "model/download";
pub const MODEL_DOWNLOAD_STATUS: &str = "model/download/status";
pub const MODEL_DOWNLOAD_CANCEL: &str = "model/download/cancel";
pub const MODEL_IMPORT_START: &str = "model/import/start";
pub const MODEL_IMPORT_STATUS: &str = "model/import/status";
pub const MODEL_IMPORT_FINALIZE: &str = "model/import/finalize";
pub const MODEL_IMPORT_CANCEL: &str = "model/import/cancel";
pub const MODEL_IMPORT_UPLOAD_PATH_PREFIX: &str = "/llm/model/import/upload";
pub const MODEL_IMPORT_OFFSET_HEADER: &str = "x-upload-offset";
pub const MODEL_IMPORT_CHUNK_SHA256_HEADER: &str = "x-chunk-sha256";
pub const MODEL_DELETE: &str = "model/delete";
pub const TTS_SYNTHESIZE_TARGET: &str = "/llm/tts";

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct EmptyRequest {}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ModelDownloadRequest {
    pub model_id: String,
    pub idempotency_key: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ModelRequest {
    pub model_id: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ModelImportStartRequest {
    pub display_name: String,
    pub file_name: String,
    pub artifact_sha256: String,
    pub size_bytes: u64,
    pub idempotency_key: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ModelImportOperationRequest {
    pub operation_id: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum ModelImportState {
    Uploading,
    Importing,
    Verifying,
    Available,
    Cancelling,
    Cancelled,
    Error,
}

impl ModelImportState {
    pub fn is_terminal(&self) -> bool {
        matches!(self, Self::Available | Self::Cancelled | Self::Error)
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelImportError {
    pub code: String,
    pub detail: String,
    pub retryable: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelImportSnapshot {
    pub operation_id: String,
    pub model_id: String,
    pub display_name: String,
    pub artifact_sha256: String,
    pub size_bytes: u64,
    pub received_bytes: u64,
    pub state: ModelImportState,
    pub status: String,
    pub started_at_epoch_ms: i64,
    pub updated_at_epoch_ms: i64,
    pub cancellable: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub error: Option<ModelImportError>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelImportUploadGrant {
    pub bearer_token: String,
    pub chunk_size_bytes: u64,
    pub expires_at_epoch_ms: i64,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelImportStartResponse {
    pub ok: bool,
    pub started: bool,
    pub operation: ModelImportSnapshot,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub upload: Option<ModelImportUploadGrant>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub reason: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub detail: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelImportStatusResponse {
    pub ok: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub operation: Option<ModelImportSnapshot>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub reason: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub detail: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelImportChunkResponse {
    pub ok: bool,
    pub operation_id: String,
    pub received_bytes: u64,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub reason: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub detail: Option<String>,
}

/// Provider-neutral speech synthesis request sent directly to an LLM Node.
///
/// `instructions`, `voice`, and `format` are optional preferences. A backend
/// may ignore a preference it cannot implement; `text` is the only semantic
/// input every TTS backend must support. `mode` deliberately does not cross
/// this boundary because it is a MeowCore routing concern.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct TtsSynthesizeRequest {
    pub text: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub model: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub instructions: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub voice: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub format: Option<String>,
}

/// Speech synthesis response returned directly by an LLM Node.
///
/// Version 1 transports short audio inline as base64. The effective model,
/// voice, format, and MIME type are returned so callers need not infer which
/// optional request preferences a backend applied.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct TtsSynthesizeResponse {
    pub ok: bool,
    pub model: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub audio_base64: Option<String>,
    pub mime_type: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub voice: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub format: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub reason: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub detail: Option<String>,
}

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

    #[test]
    fn import_start_request_is_strict_and_uses_public_names() {
        let value = serde_json::to_value(ModelImportStartRequest {
            display_name: "Local model".to_string(),
            file_name: "model.gguf".to_string(),
            artifact_sha256: "a".repeat(64),
            size_bytes: 1024,
            idempotency_key: "import-1".to_string(),
        })
        .expect("serialize import request");
        assert_eq!(value["artifactSha256"], "a".repeat(64));
        assert!(value.get("sourcePath").is_none());
        assert!(value.get("scopeId").is_none());

        let mut invalid = value;
        invalid["sourcePath"] = serde_json::json!("/private/model.gguf");
        assert!(serde_json::from_value::<ModelImportStartRequest>(invalid).is_err());
    }

    #[test]
    fn import_terminal_states_are_explicit() {
        assert!(ModelImportState::Available.is_terminal());
        assert!(ModelImportState::Cancelled.is_terminal());
        assert!(ModelImportState::Error.is_terminal());
        assert!(!ModelImportState::Uploading.is_terminal());
    }

    #[test]
    fn tts_contract_keeps_routing_mode_out_and_instructions_optional() {
        let request = TtsSynthesizeRequest {
            text: "Hello".to_string(),
            model: Some("kokoro:v1.0-en".to_string()),
            instructions: Some("Speak warmly".to_string()),
            voice: None,
            format: Some("wav".to_string()),
        };
        let value = serde_json::to_value(&request).expect("serialize TTS request");
        assert_eq!(value["instructions"], "Speak warmly");
        assert!(value.get("mode").is_none());

        let minimal: TtsSynthesizeRequest =
            serde_json::from_value(serde_json::json!({"text": "Hello"}))
                .expect("deserialize minimal TTS request");
        assert_eq!(minimal.text, "Hello");
        assert!(minimal.instructions.is_none());
    }
}