kernex-core 0.5.0

Core types, traits, config, and error handling for Kernex
Documentation
//! Request and response types for the Kernex runtime.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

/// An incoming request to the runtime.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Request {
    pub id: Uuid,
    pub sender_id: String,
    #[serde(default)]
    pub sender_name: Option<String>,
    pub text: String,
    pub timestamp: DateTime<Utc>,
    #[serde(default)]
    pub reply_to: Option<Uuid>,
    #[serde(default)]
    pub attachments: Vec<Attachment>,
    #[serde(default)]
    pub source: Option<String>,
}

impl Request {
    /// Create a simple text request.
    pub fn text(sender_id: &str, text: &str) -> Self {
        Self {
            id: Uuid::new_v4(),
            sender_id: sender_id.to_string(),
            sender_name: None,
            text: text.to_string(),
            timestamp: Utc::now(),
            reply_to: None,
            attachments: Vec::new(),
            source: None,
        }
    }
}

/// A response from an AI provider.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Response {
    pub text: String,
    #[serde(default)]
    pub metadata: CompletionMeta,
}

/// Metadata about how a completion was generated.
///
/// The token-count fields are all optional because not every provider reports
/// every dimension. `tokens_used` remains the historical "all in" total; the
/// new `input_tokens` / `output_tokens` / `cache_read_tokens` /
/// `cache_creation_tokens` fields give the breakdown for providers that
/// expose it (Anthropic, DeepSeek, OpenAI prompt-cache responses).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CompletionMeta {
    pub provider_used: String,
    /// Total tokens billed for this completion. Sum of input + output for
    /// providers that report a single number; equal to
    /// `input_tokens + output_tokens + cache_read_tokens + cache_creation_tokens`
    /// for providers that report the breakdown.
    #[serde(default)]
    pub tokens_used: Option<u64>,
    pub processing_time_ms: u64,
    #[serde(default)]
    pub model: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub session_id: Option<String>,
    /// Input tokens that were *not* served from the prompt cache. When the
    /// provider reports `cache_read_tokens`, the actual cache hits are
    /// counted there; this field counts only the fresh, billed input.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub input_tokens: Option<u64>,
    /// Output tokens generated by the model.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub output_tokens: Option<u64>,
    /// Tokens served from the provider's prompt cache. Anthropic returns
    /// this as `usage.cache_read_input_tokens`; DeepSeek and OpenAI use
    /// `prompt_tokens_details.cached_tokens` on responses where caching
    /// is enabled.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cache_read_tokens: Option<u64>,
    /// Tokens written into the provider's prompt cache (cache miss that
    /// populated the cache for a subsequent request). Anthropic returns
    /// this as `usage.cache_creation_input_tokens`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cache_creation_tokens: Option<u64>,
}

/// A file attachment on a request.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Attachment {
    pub file_type: AttachmentType,
    #[serde(default)]
    pub url: Option<String>,
    #[serde(default)]
    pub data: Option<Vec<u8>>,
    #[serde(default)]
    pub filename: Option<String>,
}

/// Supported attachment types.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum AttachmentType {
    Image,
    Document,
    Audio,
    Video,
    Other,
}

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

    #[test]
    fn test_request_text_constructor() {
        let req = Request::text("user-1", "hello");
        assert_eq!(req.sender_id, "user-1");
        assert_eq!(req.text, "hello");
        assert!(req.attachments.is_empty());
    }

    #[test]
    fn test_response_default() {
        let resp = Response::default();
        assert!(resp.text.is_empty());
        assert!(resp.metadata.provider_used.is_empty());
    }

    #[test]
    fn test_request_serde_round_trip() {
        let req = Request::text("user-1", "test message");
        let json = serde_json::to_string(&req).unwrap();
        let deserialized: Request = serde_json::from_str(&json).unwrap();
        assert_eq!(deserialized.sender_id, "user-1");
        assert_eq!(deserialized.text, "test message");
    }

    #[test]
    fn test_completion_meta_session_id_skipped_when_none() {
        let meta = CompletionMeta::default();
        let json = serde_json::to_string(&meta).unwrap();
        assert!(!json.contains("session_id"));
    }
}