From 24494dc6e6e63dba24c46b38c317bdefd0aeea00 Mon Sep 17 00:00:00 2001 From: ashum9 Date: Wed, 24 Jun 2026 13:42:57 +0530 Subject: [PATCH 01/75] add structured telemetry event definitions --- Cargo.toml | 1 + mofa-observability/Cargo.toml | 10 + mofa-observability/src/events.rs | 553 +++++++++++++++++++++++++++++++ mofa-observability/src/lib.rs | 1 + 4 files changed, 565 insertions(+) create mode 100644 mofa-observability/Cargo.toml create mode 100644 mofa-observability/src/events.rs create mode 100644 mofa-observability/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 6e7adfc..49103f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "mofa-engine-core", "mofa-engine-sdk", "mofa-engine-app", + "mofa-observability", ] [workspace.package] diff --git a/mofa-observability/Cargo.toml b/mofa-observability/Cargo.toml new file mode 100644 index 0000000..b1402f5 --- /dev/null +++ b/mofa-observability/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "mofa-observability" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } diff --git a/mofa-observability/src/events.rs b/mofa-observability/src/events.rs new file mode 100644 index 0000000..094e134 --- /dev/null +++ b/mofa-observability/src/events.rs @@ -0,0 +1,553 @@ +//! # MoFA Engine — Event Type Definitions +//! +//! Structured event types for the observability subsystem. +//! Every interesting thing the engine does is captured as one of these events. +//! +//! Design principles: +//! - Zero side effects: events are data, not actions. +//! - Privacy: no prompt text, file contents, API keys, or user-identifying info. Ever. +//! - Bounded enums: capability, reason, source, status use enums, not free-form strings. +//! +//! Reference: observability_plan.md §3 + +use serde::{Deserialize, Serialize}; +use std::time::{SystemTime, UNIX_EPOCH}; + +// ─── Shared Enums ──────────────────────────────────────────────────────────── + +/// What the caller asked for. Bounded enum — no free-form strings. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Capability { + Chat, + Tts, + Asr, + Vlm, + ImageGen, + VideoGen, + Embedding, +} + +impl std::fmt::Display for Capability { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Capability::Chat => write!(f, "chat"), + Capability::Tts => write!(f, "tts"), + Capability::Asr => write!(f, "asr"), + Capability::Vlm => write!(f, "vlm"), + Capability::ImageGen => write!(f, "image_gen"), + Capability::VideoGen => write!(f, "video_gen"), + Capability::Embedding => write!(f, "embedding"), + } + } +} + +/// Why a model was unloaded. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UnloadReason { + /// Ollama's keep_alive timer expired. + IdleTimeout, + /// Memory pressure forced eviction. + Eviction, + /// Caller explicitly requested unload. + Explicit, +} + +impl std::fmt::Display for UnloadReason { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + UnloadReason::IdleTimeout => write!(f, "idle_timeout"), + UnloadReason::Eviction => write!(f, "eviction"), + UnloadReason::Explicit => write!(f, "explicit"), + } + } +} + +/// Where the Preflight prediction came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SignalSource { + /// App explicitly said what comes next. Confidence = 1.0. + Hint, + /// Learned from past session transitions. Confidence < 1.0. + History, +} + +impl std::fmt::Display for SignalSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SignalSource::Hint => write!(f, "hint"), + SignalSource::History => write!(f, "history"), + } + } +} + +// ─── Event Envelope ────────────────────────────────────────────────────────── + +/// Every event carries this standard envelope. +/// Reference: observability_plan.md §3.1 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EventEnvelope { + /// Unix timestamp in milliseconds. + pub timestamp_ms: u64, + /// Correlation ID — ties events from the same request together. + pub request_id: Option, + /// App or user session (if provided by caller). + pub session_id: Option, + /// The event payload. + pub event: EngineEvent, +} + +impl EventEnvelope { + /// Create an envelope with the current timestamp. + pub fn now(event: EngineEvent) -> Self { + let timestamp_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + Self { + timestamp_ms, + request_id: None, + session_id: None, + event, + } + } + + /// Attach a request ID. + pub fn with_request_id(mut self, id: impl Into) -> Self { + self.request_id = Some(id.into()); + self + } + + /// Attach a session ID. + pub fn with_session_id(mut self, id: impl Into) -> Self { + self.session_id = Some(id.into()); + self + } +} + +// ─── Engine Event (discriminated union) ────────────────────────────────────── + +/// All possible engine events. The collector pattern-matches on this. +/// Reference: observability_plan.md §3.2 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum EngineEvent { + // ── Request Events ─────────────────────────────────────────────────── + RequestReceived(RequestReceived), + RoutingDecision(RoutingDecision), + RequestCompleted(RequestCompleted), + + // ── Model Lifecycle Events ─────────────────────────────────────────── + ModelLoaded(ModelLoaded), + ModelUnloaded(ModelUnloaded), + + // ── Memory Events ──────────────────────────────────────────────────── + EvictionTriggered(EvictionTriggered), + + // ── Preflight Events ───────────────────────────────────────────────── + PreflightSignal(PreflightSignal), + PreflightHit(PreflightHit), + PreflightMiss(PreflightMiss), + + // ── Infrastructure Events ──────────────────────────────────────────── + ProviderDiscovered(ProviderDiscovered), + FailoverTriggered(FailoverTriggered), +} + +// ─── Request Events ────────────────────────────────────────────────────────── + +/// Emitted when the engine accepts a new inference request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RequestReceived { + /// What the caller asked for. + pub capability: Capability, + /// Specific model name if the caller used named routing. + pub model: Option, + /// The `hint_next` value if provided. + pub hint: Option, +} + +/// Emitted after the router selects a model. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RoutingDecision { + /// Requested capability. + pub capability: Capability, + /// How many models were eligible. + pub candidates_count: u32, + /// The model chosen. + pub selected_model: String, + /// The backend hosting that model. + pub selected_backend: String, + /// Whether this was a fallback selection. + pub is_fallback: bool, + /// Why this model was chosen (e.g., "local_first", "only_candidate", "fallback_after_failure"). + pub reason: String, +} + +/// Emitted when inference finishes (success or failure). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RequestCompleted { + /// Which model ran. + pub model_id: String, + /// Which backend. + pub backend: String, + /// What capability was served. + pub capability: Capability, + /// Total request time in milliseconds. + pub duration_ms: u64, + /// Time to first token in milliseconds (for streaming). + pub ttft_ms: Option, + /// Input token count (text models). + pub tokens_in: Option, + /// Output token count (text models). + pub tokens_out: Option, + /// Was the model already loaded when the request arrived? + /// Populated by the engine. Enables warm vs cold latency split. + pub model_was_hot: Option, + /// Whether it succeeded. + pub success: bool, + /// Error code if failed. + pub error_code: Option, +} + +// ─── Model Lifecycle Events ────────────────────────────────────────────────── + +/// A model finished loading into memory. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelLoaded { + /// Which model. + pub model_id: String, + /// Which backend. + pub backend: String, + /// What capability. + pub capability: Capability, + /// How long the load took in milliseconds. + pub load_duration_ms: u64, + /// How much memory it occupies in bytes. + pub memory_bytes: u64, +} + +/// A model was removed from memory. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelUnloaded { + /// Which model. + pub model_id: String, + /// Why it was unloaded. + pub reason: UnloadReason, + /// How much memory was released in bytes. + pub memory_freed_bytes: u64, +} + +// ─── Memory Events ─────────────────────────────────────────────────────────── + +/// Memory pressure forced a model eviction. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EvictionTriggered { + /// Which model was evicted. + pub evicted_model: String, + /// Used memory before eviction in bytes. + pub memory_before_bytes: u64, + /// Used memory after eviction in bytes. + pub memory_after_bytes: u64, + /// Total memory budget in bytes. + pub budget_bytes: u64, +} + +// ─── Preflight Events ──────────────────────────────────────────────────────── + +/// Preflight made a prediction. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PreflightSignal { + /// What Preflight thinks comes next. + pub predicted_capability: Capability, + /// 0.0–1.0 (hints are always 1.0). + pub confidence: f64, + /// Where the prediction came from. + pub source: SignalSource, +} + +/// The prediction was correct — the predicted model was used next. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PreflightHit { + /// What was predicted. + pub predicted_capability: Capability, + /// How much load time was saved in milliseconds. + /// Computed by engine/scheduler, not Preflight. + pub cold_start_avoided_ms: u64, +} + +/// The prediction was wrong — a different capability was requested. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PreflightMiss { + /// What was predicted. + pub predicted_capability: Capability, + /// What actually happened. + pub actual_capability: Capability, +} + +// ─── Infrastructure Events ─────────────────────────────────────────────────── + +/// A backend was found during discovery. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProviderDiscovered { + /// Backend name. + pub provider_name: String, + /// How many models were discovered. + pub models_found: u32, + /// What capabilities are available. + pub capabilities: Vec, +} + +/// Primary model failed, switching to fallback. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FailoverTriggered { + /// The model that failed. + pub failed_model: String, + /// Its backend. + pub failed_backend: String, + /// The replacement model. + pub fallback_model: String, + /// Its backend. + pub fallback_backend: String, +} + +// ─── Privacy Contract ──────────────────────────────────────────────────────── +// +// These fields are NEVER present in any event: +// - Prompt text or generated text +// - File contents or file paths +// - API keys, tokens, or credentials +// - User-identifying information +// +// All categorical fields use bounded enums, not free-form strings: +// - capability → Capability enum (7 variants) +// - reason → UnloadReason enum (3 variants) +// - source → SignalSource enum (2 variants) +// - status → bool (success/failure) +// +// Reference: observability_plan.md §3.3 + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_capability_display() { + assert_eq!(Capability::Chat.to_string(), "chat"); + assert_eq!(Capability::Tts.to_string(), "tts"); + assert_eq!(Capability::ImageGen.to_string(), "image_gen"); + } + + #[test] + fn test_unload_reason_display() { + assert_eq!(UnloadReason::IdleTimeout.to_string(), "idle_timeout"); + assert_eq!(UnloadReason::Eviction.to_string(), "eviction"); + assert_eq!(UnloadReason::Explicit.to_string(), "explicit"); + } + + #[test] + fn test_signal_source_display() { + assert_eq!(SignalSource::Hint.to_string(), "hint"); + assert_eq!(SignalSource::History.to_string(), "history"); + } + + #[test] + fn test_envelope_creation() { + let event = EngineEvent::RequestReceived(RequestReceived { + capability: Capability::Chat, + model: None, + hint: Some("tts".into()), + }); + + let envelope = EventEnvelope::now(event) + .with_request_id("req-001") + .with_session_id("sess-001"); + + assert!(envelope.timestamp_ms > 0); + assert_eq!(envelope.request_id.as_deref(), Some("req-001")); + assert_eq!(envelope.session_id.as_deref(), Some("sess-001")); + } + + #[test] + fn test_request_completed_with_model_was_hot() { + let event = RequestCompleted { + model_id: "qwen2.5:7b".into(), + backend: "ollama".into(), + capability: Capability::Chat, + duration_ms: 15000, + ttft_ms: Some(115), + tokens_in: Some(50), + tokens_out: Some(277), + model_was_hot: Some(true), + success: true, + error_code: None, + }; + + // Warm TTFT should be ~0.1s (Phase 0 measured 0.115s) + assert_eq!(event.model_was_hot, Some(true)); + assert!(event.ttft_ms.unwrap() < 200); // warm is fast + } + + #[test] + fn test_preflight_signal_hint() { + let signal = PreflightSignal { + predicted_capability: Capability::Tts, + confidence: 1.0, + source: SignalSource::Hint, + }; + + // Phase 0 proved: hints have accuracy 1.0 + assert_eq!(signal.confidence, 1.0); + assert_eq!(signal.source, SignalSource::Hint); + } + + #[test] + fn test_preflight_hit() { + let hit = PreflightHit { + predicted_capability: Capability::Tts, + // Phase 0 measured: Kokoro TTS saving = 2404ms on Mac M4 + cold_start_avoided_ms: 2404, + }; + + assert_eq!(hit.predicted_capability, Capability::Tts); + assert!(hit.cold_start_avoided_ms > 0); + } + + #[test] + fn test_preflight_miss() { + let miss = PreflightMiss { + predicted_capability: Capability::Tts, + actual_capability: Capability::Asr, + }; + + assert_ne!(miss.predicted_capability, miss.actual_capability); + } + + #[test] + fn test_model_loaded_event() { + let loaded = ModelLoaded { + model_id: "qwen2.5:7b".into(), + backend: "ollama".into(), + capability: Capability::Chat, + // Phase 0 measured: 1532ms cold load on Mac M4 + load_duration_ms: 1532, + // Phase 0 measured: 4.7 GB + memory_bytes: 4_700_000_000, + }; + + assert!(loaded.load_duration_ms > 0); + assert!(loaded.memory_bytes > 0); + } + + #[test] + fn test_eviction_event() { + let eviction = EvictionTriggered { + evicted_model: "gemma3:4b".into(), + memory_before_bytes: 8_000_000_000, + memory_after_bytes: 4_700_000_000, + budget_bytes: 11_000_000_000, + }; + + assert!(eviction.memory_after_bytes < eviction.memory_before_bytes); + assert!(eviction.memory_after_bytes < eviction.budget_bytes); + } + + #[test] + fn test_event_serialization_roundtrip() { + let original = EventEnvelope::now(EngineEvent::PreflightSignal(PreflightSignal { + predicted_capability: Capability::Tts, + confidence: 1.0, + source: SignalSource::Hint, + })) + .with_request_id("req-042"); + + let json = serde_json::to_string(&original).expect("serialize"); + let restored: EventEnvelope = serde_json::from_str(&json).expect("deserialize"); + + assert_eq!(original.timestamp_ms, restored.timestamp_ms); + assert_eq!(original.request_id, restored.request_id); + } + + #[test] + fn test_all_event_variants_serialize() { + // Every variant must serialize without panic. + let events: Vec = vec![ + EngineEvent::RequestReceived(RequestReceived { + capability: Capability::Chat, + model: None, + hint: None, + }), + EngineEvent::RoutingDecision(RoutingDecision { + capability: Capability::Chat, + candidates_count: 2, + selected_model: "qwen2.5:7b".into(), + selected_backend: "ollama".into(), + is_fallback: false, + reason: "local_first".into(), + }), + EngineEvent::RequestCompleted(RequestCompleted { + model_id: "qwen2.5:7b".into(), + backend: "ollama".into(), + capability: Capability::Chat, + duration_ms: 15000, + ttft_ms: Some(115), + tokens_in: Some(50), + tokens_out: Some(277), + model_was_hot: Some(true), + success: true, + error_code: None, + }), + EngineEvent::ModelLoaded(ModelLoaded { + model_id: "qwen2.5:7b".into(), + backend: "ollama".into(), + capability: Capability::Chat, + load_duration_ms: 1532, + memory_bytes: 4_700_000_000, + }), + EngineEvent::ModelUnloaded(ModelUnloaded { + model_id: "gemma3:4b".into(), + reason: UnloadReason::IdleTimeout, + memory_freed_bytes: 3_300_000_000, + }), + EngineEvent::EvictionTriggered(EvictionTriggered { + evicted_model: "gemma3:4b".into(), + memory_before_bytes: 8_000_000_000, + memory_after_bytes: 4_700_000_000, + budget_bytes: 11_000_000_000, + }), + EngineEvent::PreflightSignal(PreflightSignal { + predicted_capability: Capability::Tts, + confidence: 1.0, + source: SignalSource::Hint, + }), + EngineEvent::PreflightHit(PreflightHit { + predicted_capability: Capability::Tts, + cold_start_avoided_ms: 2404, + }), + EngineEvent::PreflightMiss(PreflightMiss { + predicted_capability: Capability::Tts, + actual_capability: Capability::Chat, + }), + EngineEvent::ProviderDiscovered(ProviderDiscovered { + provider_name: "ollama".into(), + models_found: 3, + capabilities: vec![Capability::Chat, Capability::Vlm], + }), + EngineEvent::FailoverTriggered(FailoverTriggered { + failed_model: "qwen2.5:7b".into(), + failed_backend: "ollama".into(), + fallback_model: "gemma3:4b".into(), + fallback_backend: "ollama".into(), + }), + ]; + + for event in events { + let envelope = EventEnvelope::now(event); + let json = serde_json::to_string(&envelope); + assert!(json.is_ok(), "Failed to serialize: {:?}", envelope); + } + } +} diff --git a/mofa-observability/src/lib.rs b/mofa-observability/src/lib.rs new file mode 100644 index 0000000..a9970c2 --- /dev/null +++ b/mofa-observability/src/lib.rs @@ -0,0 +1 @@ +pub mod events; From 6d3e667ccf0f9febb48e8114e82eb0e0d438ac8d Mon Sep 17 00:00:00 2001 From: ashum9 Date: Wed, 24 Jun 2026 13:46:30 +0530 Subject: [PATCH 02/75] implement async metrics collector --- mofa-observability/src/collector.rs | 1016 +++++++++++++++++++++++++++ mofa-observability/src/lib.rs | 1 + 2 files changed, 1017 insertions(+) create mode 100644 mofa-observability/src/collector.rs diff --git a/mofa-observability/src/collector.rs b/mofa-observability/src/collector.rs new file mode 100644 index 0000000..1bc8e72 --- /dev/null +++ b/mofa-observability/src/collector.rs @@ -0,0 +1,1016 @@ +//! # MoFA Engine — Metrics Collector +//! +//! Receives engine events from a channel and updates in-memory metric state. +//! The Prometheus renderer reads this state when scraped. +//! +//! Design: +//! - Single writer (collector task), multiple readers (Prometheus scrapes). +//! - No blocking I/O. No disk. Just in-memory atomics behind a RwLock. +//! - Bounded event channel with drop-oldest backpressure. +//! +//! Reference: observability_plan.md §4 + +use crate::events::*; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::{mpsc, RwLock}; + +// ─── Labels ────────────────────────────────────────────────────────────────── + +/// Metric labels. Sorted by key for consistent ordering. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Labels(Vec<(String, String)>); + +impl Labels { + pub fn new() -> Self { + Self(Vec::new()) + } + + pub fn add(mut self, key: impl Into, value: impl Into) -> Self { + self.0.push((key.into(), value.into())); + self.0.sort_by(|a, b| a.0.cmp(&b.0)); + self + } + + pub fn pairs(&self) -> &[(String, String)] { + &self.0 + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl Default for Labels { + fn default() -> Self { + Self::new() + } +} + +// ─── Metric Types ──────────────────────────────────────────────────────────── + +/// A single histogram's accumulated state. +#[derive(Debug, Clone)] +pub struct HistogramValue { + /// (upper_bound, cumulative_count) — sorted ascending. + pub buckets: Vec<(f64, u64)>, + pub sum: f64, + pub count: u64, +} + +impl HistogramValue { + fn new(boundaries: &[f64]) -> Self { + let buckets = boundaries.iter().map(|&b| (b, 0u64)).collect(); + Self { + buckets, + sum: 0.0, + count: 0, + } + } + + fn observe(&mut self, value: f64) { + self.sum += value; + self.count += 1; + for bucket in &mut self.buckets { + if value <= bucket.0 { + bucket.1 += 1; + } + } + } +} + +/// A family of labeled counters (e.g., mofa_requests_total with labels). +#[derive(Debug, Clone)] +pub struct CounterFamily { + pub name: &'static str, + pub help: &'static str, + pub values: HashMap, +} + +impl CounterFamily { + fn new(name: &'static str, help: &'static str) -> Self { + Self { + name, + help, + values: HashMap::new(), + } + } + + fn inc(&mut self, labels: Labels) { + *self.values.entry(labels).or_insert(0) += 1; + } + + fn inc_by(&mut self, labels: Labels, n: u64) { + *self.values.entry(labels).or_insert(0) += n; + } +} + +/// A family of labeled histograms. +#[derive(Debug, Clone)] +pub struct HistogramFamily { + pub name: &'static str, + pub help: &'static str, + pub bucket_boundaries: Vec, + pub values: HashMap, +} + +impl HistogramFamily { + fn new(name: &'static str, help: &'static str, boundaries: Vec) -> Self { + Self { + name, + help, + bucket_boundaries: boundaries, + values: HashMap::new(), + } + } + + fn observe(&mut self, labels: Labels, value: f64) { + self.values + .entry(labels) + .or_insert_with(|| HistogramValue::new(&self.bucket_boundaries)) + .observe(value); + } +} + +/// A family of labeled gauges. +#[derive(Debug, Clone)] +pub struct GaugeFamily { + pub name: &'static str, + pub help: &'static str, + pub values: HashMap, +} + +impl GaugeFamily { + fn new(name: &'static str, help: &'static str) -> Self { + Self { + name, + help, + values: HashMap::new(), + } + } + + fn set(&mut self, labels: Labels, value: f64) { + self.values.insert(labels, value); + } + + fn inc(&mut self, labels: Labels) { + *self.values.entry(labels).or_insert(0.0) += 1.0; + } + + fn dec(&mut self, labels: Labels) { + *self.values.entry(labels).or_insert(0.0) -= 1.0; + } + + fn add(&mut self, labels: Labels, n: f64) { + *self.values.entry(labels).or_insert(0.0) += n; + } + + fn sub(&mut self, labels: Labels, n: f64) { + *self.values.entry(labels).or_insert(0.0) -= n; + } +} + +// ─── Metrics State ─────────────────────────────────────────────────────────── + +/// All metric state. One instance per engine. +/// The collector writes, the Prometheus renderer reads. +/// +/// Reference: observability_plan.md §4.2–4.4 +#[derive(Debug, Clone)] +pub struct MetricsState { + // ── Counters (§4.2) ────────────────────────────────────────────────── + pub requests_total: CounterFamily, + pub model_loads_total: CounterFamily, + pub model_unloads_total: CounterFamily, + pub failovers_total: CounterFamily, + pub evictions_total: CounterFamily, + pub preflight_predictions_total: CounterFamily, + pub preflight_hits_total: CounterFamily, + pub preflight_misses_total: CounterFamily, + pub tokens_input_total: CounterFamily, + pub tokens_output_total: CounterFamily, + pub events_dropped_total: CounterFamily, + + // ── Histograms (§4.3) ──────────────────────────────────────────────── + pub request_duration_seconds: HistogramFamily, + pub model_load_seconds: HistogramFamily, + pub ttft_seconds: HistogramFamily, + + // ── Gauges (§4.4) ──────────────────────────────────────────────────── + pub memory_used_bytes: GaugeFamily, + pub memory_budget_bytes: GaugeFamily, + pub models_loaded: GaugeFamily, + pub active_requests: GaugeFamily, +} + +impl MetricsState { + /// Create a fresh state with all metrics initialized. + /// Bucket boundaries are calibrated to Phase 0 measurements. + pub fn new() -> Self { + Self { + // Counters + requests_total: CounterFamily::new( + "mofa_requests_total", + "Total inference requests", + ), + model_loads_total: CounterFamily::new( + "mofa_model_loads_total", + "Total model load operations", + ), + model_unloads_total: CounterFamily::new( + "mofa_model_unloads_total", + "Total model unload operations", + ), + failovers_total: CounterFamily::new( + "mofa_failovers_total", + "Total failover events", + ), + evictions_total: CounterFamily::new( + "mofa_evictions_total", + "Total memory pressure evictions", + ), + preflight_predictions_total: CounterFamily::new( + "mofa_preflight_predictions_total", + "Total Preflight predictions", + ), + preflight_hits_total: CounterFamily::new( + "mofa_preflight_hits_total", + "Total correct Preflight predictions", + ), + preflight_misses_total: CounterFamily::new( + "mofa_preflight_misses_total", + "Total incorrect Preflight predictions", + ), + tokens_input_total: CounterFamily::new( + "mofa_tokens_input_total", + "Total input tokens processed", + ), + tokens_output_total: CounterFamily::new( + "mofa_tokens_output_total", + "Total output tokens generated", + ), + events_dropped_total: CounterFamily::new( + "mofa_events_dropped_total", + "Total events dropped due to channel backpressure", + ), + + // Histograms — bucket boundaries from Phase 0 measurements. + // + // mofa_request_duration_seconds: + // Phase 0: article gen = 13–18s, warm inference = 0.1–0.6s. + request_duration_seconds: HistogramFamily::new( + "mofa_request_duration_seconds", + "Request duration in seconds", + vec![0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 15.0, 20.0, 30.0, 60.0], + ), + // mofa_model_load_seconds: + // Phase 0: 1.5s (Qwen 7B Mac) to 97s (Qwen 7B FP16 CUDA). + // Most common range: 2–5s. + model_load_seconds: HistogramFamily::new( + "mofa_model_load_seconds", + "Time to load a model from cold", + vec![0.5, 1.0, 2.0, 3.0, 5.0, 10.0, 20.0, 30.0, 60.0, 120.0], + ), + // mofa_ttft_seconds: + // Phase 0: warm TTFT = 0.1–0.2s (Mac), 0.4–0.6s (CUDA). + // Cold TTFT = 1.5–5.5s. Fine granularity in 0.1–0.2s range + // because that's where Preflight's value shows up. + ttft_seconds: HistogramFamily::new( + "mofa_ttft_seconds", + "Time to first token", + vec![0.05, 0.1, 0.15, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0], + ), + + // Gauges + memory_used_bytes: GaugeFamily::new( + "mofa_memory_used_bytes", + "Current engine memory usage in bytes", + ), + memory_budget_bytes: GaugeFamily::new( + "mofa_memory_budget_bytes", + "Total engine memory budget in bytes", + ), + models_loaded: GaugeFamily::new( + "mofa_models_loaded", + "Number of models currently loaded", + ), + active_requests: GaugeFamily::new( + "mofa_active_requests", + "Number of requests currently in flight", + ), + } + } + + /// Process a single engine event, updating all relevant metrics. + pub fn process_event(&mut self, envelope: &EventEnvelope) { + match &envelope.event { + EngineEvent::RequestReceived(e) => { + self.active_requests.inc(Labels::new()); + // RequestReceived doesn't increment requests_total — + // that happens on RequestCompleted with success/error status. + let _ = e; // capability is used for routing, not counted here. + } + + EngineEvent::RoutingDecision(_) => { + // Routing metadata is captured for future decision-record phases. + // Phase A: no derived metrics from routing decisions. + } + + EngineEvent::RequestCompleted(e) => { + // Counter: requests by capability × status + let status = if e.success { "success" } else { "error" }; + self.requests_total.inc( + Labels::new() + .add("capability", e.capability.to_string()) + .add("status", status), + ); + + // Histogram: request duration + self.request_duration_seconds.observe( + Labels::new().add("capability", e.capability.to_string()), + e.duration_ms as f64 / 1000.0, + ); + + // Histogram: TTFT (if present) + if let Some(ttft) = e.ttft_ms { + self.ttft_seconds.observe( + Labels::new() + .add("model", &e.model_id) + .add("backend", &e.backend), + ttft as f64 / 1000.0, + ); + } + + // Counter: tokens + if let Some(tokens_in) = e.tokens_in { + self.tokens_input_total.inc_by( + Labels::new().add("model", &e.model_id), + tokens_in, + ); + } + if let Some(tokens_out) = e.tokens_out { + self.tokens_output_total.inc_by( + Labels::new().add("model", &e.model_id), + tokens_out, + ); + } + + // Gauge: active requests down + self.active_requests.dec(Labels::new()); + } + + EngineEvent::ModelLoaded(e) => { + // Counter + self.model_loads_total.inc( + Labels::new() + .add("model", &e.model_id) + .add("backend", &e.backend), + ); + + // Histogram: load duration + self.model_load_seconds.observe( + Labels::new() + .add("model", &e.model_id) + .add("backend", &e.backend), + e.load_duration_ms as f64 / 1000.0, + ); + + // Gauge: memory up, model count up + self.memory_used_bytes + .add(Labels::new(), e.memory_bytes as f64); + self.models_loaded.inc(Labels::new()); + } + + EngineEvent::ModelUnloaded(e) => { + // Counter + self.model_unloads_total.inc( + Labels::new() + .add("model", &e.model_id) + .add("reason", e.reason.to_string()), + ); + + // Gauge: memory down, model count down + self.memory_used_bytes + .sub(Labels::new(), e.memory_freed_bytes as f64); + self.models_loaded.dec(Labels::new()); + } + + EngineEvent::EvictionTriggered(_) => { + self.evictions_total.inc(Labels::new()); + } + + EngineEvent::PreflightSignal(e) => { + self.preflight_predictions_total.inc( + Labels::new().add("source", e.source.to_string()), + ); + } + + EngineEvent::PreflightHit(_) => { + self.preflight_hits_total.inc(Labels::new()); + } + + EngineEvent::PreflightMiss(_) => { + self.preflight_misses_total.inc(Labels::new()); + } + + EngineEvent::ProviderDiscovered(_) => { + // Logged, not metricked. Provider discovery is a startup event. + } + + EngineEvent::FailoverTriggered(e) => { + // We don't have capability on FailoverTriggered, so label-free. + let _ = e; + self.failovers_total.inc(Labels::new()); + } + } + } +} + +impl Default for MetricsState { + fn default() -> Self { + Self::new() + } +} + +// ─── Event Channel ─────────────────────────────────────────────────────────── + +/// Sender half of the event channel. Clone this for each event source. +#[derive(Clone)] +pub struct EventSender { + tx: mpsc::Sender, + dropped: Arc, +} + +impl EventSender { + /// Send an event. If the channel is full, the event is dropped + /// and the dropped counter is incremented. + pub fn send(&self, event: EventEnvelope) { + if self.tx.try_send(event).is_err() { + self.dropped + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + } + + /// How many events have been dropped due to backpressure. + pub fn dropped_count(&self) -> u64 { + self.dropped.load(std::sync::atomic::Ordering::Relaxed) + } +} + +/// Receiver half of the event channel. Owned by the collector. +pub struct EventReceiver { + rx: mpsc::Receiver, + dropped: Arc, +} + +/// Create a bounded event channel. +/// +/// `capacity`: max events buffered before backpressure kicks in. +/// Returns (sender, receiver). Clone the sender for multiple producers. +pub fn create_event_channel(capacity: usize) -> (EventSender, EventReceiver) { + let (tx, rx) = mpsc::channel(capacity); + let dropped = Arc::new(std::sync::atomic::AtomicU64::new(0)); + + let sender = EventSender { + tx, + dropped: Arc::clone(&dropped), + }; + let receiver = EventReceiver { rx, dropped }; + + (sender, receiver) +} + +// ─── Metrics Collector ─────────────────────────────────────────────────────── + +/// The collector. Owns the shared metrics state and runs the processing loop. +/// +/// Usage: +/// ```ignore +/// let (sender, receiver) = create_event_channel(1024); +/// let collector = MetricsCollector::new(receiver); +/// let state = collector.state(); // share with Prometheus renderer +/// tokio::spawn(collector.run()); +/// ``` +pub struct MetricsCollector { + state: Arc>, + receiver: EventReceiver, +} + +impl MetricsCollector { + pub fn new(receiver: EventReceiver) -> Self { + Self { + state: Arc::new(RwLock::new(MetricsState::new())), + receiver, + } + } + + /// Get a handle to the shared metrics state. + /// Pass this to the Prometheus renderer. + pub fn state(&self) -> Arc> { + Arc::clone(&self.state) + } + + /// Run the collector loop. Processes events until the channel closes. + pub async fn run(mut self) { + while let Some(envelope) = self.receiver.rx.recv().await { + let mut state = self.state.write().await; + + // Sync the dropped counter into metrics state. + let dropped = self + .receiver + .dropped + .swap(0, std::sync::atomic::Ordering::Relaxed); + if dropped > 0 { + state + .events_dropped_total + .inc_by(Labels::new(), dropped); + } + + state.process_event(&envelope); + } + } +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn make_state() -> MetricsState { + MetricsState::new() + } + + // ── Counter tests ──────────────────────────────────────────────────── + + #[test] + fn test_request_completed_increments_counter() { + let mut state = make_state(); + let event = EventEnvelope::now(EngineEvent::RequestCompleted(RequestCompleted { + model_id: "qwen2.5:7b".into(), + backend: "ollama".into(), + capability: Capability::Chat, + duration_ms: 15000, + ttft_ms: Some(115), + tokens_in: Some(50), + tokens_out: Some(277), + model_was_hot: Some(true), + success: true, + error_code: None, + })); + + state.process_event(&event); + + let labels = Labels::new() + .add("capability", "chat") + .add("status", "success"); + assert_eq!(state.requests_total.values[&labels], 1); + } + + #[test] + fn test_five_requests_counted() { + let mut state = make_state(); + + for _ in 0..5 { + let event = EventEnvelope::now(EngineEvent::RequestCompleted(RequestCompleted { + model_id: "qwen2.5:7b".into(), + backend: "ollama".into(), + capability: Capability::Chat, + duration_ms: 15000, + ttft_ms: None, + tokens_in: None, + tokens_out: None, + model_was_hot: None, + success: true, + error_code: None, + })); + state.process_event(&event); + } + + let labels = Labels::new() + .add("capability", "chat") + .add("status", "success"); + assert_eq!(state.requests_total.values[&labels], 5); + } + + #[test] + fn test_error_requests_separate_from_success() { + let mut state = make_state(); + + // 3 successes + for _ in 0..3 { + state.process_event(&EventEnvelope::now(EngineEvent::RequestCompleted( + RequestCompleted { + model_id: "qwen2.5:7b".into(), + backend: "ollama".into(), + capability: Capability::Chat, + duration_ms: 15000, + ttft_ms: None, + tokens_in: None, + tokens_out: None, + model_was_hot: None, + success: true, + error_code: None, + }, + ))); + } + + // 1 error + state.process_event(&EventEnvelope::now(EngineEvent::RequestCompleted( + RequestCompleted { + model_id: "qwen2.5:7b".into(), + backend: "ollama".into(), + capability: Capability::Chat, + duration_ms: 500, + ttft_ms: None, + tokens_in: None, + tokens_out: None, + model_was_hot: None, + success: false, + error_code: Some("model_not_found".into()), + }, + ))); + + let success = Labels::new() + .add("capability", "chat") + .add("status", "success"); + let error = Labels::new() + .add("capability", "chat") + .add("status", "error"); + + assert_eq!(state.requests_total.values[&success], 3); + assert_eq!(state.requests_total.values[&error], 1); + } + + // ── Histogram tests ────────────────────────────────────────────────── + + #[test] + fn test_model_load_histogram_bucketing() { + let mut state = make_state(); + + // Phase 0: Qwen 7B cold load on Mac = 1.532s + state.process_event(&EventEnvelope::now(EngineEvent::ModelLoaded(ModelLoaded { + model_id: "qwen2.5:7b".into(), + backend: "ollama".into(), + capability: Capability::Chat, + load_duration_ms: 1532, + memory_bytes: 4_700_000_000, + }))); + + let labels = Labels::new() + .add("backend", "ollama") + .add("model", "qwen2.5:7b"); + let hist = &state.model_load_seconds.values[&labels]; + + // 1.532s should be in the le=2.0 bucket but NOT le=1.0 + assert_eq!(hist.buckets[1].1, 0); // le=1.0: no + assert_eq!(hist.buckets[2].1, 1); // le=2.0: yes + assert_eq!(hist.count, 1); + assert!((hist.sum - 1.532).abs() < 0.001); + } + + #[test] + fn test_ttft_histogram_warm_vs_cold() { + let mut state = make_state(); + + // Phase 0: warm TTFT = 0.115s + state.process_event(&EventEnvelope::now(EngineEvent::RequestCompleted( + RequestCompleted { + model_id: "qwen2.5:7b".into(), + backend: "ollama".into(), + capability: Capability::Chat, + duration_ms: 15000, + ttft_ms: Some(115), + tokens_in: None, + tokens_out: None, + model_was_hot: Some(true), + success: true, + error_code: None, + }, + ))); + + let labels = Labels::new() + .add("backend", "ollama") + .add("model", "qwen2.5:7b"); + let hist = &state.ttft_seconds.values[&labels]; + + // 0.115s should be in le=0.15 but NOT le=0.1 + assert_eq!(hist.buckets[1].1, 0); // le=0.1: no + assert_eq!(hist.buckets[2].1, 1); // le=0.15: yes + } + + // ── Gauge tests ────────────────────────────────────────────────────── + + #[test] + fn test_models_loaded_gauge_up_down() { + let mut state = make_state(); + + // Load a model + state.process_event(&EventEnvelope::now(EngineEvent::ModelLoaded(ModelLoaded { + model_id: "qwen2.5:7b".into(), + backend: "ollama".into(), + capability: Capability::Chat, + load_duration_ms: 1532, + memory_bytes: 4_700_000_000, + }))); + assert_eq!(state.models_loaded.values[&Labels::new()], 1.0); + + // Load another + state.process_event(&EventEnvelope::now(EngineEvent::ModelLoaded(ModelLoaded { + model_id: "gemma3:4b".into(), + backend: "ollama".into(), + capability: Capability::Chat, + load_duration_ms: 2428, + memory_bytes: 3_300_000_000, + }))); + assert_eq!(state.models_loaded.values[&Labels::new()], 2.0); + + // Unload one + state.process_event(&EventEnvelope::now(EngineEvent::ModelUnloaded( + ModelUnloaded { + model_id: "gemma3:4b".into(), + reason: UnloadReason::IdleTimeout, + memory_freed_bytes: 3_300_000_000, + }, + ))); + assert_eq!(state.models_loaded.values[&Labels::new()], 1.0); + } + + #[test] + fn test_memory_gauge_tracks_bytes() { + let mut state = make_state(); + + // Load Qwen 7B (4.7 GB) + state.process_event(&EventEnvelope::now(EngineEvent::ModelLoaded(ModelLoaded { + model_id: "qwen2.5:7b".into(), + backend: "ollama".into(), + capability: Capability::Chat, + load_duration_ms: 1532, + memory_bytes: 4_700_000_000, + }))); + assert_eq!( + state.memory_used_bytes.values[&Labels::new()], + 4_700_000_000.0 + ); + + // Load Gemma 4B (3.3 GB) + state.process_event(&EventEnvelope::now(EngineEvent::ModelLoaded(ModelLoaded { + model_id: "gemma3:4b".into(), + backend: "ollama".into(), + capability: Capability::Chat, + load_duration_ms: 2428, + memory_bytes: 3_300_000_000, + }))); + assert_eq!( + state.memory_used_bytes.values[&Labels::new()], + 8_000_000_000.0 + ); + + // Unload Gemma + state.process_event(&EventEnvelope::now(EngineEvent::ModelUnloaded( + ModelUnloaded { + model_id: "gemma3:4b".into(), + reason: UnloadReason::Eviction, + memory_freed_bytes: 3_300_000_000, + }, + ))); + assert_eq!( + state.memory_used_bytes.values[&Labels::new()], + 4_700_000_000.0 + ); + } + + #[test] + fn test_active_requests_gauge() { + let mut state = make_state(); + + // Request arrives + state.process_event(&EventEnvelope::now(EngineEvent::RequestReceived( + RequestReceived { + capability: Capability::Chat, + model: None, + hint: Some("tts".into()), + }, + ))); + assert_eq!(state.active_requests.values[&Labels::new()], 1.0); + + // Another arrives + state.process_event(&EventEnvelope::now(EngineEvent::RequestReceived( + RequestReceived { + capability: Capability::Tts, + model: None, + hint: None, + }, + ))); + assert_eq!(state.active_requests.values[&Labels::new()], 2.0); + + // First completes + state.process_event(&EventEnvelope::now(EngineEvent::RequestCompleted( + RequestCompleted { + model_id: "qwen2.5:7b".into(), + backend: "ollama".into(), + capability: Capability::Chat, + duration_ms: 15000, + ttft_ms: None, + tokens_in: None, + tokens_out: None, + model_was_hot: None, + success: true, + error_code: None, + }, + ))); + assert_eq!(state.active_requests.values[&Labels::new()], 1.0); + } + + // ── Preflight tests ────────────────────────────────────────────────── + + #[test] + fn test_preflight_accuracy_from_counters() { + let mut state = make_state(); + + // 3 hits + 1 miss = 75% accuracy + for _ in 0..3 { + state.process_event(&EventEnvelope::now(EngineEvent::PreflightHit( + PreflightHit { + predicted_capability: Capability::Tts, + cold_start_avoided_ms: 2404, + }, + ))); + } + state.process_event(&EventEnvelope::now(EngineEvent::PreflightMiss( + PreflightMiss { + predicted_capability: Capability::Tts, + actual_capability: Capability::Chat, + }, + ))); + + let hits = state.preflight_hits_total.values[&Labels::new()]; + let misses = state.preflight_misses_total.values[&Labels::new()]; + let accuracy = hits as f64 / (hits + misses) as f64; + + assert_eq!(hits, 3); + assert_eq!(misses, 1); + assert!((accuracy - 0.75).abs() < 0.001); + } + + #[test] + fn test_preflight_predictions_by_source() { + let mut state = make_state(); + + // 2 hint predictions + 1 history prediction + for _ in 0..2 { + state.process_event(&EventEnvelope::now(EngineEvent::PreflightSignal( + PreflightSignal { + predicted_capability: Capability::Tts, + confidence: 1.0, + source: SignalSource::Hint, + }, + ))); + } + state.process_event(&EventEnvelope::now(EngineEvent::PreflightSignal( + PreflightSignal { + predicted_capability: Capability::Tts, + confidence: 0.9, + source: SignalSource::History, + }, + ))); + + let hint_labels = Labels::new().add("source", "hint"); + let history_labels = Labels::new().add("source", "history"); + + assert_eq!(state.preflight_predictions_total.values[&hint_labels], 2); + assert_eq!( + state.preflight_predictions_total.values[&history_labels], + 1 + ); + } + + // ── Token counting tests ───────────────────────────────────────────── + + #[test] + fn test_token_counting() { + let mut state = make_state(); + + state.process_event(&EventEnvelope::now(EngineEvent::RequestCompleted( + RequestCompleted { + model_id: "qwen2.5:7b".into(), + backend: "ollama".into(), + capability: Capability::Chat, + duration_ms: 15000, + ttft_ms: None, + tokens_in: Some(50), + tokens_out: Some(277), + model_was_hot: None, + success: true, + error_code: None, + }, + ))); + + let labels = Labels::new().add("model", "qwen2.5:7b"); + assert_eq!(state.tokens_input_total.values[&labels], 50); + assert_eq!(state.tokens_output_total.values[&labels], 277); + } + + // ── Channel + collector integration test ───────────────────────────── + + #[tokio::test] + async fn test_collector_processes_events_from_channel() { + let (sender, receiver) = create_event_channel(64); + let collector = MetricsCollector::new(receiver); + let state = collector.state(); + + // Start collector in background + let handle = tokio::spawn(collector.run()); + + // Send events + sender.send(EventEnvelope::now(EngineEvent::ModelLoaded(ModelLoaded { + model_id: "qwen2.5:7b".into(), + backend: "ollama".into(), + capability: Capability::Chat, + load_duration_ms: 1532, + memory_bytes: 4_700_000_000, + }))); + + sender.send(EventEnvelope::now(EngineEvent::PreflightSignal( + PreflightSignal { + predicted_capability: Capability::Tts, + confidence: 1.0, + source: SignalSource::Hint, + }, + ))); + + // Drop sender to close channel — collector loop will exit + drop(sender); + handle.await.unwrap(); + + // Verify state + let state = state.read().await; + assert_eq!(state.models_loaded.values[&Labels::new()], 1.0); + + let hint_labels = Labels::new().add("source", "hint"); + assert_eq!(state.preflight_predictions_total.values[&hint_labels], 1); + } + + // ── Backpressure test ──────────────────────────────────────────────── + + #[test] + fn test_backpressure_counts_drops() { + // Channel of size 2 + let (sender, _receiver) = create_event_channel(2); + + // Fill channel + for _ in 0..5 { + sender.send(EventEnvelope::now(EngineEvent::EvictionTriggered( + EvictionTriggered { + evicted_model: "test".into(), + memory_before_bytes: 100, + memory_after_bytes: 50, + budget_bytes: 100, + }, + ))); + } + + // At least some should be dropped (channel size = 2, sent 5) + assert!(sender.dropped_count() >= 3); + } + + // ── Label cardinality test ─────────────────────────────────────────── + + #[test] + fn test_label_cardinality_bounded() { + let mut state = make_state(); + + // Simulate 100 events across all 7 capabilities × 2 statuses = 14 series max + let capabilities = [ + Capability::Chat, + Capability::Tts, + Capability::Asr, + Capability::Vlm, + Capability::ImageGen, + Capability::VideoGen, + Capability::Embedding, + ]; + + for cap in &capabilities { + for success in [true, false] { + state.process_event(&EventEnvelope::now(EngineEvent::RequestCompleted( + RequestCompleted { + model_id: "test".into(), + backend: "ollama".into(), + capability: *cap, + duration_ms: 1000, + ttft_ms: None, + tokens_in: None, + tokens_out: None, + model_was_hot: None, + success, + error_code: None, + }, + ))); + } + } + + // 7 capabilities × 2 statuses = 14 series. Within §4.5 bounds. + assert_eq!(state.requests_total.values.len(), 14); + } +} diff --git a/mofa-observability/src/lib.rs b/mofa-observability/src/lib.rs index a9970c2..bf168e8 100644 --- a/mofa-observability/src/lib.rs +++ b/mofa-observability/src/lib.rs @@ -1 +1,2 @@ pub mod events; +pub mod collector; From 43ae6e52b6033cc65c8ec582e684a358d3afce67 Mon Sep 17 00:00:00 2001 From: ashum9 Date: Wed, 24 Jun 2026 13:50:50 +0530 Subject: [PATCH 03/75] add OpenTelemetry span fields for distributed tracing to envelope --- mofa-observability/src/events.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/mofa-observability/src/events.rs b/mofa-observability/src/events.rs index 094e134..e5682f5 100644 --- a/mofa-observability/src/events.rs +++ b/mofa-observability/src/events.rs @@ -95,6 +95,12 @@ pub struct EventEnvelope { pub request_id: Option, /// App or user session (if provided by caller). pub session_id: Option, + /// OpenTelemetry trace ID (128-bit hex string). + pub trace_id: Option, + /// OpenTelemetry span ID (64-bit hex string). + pub span_id: Option, + /// OpenTelemetry span end timestamp (Unix ms). + pub span_end_timestamp: Option, /// The event payload. pub event: EngineEvent, } @@ -111,6 +117,9 @@ impl EventEnvelope { timestamp_ms, request_id: None, session_id: None, + trace_id: None, + span_id: None, + span_end_timestamp: None, event, } } @@ -126,6 +135,19 @@ impl EventEnvelope { self.session_id = Some(id.into()); self } + + /// Attach OpenTelemetry tracing context. + pub fn with_trace(mut self, trace_id: impl Into, span_id: impl Into) -> Self { + self.trace_id = Some(trace_id.into()); + self.span_id = Some(span_id.into()); + self + } + + /// Set span end timestamp. + pub fn with_span_end(mut self, timestamp_ms: u64) -> Self { + self.span_end_timestamp = Some(timestamp_ms); + self + } } // ─── Engine Event (discriminated union) ────────────────────────────────────── From 5680e769368f47bcdef17450a08af36d66137f57 Mon Sep 17 00:00:00 2001 From: ashum9 Date: Wed, 24 Jun 2026 13:53:40 +0530 Subject: [PATCH 04/75] chore: update Cargo.lock for mofa-observability --- Cargo.lock | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 022e0d5..1e4489e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1093,6 +1093,15 @@ dependencies = [ "uuid", ] +[[package]] +name = "mofa-observability" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tokio", +] + [[package]] name = "native-tls" version = "0.2.18" From 1a9d07a4941d2c87beed7a20eb3ac2fd7c4b7b74 Mon Sep 17 00:00:00 2001 From: ashum9 Date: Sat, 27 Jun 2026 19:29:18 +0530 Subject: [PATCH 05/75] add prometheus text exposition renderer --- mofa-observability/src/lib.rs | 1 + mofa-observability/src/prometheus.rs | 506 +++++++++++++++++++++++++++ 2 files changed, 507 insertions(+) create mode 100644 mofa-observability/src/prometheus.rs diff --git a/mofa-observability/src/lib.rs b/mofa-observability/src/lib.rs index bf168e8..9105b77 100644 --- a/mofa-observability/src/lib.rs +++ b/mofa-observability/src/lib.rs @@ -1,2 +1,3 @@ pub mod events; pub mod collector; +pub mod prometheus; diff --git a/mofa-observability/src/prometheus.rs b/mofa-observability/src/prometheus.rs new file mode 100644 index 0000000..b62c487 --- /dev/null +++ b/mofa-observability/src/prometheus.rs @@ -0,0 +1,506 @@ +//! # Prometheus Text Exposition Renderer +//! +//! Converts the in-memory `MetricsState` into valid Prometheus text format. +//! +//! Reference: https://prometheus.io/docs/instrumenting/exposition_formats/ +//! +//! The renderer is a pure function: `MetricsState` in, `String` out. +//! No I/O. Minimal allocations for sorting and formatting. + +use crate::collector::{CounterFamily, GaugeFamily, HistogramFamily, Labels, MetricsState}; +use std::fmt::Write; + +// ─── Label Formatting ──────────────────────────────────────────────────────── + +/// Format labels into Prometheus `{key="value",key2="value2"}` syntax. +/// Returns empty string if there are no labels. +fn format_labels(labels: &Labels) -> String { + if labels.is_empty() { + return String::new(); + } + let mut out = String::from("{"); + for (i, (key, value)) in labels.pairs().iter().enumerate() { + if i > 0 { + out.push(','); + } + // Escape backslash, double-quote, and newline in label values. + let escaped = value + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n"); + write!(out, "{}=\"{}\"", key, escaped).unwrap(); + } + out.push('}'); + out +} + +/// Format labels with an extra label appended (used for histogram `le` buckets). +/// The extra label is added after existing labels. +fn format_labels_with_extra(labels: &Labels, extra_key: &str, extra_value: &str) -> String { + let mut out = String::from("{"); + for (key, value) in labels.pairs() { + let escaped = value + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n"); + write!(out, "{}=\"{}\",", key, escaped).unwrap(); + } + write!(out, "{}=\"{}\"", extra_key, extra_value).unwrap(); + out.push('}'); + out +} + +// ─── Metric Rendering ──────────────────────────────────────────────────────── + +/// Render a counter family into Prometheus text format. +fn render_counter(buf: &mut String, family: &CounterFamily) { + if family.values.is_empty() { + return; + } + writeln!(buf, "# HELP {} {}", family.name, family.help).unwrap(); + writeln!(buf, "# TYPE {} counter", family.name).unwrap(); + + // Sort label sets for deterministic output without allocations. + let mut entries: Vec<_> = family.values.iter().collect(); + entries.sort_by(|a, b| a.0.pairs().cmp(b.0.pairs())); + + for (labels, value) in entries { + writeln!(buf, "{}{} {}", family.name, format_labels(labels), value).unwrap(); + } + buf.push('\n'); +} + +/// Render a gauge family into Prometheus text format. +fn render_gauge(buf: &mut String, family: &GaugeFamily) { + if family.values.is_empty() { + return; + } + writeln!(buf, "# HELP {} {}", family.name, family.help).unwrap(); + writeln!(buf, "# TYPE {} gauge", family.name).unwrap(); + + let mut entries: Vec<_> = family.values.iter().collect(); + entries.sort_by(|a, b| a.0.pairs().cmp(b.0.pairs())); + + for (labels, value) in entries { + // Render integer values without decimal point for cleanliness. + if *value == value.floor() && value.is_finite() { + writeln!( + buf, + "{}{} {}", + family.name, + format_labels(labels), + *value as i64 + ) + .unwrap(); + } else { + writeln!(buf, "{}{} {}", family.name, format_labels(labels), format_float(*value)).unwrap(); + } + } + buf.push('\n'); +} + +/// Render a histogram family into Prometheus text format. +/// +/// Each histogram produces: +/// - One line per bucket: `name_bucket{...,le="bound"} count` +/// - A `+Inf` bucket line +/// - A `name_sum{...} value` line +/// - A `name_count{...} value` line +fn render_histogram(buf: &mut String, family: &HistogramFamily) { + if family.values.is_empty() { + return; + } + writeln!(buf, "# HELP {} {}", family.name, family.help).unwrap(); + writeln!(buf, "# TYPE {} histogram", family.name).unwrap(); + + let mut entries: Vec<_> = family.values.iter().collect(); + entries.sort_by(|a, b| a.0.pairs().cmp(b.0.pairs())); + + for (labels, hist) in entries { + // Render each bucket. + for (bound, count) in &hist.buckets { + // Format the bound: remove trailing zeros for cleanliness. + let bound_str = format_float(*bound); + writeln!( + buf, + "{}_bucket{} {}", + family.name, + format_labels_with_extra(labels, "le", &bound_str), + count + ) + .unwrap(); + } + + // +Inf bucket (always equals total count). + writeln!( + buf, + "{}_bucket{} {}", + family.name, + format_labels_with_extra(labels, "le", "+Inf"), + hist.count + ) + .unwrap(); + + // Sum and count. + writeln!( + buf, + "{}_sum{} {}", + family.name, + format_labels(labels), + format_float(hist.sum) + ) + .unwrap(); + writeln!( + buf, + "{}_count{} {}", + family.name, + format_labels(labels), + hist.count + ) + .unwrap(); + } + buf.push('\n'); +} + +/// Format a float for Prometheus output. +/// Handles special values (+Inf, -Inf, NaN) strictly per the Prometheus spec. +fn format_float(v: f64) -> String { + if v.is_nan() { + "NaN".to_string() + } else if v == f64::INFINITY { + "+Inf".to_string() + } else if v == f64::NEG_INFINITY { + "-Inf".to_string() + } else if v == v.floor() && v.is_finite() { + // Whole number — render with one decimal place (e.g., "1.0", "10.0"). + format!("{:.1}", v) + } else { + // Has fractional part — render naturally. + format!("{}", v) + } +} + +// ─── Public API ────────────────────────────────────────────────────────────── + +/// Render the full metrics state into Prometheus text exposition format. +/// +/// The output is suitable for serving at a `/metrics` HTTP endpoint. +/// Prometheus can scrape this directly. +/// +/// # Example +/// +/// ```ignore +/// let state = collector.state().read().await; +/// let output = prometheus::render(&state); +/// // Return `output` as text/plain from your HTTP handler. +/// ``` +pub fn render(state: &MetricsState) -> String { + let mut buf = String::with_capacity(4096); + + // ── Counters ───────────────────────────────────────────────────────── + render_counter(&mut buf, &state.requests_total); + render_counter(&mut buf, &state.model_loads_total); + render_counter(&mut buf, &state.model_unloads_total); + render_counter(&mut buf, &state.failovers_total); + render_counter(&mut buf, &state.evictions_total); + render_counter(&mut buf, &state.preflight_predictions_total); + render_counter(&mut buf, &state.preflight_hits_total); + render_counter(&mut buf, &state.preflight_misses_total); + render_counter(&mut buf, &state.tokens_input_total); + render_counter(&mut buf, &state.tokens_output_total); + render_counter(&mut buf, &state.events_dropped_total); + + // ── Histograms ─────────────────────────────────────────────────────── + render_histogram(&mut buf, &state.request_duration_seconds); + render_histogram(&mut buf, &state.model_load_seconds); + render_histogram(&mut buf, &state.ttft_seconds); + + // ── Gauges ─────────────────────────────────────────────────────────── + render_gauge(&mut buf, &state.memory_used_bytes); + render_gauge(&mut buf, &state.memory_budget_bytes); + render_gauge(&mut buf, &state.models_loaded); + render_gauge(&mut buf, &state.active_requests); + + buf +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::collector::MetricsState; + use crate::events::*; + + #[test] + fn test_empty_state_renders_empty() { + let state = MetricsState::new(); + let output = render(&state); + // Empty state has no values, so no output. + assert!(output.is_empty(), "Expected empty output, got:\n{}", output); + } + + #[test] + fn test_counter_format() { + let mut state = MetricsState::new(); + state.process_event(&EventEnvelope::now(EngineEvent::RequestCompleted( + RequestCompleted { + model_id: "qwen2.5:7b".into(), + backend: "ollama".into(), + capability: Capability::Chat, + duration_ms: 15000, + ttft_ms: None, + tokens_in: None, + tokens_out: None, + model_was_hot: None, + success: true, + error_code: None, + }, + ))); + + let output = render(&state); + + // Must contain HELP and TYPE headers. + assert!(output.contains("# HELP mofa_requests_total Total inference requests")); + assert!(output.contains("# TYPE mofa_requests_total counter")); + // Must contain the labeled counter value. + assert!(output.contains("mofa_requests_total{capability=\"chat\",status=\"success\"} 1")); + } + + #[test] + fn test_gauge_format() { + let mut state = MetricsState::new(); + state.process_event(&EventEnvelope::now(EngineEvent::ModelLoaded(ModelLoaded { + model_id: "qwen2.5:7b".into(), + backend: "ollama".into(), + capability: Capability::Chat, + load_duration_ms: 1532, + memory_bytes: 4_700_000_000, + }))); + + let output = render(&state); + + assert!(output.contains("# HELP mofa_models_loaded Number of models currently loaded")); + assert!(output.contains("# TYPE mofa_models_loaded gauge")); + assert!(output.contains("mofa_models_loaded 1")); + assert!(output.contains("mofa_memory_used_bytes 4700000000")); + } + + #[test] + fn test_histogram_format() { + let mut state = MetricsState::new(); + state.process_event(&EventEnvelope::now(EngineEvent::ModelLoaded(ModelLoaded { + model_id: "qwen2.5:7b".into(), + backend: "ollama".into(), + capability: Capability::Chat, + load_duration_ms: 1532, + memory_bytes: 4_700_000_000, + }))); + + let output = render(&state); + + // Must have TYPE histogram. + assert!(output.contains("# TYPE mofa_model_load_seconds histogram")); + // Must have bucket lines with le labels. + assert!(output.contains("mofa_model_load_seconds_bucket{backend=\"ollama\",model=\"qwen2.5:7b\",le=\"0.5\"} 0")); + assert!(output.contains("mofa_model_load_seconds_bucket{backend=\"ollama\",model=\"qwen2.5:7b\",le=\"2.0\"} 1")); + // Must have +Inf bucket. + assert!(output.contains("mofa_model_load_seconds_bucket{backend=\"ollama\",model=\"qwen2.5:7b\",le=\"+Inf\"} 1")); + // Must have sum and count. + assert!(output.contains("mofa_model_load_seconds_sum{backend=\"ollama\",model=\"qwen2.5:7b\"} 1.532")); + assert!(output.contains("mofa_model_load_seconds_count{backend=\"ollama\",model=\"qwen2.5:7b\"} 1")); + } + + #[test] + fn test_labels_sorted_alphabetically() { + let mut state = MetricsState::new(); + // RequestCompleted generates labels: capability, status. + // These should appear in alphabetical order: capability before status. + state.process_event(&EventEnvelope::now(EngineEvent::RequestCompleted( + RequestCompleted { + model_id: "test".into(), + backend: "ollama".into(), + capability: Capability::Tts, + duration_ms: 1000, + ttft_ms: None, + tokens_in: None, + tokens_out: None, + model_was_hot: None, + success: true, + error_code: None, + }, + ))); + + let output = render(&state); + // Labels must be sorted: capability comes before status. + assert!(output.contains("{capability=\"tts\",status=\"success\"}")); + } + + #[test] + fn test_label_value_escaping() { + let mut state = MetricsState::new(); + // Simulate a model ID with a quote character to test escaping. + state.process_event(&EventEnvelope::now(EngineEvent::ModelLoaded(ModelLoaded { + model_id: "model\"with\"quotes".into(), + backend: "test\\backend".into(), + capability: Capability::Chat, + load_duration_ms: 1000, + memory_bytes: 1000, + }))); + + let output = render(&state); + // Quotes and backslashes must be escaped. + assert!(output.contains("model\\\"with\\\"quotes")); + assert!(output.contains("test\\\\backend")); + } + + #[test] + fn test_multiple_label_combinations() { + let mut state = MetricsState::new(); + + // Chat success + state.process_event(&EventEnvelope::now(EngineEvent::RequestCompleted( + RequestCompleted { + model_id: "qwen".into(), + backend: "ollama".into(), + capability: Capability::Chat, + duration_ms: 1000, + ttft_ms: None, + tokens_in: None, + tokens_out: None, + model_was_hot: None, + success: true, + error_code: None, + }, + ))); + + // TTS error + state.process_event(&EventEnvelope::now(EngineEvent::RequestCompleted( + RequestCompleted { + model_id: "kokoro".into(), + backend: "ollama".into(), + capability: Capability::Tts, + duration_ms: 500, + ttft_ms: None, + tokens_in: None, + tokens_out: None, + model_was_hot: None, + success: false, + error_code: Some("timeout".into()), + }, + ))); + + let output = render(&state); + + // Both label combinations must appear. + assert!(output.contains("{capability=\"chat\",status=\"success\"} 1")); + assert!(output.contains("{capability=\"tts\",status=\"error\"} 1")); + } + + #[test] + fn test_full_render_is_valid_prometheus() { + let mut state = MetricsState::new(); + + // Generate a mix of events. + state.process_event(&EventEnvelope::now(EngineEvent::ModelLoaded(ModelLoaded { + model_id: "qwen2.5:7b".into(), + backend: "ollama".into(), + capability: Capability::Chat, + load_duration_ms: 1532, + memory_bytes: 4_700_000_000, + }))); + state.process_event(&EventEnvelope::now(EngineEvent::RequestReceived( + RequestReceived { + capability: Capability::Chat, + model: None, + hint: None, + }, + ))); + state.process_event(&EventEnvelope::now(EngineEvent::RequestCompleted( + RequestCompleted { + model_id: "qwen2.5:7b".into(), + backend: "ollama".into(), + capability: Capability::Chat, + duration_ms: 15000, + ttft_ms: Some(115), + tokens_in: Some(50), + tokens_out: Some(277), + model_was_hot: Some(true), + success: true, + error_code: None, + }, + ))); + state.process_event(&EventEnvelope::now(EngineEvent::PreflightHit( + PreflightHit { + predicted_capability: Capability::Tts, + cold_start_avoided_ms: 2404, + }, + ))); + + let output = render(&state); + + // Basic validity checks: + // 1. Every HELP line is followed by a TYPE line. + let lines: Vec<&str> = output.lines().collect(); + for (i, line) in lines.iter().enumerate() { + if line.starts_with("# HELP") { + assert!( + i + 1 < lines.len() && lines[i + 1].starts_with("# TYPE"), + "HELP line not followed by TYPE: {}", + line + ); + } + } + + // 2. No empty metric names. + for line in &lines { + if !line.starts_with('#') && !line.is_empty() { + assert!( + !line.starts_with('{') && !line.starts_with(' '), + "Metric line has no name: {}", + line + ); + } + } + + // 3. Contains expected sections. + assert!(output.contains("mofa_requests_total")); + assert!(output.contains("mofa_model_load_seconds")); + assert!(output.contains("mofa_memory_used_bytes")); + assert!(output.contains("mofa_preflight_hits_total")); + } + + #[test] + fn test_no_labels_metric_has_no_braces() { + let mut state = MetricsState::new(); + state.process_event(&EventEnvelope::now(EngineEvent::EvictionTriggered( + EvictionTriggered { + evicted_model: "test".into(), + memory_before_bytes: 100, + memory_after_bytes: 50, + budget_bytes: 100, + }, + ))); + + let output = render(&state); + // The evictions_total counter has no labels. + // It should render as "mofa_evictions_total 1" without {}. + assert!( + output.contains("mofa_evictions_total 1"), + "Expected no-label metric, got:\n{}", + output + ); + // Make sure there's no empty braces. + assert!(!output.contains("mofa_evictions_total{}")); + } + + #[test] + fn test_float_formatting_spec_compliance() { + let mut state = MetricsState::new(); + state.memory_used_bytes.values.insert(Labels::new(), f64::INFINITY); + state.active_requests.values.insert(Labels::new(), f64::NAN); + let output = render(&state); + assert!(output.contains("mofa_memory_used_bytes +Inf"), "Failed to render +Inf: {}", output); + assert!(output.contains("mofa_active_requests NaN"), "Failed to render NaN: {}", output); + } +} From d09b64daf582790b1b1011b4ea0206b460d9e1aa Mon Sep 17 00:00:00 2001 From: ashum9 Date: Sun, 28 Jun 2026 00:44:40 +0530 Subject: [PATCH 06/75] add privacy contract test, fix memory gauge drift via eviction reconciliation --- mofa-observability/src/collector.rs | 22 +++--- mofa-observability/src/events.rs | 108 ++++++++++++++++++++++++++-- 2 files changed, 116 insertions(+), 14 deletions(-) diff --git a/mofa-observability/src/collector.rs b/mofa-observability/src/collector.rs index 1bc8e72..524d5b8 100644 --- a/mofa-observability/src/collector.rs +++ b/mofa-observability/src/collector.rs @@ -7,8 +7,8 @@ //! - Single writer (collector task), multiple readers (Prometheus scrapes). //! - No blocking I/O. No disk. Just in-memory atomics behind a RwLock. //! - Bounded event channel with drop-oldest backpressure. -//! -//! Reference: observability_plan.md §4 +//! - Memory gauge reconciles against absolute values from eviction events +//! to prevent cumulative drift under event loss. use crate::events::*; use std::collections::HashMap; @@ -174,11 +174,9 @@ impl GaugeFamily { /// All metric state. One instance per engine. /// The collector writes, the Prometheus renderer reads. -/// -/// Reference: observability_plan.md §4.2–4.4 #[derive(Debug, Clone)] pub struct MetricsState { - // ── Counters (§4.2) ────────────────────────────────────────────────── + // ── Counters ───────────────────────────────────────────────────────── pub requests_total: CounterFamily, pub model_loads_total: CounterFamily, pub model_unloads_total: CounterFamily, @@ -191,12 +189,12 @@ pub struct MetricsState { pub tokens_output_total: CounterFamily, pub events_dropped_total: CounterFamily, - // ── Histograms (§4.3) ──────────────────────────────────────────────── + // ── Histograms ─────────────────────────────────────────────────────── pub request_duration_seconds: HistogramFamily, pub model_load_seconds: HistogramFamily, pub ttft_seconds: HistogramFamily, - // ── Gauges (§4.4) ──────────────────────────────────────────────────── + // ── Gauges ─────────────────────────────────────────────────────────── pub memory_used_bytes: GaugeFamily, pub memory_budget_bytes: GaugeFamily, pub models_loaded: GaugeFamily, @@ -395,8 +393,14 @@ impl MetricsState { self.models_loaded.dec(Labels::new()); } - EngineEvent::EvictionTriggered(_) => { + EngineEvent::EvictionTriggered(e) => { self.evictions_total.inc(Labels::new()); + + // Reconcile memory gauge against the absolute post-eviction value. + // This corrects any cumulative drift caused by dropped ModelUnloaded + // events under channel backpressure. The running sum is replaced with + // the authoritative value from the eviction subsystem. + self.memory_used_bytes.set(Labels::new(), e.memory_after_bytes as f64); } EngineEvent::PreflightSignal(e) => { @@ -1010,7 +1014,7 @@ mod tests { } } - // 7 capabilities × 2 statuses = 14 series. Within §4.5 bounds. + // 7 capabilities × 2 statuses = 14 series. Within defined cardinality bounds. assert_eq!(state.requests_total.values.len(), 14); } } diff --git a/mofa-observability/src/events.rs b/mofa-observability/src/events.rs index e5682f5..1541c74 100644 --- a/mofa-observability/src/events.rs +++ b/mofa-observability/src/events.rs @@ -7,8 +7,6 @@ //! - Zero side effects: events are data, not actions. //! - Privacy: no prompt text, file contents, API keys, or user-identifying info. Ever. //! - Bounded enums: capability, reason, source, status use enums, not free-form strings. -//! -//! Reference: observability_plan.md §3 use serde::{Deserialize, Serialize}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -86,7 +84,6 @@ impl std::fmt::Display for SignalSource { // ─── Event Envelope ────────────────────────────────────────────────────────── /// Every event carries this standard envelope. -/// Reference: observability_plan.md §3.1 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EventEnvelope { /// Unix timestamp in milliseconds. @@ -153,7 +150,6 @@ impl EventEnvelope { // ─── Engine Event (discriminated union) ────────────────────────────────────── /// All possible engine events. The collector pattern-matches on this. -/// Reference: observability_plan.md §3.2 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum EngineEvent { @@ -350,7 +346,7 @@ pub struct FailoverTriggered { // - source → SignalSource enum (2 variants) // - status → bool (success/failure) // -// Reference: observability_plan.md §3.3 +// Reference: see privacy contract in module-level doc comment above. #[cfg(test)] mod tests { @@ -572,4 +568,106 @@ mod tests { assert!(json.is_ok(), "Failed to serialize: {:?}", envelope); } } + + /// Privacy contract: serialized events must never contain prompt text, + /// file contents, API keys, credentials, or user-identifying information. + /// This test enforces the contract by serializing every event variant and + /// asserting that no forbidden field names appear in the JSON output. + #[test] + fn test_privacy_contract_no_forbidden_fields() { + let forbidden_fields = [ + "prompt", "text", "generated_text", "response_text", + "file_path", "file_content", "file_contents", + "api_key", "api_secret", "token", "auth_token", "access_token", + "password", "credential", "credentials", + "user_id", "username", "email", "ip_address", + ]; + + let events = vec![ + EngineEvent::RequestReceived(RequestReceived { + capability: Capability::Chat, + model: Some("test-model".into()), + hint: Some("test-hint".into()), + }), + EngineEvent::RoutingDecision(RoutingDecision { + capability: Capability::Chat, + candidates_count: 3, + selected_model: "qwen2.5:7b".into(), + selected_backend: "ollama".into(), + is_fallback: false, + reason: "local_first".into(), + }), + EngineEvent::RequestCompleted(RequestCompleted { + model_id: "qwen2.5:7b".into(), + backend: "ollama".into(), + capability: Capability::Chat, + duration_ms: 1500, + ttft_ms: Some(115), + tokens_in: Some(50), + tokens_out: Some(277), + model_was_hot: Some(true), + success: true, + error_code: None, + }), + EngineEvent::ModelLoaded(ModelLoaded { + model_id: "qwen2.5:7b".into(), + backend: "ollama".into(), + capability: Capability::Chat, + load_duration_ms: 1532, + memory_bytes: 4_700_000_000, + }), + EngineEvent::ModelUnloaded(ModelUnloaded { + model_id: "gemma3:4b".into(), + reason: UnloadReason::IdleTimeout, + memory_freed_bytes: 3_300_000_000, + }), + EngineEvent::EvictionTriggered(EvictionTriggered { + evicted_model: "gemma3:4b".into(), + memory_before_bytes: 8_000_000_000, + memory_after_bytes: 4_700_000_000, + budget_bytes: 11_000_000_000, + }), + EngineEvent::PreflightSignal(PreflightSignal { + predicted_capability: Capability::Tts, + confidence: 0.95, + source: SignalSource::History, + }), + EngineEvent::PreflightHit(PreflightHit { + predicted_capability: Capability::Tts, + cold_start_avoided_ms: 2404, + }), + EngineEvent::PreflightMiss(PreflightMiss { + predicted_capability: Capability::Tts, + actual_capability: Capability::Chat, + }), + EngineEvent::ProviderDiscovered(ProviderDiscovered { + provider_name: "ollama".into(), + models_found: 3, + capabilities: vec![Capability::Chat, Capability::Vlm], + }), + EngineEvent::FailoverTriggered(FailoverTriggered { + failed_model: "qwen2.5:7b".into(), + failed_backend: "ollama".into(), + fallback_model: "gemma3:4b".into(), + fallback_backend: "ollama".into(), + }), + ]; + + for event in events { + let envelope = EventEnvelope::now(event); + let json = serde_json::to_string(&envelope).expect("serialize failed"); + let json_lower = json.to_lowercase(); + + for field in &forbidden_fields { + // Check for the field as a JSON key: "field_name": + let pattern = format!("\"{}\":", field); + assert!( + !json_lower.contains(&pattern), + "Privacy violation: forbidden field '{}' found in serialized event: {}", + field, + json + ); + } + } + } } From 2c7258559fe3ba5b19b44cf0ba97471a2e39f143 Mon Sep 17 00:00:00 2001 From: ashum9 Date: Sun, 28 Jun 2026 00:44:53 +0530 Subject: [PATCH 07/75] add Grafana dashboard definitions for engine overview, memory lifecycle, and preflight routing --- .../dashboards/engine_overview.json | 99 +++++++++++++++++++ .../dashboards/memory_lifecycle.json | 94 ++++++++++++++++++ .../dashboards/preflight_routing.json | 81 +++++++++++++++ 3 files changed, 274 insertions(+) create mode 100644 mofa-observability/dashboards/engine_overview.json create mode 100644 mofa-observability/dashboards/memory_lifecycle.json create mode 100644 mofa-observability/dashboards/preflight_routing.json diff --git a/mofa-observability/dashboards/engine_overview.json b/mofa-observability/dashboards/engine_overview.json new file mode 100644 index 0000000..a1e4c50 --- /dev/null +++ b/mofa-observability/dashboards/engine_overview.json @@ -0,0 +1,99 @@ +{ + "title": "MoFA Engine Overview", + "refresh": "10s", + "schemaVersion": 39, + "timezone": "browser", + "panels": [ + { + "type": "stat", + "title": "Request Rate (1m)", + "gridPos": { "x": 0, "y": 0, "w": 6, "h": 4 }, + "targets": [ + { + "expr": "sum(rate(mofa_requests_total[1m]))", + "legendFormat": "Requests / sec" + } + ] + }, + { + "type": "stat", + "title": "Error Rate", + "gridPos": { "x": 6, "y": 0, "w": 6, "h": 4 }, + "targets": [ + { + "expr": "sum(rate(mofa_requests_total{status=\"error\"}[5m])) / sum(rate(mofa_requests_total[5m])) * 100", + "legendFormat": "Error %" + } + ] + }, + { + "type": "stat", + "title": "Models Loaded", + "gridPos": { "x": 12, "y": 0, "w": 6, "h": 4 }, + "targets": [ + { + "expr": "mofa_models_loaded", + "legendFormat": "Count" + } + ] + }, + { + "type": "stat", + "title": "Memory Usage %", + "gridPos": { "x": 18, "y": 0, "w": 6, "h": 4 }, + "targets": [ + { + "expr": "mofa_memory_used_bytes / mofa_memory_budget_bytes * 100", + "legendFormat": "%" + } + ] + }, + { + "type": "timeseries", + "title": "P95 Request Latency by Capability", + "gridPos": { "x": 0, "y": 4, "w": 12, "h": 8 }, + "targets": [ + { + "expr": "histogram_quantile(0.95, sum(rate(mofa_request_duration_seconds_bucket[5m])) by (le, capability))", + "legendFormat": "{{capability}} P95" + } + ], + "fieldConfig": { "defaults": { "unit": "s" } } + }, + { + "type": "timeseries", + "title": "P95 Time to First Token", + "gridPos": { "x": 12, "y": 4, "w": 12, "h": 8 }, + "targets": [ + { + "expr": "histogram_quantile(0.95, sum(rate(mofa_ttft_seconds_bucket[5m])) by (le, model))", + "legendFormat": "{{model}} TTFT P95" + } + ], + "fieldConfig": { "defaults": { "unit": "s" } } + }, + { + "type": "timeseries", + "title": "Requests per Second (by Capability)", + "gridPos": { "x": 0, "y": 12, "w": 12, "h": 8 }, + "targets": [ + { + "expr": "sum(rate(mofa_requests_total[1m])) by (capability)", + "legendFormat": "{{capability}}" + } + ], + "fieldConfig": { "defaults": { "custom": { "fillOpacity": 70 } } } + }, + { + "type": "timeseries", + "title": "Output Tokens per Second (by Model)", + "gridPos": { "x": 12, "y": 12, "w": 12, "h": 8 }, + "targets": [ + { + "expr": "sum(rate(mofa_tokens_output_total[1m])) by (model)", + "legendFormat": "{{model}}" + } + ] + } + ] +} diff --git a/mofa-observability/dashboards/memory_lifecycle.json b/mofa-observability/dashboards/memory_lifecycle.json new file mode 100644 index 0000000..65a03f0 --- /dev/null +++ b/mofa-observability/dashboards/memory_lifecycle.json @@ -0,0 +1,94 @@ +{ + "title": "MoFA Memory & Lifecycle", + "refresh": "10s", + "schemaVersion": 39, + "timezone": "browser", + "panels": [ + { + "type": "gauge", + "title": "Memory Utilization", + "gridPos": { "x": 0, "y": 0, "w": 6, "h": 6 }, + "targets": [ + { + "expr": "mofa_memory_used_bytes / mofa_memory_budget_bytes * 100", + "legendFormat": "% Used" + } + ], + "fieldConfig": { + "defaults": { + "min": 0, + "max": 100, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "orange", "value": 70 }, + { "color": "red", "value": 90 } + ] + } + } + } + }, + { + "type": "timeseries", + "title": "Memory Usage vs Budget", + "gridPos": { "x": 6, "y": 0, "w": 18, "h": 6 }, + "targets": [ + { + "expr": "mofa_memory_used_bytes", + "legendFormat": "Used" + }, + { + "expr": "mofa_memory_budget_bytes", + "legendFormat": "Budget" + } + ], + "fieldConfig": { "defaults": { "unit": "bytes" } } + }, + { + "type": "barchart", + "title": "Model Loads (Last 5m)", + "gridPos": { "x": 0, "y": 6, "w": 8, "h": 8 }, + "targets": [ + { + "expr": "sum(increase(mofa_model_loads_total[5m])) by (model)", + "legendFormat": "{{model}}" + } + ] + }, + { + "type": "barchart", + "title": "Model Unloads by Reason", + "gridPos": { "x": 8, "y": 6, "w": 8, "h": 8 }, + "targets": [ + { + "expr": "sum(increase(mofa_model_unloads_total[5m])) by (reason)", + "legendFormat": "{{reason}}" + } + ] + }, + { + "type": "timeseries", + "title": "Eviction Rate", + "gridPos": { "x": 16, "y": 6, "w": 8, "h": 8 }, + "targets": [ + { + "expr": "sum(rate(mofa_evictions_total[1m]))", + "legendFormat": "Evictions / sec" + } + ] + }, + { + "type": "heatmap", + "title": "Cold Load Duration Heatmap", + "gridPos": { "x": 0, "y": 14, "w": 24, "h": 8 }, + "targets": [ + { + "expr": "sum(rate(mofa_model_load_seconds_bucket[5m])) by (le)", + "format": "heatmap", + "legendFormat": "{{le}}" + } + ] + } + ] +} diff --git a/mofa-observability/dashboards/preflight_routing.json b/mofa-observability/dashboards/preflight_routing.json new file mode 100644 index 0000000..7442ac8 --- /dev/null +++ b/mofa-observability/dashboards/preflight_routing.json @@ -0,0 +1,81 @@ +{ + "title": "MoFA Preflight & Routing", + "refresh": "10s", + "schemaVersion": 39, + "timezone": "browser", + "panels": [ + { + "type": "gauge", + "title": "Prediction Accuracy", + "gridPos": { "x": 0, "y": 0, "w": 6, "h": 6 }, + "targets": [ + { + "expr": "sum(rate(mofa_preflight_hits_total[1h])) / (sum(rate(mofa_preflight_hits_total[1h])) + sum(rate(mofa_preflight_misses_total[1h]))) * 100", + "legendFormat": "Accuracy %" + } + ], + "fieldConfig": { + "defaults": { + "min": 0, + "max": 100, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": null }, + { "color": "orange", "value": 70 }, + { "color": "green", "value": 90 } + ] + } + } + } + }, + { + "type": "timeseries", + "title": "Predictions by Source", + "gridPos": { "x": 6, "y": 0, "w": 10, "h": 6 }, + "targets": [ + { + "expr": "sum(rate(mofa_preflight_predictions_total[5m])) by (source)", + "legendFormat": "{{source}}" + } + ] + }, + { + "type": "piechart", + "title": "Hit vs Miss", + "gridPos": { "x": 16, "y": 0, "w": 8, "h": 6 }, + "targets": [ + { + "expr": "sum(mofa_preflight_hits_total)", + "legendFormat": "Hits" + }, + { + "expr": "sum(mofa_preflight_misses_total)", + "legendFormat": "Misses" + } + ] + }, + { + "type": "timeseries", + "title": "Failover Rate", + "gridPos": { "x": 0, "y": 6, "w": 12, "h": 6 }, + "targets": [ + { + "expr": "sum(rate(mofa_failovers_total[5m]))", + "legendFormat": "Failovers / sec" + } + ] + }, + { + "type": "piechart", + "title": "Requests by Backend", + "gridPos": { "x": 12, "y": 6, "w": 12, "h": 6 }, + "targets": [ + { + "expr": "sum(rate(mofa_requests_total[5m])) by (backend)", + "legendFormat": "{{backend}}" + } + ] + } + ] +} From 006ccc1ff684632a3c1791d48c60a2c6592dcf78 Mon Sep 17 00:00:00 2001 From: ashum9 Date: Sun, 28 Jun 2026 00:45:23 +0530 Subject: [PATCH 08/75] add mock event harness and Docker compose stack for local observability demo --- mofa-observability/Cargo.toml | 4 + mofa-observability/docker/Dockerfile | 15 ++ mofa-observability/docker/docker-compose.yml | 34 +++ .../provisioning/dashboards/dashboards.yml | 11 + .../provisioning/datasources/prometheus.yml | 9 + .../docker/prometheus/prometheus.yml | 7 + mofa-observability/examples/mock_harness.rs | 251 ++++++++++++++++++ 7 files changed, 331 insertions(+) create mode 100644 mofa-observability/docker/Dockerfile create mode 100644 mofa-observability/docker/docker-compose.yml create mode 100644 mofa-observability/docker/grafana/provisioning/dashboards/dashboards.yml create mode 100644 mofa-observability/docker/grafana/provisioning/datasources/prometheus.yml create mode 100644 mofa-observability/docker/prometheus/prometheus.yml create mode 100644 mofa-observability/examples/mock_harness.rs diff --git a/mofa-observability/Cargo.toml b/mofa-observability/Cargo.toml index b1402f5..589570a 100644 --- a/mofa-observability/Cargo.toml +++ b/mofa-observability/Cargo.toml @@ -8,3 +8,7 @@ license.workspace = true serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } + +[dev-dependencies] +axum = { workspace = true } +rand = "0.8" diff --git a/mofa-observability/docker/Dockerfile b/mofa-observability/docker/Dockerfile new file mode 100644 index 0000000..d04751d --- /dev/null +++ b/mofa-observability/docker/Dockerfile @@ -0,0 +1,15 @@ +FROM rust:1.89-slim as builder + +WORKDIR /usr/src/mofa +# Copy entire workspace since mofa-observability depends on workspace Cargo.toml +COPY . . + +WORKDIR /usr/src/mofa/mofa-observability +RUN cargo build --example mock_harness --release + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y libssl-dev ca-certificates && rm -rf /var/lib/apt/lists/* +COPY --from=builder /usr/src/mofa/target/release/examples/mock_harness /usr/local/bin/mock_harness + +EXPOSE 9090 +CMD ["mock_harness"] diff --git a/mofa-observability/docker/docker-compose.yml b/mofa-observability/docker/docker-compose.yml new file mode 100644 index 0000000..1f0fde8 --- /dev/null +++ b/mofa-observability/docker/docker-compose.yml @@ -0,0 +1,34 @@ +version: '3.8' + +services: + mock-harness: + build: + context: ../../ + dockerfile: mofa-observability/docker/Dockerfile + ports: + - "9090:9090" + + prometheus: + image: prom/prometheus:latest + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml + ports: + - "9091:9090" + command: + - '--config.file=/etc/prometheus/prometheus.yml' + depends_on: + - mock-harness + + grafana: + image: grafana/grafana:latest + ports: + - "3000:3000" + volumes: + - ./grafana/provisioning/datasources:/etc/grafana/provisioning/datasources + - ./grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards + - ../dashboards:/var/lib/grafana/dashboards + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + - GF_USERS_ALLOW_SIGN_UP=false + depends_on: + - prometheus diff --git a/mofa-observability/docker/grafana/provisioning/dashboards/dashboards.yml b/mofa-observability/docker/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 0000000..885f802 --- /dev/null +++ b/mofa-observability/docker/grafana/provisioning/dashboards/dashboards.yml @@ -0,0 +1,11 @@ +apiVersion: 1 + +providers: + - name: 'MoFA Dashboards' + orgId: 1 + folder: '' + type: file + disableDeletion: false + updateIntervalSeconds: 10 + options: + path: /var/lib/grafana/dashboards diff --git a/mofa-observability/docker/grafana/provisioning/datasources/prometheus.yml b/mofa-observability/docker/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 0000000..bb009bb --- /dev/null +++ b/mofa-observability/docker/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,9 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: false diff --git a/mofa-observability/docker/prometheus/prometheus.yml b/mofa-observability/docker/prometheus/prometheus.yml new file mode 100644 index 0000000..454bc13 --- /dev/null +++ b/mofa-observability/docker/prometheus/prometheus.yml @@ -0,0 +1,7 @@ +global: + scrape_interval: 5s + +scrape_configs: + - job_name: 'mofa_mock_harness' + static_configs: + - targets: ['mock-harness:9090'] diff --git a/mofa-observability/examples/mock_harness.rs b/mofa-observability/examples/mock_harness.rs new file mode 100644 index 0000000..d7d55dc --- /dev/null +++ b/mofa-observability/examples/mock_harness.rs @@ -0,0 +1,251 @@ +//! Mock Event Harness for MoFA Engine Observability +//! +//! Generates synthetic engine events and serves them over an HTTP `/metrics` endpoint. +//! This allows testing the Prometheus renderer and populating Grafana dashboards +//! before the real engine is fully integrated. +//! +//! Run with: `cargo run --example mock_harness` + +use axum::{extract::State, routing::get, Router}; +use mofa_observability::{ + collector::{create_event_channel, EventSender, MetricsCollector, MetricsState}, + events::*, + prometheus, +}; +use rand::{rngs::StdRng, seq::SliceRandom, Rng, SeedableRng}; +use std::{net::SocketAddr, sync::Arc, time::Duration}; +use tokio::sync::RwLock; + +/// Starts the HTTP server serving `/metrics`. +async fn metrics_handler(State(state): State>>) -> String { + let state_reader = state.read().await; + prometheus::render(&state_reader) +} + +/// Generates a continuous stream of synthetic engine events. +async fn simulate_engine_traffic(sender: EventSender) { + let mut rng = StdRng::from_entropy(); + + // Available models for the mock + let models = [ + ("qwen2.5:7b", "ollama", Capability::Chat), + ("gemma3:4b", "ollama", Capability::Chat), + ("whisper-1", "openai", Capability::Asr), + ("kokoro", "local", Capability::Tts), + ]; + + // Initial setup: Load a couple of models to have baseline memory. + let m1 = models[0]; + sender.send(EventEnvelope::now(EngineEvent::ModelLoaded(ModelLoaded { + model_id: m1.0.into(), + backend: m1.1.into(), + capability: m1.2, + load_duration_ms: rng.gen_range(1500..3500), + memory_bytes: 4_700_000_000, + }))); + + let m2 = models[3]; + sender.send(EventEnvelope::now(EngineEvent::ModelLoaded(ModelLoaded { + model_id: m2.0.into(), + backend: m2.1.into(), + capability: m2.2, + load_duration_ms: rng.gen_range(500..1200), + memory_bytes: 800_000_000, + }))); + + // Continuous event loop + loop { + // Sleep between 50ms and 500ms to generate roughly 2-20 requests/sec. + tokio::time::sleep(Duration::from_millis(rng.gen_range(50..500))).await; + + let action = rng.gen_range(0..100); + let selected_model = models.choose(&mut rng).unwrap(); + + match action { + // 5% chance: Load a new model + 0..=4 => { + sender.send(EventEnvelope::now(EngineEvent::ModelLoaded(ModelLoaded { + model_id: selected_model.0.into(), + backend: selected_model.1.into(), + capability: selected_model.2, + load_duration_ms: rng.gen_range(1000..5000), + memory_bytes: rng.gen_range(500_000_000..8_000_000_000), + }))); + } + + // 5% chance: Unload a model + 5..=9 => { + let reason = match rng.gen_range(0..3) { + 0 => UnloadReason::IdleTimeout, + 1 => UnloadReason::Eviction, + _ => UnloadReason::Explicit, + }; + sender.send(EventEnvelope::now(EngineEvent::ModelUnloaded( + ModelUnloaded { + model_id: selected_model.0.into(), + reason, + memory_freed_bytes: rng.gen_range(500_000_000..8_000_000_000), + }, + ))); + + // If eviction, also trigger eviction event + if reason == UnloadReason::Eviction { + sender.send(EventEnvelope::now(EngineEvent::EvictionTriggered( + EvictionTriggered { + evicted_model: selected_model.0.into(), + memory_before_bytes: 15_000_000_000, + memory_after_bytes: 12_000_000_000, + budget_bytes: 16_000_000_000, + }, + ))); + } + } + + // 10% chance: Preflight Hit/Miss + 10..=19 => { + let is_hit = rng.gen_bool(0.85); // 85% accuracy + let source = if rng.gen_bool(0.7) { + SignalSource::History + } else { + SignalSource::Hint + }; + + sender.send(EventEnvelope::now(EngineEvent::PreflightSignal( + PreflightSignal { + predicted_capability: selected_model.2, + confidence: rng.gen_range(0.6..1.0), + source, + }, + ))); + + if is_hit { + sender.send(EventEnvelope::now(EngineEvent::PreflightHit( + PreflightHit { + predicted_capability: selected_model.2, + cold_start_avoided_ms: rng.gen_range(1000..5000), + }, + ))); + } else { + let mut wrong_cap = Capability::Chat; + if selected_model.2 == Capability::Chat { + wrong_cap = Capability::Tts; + } + sender.send(EventEnvelope::now(EngineEvent::PreflightMiss( + PreflightMiss { + predicted_capability: selected_model.2, + actual_capability: wrong_cap, + }, + ))); + } + } + + // 1% chance: Failover + 20..=20 => { + sender.send(EventEnvelope::now(EngineEvent::FailoverTriggered( + FailoverTriggered { + failed_model: selected_model.0.into(), + failed_backend: selected_model.1.into(), + fallback_model: "fallback-model".into(), + fallback_backend: "cloud".into(), + }, + ))); + } + + // 79% chance: Normal Inference Request + _ => { + // 1. Request Received + sender.send(EventEnvelope::now(EngineEvent::RequestReceived( + RequestReceived { + capability: selected_model.2, + model: None, + hint: None, + }, + ))); + + // Sleep slightly to simulate inference time + let duration_ms = match selected_model.2 { + Capability::Chat => rng.gen_range(200..15000), // Text generation is variable + _ => rng.gen_range(100..2000), // API calls faster + }; + tokio::time::sleep(Duration::from_millis(duration_ms.min(100))).await; + + let success = rng.gen_bool(0.98); // 98% success rate + + let ttft_ms = if selected_model.2 == Capability::Chat && success { + Some(rng.gen_range(50..800)) + } else { + None + }; + + let tokens_in = if selected_model.2 == Capability::Chat { + Some(rng.gen_range(10..1000)) + } else { + None + }; + + let tokens_out = if selected_model.2 == Capability::Chat && success { + Some(rng.gen_range(5..800)) + } else { + None + }; + + // 2. Request Completed + sender.send(EventEnvelope::now(EngineEvent::RequestCompleted( + RequestCompleted { + model_id: selected_model.0.into(), + backend: selected_model.1.into(), + capability: selected_model.2, + duration_ms, + ttft_ms, + tokens_in, + tokens_out, + model_was_hot: Some(rng.gen_bool(0.9)), // Usually hot + success, + error_code: if success { + None + } else { + Some("internal_error".into()) + }, + }, + ))); + } + } + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + println!("Starting MoFA Engine Observability Mock Harness..."); + + // 1. Initialize Event Channel (capacity 1024) + let (sender, receiver) = create_event_channel(1024); + + // 2. Initialize Metrics Collector + let collector = MetricsCollector::new(receiver); + let state = collector.state(); + + // 3. Spawn Collector Task + tokio::spawn(collector.run()); + + // 4. Spawn Event Generator Task + tokio::spawn(simulate_engine_traffic(sender)); + + // 5. Setup HTTP Server for Prometheus scraping + let app = Router::new() + .route("/metrics", get(metrics_handler)) + .with_state(state); + + let port = 9090; + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + let listener = tokio::net::TcpListener::bind(addr).await?; + + println!("======================================================="); + println!("🚀 Mock Harness Running!"); + println!("📊 Prometheus Endpoint: http://localhost:{}/metrics", port); + println!(" Try running: curl http://localhost:{}/metrics", port); + println!("======================================================="); + + axum::serve(listener, app).await?; + + Ok(()) +} From 6c32e245d1e0d23c6aa8f670a6f3f1bf5511d28c Mon Sep 17 00:00:00 2001 From: ashum9 Date: Sun, 28 Jun 2026 00:46:20 +0530 Subject: [PATCH 09/75] update cargo.lock for mofa-obseravbility --- Cargo.lock | 40 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1e4489e..cb24070 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1097,6 +1097,8 @@ dependencies = [ name = "mofa-observability" version = "0.1.0" dependencies = [ + "axum", + "rand 0.8.6", "serde", "serde_json", "tokio", @@ -1315,14 +1317,35 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha", - "rand_core", + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", ] [[package]] @@ -1332,7 +1355,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -2072,7 +2104,7 @@ dependencies = [ "http", "httparse", "log", - "rand", + "rand 0.9.4", "sha1", "thiserror", ] From f947d37e41ef3fdf3a4135e2510e046e13e60671 Mon Sep 17 00:00:00 2001 From: ashum9 Date: Tue, 30 Jun 2026 21:25:17 +0530 Subject: [PATCH 10/75] prevent memory leak by evicting stale metric labels --- mofa-observability/src/collector.rs | 233 ++++++++++++++++++++++++++-- 1 file changed, 218 insertions(+), 15 deletions(-) diff --git a/mofa-observability/src/collector.rs b/mofa-observability/src/collector.rs index 524d5b8..a6c8190 100644 --- a/mofa-observability/src/collector.rs +++ b/mofa-observability/src/collector.rs @@ -13,6 +13,7 @@ use crate::events::*; use std::collections::HashMap; use std::sync::Arc; +use std::time::{Duration, Instant}; use tokio::sync::{mpsc, RwLock}; // ─── Labels ────────────────────────────────────────────────────────────────── @@ -85,6 +86,7 @@ pub struct CounterFamily { pub name: &'static str, pub help: &'static str, pub values: HashMap, + pub last_seen: HashMap, } impl CounterFamily { @@ -93,16 +95,31 @@ impl CounterFamily { name, help, values: HashMap::new(), + last_seen: HashMap::new(), } } fn inc(&mut self, labels: Labels) { + self.last_seen.insert(labels.clone(), Instant::now()); *self.values.entry(labels).or_insert(0) += 1; } fn inc_by(&mut self, labels: Labels, n: u64) { + self.last_seen.insert(labels.clone(), Instant::now()); *self.values.entry(labels).or_insert(0) += n; } + + fn evict_stale(&mut self, now: Instant, max_idle: Duration) { + let last_seen = &mut self.last_seen; + self.values.retain(|k, _| { + if let Some(&time) = last_seen.get(k) { + now.saturating_duration_since(time) < max_idle + } else { + true + } + }); + self.last_seen.retain(|_, v| now.saturating_duration_since(*v) < max_idle); + } } /// A family of labeled histograms. @@ -112,6 +129,7 @@ pub struct HistogramFamily { pub help: &'static str, pub bucket_boundaries: Vec, pub values: HashMap, + pub last_seen: HashMap, } impl HistogramFamily { @@ -121,15 +139,29 @@ impl HistogramFamily { help, bucket_boundaries: boundaries, values: HashMap::new(), + last_seen: HashMap::new(), } } fn observe(&mut self, labels: Labels, value: f64) { + self.last_seen.insert(labels.clone(), Instant::now()); self.values .entry(labels) .or_insert_with(|| HistogramValue::new(&self.bucket_boundaries)) .observe(value); } + + fn evict_stale(&mut self, now: Instant, max_idle: Duration) { + let last_seen = &mut self.last_seen; + self.values.retain(|k, _| { + if let Some(&time) = last_seen.get(k) { + now.saturating_duration_since(time) < max_idle + } else { + true + } + }); + self.last_seen.retain(|_, v| now.saturating_duration_since(*v) < max_idle); + } } /// A family of labeled gauges. @@ -138,6 +170,7 @@ pub struct GaugeFamily { pub name: &'static str, pub help: &'static str, pub values: HashMap, + pub last_seen: HashMap, } impl GaugeFamily { @@ -146,28 +179,46 @@ impl GaugeFamily { name, help, values: HashMap::new(), + last_seen: HashMap::new(), } } fn set(&mut self, labels: Labels, value: f64) { + self.last_seen.insert(labels.clone(), Instant::now()); self.values.insert(labels, value); } fn inc(&mut self, labels: Labels) { + self.last_seen.insert(labels.clone(), Instant::now()); *self.values.entry(labels).or_insert(0.0) += 1.0; } fn dec(&mut self, labels: Labels) { + self.last_seen.insert(labels.clone(), Instant::now()); *self.values.entry(labels).or_insert(0.0) -= 1.0; } fn add(&mut self, labels: Labels, n: f64) { + self.last_seen.insert(labels.clone(), Instant::now()); *self.values.entry(labels).or_insert(0.0) += n; } fn sub(&mut self, labels: Labels, n: f64) { + self.last_seen.insert(labels.clone(), Instant::now()); *self.values.entry(labels).or_insert(0.0) -= n; } + + fn evict_stale(&mut self, now: Instant, max_idle: Duration) { + let last_seen = &mut self.last_seen; + self.values.retain(|k, _| { + if let Some(&time) = last_seen.get(k) { + now.saturating_duration_since(time) < max_idle + } else { + true + } + }); + self.last_seen.retain(|_, v| now.saturating_duration_since(*v) < max_idle); + } } // ─── Metrics State ─────────────────────────────────────────────────────────── @@ -315,6 +366,15 @@ impl MetricsState { } EngineEvent::RequestCompleted(e) => { + tracing::info!( + trace_id = ?envelope.trace_id, + request_id = ?envelope.request_id, + model = %e.model_id, + duration_ms = e.duration_ms, + success = e.success, + "Request completed" + ); + // Counter: requests by capability × status let status = if e.success { "success" } else { "error" }; self.requests_total.inc( @@ -358,6 +418,13 @@ impl MetricsState { } EngineEvent::ModelLoaded(e) => { + tracing::info!( + model = %e.model_id, + backend = %e.backend, + load_duration_ms = e.load_duration_ms, + "Model loaded" + ); + // Counter self.model_loads_total.inc( Labels::new() @@ -394,6 +461,13 @@ impl MetricsState { } EngineEvent::EvictionTriggered(e) => { + tracing::warn!( + evicted_model = %e.evicted_model, + memory_before = e.memory_before_bytes, + memory_after = e.memory_after_bytes, + "Eviction triggered" + ); + self.evictions_total.inc(Labels::new()); // Reconcile memory gauge against the absolute post-eviction value. @@ -422,12 +496,47 @@ impl MetricsState { } EngineEvent::FailoverTriggered(e) => { + tracing::error!( + failed_model = %e.failed_model, + fallback_model = %e.fallback_model, + "Failover triggered" + ); + // We don't have capability on FailoverTriggered, so label-free. let _ = e; self.failovers_total.inc(Labels::new()); } } } + + /// Evict metric labels that haven't been updated in `max_idle` duration. + pub fn evict_stale_labels(&mut self, max_idle: Duration) { + let now = Instant::now(); + + // Counters + self.requests_total.evict_stale(now, max_idle); + self.model_loads_total.evict_stale(now, max_idle); + self.model_unloads_total.evict_stale(now, max_idle); + self.failovers_total.evict_stale(now, max_idle); + self.evictions_total.evict_stale(now, max_idle); + self.preflight_predictions_total.evict_stale(now, max_idle); + self.preflight_hits_total.evict_stale(now, max_idle); + self.preflight_misses_total.evict_stale(now, max_idle); + self.tokens_input_total.evict_stale(now, max_idle); + self.tokens_output_total.evict_stale(now, max_idle); + self.events_dropped_total.evict_stale(now, max_idle); + + // Histograms + self.request_duration_seconds.evict_stale(now, max_idle); + self.model_load_seconds.evict_stale(now, max_idle); + self.ttft_seconds.evict_stale(now, max_idle); + + // Gauges + self.memory_used_bytes.evict_stale(now, max_idle); + self.memory_budget_bytes.evict_stale(now, max_idle); + self.models_loaded.evict_stale(now, max_idle); + self.active_requests.evict_stale(now, max_idle); + } } impl Default for MetricsState { @@ -447,7 +556,7 @@ pub struct EventSender { impl EventSender { /// Send an event. If the channel is full, the event is dropped - /// and the dropped counter is incremented. + /// and the dropped counter is incremented. Use for high-volume telemetry. pub fn send(&self, event: EventEnvelope) { if self.tx.try_send(event).is_err() { self.dropped @@ -455,6 +564,19 @@ impl EventSender { } } + /// Send a critical event asynchronously. If the channel is full, this will await + /// and apply backpressure to the caller instead of dropping the event. + /// Use for rare, critical events like `FailoverTriggered` and `EvictionTriggered`. + pub async fn send_critical(&self, event: EventEnvelope) -> Result<(), mpsc::error::SendError> { + self.tx.send(event).await + } + + /// Send a critical event synchronously. If the channel is full, this will block + /// the current thread. Must not be called from within an async runtime context. + pub fn send_blocking(&self, event: EventEnvelope) -> Result<(), mpsc::error::SendError> { + self.tx.blocking_send(event) + } + /// How many events have been dropped due to backpressure. pub fn dropped_count(&self) -> u64 { self.dropped.load(std::sync::atomic::Ordering::Relaxed) @@ -515,22 +637,39 @@ impl MetricsCollector { } /// Run the collector loop. Processes events until the channel closes. + /// Also periodically evicts stale metric labels to prevent memory leaks. pub async fn run(mut self) { - while let Some(envelope) = self.receiver.rx.recv().await { - let mut state = self.state.write().await; - - // Sync the dropped counter into metrics state. - let dropped = self - .receiver - .dropped - .swap(0, std::sync::atomic::Ordering::Relaxed); - if dropped > 0 { - state - .events_dropped_total - .inc_by(Labels::new(), dropped); + let mut cleanup_interval = tokio::time::interval(Duration::from_secs(60)); + let max_idle = Duration::from_secs(600); // 10 minutes + + loop { + tokio::select! { + envelope_opt = self.receiver.rx.recv() => { + match envelope_opt { + Some(envelope) => { + let mut state = self.state.write().await; + + // Sync the dropped counter into metrics state. + let dropped = self + .receiver + .dropped + .swap(0, std::sync::atomic::Ordering::Relaxed); + if dropped > 0 { + state + .events_dropped_total + .inc_by(Labels::new(), dropped); + } + + state.process_event(&envelope); + } + None => break, // Channel closed + } + } + _ = cleanup_interval.tick() => { + let mut state = self.state.write().await; + state.evict_stale_labels(max_idle); + } } - - state.process_event(&envelope); } } } @@ -978,6 +1117,46 @@ mod tests { assert!(sender.dropped_count() >= 3); } + #[tokio::test] + async fn test_send_critical_blocks() { + // Channel of size 1 + let (sender, mut receiver) = create_event_channel(1); + + // Fill channel + sender.send(EventEnvelope::now(EngineEvent::EvictionTriggered( + EvictionTriggered { + evicted_model: "test1".into(), + memory_before_bytes: 100, + memory_after_bytes: 50, + budget_bytes: 100, + }, + ))); + + // Try to send critical event. It should block because the channel is full. + // We use tokio::time::timeout to prove it blocks. + let critical_event = EventEnvelope::now(EngineEvent::FailoverTriggered( + FailoverTriggered { + failed_model: "failed".into(), + failed_backend: "backend".into(), + fallback_model: "fallback".into(), + fallback_backend: "backend".into(), + }, + )); + + let send_future = sender.send_critical(critical_event.clone()); + let result = tokio::time::timeout(Duration::from_millis(50), send_future).await; + + // It should timeout because it's blocking + assert!(result.is_err()); + + // Now drain the channel + let _ = receiver.rx.recv().await; + + // Sending should now succeed + let send_result = sender.send_critical(critical_event).await; + assert!(send_result.is_ok()); + } + // ── Label cardinality test ─────────────────────────────────────────── #[test] @@ -1017,4 +1196,28 @@ mod tests { // 7 capabilities × 2 statuses = 14 series. Within defined cardinality bounds. assert_eq!(state.requests_total.values.len(), 14); } + + #[test] + fn test_evict_stale_labels() { + let mut state = make_state(); + let labels_fresh = Labels::new().add("model", "fresh"); + let labels_stale = Labels::new().add("model", "stale"); + + // Insert labels + state.requests_total.inc(labels_fresh.clone()); + state.requests_total.inc(labels_stale.clone()); + + // Override the last_seen for the stale label to be 20 minutes ago + let twenty_mins_ago = Instant::now() - Duration::from_secs(1200); + state.requests_total.last_seen.insert(labels_stale.clone(), twenty_mins_ago); + + // Evict labels older than 10 minutes + state.evict_stale_labels(Duration::from_secs(600)); + + // The stale label should be gone, the fresh one should remain + assert!(state.requests_total.values.contains_key(&labels_fresh)); + assert!(!state.requests_total.values.contains_key(&labels_stale)); + assert!(state.requests_total.last_seen.contains_key(&labels_fresh)); + assert!(!state.requests_total.last_seen.contains_key(&labels_stale)); + } } From e5113bd1b998780d41b7948fe26f540af53dbe59 Mon Sep 17 00:00:00 2001 From: ashum9 Date: Tue, 30 Jun 2026 21:25:48 +0530 Subject: [PATCH 11/75] introduce send_critical to prevent dropping key events under backpressure --- mofa-observability/examples/mock_harness.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/mofa-observability/examples/mock_harness.rs b/mofa-observability/examples/mock_harness.rs index d7d55dc..384c98b 100644 --- a/mofa-observability/examples/mock_harness.rs +++ b/mofa-observability/examples/mock_harness.rs @@ -90,14 +90,14 @@ async fn simulate_engine_traffic(sender: EventSender) { // If eviction, also trigger eviction event if reason == UnloadReason::Eviction { - sender.send(EventEnvelope::now(EngineEvent::EvictionTriggered( + sender.send_critical(EventEnvelope::now(EngineEvent::EvictionTriggered( EvictionTriggered { evicted_model: selected_model.0.into(), memory_before_bytes: 15_000_000_000, memory_after_bytes: 12_000_000_000, budget_bytes: 16_000_000_000, }, - ))); + ))).await.expect("Failed to send critical event"); } } @@ -141,14 +141,14 @@ async fn simulate_engine_traffic(sender: EventSender) { // 1% chance: Failover 20..=20 => { - sender.send(EventEnvelope::now(EngineEvent::FailoverTriggered( + sender.send_critical(EventEnvelope::now(EngineEvent::FailoverTriggered( FailoverTriggered { failed_model: selected_model.0.into(), failed_backend: selected_model.1.into(), fallback_model: "fallback-model".into(), fallback_backend: "cloud".into(), }, - ))); + ))).await.expect("Failed to send critical event"); } // 79% chance: Normal Inference Request @@ -215,6 +215,10 @@ async fn simulate_engine_traffic(sender: EventSender) { #[tokio::main] async fn main() -> Result<(), Box> { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env().add_directive("mofa_observability=info".parse().unwrap())) + .init(); + println!("Starting MoFA Engine Observability Mock Harness..."); // 1. Initialize Event Channel (capacity 1024) From 858c8da02a606f379df98ffa6fcbdbb3fd4f5b25 Mon Sep 17 00:00:00 2001 From: ashum9 Date: Wed, 8 Jul 2026 12:29:37 +0530 Subject: [PATCH 12/75] add ObservabilityConfig struct --- mofa-engine-core/src/config.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/mofa-engine-core/src/config.rs b/mofa-engine-core/src/config.rs index a31c82c..f502ed6 100644 --- a/mofa-engine-core/src/config.rs +++ b/mofa-engine-core/src/config.rs @@ -22,6 +22,20 @@ pub struct EngineConfig { /// Provider definitions #[serde(default)] pub providers: Vec, + /// Observability settings + #[serde(default)] + pub observability: ObservabilityConfig, +} + +/// Observability configuration. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ObservabilityConfig { + /// Whether observability is enabled. + #[serde(default)] + pub enabled: bool, + /// The OpenTelemetry OTLP endpoint (e.g., http://localhost:4317) + #[serde(default)] + pub otlp_endpoint: Option, } /// Network listen configuration. @@ -399,6 +413,7 @@ impl EngineConfig { listen: ListenConfig::default(), memory: MemoryConfig::default(), providers, + observability: ObservabilityConfig::default(), } } } @@ -470,6 +485,7 @@ mod tests { #[test] fn toml_roundtrip() { let cfg = EngineConfig { + observability: Default::default(), listen: ListenConfig::default(), memory: MemoryConfig::default(), providers: vec![ProviderConfig { @@ -492,6 +508,7 @@ mod tests { #[test] fn validate_rejects_unknown_provider_kind() { let cfg = EngineConfig { + observability: Default::default(), listen: ListenConfig::default(), memory: MemoryConfig::default(), providers: vec![ProviderConfig { From 12f83251fbb2deb26642ed12a39d6cc15dfe0373 Mon Sep 17 00:00:00 2001 From: ashum9 Date: Wed, 8 Jul 2026 12:29:37 +0530 Subject: [PATCH 13/75] add OpenTelemetry workspace dependencies --- Cargo.lock | 417 ++++++++++++++++++++++++++++++++-- Cargo.toml | 4 + mofa-engine-app/Cargo.toml | 5 + mofa-engine-sdk/Cargo.toml | 1 + mofa-observability/Cargo.toml | 2 + 5 files changed, 410 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cb24070..99121ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,6 +76,28 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -99,13 +121,40 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core 0.4.5", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit 0.7.3", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper", + "tower 0.5.3", + "tower-layer", + "tower-service", +] + [[package]] name = "axum" version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ - "axum-core", + "axum-core 0.5.6", "base64", "bytes", "form_urlencoded", @@ -116,7 +165,7 @@ dependencies = [ "hyper", "hyper-util", "itoa", - "matchit", + "matchit 0.8.4", "memchr", "mime", "percent-encoding", @@ -129,12 +178,32 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-tungstenite", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", "tracing", ] +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", +] + [[package]] name = "axum-core" version = "0.5.6" @@ -160,8 +229,8 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" dependencies = [ - "axum", - "axum-core", + "axum 0.8.9", + "axum-core 0.5.6", "bytes", "futures-util", "headers", @@ -399,6 +468,12 @@ dependencies = [ "syn", ] +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -479,6 +554,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -487,6 +563,34 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -506,8 +610,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", + "futures-io", + "futures-macro", "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -558,6 +665,12 @@ dependencies = [ "wasip3", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "h2" version = "0.4.14" @@ -570,13 +683,19 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", "tracing", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" @@ -710,6 +829,19 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-tls" version = "0.6.0" @@ -743,7 +875,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.4", "system-configuration", "tokio", "tower-service", @@ -884,6 +1016,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -908,6 +1050,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -989,6 +1140,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "matchit" version = "0.8.4" @@ -1036,8 +1193,13 @@ dependencies = [ "clap", "mofa-engine-core", "mofa-engine-sdk", + "mofa-observability", + "opentelemetry", + "opentelemetry-otlp", + "opentelemetry_sdk", "tokio", "tracing", + "tracing-opentelemetry", "tracing-subscriber", ] @@ -1067,17 +1229,18 @@ dependencies = [ name = "mofa-engine-sdk" version = "0.1.0" dependencies = [ - "axum", + "axum 0.8.9", "axum-extra", "chrono", "mofa-engine-core", "mofa-kernel", + "mofa-observability", "serde", "serde_json", "thiserror", "tokio", "tokio-stream", - "tower", + "tower 0.5.3", "tower-http", "tracing", ] @@ -1097,11 +1260,13 @@ dependencies = [ name = "mofa-observability" version = "0.1.0" dependencies = [ - "axum", + "axum 0.8.9", "rand 0.8.6", "serde", "serde_json", "tokio", + "tracing", + "tracing-subscriber", ] [[package]] @@ -1212,6 +1377,88 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "opentelemetry" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "236e667b670a5cdf90c258f5a55794ec5ac5027e960c224bff8367a59e1e6426" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror", + "tracing", +] + +[[package]] +name = "opentelemetry-http" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8863faf2910030d139fb48715ad5ff2f35029fc5f244f6d5f689ddcf4d26253" +dependencies = [ + "async-trait", + "bytes", + "http", + "opentelemetry", + "reqwest", + "tracing", +] + +[[package]] +name = "opentelemetry-otlp" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bef114c6d41bea83d6dc60eb41720eedd0261a67af57b66dd2b84ac46c01d91" +dependencies = [ + "async-trait", + "futures-core", + "http", + "opentelemetry", + "opentelemetry-http", + "opentelemetry-proto", + "opentelemetry_sdk", + "prost", + "reqwest", + "thiserror", + "tokio", + "tonic", + "tracing", +] + +[[package]] +name = "opentelemetry-proto" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f8870d3024727e99212eb3bb1762ec16e255e3e6f58eeb3dc8db1aa226746d" +dependencies = [ + "opentelemetry", + "opentelemetry_sdk", + "prost", + "tonic", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84dfad6042089c7fc1f6118b7040dc2eb4ab520abbf410b79dc481032af39570" +dependencies = [ + "async-trait", + "futures-channel", + "futures-executor", + "futures-util", + "glob", + "opentelemetry", + "percent-encoding", + "rand 0.8.6", + "serde_json", + "thiserror", + "tokio", + "tokio-stream", + "tracing", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -1247,6 +1494,26 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1296,6 +1563,29 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "quote" version = "1.0.45" @@ -1422,6 +1712,7 @@ dependencies = [ "base64", "bytes", "encoding_rs", + "futures-channel", "futures-core", "futures-util", "h2", @@ -1446,7 +1737,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", - "tower", + "tower 0.5.3", "tower-http", "tower-service", "url", @@ -1694,6 +1985,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.4" @@ -1851,7 +2152,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.4", "tokio-macros", "windows-sys 0.61.2", ] @@ -1951,7 +2252,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde", "serde_spanned", "toml_datetime", @@ -1965,6 +2266,56 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tonic" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +dependencies = [ + "async-stream", + "async-trait", + "axum 0.7.9", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost", + "socket2 0.5.10", + "tokio", + "tokio-stream", + "tower 0.4.13", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand 0.8.6", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.3" @@ -1993,7 +2344,7 @@ dependencies = [ "http", "http-body", "pin-project-lite", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", "tracing", @@ -2056,6 +2407,24 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-opentelemetry" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "721f2d2569dce9f3dfbbddee5906941e953bfcdf736a62da3377f5751650cc36" +dependencies = [ + "js-sys", + "once_cell", + "opentelemetry", + "opentelemetry_sdk", + "smallvec", + "tracing", + "tracing-core", + "tracing-log", + "tracing-subscriber", + "web-time", +] + [[package]] name = "tracing-serde" version = "0.2.0" @@ -2297,7 +2666,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.14.0", "wasm-encoder", "wasmparser", ] @@ -2310,7 +2679,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", "semver", ] @@ -2324,6 +2693,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2594,7 +2973,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.14.0", "prettyplease", "syn", "wasm-metadata", @@ -2625,7 +3004,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -2644,7 +3023,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.14.0", "log", "semver", "serde", diff --git a/Cargo.toml b/Cargo.toml index 49103f3..a7a9867 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,10 @@ anyhow = "1" # Logging tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +opentelemetry = "0.28.0" +opentelemetry_sdk = { version = "0.28.0", features = ["rt-tokio"] } +tracing-opentelemetry = "0.29.0" +opentelemetry-otlp = { version = "0.28.0", features = ["grpc-tonic", "trace"] } # Utilities uuid = { version = "1", features = ["v4"] } diff --git a/mofa-engine-app/Cargo.toml b/mofa-engine-app/Cargo.toml index 6e7bc16..9bd22b6 100644 --- a/mofa-engine-app/Cargo.toml +++ b/mofa-engine-app/Cargo.toml @@ -8,8 +8,13 @@ description = "MoFA Engine binary entry point" [dependencies] mofa-engine-core = { workspace = true } mofa-engine-sdk = { workspace = true } +mofa-observability = { path = "../mofa-observability" } tokio = { workspace = true } clap = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } +tracing-opentelemetry = { workspace = true } +opentelemetry = { workspace = true } +opentelemetry_sdk = { workspace = true } +opentelemetry-otlp = { workspace = true } anyhow = { workspace = true } diff --git a/mofa-engine-sdk/Cargo.toml b/mofa-engine-sdk/Cargo.toml index eb785c4..9c20c37 100644 --- a/mofa-engine-sdk/Cargo.toml +++ b/mofa-engine-sdk/Cargo.toml @@ -19,3 +19,4 @@ thiserror = { workspace = true } tracing = { workspace = true } chrono = { workspace = true } tokio-stream = { workspace = true } +mofa-observability = { path = "../mofa-observability" } diff --git a/mofa-observability/Cargo.toml b/mofa-observability/Cargo.toml index 589570a..9c36c5c 100644 --- a/mofa-observability/Cargo.toml +++ b/mofa-observability/Cargo.toml @@ -8,7 +8,9 @@ license.workspace = true serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } +tracing = { workspace = true } [dev-dependencies] axum = { workspace = true } +tracing-subscriber = { version = "0.3", features = ["env-filter"] } rand = "0.8" From 56e2611e231a49a98ebb09bde4e2c631eb96e100 Mon Sep 17 00:00:00 2001 From: ashum9 Date: Wed, 8 Jul 2026 12:29:37 +0530 Subject: [PATCH 14/75] wire observability pipeline and OTLP tracer --- mofa-engine-app/src/main.rs | 85 ++++++++++++++++++++++++++++++++----- 1 file changed, 75 insertions(+), 10 deletions(-) diff --git a/mofa-engine-app/src/main.rs b/mofa-engine-app/src/main.rs index 2f163c8..859797e 100644 --- a/mofa-engine-app/src/main.rs +++ b/mofa-engine-app/src/main.rs @@ -7,7 +7,30 @@ use clap::Parser; use mofa_engine_core::{Engine, EngineConfig}; use mofa_engine_sdk::start_server; use std::path::PathBuf; -use tracing_subscriber::{EnvFilter, fmt}; +use std::sync::Arc; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, fmt}; +use opentelemetry_otlp::WithExportConfig; +use opentelemetry_sdk::trace::SdkTracerProvider; +use opentelemetry_sdk::Resource; + +fn init_tracer(endpoint: &str) -> Result { + let exporter = opentelemetry_otlp::SpanExporter::builder() + .with_tonic() + .with_endpoint(endpoint) + .build()?; + + let provider = SdkTracerProvider::builder() + .with_batch_exporter(exporter) + .with_resource( + Resource::builder().with_service_name("mofa-engine").build() + ) + .build(); + + opentelemetry::global::set_tracer_provider(provider.clone()); + + use opentelemetry::trace::TracerProvider; + Ok(provider.tracer("mofa-engine")) +} /// MoFA Engine — multimodal AI model orchestration #[derive(Parser, Debug)] @@ -24,14 +47,6 @@ struct Cli { #[tokio::main] async fn main() -> anyhow::Result<()> { - // Initialise tracing - fmt() - .with_env_filter( - EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), - ) - .with_target(false) - .init(); - let cli = Cli::parse(); // Load and validate configuration. @@ -45,13 +60,63 @@ async fn main() -> anyhow::Result<()> { let host = config.listen.host.clone(); let port = config.listen.port; + #[allow(unused_variables)] + let observability_enabled = config.observability.enabled; + #[allow(unused_variables)] + let otlp_endpoint = config.observability.otlp_endpoint.clone(); + + // Initialize Tracing Subscriber + let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + let fmt_layer = fmt::layer().with_target(false); + + let otel_layer = if observability_enabled { + if let Some(endpoint) = &otlp_endpoint { + match init_tracer(endpoint) { + Ok(tracer) => Some(tracing_opentelemetry::layer().with_tracer(tracer)), + Err(e) => { + eprintln!("Failed to initialize OTLP tracer: {e}"); + None + } + } + } else { + None + } + } else { + None + }; + + tracing_subscriber::registry() + .with(env_filter) + .with(fmt_layer) + .with(otel_layer) + .init(); + + let mut metrics_state = None; + let mut obs_sender = None; + + if observability_enabled { + tracing::info!("Initializing observability pipeline..."); + let (metrics, sender) = mofa_observability::collector::init_pipeline(otlp_endpoint.as_deref()); + metrics_state = Some(metrics); + obs_sender = Some(sender); + } + tracing::info!("MoFA Engine v{} starting", env!("CARGO_PKG_VERSION")); // Create engine let engine = Engine::try_new(config).await?; + // Start Observability Bridge + if let Some(sender) = obs_sender { + let rx = engine.subscribe_events(); + let engine_clone = Arc::clone(&engine); + tokio::spawn(async move { + mofa_engine_sdk::observability_bridge::run(rx, sender, engine_clone).await; + }); + } + // Start server - start_server(engine, &host, port) + start_server(engine, metrics_state, &host, port) .await .map_err(|e| anyhow::anyhow!("{e}"))?; From c6691331567511b3d8c6f073d8db80c23cbd2e32 Mon Sep 17 00:00:00 2001 From: ashum9 Date: Wed, 8 Jul 2026 12:29:37 +0530 Subject: [PATCH 15/75] add GET /metrics Prometheus endpoint --- mofa-engine-sdk/src/server.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/mofa-engine-sdk/src/server.rs b/mofa-engine-sdk/src/server.rs index 68c2623..8ed1aa2 100644 --- a/mofa-engine-sdk/src/server.rs +++ b/mofa-engine-sdk/src/server.rs @@ -26,20 +26,24 @@ use crate::dashboard; /// Shared application state. #[derive(Clone)] +#[allow(dead_code)] struct AppState { engine: Arc, started_at: std::time::Instant, + metrics: Option>>, } /// Start the HTTP server. pub async fn start_server( engine: Arc, + metrics: Option>>, host: &str, port: u16, ) -> Result<(), Box> { let state = AppState { engine, started_at: std::time::Instant::now(), + metrics, }; let app = Router::new() @@ -47,6 +51,7 @@ pub async fn start_server( .route("/", get(dashboard_handler)) // API routes .route("/health", get(health_handler)) + .route("/metrics", get(metrics_handler)) .route("/v1/capabilities", get(capabilities_handler)) .route("/v1/invoke", post(invoke_handler)) .route("/v1/status", get(status_handler)) @@ -140,6 +145,15 @@ async fn dashboard_handler() -> Html<&'static str> { Html(dashboard::DASHBOARD_HTML) } +async fn metrics_handler(State(state): State) -> Result { + if let Some(metrics_arc) = &state.metrics { + let guard = metrics_arc.read().await; + Ok(mofa_observability::prometheus::render(&guard)) + } else { + Err(axum::http::StatusCode::SERVICE_UNAVAILABLE) + } +} + #[cfg(test)] mod tests { use super::*; From 0fe9c0c203ea1b53a54f573a59a8f55318d3d4ea Mon Sep 17 00:00:00 2001 From: ashum9 Date: Wed, 8 Jul 2026 12:29:37 +0530 Subject: [PATCH 16/75] observability bridge with startup gauge seeding --- mofa-engine-sdk/src/lib.rs | 2 + mofa-engine-sdk/src/observability_bridge.rs | 260 ++++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 mofa-engine-sdk/src/observability_bridge.rs diff --git a/mofa-engine-sdk/src/lib.rs b/mofa-engine-sdk/src/lib.rs index df91a52..42c42c3 100644 --- a/mofa-engine-sdk/src/lib.rs +++ b/mofa-engine-sdk/src/lib.rs @@ -3,7 +3,9 @@ //! HTTP API server (Axum), SSE event streaming, and embedded dashboard //! for the MoFA Engine. + pub mod dashboard; +pub mod observability_bridge; pub mod server; pub use server::start_server; diff --git a/mofa-engine-sdk/src/observability_bridge.rs b/mofa-engine-sdk/src/observability_bridge.rs new file mode 100644 index 0000000..105b11c --- /dev/null +++ b/mofa-engine-sdk/src/observability_bridge.rs @@ -0,0 +1,260 @@ +//! Observability integration bridge. +//! +//! This module handles translating core engine events into the observability subsystem's format. +//! It also seeds initial metric values from the engine's status on startup. + +use mofa_engine_core::Engine; +use mofa_kernel::EngineEvent; +use mofa_observability::collector::EventSender; +use mofa_observability::events::{ + EngineEvent as ObsEngineEvent, EventEnvelope, EvictionTriggered, ModelLoaded, ModelUnloaded, + RequestCompleted, RequestReceived, UnloadReason, +}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::broadcast; + +struct RequestContext { + capability: mofa_observability::events::Capability, + model_id: String, + backend: String, +} + +/// Runs the observability bridge. +/// +/// Consumes engine events and translates them into observability events. +/// Seeds initial memory and model-count gauges from the engine's status snapshot. +pub async fn run( + mut engine_events: broadcast::Receiver, + sender: EventSender, + engine: Arc, +) { + let mut cache: HashMap = HashMap::new(); + + // ── Seed gauges from the engine's current state ────────────────────── + // Discovery has already run by the time the bridge starts, so the + // engine knows which models are loaded and how much memory is used. + let status = engine.status().await; + tracing::info!( + "Bridge: seeding gauges — {} models loaded, {:.1} MiB used / {:.1} MiB budget", + status.loaded_models, + status.memory_used_bytes as f64 / 1_048_576.0, + status.memory_budget_bytes as f64 / 1_048_576.0, + ); + + // Set memory gauges + let _ = sender + .send_critical(EventEnvelope::now(ObsEngineEvent::EvictionTriggered( + EvictionTriggered { + evicted_model: "_seed".into(), + memory_before_bytes: status.memory_used_bytes, + memory_after_bytes: status.memory_used_bytes, + budget_bytes: status.memory_budget_bytes, + }, + ))) + .await; + + // Set models_loaded gauge by emitting one ModelLoaded per loaded model + let caps = engine.capabilities().await; + for card in &caps { + if matches!( + card.residency, + mofa_kernel::ModelResidency::Loaded | mofa_kernel::ModelResidency::Remote + ) { + sender.send(EventEnvelope::now(ObsEngineEvent::ModelLoaded( + ModelLoaded { + model_id: card.id.clone(), + backend: card.provider.clone(), + capability: map_capability(&card.capability), + load_duration_ms: 0, + memory_bytes: card.memory_estimate_bytes, + }, + ))); + } + } + + // ── Event loop ─────────────────────────────────────────────────────── + loop { + match engine_events.recv().await { + Ok(event) => { + match event { + EngineEvent::RequestStarted { + request_id, + capability, + model_id, + } => { + let obs_cap = capability + .as_ref() + .map(map_capability) + .unwrap_or(mofa_observability::events::Capability::Chat); + + // Extract provider from "provider/model" format + let backend = if model_id.contains('/') { + model_id.split('/').next().unwrap_or("unknown").to_string() + } else { + "local".to_string() + }; + + cache.insert( + request_id.clone(), + RequestContext { + capability: obs_cap, + model_id: model_id.clone(), + backend: backend.clone(), + }, + ); + + let obs_event = ObsEngineEvent::RequestReceived(RequestReceived { + capability: obs_cap, + model: Some(model_id), + hint: None, + }); + let mut envelope = + EventEnvelope::now(obs_event).with_request_id(&request_id); + if let Some((trace_id, span_id)) = derive_trace_context(&request_id) { + envelope = envelope.with_trace(trace_id, span_id); + } + sender.send(envelope); + } + EngineEvent::RequestCompleted { + request_id, + duration_ms, + success, + } => { + let (obs_model_id, obs_capability, obs_backend) = cache + .remove(&request_id) + .map(|ctx| (ctx.model_id, ctx.capability, ctx.backend)) + .unwrap_or_else(|| { + ( + "unknown".into(), + mofa_observability::events::Capability::Chat, + "unknown".into(), + ) + }); + + let obs_event = + ObsEngineEvent::RequestCompleted(RequestCompleted { + model_id: obs_model_id, + backend: obs_backend, + capability: obs_capability, + duration_ms, + ttft_ms: None, + tokens_in: None, + tokens_out: None, + model_was_hot: None, + success, + error_code: None, + }); + let mut envelope = + EventEnvelope::now(obs_event).with_request_id(&request_id); + if let Some((trace_id, span_id)) = derive_trace_context(&request_id) { + envelope = envelope.with_trace(trace_id, span_id); + } + sender.send(envelope); + } + EngineEvent::ModelResidencyChanged { + model_id, + old, + new, + } => { + use mofa_kernel::ModelResidency; + match (old, new) { + // Model loaded into memory + (_, ModelResidency::Loaded) => { + let backend = if model_id.contains('/') { + model_id.split('/').next().unwrap_or("unknown").to_string() + } else { + "local".to_string() + }; + sender.send(EventEnvelope::now( + ObsEngineEvent::ModelLoaded(ModelLoaded { + model_id: model_id.clone(), + backend, + capability: + mofa_observability::events::Capability::Chat, + load_duration_ms: 0, + memory_bytes: 0, + }), + )); + } + // Model unloaded from memory + (ModelResidency::Loaded, _) => { + sender.send(EventEnvelope::now( + ObsEngineEvent::ModelUnloaded(ModelUnloaded { + model_id: model_id.clone(), + reason: UnloadReason::Explicit, + memory_freed_bytes: 0, + }), + )); + } + _ => {} // Loading, Unloading — intermediate states, skip + } + } + EngineEvent::MemoryChanged { + used_bytes, + total_bytes, + } => { + // Set the memory gauges from the authoritative engine value + let _ = sender + .send_critical(EventEnvelope::now( + ObsEngineEvent::EvictionTriggered(EvictionTriggered { + evicted_model: "_memory_sync".into(), + memory_before_bytes: used_bytes, + memory_after_bytes: used_bytes, + budget_bytes: total_bytes, + }), + )) + .await; + } + EngineEvent::DiscoveryCompleted { + provider, models, .. + } => { + tracing::info!( + provider = %provider, + models = models, + "Bridge: discovery completed" + ); + } + _ => { + // ModelStatusChanged, ProviderHealthChanged — not mapped yet + } + } + } + Err(broadcast::error::RecvError::Lagged(skipped)) => { + tracing::warn!("Observability bridge lagged, skipped {} events", skipped); + } + Err(broadcast::error::RecvError::Closed) => { + tracing::info!("Observability bridge shutting down"); + break; + } + } + } +} + +fn map_capability(cap: &mofa_kernel::Capability) -> mofa_observability::events::Capability { + use mofa_kernel::Capability as K; + use mofa_observability::events::Capability as O; + match cap { + K::Chat => O::Chat, + K::Tts => O::Tts, + K::Asr => O::Asr, + K::Vlm => O::Vlm, + K::ImageGen => O::ImageGen, + K::VideoGen => O::VideoGen, + K::Embedding => O::Embedding, + _ => { + tracing::warn!("Unknown capability variant, defaulting to Chat for observability"); + O::Chat + } + } +} + +fn derive_trace_context(request_id: &str) -> Option<(String, String)> { + let trace_id = request_id.replace('-', ""); + if trace_id.len() == 32 { + let span_id = trace_id[0..16].to_string(); + Some((trace_id, span_id)) + } else { + None + } +} From f6fa0b4b950e72a0e32b13e86219b703075b8b62 Mon Sep 17 00:00:00 2001 From: ashum9 Date: Wed, 8 Jul 2026 12:29:37 +0530 Subject: [PATCH 17/75] add ObservabilityConfig default to test configs --- mofa-engine-core/src/engine.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mofa-engine-core/src/engine.rs b/mofa-engine-core/src/engine.rs index e656d6c..8748c0a 100644 --- a/mofa-engine-core/src/engine.rs +++ b/mofa-engine-core/src/engine.rs @@ -674,6 +674,7 @@ mod tests { fn minimal_config() -> EngineConfig { EngineConfig { + observability: Default::default(), listen: ListenConfig::default(), memory: MemoryConfig { budget_mb: Some(100), @@ -849,6 +850,7 @@ mod tests { #[tokio::test] async fn disabled_provider_is_skipped() { let config = EngineConfig { + observability: Default::default(), listen: ListenConfig::default(), memory: MemoryConfig { budget_mb: Some(100), From a17f015624e3535a04d87cf264194bbfac3cf1de Mon Sep 17 00:00:00 2001 From: ashum9 Date: Wed, 8 Jul 2026 12:29:37 +0530 Subject: [PATCH 18/75] collector tracing and mock harness memory budget --- mofa-observability/examples/mock_harness.rs | 51 ++++++++++++++++----- mofa-observability/src/collector.rs | 17 ++++++- 2 files changed, 56 insertions(+), 12 deletions(-) diff --git a/mofa-observability/examples/mock_harness.rs b/mofa-observability/examples/mock_harness.rs index 384c98b..0755ca3 100644 --- a/mofa-observability/examples/mock_harness.rs +++ b/mofa-observability/examples/mock_harness.rs @@ -53,28 +53,57 @@ async fn simulate_engine_traffic(sender: EventSender) { memory_bytes: 800_000_000, }))); + // Send an initial event to seed the memory budget for Grafana + sender.send_critical(EventEnvelope::now(EngineEvent::EvictionTriggered( + EvictionTriggered { + evicted_model: "init".into(), + memory_before_bytes: 0, + memory_after_bytes: 0, + budget_bytes: 16_000_000_000, + }, + ))).await.expect("Failed to send init event"); + let mut active_models = 2; // We pre-loaded 2 models + let mut current_memory = 4_000_000_000_u64; + // Continuous event loop loop { - // Sleep between 50ms and 500ms to generate roughly 2-20 requests/sec. tokio::time::sleep(Duration::from_millis(rng.gen_range(50..500))).await; - let action = rng.gen_range(0..100); + let mut action = rng.gen_range(0..100); let selected_model = models.choose(&mut rng).unwrap(); + let mem_size = rng.gen_range(500_000_000..2_500_000_000); // 0.5 - 2.5 GB + + // Force eviction if we're hitting the ceiling + if action < 5 && current_memory + mem_size > 15_000_000_000 { + action = 5; // Convert to an unload/eviction + } + match action { // 5% chance: Load a new model 0..=4 => { + active_models += 1; + current_memory += mem_size; sender.send(EventEnvelope::now(EngineEvent::ModelLoaded(ModelLoaded { model_id: selected_model.0.into(), backend: selected_model.1.into(), capability: selected_model.2, load_duration_ms: rng.gen_range(1000..5000), - memory_bytes: rng.gen_range(500_000_000..8_000_000_000), + memory_bytes: mem_size, }))); } // 5% chance: Unload a model 5..=9 => { + if active_models == 0 { + continue; // Can't unload what isn't there! + } + active_models -= 1; + + // Don't free more than we have + let freed = mem_size.min(current_memory); + current_memory -= freed; + let reason = match rng.gen_range(0..3) { 0 => UnloadReason::IdleTimeout, 1 => UnloadReason::Eviction, @@ -84,7 +113,7 @@ async fn simulate_engine_traffic(sender: EventSender) { ModelUnloaded { model_id: selected_model.0.into(), reason, - memory_freed_bytes: rng.gen_range(500_000_000..8_000_000_000), + memory_freed_bytes: freed, }, ))); @@ -93,11 +122,11 @@ async fn simulate_engine_traffic(sender: EventSender) { sender.send_critical(EventEnvelope::now(EngineEvent::EvictionTriggered( EvictionTriggered { evicted_model: selected_model.0.into(), - memory_before_bytes: 15_000_000_000, - memory_after_bytes: 12_000_000_000, + memory_before_bytes: current_memory + freed, + memory_after_bytes: current_memory, budget_bytes: 16_000_000_000, }, - ))).await.expect("Failed to send critical event"); + ))).await.expect("Failed to send eviction event"); } } @@ -239,14 +268,14 @@ async fn main() -> Result<(), Box> { .route("/metrics", get(metrics_handler)) .with_state(state); - let port = 9090; + let port = 9092; let addr = SocketAddr::from(([0, 0, 0, 0], port)); let listener = tokio::net::TcpListener::bind(addr).await?; println!("======================================================="); - println!("🚀 Mock Harness Running!"); - println!("📊 Prometheus Endpoint: http://localhost:{}/metrics", port); - println!(" Try running: curl http://localhost:{}/metrics", port); + println!("Mock Harness Running!"); + println!("Prometheus Endpoint: http://localhost:{}/metrics", port); + println!("Try running: curl -s http://localhost:{}/metrics | grep \"^mofa_\"", port); println!("======================================================="); axum::serve(listener, app).await?; diff --git a/mofa-observability/src/collector.rs b/mofa-observability/src/collector.rs index a6c8190..327309c 100644 --- a/mofa-observability/src/collector.rs +++ b/mofa-observability/src/collector.rs @@ -472,9 +472,9 @@ impl MetricsState { // Reconcile memory gauge against the absolute post-eviction value. // This corrects any cumulative drift caused by dropped ModelUnloaded - // events under channel backpressure. The running sum is replaced with // the authoritative value from the eviction subsystem. self.memory_used_bytes.set(Labels::new(), e.memory_after_bytes as f64); + self.memory_budget_bytes.set(Labels::new(), e.budget_bytes as f64); } EngineEvent::PreflightSignal(e) => { @@ -573,6 +573,7 @@ impl EventSender { /// Send a critical event synchronously. If the channel is full, this will block /// the current thread. Must not be called from within an async runtime context. + #[allow(clippy::result_large_err)] pub fn send_blocking(&self, event: EventEnvelope) -> Result<(), mpsc::error::SendError> { self.tx.blocking_send(event) } @@ -674,6 +675,20 @@ impl MetricsCollector { } } +/// Initialize the observability pipeline. +/// Spawns the collector loop and returns the shared state and event sender. +pub fn init_pipeline(_otlp_endpoint: Option<&str>) -> (Arc>, EventSender) { + let (sender, receiver) = create_event_channel(10000); + let collector_loop = MetricsCollector::new(receiver); + let state = collector_loop.state(); + + tokio::spawn(async move { + collector_loop.run().await; + }); + + (state, sender) +} + // ─── Tests ─────────────────────────────────────────────────────────────────── #[cfg(test)] From 0448d95cc0e09b592e34f6bed6ef12c92e772042 Mon Sep 17 00:00:00 2001 From: ashum9 Date: Wed, 8 Jul 2026 12:29:37 +0530 Subject: [PATCH 19/75] add engine scrape target to Prometheus --- mofa-observability/docker/docker-compose.yml | 2 -- mofa-observability/docker/prometheus/prometheus.yml | 6 +++++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/mofa-observability/docker/docker-compose.yml b/mofa-observability/docker/docker-compose.yml index 1f0fde8..5964d22 100644 --- a/mofa-observability/docker/docker-compose.yml +++ b/mofa-observability/docker/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: mock-harness: build: diff --git a/mofa-observability/docker/prometheus/prometheus.yml b/mofa-observability/docker/prometheus/prometheus.yml index 454bc13..acd8734 100644 --- a/mofa-observability/docker/prometheus/prometheus.yml +++ b/mofa-observability/docker/prometheus/prometheus.yml @@ -4,4 +4,8 @@ global: scrape_configs: - job_name: 'mofa_mock_harness' static_configs: - - targets: ['mock-harness:9090'] + - targets: ['host.docker.internal:9092'] + + - job_name: 'mofa_engine' + static_configs: + - targets: ['host.docker.internal:8420'] From abfab9f9bd2de9dad3e7b3d9fbf615db52a2913f Mon Sep 17 00:00:00 2001 From: ashum9 Date: Wed, 8 Jul 2026 12:29:37 +0530 Subject: [PATCH 20/75] add test config and dockerignore --- .dockerignore | 3 +++ test_obs_config.toml | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 .dockerignore create mode 100644 test_obs_config.toml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5931875 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +target/ +.git/ +.env diff --git a/test_obs_config.toml b/test_obs_config.toml new file mode 100644 index 0000000..13bc7d6 --- /dev/null +++ b/test_obs_config.toml @@ -0,0 +1,20 @@ +[listen] +host = "127.0.0.1" +port = 8420 + +[memory] +budget_mb = 100 +idle_timeout_secs = 60 + +[observability] +enabled = true + +[[providers]] +name = "test" +kind = "openai_compatible" +base_url = "http://127.0.0.1:11435/v1" +api_key = "dummy" + +[[providers.models]] +name = "mock_chat" +capability = "chat" From b2a11a9f7857b9cc6df29cd64fe8845717f100da Mon Sep 17 00:00:00 2001 From: ashum9 Date: Thu, 16 Jul 2026 21:14:15 +0530 Subject: [PATCH 21/75] fix: update core engine memory tracking and routing endpoints --- mofa-engine-app/Cargo.toml | 5 + mofa-engine-app/src/main.rs | 86 +++++- .../src/backends/openai_compat.rs | 6 +- mofa-engine-core/src/config.rs | 17 ++ mofa-engine-core/src/engine.rs | 20 +- mofa-engine-sdk/Cargo.toml | 1 + mofa-engine-sdk/src/lib.rs | 2 + mofa-engine-sdk/src/observability_bridge.rs | 287 ++++++++++++++++++ mofa-engine-sdk/src/server.rs | 39 +++ 9 files changed, 449 insertions(+), 14 deletions(-) create mode 100644 mofa-engine-sdk/src/observability_bridge.rs diff --git a/mofa-engine-app/Cargo.toml b/mofa-engine-app/Cargo.toml index 6e7bc16..9bd22b6 100644 --- a/mofa-engine-app/Cargo.toml +++ b/mofa-engine-app/Cargo.toml @@ -8,8 +8,13 @@ description = "MoFA Engine binary entry point" [dependencies] mofa-engine-core = { workspace = true } mofa-engine-sdk = { workspace = true } +mofa-observability = { path = "../mofa-observability" } tokio = { workspace = true } clap = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } +tracing-opentelemetry = { workspace = true } +opentelemetry = { workspace = true } +opentelemetry_sdk = { workspace = true } +opentelemetry-otlp = { workspace = true } anyhow = { workspace = true } diff --git a/mofa-engine-app/src/main.rs b/mofa-engine-app/src/main.rs index 2f163c8..3a9cb27 100644 --- a/mofa-engine-app/src/main.rs +++ b/mofa-engine-app/src/main.rs @@ -7,7 +7,30 @@ use clap::Parser; use mofa_engine_core::{Engine, EngineConfig}; use mofa_engine_sdk::start_server; use std::path::PathBuf; -use tracing_subscriber::{EnvFilter, fmt}; +use std::sync::Arc; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, fmt}; +use opentelemetry_otlp::WithExportConfig; +use opentelemetry_sdk::trace::SdkTracerProvider; +use opentelemetry_sdk::Resource; + +fn init_tracer(endpoint: &str) -> Result { + let exporter = opentelemetry_otlp::SpanExporter::builder() + .with_tonic() + .with_endpoint(endpoint) + .build()?; + + let provider = SdkTracerProvider::builder() + .with_batch_exporter(exporter) + .with_resource( + Resource::builder().with_service_name("mofa-engine").build() + ) + .build(); + + opentelemetry::global::set_tracer_provider(provider.clone()); + + use opentelemetry::trace::TracerProvider; + Ok(provider.tracer("mofa-engine")) +} /// MoFA Engine — multimodal AI model orchestration #[derive(Parser, Debug)] @@ -24,14 +47,6 @@ struct Cli { #[tokio::main] async fn main() -> anyhow::Result<()> { - // Initialise tracing - fmt() - .with_env_filter( - EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), - ) - .with_target(false) - .init(); - let cli = Cli::parse(); // Load and validate configuration. @@ -45,13 +60,64 @@ async fn main() -> anyhow::Result<()> { let host = config.listen.host.clone(); let port = config.listen.port; + #[allow(unused_variables)] + let observability_enabled = config.observability.enabled; + #[allow(unused_variables)] + let otlp_endpoint = config.observability.otlp_endpoint.clone(); + + // Initialize Tracing Subscriber + let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + let fmt_layer = fmt::layer().with_target(false); + + let otel_layer = if observability_enabled { + if let Some(endpoint) = &otlp_endpoint { + match init_tracer(endpoint) { + Ok(tracer) => Some(tracing_opentelemetry::layer().with_tracer(tracer)), + Err(e) => { + eprintln!("Failed to initialize OTLP tracer: {e}"); + None + } + } + } else { + None + } + } else { + None + }; + + tracing_subscriber::registry() + .with(env_filter) + .with(fmt_layer) + .with(otel_layer) + .init(); + + let mut metrics_state = None; + let mut obs_sender = None; + + if observability_enabled { + tracing::info!("Initializing observability pipeline..."); + let (metrics, sender) = mofa_observability::collector::init_pipeline(otlp_endpoint.as_deref()); + metrics_state = Some(metrics); + obs_sender = Some(sender); + } + tracing::info!("MoFA Engine v{} starting", env!("CARGO_PKG_VERSION")); // Create engine let engine = Engine::try_new(config).await?; + // Start Observability Bridge + if let Some(sender) = obs_sender { + let rx = engine.subscribe_events(); + let engine_clone = Arc::clone(&engine); + let metrics_clone = metrics_state.clone(); + tokio::spawn(async move { + mofa_engine_sdk::observability_bridge::run(rx, sender, engine_clone, metrics_clone).await; + }); + } + // Start server - start_server(engine, &host, port) + start_server(engine, metrics_state, &host, port) .await .map_err(|e| anyhow::anyhow!("{e}"))?; diff --git a/mofa-engine-core/src/backends/openai_compat.rs b/mofa-engine-core/src/backends/openai_compat.rs index 60a1ba9..e2dd8ff 100644 --- a/mofa-engine-core/src/backends/openai_compat.rs +++ b/mofa-engine-core/src/backends/openai_compat.rs @@ -385,15 +385,15 @@ impl OpenAiCompatProvider { detail: format!("TTS read error: {e}"), })?; - let path = std::env::temp_dir().join(format!("mofa_tts_{}.mp3", uuid::Uuid::new_v4())); + let file_name = format!("mofa_tts_{}.wav", uuid::Uuid::new_v4()); + let path = std::env::temp_dir().join(&file_name); std::fs::write(&path, &bytes) .map_err(|e| EngineError::Internal(format!("write error: {e}")))?; - let path = path.to_string_lossy().to_string(); let duration_ms = start.elapsed().as_millis() as u64; Ok(InferenceResponse { text: None, - file: Some(path), + file: Some(file_name), model_used: model_name.to_string(), provider: self.name.clone(), duration_ms, diff --git a/mofa-engine-core/src/config.rs b/mofa-engine-core/src/config.rs index a31c82c..f502ed6 100644 --- a/mofa-engine-core/src/config.rs +++ b/mofa-engine-core/src/config.rs @@ -22,6 +22,20 @@ pub struct EngineConfig { /// Provider definitions #[serde(default)] pub providers: Vec, + /// Observability settings + #[serde(default)] + pub observability: ObservabilityConfig, +} + +/// Observability configuration. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ObservabilityConfig { + /// Whether observability is enabled. + #[serde(default)] + pub enabled: bool, + /// The OpenTelemetry OTLP endpoint (e.g., http://localhost:4317) + #[serde(default)] + pub otlp_endpoint: Option, } /// Network listen configuration. @@ -399,6 +413,7 @@ impl EngineConfig { listen: ListenConfig::default(), memory: MemoryConfig::default(), providers, + observability: ObservabilityConfig::default(), } } } @@ -470,6 +485,7 @@ mod tests { #[test] fn toml_roundtrip() { let cfg = EngineConfig { + observability: Default::default(), listen: ListenConfig::default(), memory: MemoryConfig::default(), providers: vec![ProviderConfig { @@ -492,6 +508,7 @@ mod tests { #[test] fn validate_rejects_unknown_provider_kind() { let cfg = EngineConfig { + observability: Default::default(), listen: ListenConfig::default(), memory: MemoryConfig::default(), providers: vec![ProviderConfig { diff --git a/mofa-engine-core/src/engine.rs b/mofa-engine-core/src/engine.rs index e656d6c..c2d3206 100644 --- a/mofa-engine-core/src/engine.rs +++ b/mofa-engine-core/src/engine.rs @@ -172,9 +172,25 @@ impl Engine { } let count = cards.len(); + let mut allocated_any = false; for mut card in cards { card.refresh_status(); - self.models.insert(card.id.clone(), card); + let id = card.id.clone(); + if matches!(card.residency, ModelResidency::Loaded) && card.memory_estimate_bytes > 0 { + // If it's already loaded but we don't have it allocated yet + let snapshot = self.memory.snapshot(); + if !snapshot.iter().any(|(mem_id, _)| mem_id == &id) { + self.memory.allocate(&id, card.memory_estimate_bytes); + allocated_any = true; + } + } + self.models.insert(id, card); + } + if allocated_any { + let _ = self.event_tx.send(EngineEvent::MemoryChanged { + used_bytes: self.memory.used_bytes(), + total_bytes: self.memory.budget_bytes(), + }); } let _ = self.event_tx.send(EngineEvent::DiscoveryCompleted { provider: name.clone(), @@ -674,6 +690,7 @@ mod tests { fn minimal_config() -> EngineConfig { EngineConfig { + observability: Default::default(), listen: ListenConfig::default(), memory: MemoryConfig { budget_mb: Some(100), @@ -849,6 +866,7 @@ mod tests { #[tokio::test] async fn disabled_provider_is_skipped() { let config = EngineConfig { + observability: Default::default(), listen: ListenConfig::default(), memory: MemoryConfig { budget_mb: Some(100), diff --git a/mofa-engine-sdk/Cargo.toml b/mofa-engine-sdk/Cargo.toml index eb785c4..9c20c37 100644 --- a/mofa-engine-sdk/Cargo.toml +++ b/mofa-engine-sdk/Cargo.toml @@ -19,3 +19,4 @@ thiserror = { workspace = true } tracing = { workspace = true } chrono = { workspace = true } tokio-stream = { workspace = true } +mofa-observability = { path = "../mofa-observability" } diff --git a/mofa-engine-sdk/src/lib.rs b/mofa-engine-sdk/src/lib.rs index df91a52..42c42c3 100644 --- a/mofa-engine-sdk/src/lib.rs +++ b/mofa-engine-sdk/src/lib.rs @@ -3,7 +3,9 @@ //! HTTP API server (Axum), SSE event streaming, and embedded dashboard //! for the MoFA Engine. + pub mod dashboard; +pub mod observability_bridge; pub mod server; pub use server::start_server; diff --git a/mofa-engine-sdk/src/observability_bridge.rs b/mofa-engine-sdk/src/observability_bridge.rs new file mode 100644 index 0000000..e2a5bf1 --- /dev/null +++ b/mofa-engine-sdk/src/observability_bridge.rs @@ -0,0 +1,287 @@ +//! Observability integration bridge. +//! +//! This module handles translating core engine events into the observability subsystem's format. +//! It also seeds initial metric values from the engine's status on startup. + +use mofa_engine_core::Engine; +use mofa_kernel::EngineEvent; +use mofa_observability::collector::{EventSender, Labels, MetricsState}; +use mofa_observability::events::{ + EngineEvent as ObsEngineEvent, EventEnvelope, EvictionTriggered, ModelLoaded, ModelUnloaded, + RequestCompleted, RequestReceived, UnloadReason, +}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; +use tokio::sync::broadcast; + +struct RequestContext { + capability: mofa_observability::events::Capability, + model_id: String, + backend: String, +} + +/// Runs the observability bridge. +/// +/// Consumes engine events and translates them into observability events. +/// Seeds initial memory and model-count gauges from the engine's status snapshot. +pub async fn run( + mut engine_events: broadcast::Receiver, + sender: EventSender, + engine: Arc, + metrics_state: Option>>, +) { + let mut cache: HashMap = HashMap::new(); + + // ── Seed gauges from the engine's current state ────────────────────── + // Discovery has already run by the time the bridge starts, so the + // engine knows which models are loaded and how much memory is used. + let status = engine.status().await; + tracing::info!( + "Bridge: seeding gauges — {} models loaded, {:.1} MiB used / {:.1} MiB budget", + status.loaded_models, + status.memory_used_bytes as f64 / 1_048_576.0, + status.memory_budget_bytes as f64 / 1_048_576.0, + ); + + // Set memory gauges + let _ = sender + .send_critical(EventEnvelope::now(ObsEngineEvent::EvictionTriggered( + EvictionTriggered { + evicted_model: "_seed".into(), + memory_before_bytes: status.memory_used_bytes, + memory_after_bytes: status.memory_used_bytes, + budget_bytes: status.memory_budget_bytes, + }, + ))) + .await; + + // Set models_loaded gauge by emitting one ModelLoaded per loaded model + let caps = engine.capabilities().await; + for card in &caps { + if matches!( + card.residency, + mofa_kernel::ModelResidency::Loaded | mofa_kernel::ModelResidency::Remote + ) { + sender.send(EventEnvelope::now(ObsEngineEvent::ModelLoaded( + ModelLoaded { + model_id: card.id.clone(), + backend: card.provider.clone(), + capability: map_capability(&card.capability), + load_duration_ms: 0, + memory_bytes: card.memory_estimate_bytes, + }, + ))); + } + } + + // ── Event loop with periodic gauge sync ──────────────────────────── + let mut gauge_interval = tokio::time::interval(std::time::Duration::from_secs(10)); + gauge_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + tokio::select! { + event_result = engine_events.recv() => { + match event_result { + Ok(event) => { + match event { + EngineEvent::RequestStarted { + request_id, + capability, + model_id, + } => { + let obs_cap = capability + .as_ref() + .map(map_capability) + .unwrap_or(mofa_observability::events::Capability::Chat); + + // Extract provider from "provider/model" format + let backend = if model_id.contains('/') { + model_id.split('/').next().unwrap_or("unknown").to_string() + } else { + "local".to_string() + }; + + cache.insert( + request_id.clone(), + RequestContext { + capability: obs_cap, + model_id: model_id.clone(), + backend: backend.clone(), + }, + ); + + let obs_event = ObsEngineEvent::RequestReceived(RequestReceived { + capability: obs_cap, + model: Some(model_id), + hint: None, + }); + let mut envelope = + EventEnvelope::now(obs_event).with_request_id(&request_id); + if let Some((trace_id, span_id)) = derive_trace_context(&request_id) { + envelope = envelope.with_trace(trace_id, span_id); + } + sender.send(envelope); + } + EngineEvent::RequestCompleted { + request_id, + duration_ms, + success, + } => { + let (obs_model_id, obs_capability, obs_backend) = cache + .remove(&request_id) + .map(|ctx| (ctx.model_id, ctx.capability, ctx.backend)) + .unwrap_or_else(|| { + ( + "unknown".into(), + mofa_observability::events::Capability::Chat, + "unknown".into(), + ) + }); + + let obs_event = + ObsEngineEvent::RequestCompleted(RequestCompleted { + model_id: obs_model_id, + backend: obs_backend, + capability: obs_capability, + duration_ms, + ttft_ms: None, + tokens_in: None, + tokens_out: None, + model_was_hot: None, + success, + error_code: None, + }); + let mut envelope = + EventEnvelope::now(obs_event).with_request_id(&request_id); + if let Some((trace_id, span_id)) = derive_trace_context(&request_id) { + envelope = envelope.with_trace(trace_id, span_id); + } + sender.send(envelope); + } + EngineEvent::ModelResidencyChanged { + model_id, + old, + new, + } => { + use mofa_kernel::ModelResidency; + match (old, new) { + // Model loaded into memory + (_, ModelResidency::Loaded) => { + let backend = if model_id.contains('/') { + model_id.split('/').next().unwrap_or("unknown").to_string() + } else { + "local".to_string() + }; + sender.send(EventEnvelope::now( + ObsEngineEvent::ModelLoaded(ModelLoaded { + model_id: model_id.clone(), + backend, + capability: + mofa_observability::events::Capability::Chat, + load_duration_ms: 0, + memory_bytes: 0, + }), + )); + } + // Model unloaded from memory + (ModelResidency::Loaded, _) => { + sender.send(EventEnvelope::now( + ObsEngineEvent::ModelUnloaded(ModelUnloaded { + model_id: model_id.clone(), + reason: UnloadReason::Explicit, + memory_freed_bytes: 0, + }), + )); + } + _ => {} // Loading, Unloading — intermediate states, skip + } + } + EngineEvent::MemoryChanged { + used_bytes, + total_bytes, + } => { + // Set the memory gauges from the authoritative engine value + let _ = sender + .send_critical(EventEnvelope::now( + ObsEngineEvent::EvictionTriggered(EvictionTriggered { + evicted_model: "_memory_sync".into(), + memory_before_bytes: used_bytes, + memory_after_bytes: used_bytes, + budget_bytes: total_bytes, + }), + )) + .await; + } + EngineEvent::DiscoveryCompleted { + provider, models, .. + } => { + tracing::info!( + provider = %provider, + models = models, + "Bridge: discovery completed" + ); + } + _ => { + // ModelStatusChanged, ProviderHealthChanged — not mapped yet + } + } + } + Err(broadcast::error::RecvError::Lagged(skipped)) => { + tracing::warn!("Observability bridge lagged, skipped {} events", skipped); + } + Err(broadcast::error::RecvError::Closed) => { + tracing::info!("Observability bridge shutting down"); + break; + } + } + } + _ = gauge_interval.tick() => { + // Periodic gauge sync: directly write memory and model-count + // gauges from the authoritative engine state every 10 seconds. + if let Some(ref ms) = metrics_state { + let status = engine.status().await; + let mut state = ms.write().await; + state.memory_used_bytes.set(Labels::new(), status.memory_used_bytes as f64); + state.memory_budget_bytes.set(Labels::new(), status.memory_budget_bytes as f64); + state.models_loaded.set(Labels::new(), status.loaded_models as f64); + + tracing::trace!( + memory_used_mib = status.memory_used_bytes as f64 / 1_048_576.0, + memory_budget_mib = status.memory_budget_bytes as f64 / 1_048_576.0, + loaded_models = status.loaded_models, + "Bridge: periodic gauge sync" + ); + } + } + } + } +} + +fn map_capability(cap: &mofa_kernel::Capability) -> mofa_observability::events::Capability { + use mofa_kernel::Capability as K; + use mofa_observability::events::Capability as O; + match cap { + K::Chat => O::Chat, + K::Tts => O::Tts, + K::Asr => O::Asr, + K::Vlm => O::Vlm, + K::ImageGen => O::ImageGen, + K::VideoGen => O::VideoGen, + K::Embedding => O::Embedding, + _ => { + tracing::warn!("Unknown capability variant, defaulting to Chat for observability"); + O::Chat + } + } +} + +fn derive_trace_context(request_id: &str) -> Option<(String, String)> { + let trace_id = request_id.replace('-', ""); + if trace_id.len() == 32 { + let span_id = trace_id[0..16].to_string(); + Some((trace_id, span_id)) + } else { + None + } +} diff --git a/mofa-engine-sdk/src/server.rs b/mofa-engine-sdk/src/server.rs index 68c2623..9f2be32 100644 --- a/mofa-engine-sdk/src/server.rs +++ b/mofa-engine-sdk/src/server.rs @@ -26,20 +26,24 @@ use crate::dashboard; /// Shared application state. #[derive(Clone)] +#[allow(dead_code)] struct AppState { engine: Arc, started_at: std::time::Instant, + metrics: Option>>, } /// Start the HTTP server. pub async fn start_server( engine: Arc, + metrics: Option>>, host: &str, port: u16, ) -> Result<(), Box> { let state = AppState { engine, started_at: std::time::Instant::now(), + metrics, }; let app = Router::new() @@ -47,10 +51,12 @@ pub async fn start_server( .route("/", get(dashboard_handler)) // API routes .route("/health", get(health_handler)) + .route("/metrics", get(metrics_handler)) .route("/v1/capabilities", get(capabilities_handler)) .route("/v1/invoke", post(invoke_handler)) .route("/v1/status", get(status_handler)) .route("/v1/events", get(events_handler)) + .route("/v1/files/{filename}", get(files_handler)) .route("/v1/discovery/refresh", post(refresh_handler)) .layer(CorsLayer::permissive()) .layer(TraceLayer::new_for_http()) @@ -136,10 +142,43 @@ async fn events_handler( ) } +use axum::extract::Path; +use axum::response::IntoResponse; +use axum::http::header; + +async fn files_handler(Path(filename): Path) -> Result { + if filename.contains('/') || filename.contains('\\') || filename.contains("..") { + return Err(StatusCode::BAD_REQUEST); + } + + let temp_dir = std::env::temp_dir(); + let file_path = temp_dir.join(&filename); + + if !file_path.exists() { + return Err(StatusCode::NOT_FOUND); + } + + let bytes = std::fs::read(file_path).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(( + [(header::CONTENT_TYPE, "audio/wav")], + bytes, + )) +} + async fn dashboard_handler() -> Html<&'static str> { Html(dashboard::DASHBOARD_HTML) } +async fn metrics_handler(State(state): State) -> Result { + if let Some(metrics_arc) = &state.metrics { + let guard = metrics_arc.read().await; + Ok(mofa_observability::prometheus::render(&guard)) + } else { + Err(axum::http::StatusCode::SERVICE_UNAVAILABLE) + } +} + #[cfg(test)] mod tests { use super::*; From e38146168a67257dc69c1e9e28056e54061b9319 Mon Sep 17 00:00:00 2001 From: ashum9 Date: Fri, 17 Jul 2026 00:58:17 +0530 Subject: [PATCH 22/75] fix: make collector set method pub --- mofa-observability/src/collector.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mofa-observability/src/collector.rs b/mofa-observability/src/collector.rs index 327309c..7e9539f 100644 --- a/mofa-observability/src/collector.rs +++ b/mofa-observability/src/collector.rs @@ -183,7 +183,7 @@ impl GaugeFamily { } } - fn set(&mut self, labels: Labels, value: f64) { + pub fn set(&mut self, labels: Labels, value: f64) { self.last_seen.insert(labels.clone(), Instant::now()); self.values.insert(labels, value); } From 0f982f69db22043b4b3cc2bea3b6187b7adbccb2 Mon Sep 17 00:00:00 2001 From: ashum9 Date: Wed, 8 Jul 2026 12:27:18 +0530 Subject: [PATCH 23/75] kokoro local TTS bridge and hybrid production config --- .gitignore | 1 + kokoro_tts_server.py | 139 +++++++++++++++++++++++++++++++++++++++++++ mofa_hybrid.toml | 25 ++++++++ 3 files changed, 165 insertions(+) create mode 100644 kokoro_tts_server.py create mode 100644 mofa_hybrid.toml diff --git a/.gitignore b/.gitignore index 7ee12e8..8002557 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ __pycache__/ node_modules/ e2e/test-results/ e2e/playwright-report/ +.kokoro-models/ diff --git a/kokoro_tts_server.py b/kokoro_tts_server.py new file mode 100644 index 0000000..35f58e9 --- /dev/null +++ b/kokoro_tts_server.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Kokoro TTS Bridge — exposes local Kokoro as an OpenAI-compatible TTS endpoint. + +Usage: + source .kokoro-venv/bin/activate + python3 kokoro_tts_server.py +""" + +import json, io, os, tempfile +from http.server import HTTPServer, BaseHTTPRequestHandler + +_pipeline = None + +from pathlib import Path + +def get_pipeline(): + global _pipeline + if _pipeline is None: + print("[kokoro] Loading ONNX pipeline...") + + from kokoro_onnx import Kokoro + + model_dir = Path(__file__).parent / ".kokoro-models" + + _pipeline = Kokoro( + str(model_dir / "kokoro-v1.0.fp16.onnx"), + str(model_dir / "voices-v1.0.bin"), + ) + + print("[kokoro] Pipeline ready!") + + return _pipeline + + +class KokoroHandler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/v1/models": + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({ + "data": [{"id": "kokoro", "object": "model"}] + }).encode()) + else: + self.send_response(200) + self.end_headers() + self.wfile.write(b'{"status":"ok"}') + + def do_POST(self): + if self.path != "/v1/audio/speech": + self.send_response(404) + self.end_headers() + return + + content_length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(content_length)) if content_length else {} + text = body.get("input", "Hello from Kokoro") + raw_voice = body.get("voice", "heart") + # Map OpenAI voice names to Kokoro format (af_ prefix) + voice = raw_voice if raw_voice.startswith("af_") else f"af_{raw_voice}" + + print(f"[kokoro] Generating: {text[:80]}... ({len(text)} chars)") + try: + import soundfile as sf + import numpy as np + import re + pipeline = get_pipeline() + + # Kokoro has a 510 phoneme limit. Chinese chars expand to ~15 phonemes + # each, so max ~30 chars. Use 25 for safety margin. + MAX_CHARS = 25 + # Split on ANY punctuation (Chinese + English) + parts = re.split(r'(?<=[。!?.!?\n,,、;;::))」』])', text) + chunks = [] + current = "" + for p in parts: + if not p: + continue + if len(current) + len(p) > MAX_CHARS and current: + chunks.append(current) + # If single part is still too long, force-split it + while len(p) > MAX_CHARS: + chunks.append(p[:MAX_CHARS]) + p = p[MAX_CHARS:] + current = p + else: + current += p + if current: + chunks.append(current) + if not chunks: + chunks = [text[:MAX_CHARS]] + + print(f"[kokoro] Processing {len(chunks)} chunk(s)...") + all_samples = [] + sample_rate = 24000 + for i, chunk in enumerate(chunks): + chunk = chunk.strip() + if not chunk: + continue + samples, sr = pipeline.create(chunk, voice=voice, speed=1.0) + all_samples.append(samples) + sample_rate = sr + print(f"[kokoro] chunk {i+1}/{len(chunks)}: '{chunk[:20]}' -> {len(samples)} samples") + + if not all_samples: + raise RuntimeError("Kokoro returned no audio") + + full_audio = np.concatenate(all_samples) + + buf = io.BytesIO() + sf.write(buf, full_audio, sample_rate, format="WAV") + audio_bytes = buf.getvalue() + + tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False, dir="/tmp") + tmp.write(audio_bytes) + tmp.close() + print(f"[kokoro] Done: {tmp.name} ({len(audio_bytes):,} bytes)") + + self.send_response(200) + self.send_header("Content-Type", "audio/wav") + self.send_header("X-Audio-File", tmp.name) + self.send_header("Content-Length", str(len(audio_bytes))) + self.end_headers() + self.wfile.write(audio_bytes) + except Exception as e: + print(f"[kokoro] ERROR: {e}") + import traceback; traceback.print_exc() + self.send_response(500) + self.end_headers() + self.wfile.write(json.dumps({"error": str(e)}).encode()) + + def log_message(self, *a): pass + +if __name__ == "__main__": + port = int(os.environ.get("KOKORO_PORT", "8421")) + print(f"[kokoro] Kokoro TTS bridge on http://127.0.0.1:{port}") + print(f"[kokoro] POST /v1/audio/speech") + get_pipeline() # pre-warm + HTTPServer(("127.0.0.1", port), KokoroHandler).serve_forever() diff --git a/mofa_hybrid.toml b/mofa_hybrid.toml new file mode 100644 index 0000000..d2bdf47 --- /dev/null +++ b/mofa_hybrid.toml @@ -0,0 +1,25 @@ +[listen] +host = "127.0.0.1" +port = 8420 + +[memory] +budget_mb = 8192 +idle_timeout_secs = 300 + +[observability] +enabled = true + +[[providers]] +name = "ollama" +kind = "ollama" +base_url = "http://127.0.0.1:11434" + +[[providers]] +name = "kokoro" +kind = "openai_compatible" +base_url = "http://127.0.0.1:8421/v1" +api_key = "local" + +[[providers.models]] +name = "kokoro" +capability = "tts" From cf59fbd4834295f51cda546bb4aa5b4fd64da516 Mon Sep 17 00:00:00 2001 From: ashum9 Date: Fri, 17 Jul 2026 00:59:00 +0530 Subject: [PATCH 24/75] add misaki chinese phonemizer and voice mapping --- kokoro_tts_server.py | 209 ++++++++++++++++++++++++++++--------------- 1 file changed, 138 insertions(+), 71 deletions(-) diff --git a/kokoro_tts_server.py b/kokoro_tts_server.py index 35f58e9..77266b9 100644 --- a/kokoro_tts_server.py +++ b/kokoro_tts_server.py @@ -1,50 +1,156 @@ #!/usr/bin/env python3 """Kokoro TTS Bridge — exposes local Kokoro as an OpenAI-compatible TTS endpoint. +Uses kokoro_onnx for synthesis and misaki for Chinese phonemization. + Usage: source .kokoro-venv/bin/activate python3 kokoro_tts_server.py """ -import json, io, os, tempfile +import json, io, os, tempfile, re from http.server import HTTPServer, BaseHTTPRequestHandler +from pathlib import Path -_pipeline = None +_kokoro = None +_zh_g2p = None -from pathlib import Path +# Voice name mapping: friendly names → Kokoro voice IDs +VOICE_MAP = { + # Chinese voices + "xiaoxiao": "zf_xiaoxiao", "yunxi": "zm_yunxi", "xiaoni": "zf_xiaoni", + "xiaoyi": "zf_xiaoyi", "xiaobei": "zf_xiaobei", + "yunjian": "zm_yunjian", "yunyang": "zm_yunyang", "yunxia": "zm_yunxia", + # English voices + "alloy": "af_alloy", "nova": "af_nova", "echo": "am_echo", + "fable": "bm_fable", "onyx": "am_onyx", "shimmer": "af_bella", + "heart": "af_heart", +} -def get_pipeline(): - global _pipeline - if _pipeline is None: - print("[kokoro] Loading ONNX pipeline...") +# Chinese voice prefixes +CHINESE_PREFIXES = ("zf_", "zm_") - from kokoro_onnx import Kokoro +def get_kokoro(): + global _kokoro + if _kokoro is None: + print("[kokoro] Loading ONNX pipeline...") + from kokoro_onnx import Kokoro model_dir = Path(__file__).parent / ".kokoro-models" - - _pipeline = Kokoro( + _kokoro = Kokoro( str(model_dir / "kokoro-v1.0.fp16.onnx"), str(model_dir / "voices-v1.0.bin"), ) + print("[kokoro] ONNX pipeline ready!") + return _kokoro + + +def get_zh_g2p(): + global _zh_g2p + if _zh_g2p is None: + print("[kokoro] Loading Chinese phonemizer (misaki)...") + from misaki.zh import ZHG2P + _zh_g2p = ZHG2P() + print("[kokoro] Chinese phonemizer ready!") + return _zh_g2p + + +def resolve_voice(raw_voice: str) -> str: + """Resolve a voice name to a Kokoro voice ID.""" + raw = raw_voice.lower().strip() + if raw in VOICE_MAP: + return VOICE_MAP[raw] + if any(raw.startswith(p) for p in ("af_", "am_", "bf_", "bm_", "ef_", "em_", "zf_", "zm_")): + return raw + return f"af_{raw}" + + +def is_chinese_voice(voice_id: str) -> bool: + return voice_id.startswith(CHINESE_PREFIXES) + + +def synthesize(text: str, voice: str) -> bytes: + """Synthesize text to WAV bytes.""" + import soundfile as sf + import numpy as np + + kokoro = get_kokoro() + use_chinese = is_chinese_voice(voice) + + if use_chinese: + # Use misaki to convert Chinese text → phonemes + g2p = get_zh_g2p() + + # Split text into chunks for processing + MAX_CHARS = 80 if use_chinese else 200 + parts = re.split(r'(?<=[。!?.!?\n,,;;])', text) + chunks = [] + current = "" + for p in parts: + if not p: + continue + if len(current) + len(p) > MAX_CHARS and current: + chunks.append(current) + while len(p) > MAX_CHARS: + chunks.append(p[:MAX_CHARS]) + p = p[MAX_CHARS:] + current = p + else: + current += p + if current: + chunks.append(current) + if not chunks: + chunks = [text[:MAX_CHARS]] + + print(f"[kokoro] Processing {len(chunks)} chunk(s), chinese={use_chinese}...") + all_samples = [] + sample_rate = 24000 + + for i, chunk in enumerate(chunks): + chunk = chunk.strip() + if not chunk: + continue + + if use_chinese: + # Convert Chinese text to phonemes, then synthesize + phonemes = g2p(chunk) + print(f"[kokoro] chunk {i+1}/{len(chunks)}: '{chunk[:30]}' -> phonemes: '{phonemes[:40]}'") + samples, sr = kokoro.create(phonemes, voice=voice, speed=1.0, is_phonemes=True) + else: + print(f"[kokoro] chunk {i+1}/{len(chunks)}: '{chunk[:30]}'") + samples, sr = kokoro.create(chunk, voice=voice, speed=1.0) + + all_samples.append(samples) + sample_rate = sr + + if not all_samples: + raise RuntimeError("Kokoro returned no audio") - print("[kokoro] Pipeline ready!") + full_audio = np.concatenate(all_samples) - return _pipeline + buf = io.BytesIO() + sf.write(buf, full_audio, sample_rate, format="WAV") + return buf.getvalue() class KokoroHandler(BaseHTTPRequestHandler): def do_GET(self): if self.path == "/v1/models": + body = json.dumps({"data": [{"id": "kokoro", "object": "model"}]}).encode() self.send_response(200) self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") self.end_headers() - self.wfile.write(json.dumps({ - "data": [{"id": "kokoro", "object": "model"}] - }).encode()) + self.wfile.write(body) else: + body = b'{"status":"ok"}' self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") self.end_headers() - self.wfile.write(b'{"status":"ok"}') + self.wfile.write(body) def do_POST(self): if self.path != "/v1/audio/speech": @@ -55,61 +161,13 @@ def do_POST(self): content_length = int(self.headers.get("Content-Length", 0)) body = json.loads(self.rfile.read(content_length)) if content_length else {} text = body.get("input", "Hello from Kokoro") - raw_voice = body.get("voice", "heart") - # Map OpenAI voice names to Kokoro format (af_ prefix) - voice = raw_voice if raw_voice.startswith("af_") else f"af_{raw_voice}" + raw_voice = body.get("voice", "xiaoxiao") + voice = resolve_voice(raw_voice) + + print(f"[kokoro] Request: voice={voice} text='{text[:60]}...' ({len(text)} chars)") - print(f"[kokoro] Generating: {text[:80]}... ({len(text)} chars)") try: - import soundfile as sf - import numpy as np - import re - pipeline = get_pipeline() - - # Kokoro has a 510 phoneme limit. Chinese chars expand to ~15 phonemes - # each, so max ~30 chars. Use 25 for safety margin. - MAX_CHARS = 25 - # Split on ANY punctuation (Chinese + English) - parts = re.split(r'(?<=[。!?.!?\n,,、;;::))」』])', text) - chunks = [] - current = "" - for p in parts: - if not p: - continue - if len(current) + len(p) > MAX_CHARS and current: - chunks.append(current) - # If single part is still too long, force-split it - while len(p) > MAX_CHARS: - chunks.append(p[:MAX_CHARS]) - p = p[MAX_CHARS:] - current = p - else: - current += p - if current: - chunks.append(current) - if not chunks: - chunks = [text[:MAX_CHARS]] - - print(f"[kokoro] Processing {len(chunks)} chunk(s)...") - all_samples = [] - sample_rate = 24000 - for i, chunk in enumerate(chunks): - chunk = chunk.strip() - if not chunk: - continue - samples, sr = pipeline.create(chunk, voice=voice, speed=1.0) - all_samples.append(samples) - sample_rate = sr - print(f"[kokoro] chunk {i+1}/{len(chunks)}: '{chunk[:20]}' -> {len(samples)} samples") - - if not all_samples: - raise RuntimeError("Kokoro returned no audio") - - full_audio = np.concatenate(all_samples) - - buf = io.BytesIO() - sf.write(buf, full_audio, sample_rate, format="WAV") - audio_bytes = buf.getvalue() + audio_bytes = synthesize(text, voice) tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False, dir="/tmp") tmp.write(audio_bytes) @@ -125,15 +183,24 @@ def do_POST(self): except Exception as e: print(f"[kokoro] ERROR: {e}") import traceback; traceback.print_exc() + body = json.dumps({"error": str(e)}).encode() self.send_response(500) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") self.end_headers() - self.wfile.write(json.dumps({"error": str(e)}).encode()) + self.wfile.write(body) def log_message(self, *a): pass + if __name__ == "__main__": port = int(os.environ.get("KOKORO_PORT", "8421")) print(f"[kokoro] Kokoro TTS bridge on http://127.0.0.1:{port}") print(f"[kokoro] POST /v1/audio/speech") - get_pipeline() # pre-warm + print(f"[kokoro] Voices: {', '.join(sorted(VOICE_MAP.keys()))}") + # Pre-warm both the ONNX model and the Chinese phonemizer + get_kokoro() + get_zh_g2p() + print(f"[kokoro] Ready! Listening...") HTTPServer(("127.0.0.1", port), KokoroHandler).serve_forever() From a4dec8df283ad9c44824b59a6f83b4f56c91dd7f Mon Sep 17 00:00:00 2001 From: ashum9 Date: Fri, 17 Jul 2026 11:06:44 +0530 Subject: [PATCH 25/75] address CI fmt check and copilot review feedback --- .gitignore | 1 + mofa-engine-app/src/main.rs | 28 +++---- mofa-engine-core/src/engine.rs | 4 +- mofa-engine-sdk/src/lib.rs | 1 - mofa-engine-sdk/src/server.rs | 15 ++-- .../dashboards/preflight_routing.json | 6 +- mofa-observability/docker/Dockerfile | 2 +- mofa-observability/docker/docker-compose.yml | 2 +- .../docker/prometheus/prometheus.yml | 2 +- mofa-observability/examples/mock_harness.rs | 75 +++++++++++-------- mofa-observability/src/collector.rs | 75 +++++++++---------- mofa-observability/src/events.rs | 26 +++++-- mofa-observability/src/lib.rs | 2 +- mofa-observability/src/prometheus.rs | 48 +++++++++--- 14 files changed, 173 insertions(+), 114 deletions(-) diff --git a/.gitignore b/.gitignore index 7ee12e8..8002557 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ __pycache__/ node_modules/ e2e/test-results/ e2e/playwright-report/ +.kokoro-models/ diff --git a/mofa-engine-app/src/main.rs b/mofa-engine-app/src/main.rs index 3a9cb27..9262314 100644 --- a/mofa-engine-app/src/main.rs +++ b/mofa-engine-app/src/main.rs @@ -6,28 +6,28 @@ use clap::Parser; use mofa_engine_core::{Engine, EngineConfig}; use mofa_engine_sdk::start_server; -use std::path::PathBuf; -use std::sync::Arc; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, fmt}; use opentelemetry_otlp::WithExportConfig; -use opentelemetry_sdk::trace::SdkTracerProvider; use opentelemetry_sdk::Resource; +use opentelemetry_sdk::trace::SdkTracerProvider; +use std::path::PathBuf; +use std::sync::Arc; +use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt}; -fn init_tracer(endpoint: &str) -> Result { +fn init_tracer( + endpoint: &str, +) -> Result { let exporter = opentelemetry_otlp::SpanExporter::builder() .with_tonic() .with_endpoint(endpoint) .build()?; - + let provider = SdkTracerProvider::builder() .with_batch_exporter(exporter) - .with_resource( - Resource::builder().with_service_name("mofa-engine").build() - ) + .with_resource(Resource::builder().with_service_name("mofa-engine").build()) .build(); - + opentelemetry::global::set_tracer_provider(provider.clone()); - + use opentelemetry::trace::TracerProvider; Ok(provider.tracer("mofa-engine")) } @@ -96,7 +96,8 @@ async fn main() -> anyhow::Result<()> { if observability_enabled { tracing::info!("Initializing observability pipeline..."); - let (metrics, sender) = mofa_observability::collector::init_pipeline(otlp_endpoint.as_deref()); + let (metrics, sender) = + mofa_observability::collector::init_pipeline(otlp_endpoint.as_deref()); metrics_state = Some(metrics); obs_sender = Some(sender); } @@ -112,7 +113,8 @@ async fn main() -> anyhow::Result<()> { let engine_clone = Arc::clone(&engine); let metrics_clone = metrics_state.clone(); tokio::spawn(async move { - mofa_engine_sdk::observability_bridge::run(rx, sender, engine_clone, metrics_clone).await; + mofa_engine_sdk::observability_bridge::run(rx, sender, engine_clone, metrics_clone) + .await; }); } diff --git a/mofa-engine-core/src/engine.rs b/mofa-engine-core/src/engine.rs index c2d3206..704fb7a 100644 --- a/mofa-engine-core/src/engine.rs +++ b/mofa-engine-core/src/engine.rs @@ -176,7 +176,9 @@ impl Engine { for mut card in cards { card.refresh_status(); let id = card.id.clone(); - if matches!(card.residency, ModelResidency::Loaded) && card.memory_estimate_bytes > 0 { + if matches!(card.residency, ModelResidency::Loaded) + && card.memory_estimate_bytes > 0 + { // If it's already loaded but we don't have it allocated yet let snapshot = self.memory.snapshot(); if !snapshot.iter().any(|(mem_id, _)| mem_id == &id) { diff --git a/mofa-engine-sdk/src/lib.rs b/mofa-engine-sdk/src/lib.rs index 42c42c3..5a5eef5 100644 --- a/mofa-engine-sdk/src/lib.rs +++ b/mofa-engine-sdk/src/lib.rs @@ -3,7 +3,6 @@ //! HTTP API server (Axum), SSE event streaming, and embedded dashboard //! for the MoFA Engine. - pub mod dashboard; pub mod observability_bridge; pub mod server; diff --git a/mofa-engine-sdk/src/server.rs b/mofa-engine-sdk/src/server.rs index 9f2be32..3071992 100644 --- a/mofa-engine-sdk/src/server.rs +++ b/mofa-engine-sdk/src/server.rs @@ -143,27 +143,24 @@ async fn events_handler( } use axum::extract::Path; -use axum::response::IntoResponse; use axum::http::header; +use axum::response::IntoResponse; async fn files_handler(Path(filename): Path) -> Result { if filename.contains('/') || filename.contains('\\') || filename.contains("..") { return Err(StatusCode::BAD_REQUEST); } - + let temp_dir = std::env::temp_dir(); let file_path = temp_dir.join(&filename); - + if !file_path.exists() { return Err(StatusCode::NOT_FOUND); } - + let bytes = std::fs::read(file_path).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - Ok(( - [(header::CONTENT_TYPE, "audio/wav")], - bytes, - )) + + Ok(([(header::CONTENT_TYPE, "audio/wav")], bytes)) } async fn dashboard_handler() -> Html<&'static str> { diff --git a/mofa-observability/dashboards/preflight_routing.json b/mofa-observability/dashboards/preflight_routing.json index 7442ac8..ed87825 100644 --- a/mofa-observability/dashboards/preflight_routing.json +++ b/mofa-observability/dashboards/preflight_routing.json @@ -68,12 +68,12 @@ }, { "type": "piechart", - "title": "Requests by Backend", + "title": "Requests by Capability", "gridPos": { "x": 12, "y": 6, "w": 12, "h": 6 }, "targets": [ { - "expr": "sum(rate(mofa_requests_total[5m])) by (backend)", - "legendFormat": "{{backend}}" + "expr": "sum(rate(mofa_requests_total[5m])) by (capability)", + "legendFormat": "{{capability}}" } ] } diff --git a/mofa-observability/docker/Dockerfile b/mofa-observability/docker/Dockerfile index d04751d..ee45850 100644 --- a/mofa-observability/docker/Dockerfile +++ b/mofa-observability/docker/Dockerfile @@ -11,5 +11,5 @@ FROM debian:bookworm-slim RUN apt-get update && apt-get install -y libssl-dev ca-certificates && rm -rf /var/lib/apt/lists/* COPY --from=builder /usr/src/mofa/target/release/examples/mock_harness /usr/local/bin/mock_harness -EXPOSE 9090 +EXPOSE 9092 CMD ["mock_harness"] diff --git a/mofa-observability/docker/docker-compose.yml b/mofa-observability/docker/docker-compose.yml index 5964d22..13e76e6 100644 --- a/mofa-observability/docker/docker-compose.yml +++ b/mofa-observability/docker/docker-compose.yml @@ -4,7 +4,7 @@ services: context: ../../ dockerfile: mofa-observability/docker/Dockerfile ports: - - "9090:9090" + - "9092:9092" prometheus: image: prom/prometheus:latest diff --git a/mofa-observability/docker/prometheus/prometheus.yml b/mofa-observability/docker/prometheus/prometheus.yml index acd8734..3dd3355 100644 --- a/mofa-observability/docker/prometheus/prometheus.yml +++ b/mofa-observability/docker/prometheus/prometheus.yml @@ -4,7 +4,7 @@ global: scrape_configs: - job_name: 'mofa_mock_harness' static_configs: - - targets: ['host.docker.internal:9092'] + - targets: ['mock-harness:9092'] - job_name: 'mofa_engine' static_configs: diff --git a/mofa-observability/examples/mock_harness.rs b/mofa-observability/examples/mock_harness.rs index 0755ca3..497b6c4 100644 --- a/mofa-observability/examples/mock_harness.rs +++ b/mofa-observability/examples/mock_harness.rs @@ -6,13 +6,13 @@ //! //! Run with: `cargo run --example mock_harness` -use axum::{extract::State, routing::get, Router}; +use axum::{Router, extract::State, routing::get}; use mofa_observability::{ - collector::{create_event_channel, EventSender, MetricsCollector, MetricsState}, + collector::{EventSender, MetricsCollector, MetricsState, create_event_channel}, events::*, prometheus, }; -use rand::{rngs::StdRng, seq::SliceRandom, Rng, SeedableRng}; +use rand::{Rng, SeedableRng, rngs::StdRng, seq::SliceRandom}; use std::{net::SocketAddr, sync::Arc, time::Duration}; use tokio::sync::RwLock; @@ -54,14 +54,17 @@ async fn simulate_engine_traffic(sender: EventSender) { }))); // Send an initial event to seed the memory budget for Grafana - sender.send_critical(EventEnvelope::now(EngineEvent::EvictionTriggered( - EvictionTriggered { - evicted_model: "init".into(), - memory_before_bytes: 0, - memory_after_bytes: 0, - budget_bytes: 16_000_000_000, - }, - ))).await.expect("Failed to send init event"); + sender + .send_critical(EventEnvelope::now(EngineEvent::EvictionTriggered( + EvictionTriggered { + evicted_model: "init".into(), + memory_before_bytes: 0, + memory_after_bytes: 0, + budget_bytes: 16_000_000_000, + }, + ))) + .await + .expect("Failed to send init event"); let mut active_models = 2; // We pre-loaded 2 models let mut current_memory = 4_000_000_000_u64; @@ -99,7 +102,7 @@ async fn simulate_engine_traffic(sender: EventSender) { continue; // Can't unload what isn't there! } active_models -= 1; - + // Don't free more than we have let freed = mem_size.min(current_memory); current_memory -= freed; @@ -119,14 +122,17 @@ async fn simulate_engine_traffic(sender: EventSender) { // If eviction, also trigger eviction event if reason == UnloadReason::Eviction { - sender.send_critical(EventEnvelope::now(EngineEvent::EvictionTriggered( - EvictionTriggered { - evicted_model: selected_model.0.into(), - memory_before_bytes: current_memory + freed, - memory_after_bytes: current_memory, - budget_bytes: 16_000_000_000, - }, - ))).await.expect("Failed to send eviction event"); + sender + .send_critical(EventEnvelope::now(EngineEvent::EvictionTriggered( + EvictionTriggered { + evicted_model: selected_model.0.into(), + memory_before_bytes: current_memory + freed, + memory_after_bytes: current_memory, + budget_bytes: 16_000_000_000, + }, + ))) + .await + .expect("Failed to send eviction event"); } } @@ -170,14 +176,17 @@ async fn simulate_engine_traffic(sender: EventSender) { // 1% chance: Failover 20..=20 => { - sender.send_critical(EventEnvelope::now(EngineEvent::FailoverTriggered( - FailoverTriggered { - failed_model: selected_model.0.into(), - failed_backend: selected_model.1.into(), - fallback_model: "fallback-model".into(), - fallback_backend: "cloud".into(), - }, - ))).await.expect("Failed to send critical event"); + sender + .send_critical(EventEnvelope::now(EngineEvent::FailoverTriggered( + FailoverTriggered { + failed_model: selected_model.0.into(), + failed_backend: selected_model.1.into(), + fallback_model: "fallback-model".into(), + fallback_backend: "cloud".into(), + }, + ))) + .await + .expect("Failed to send critical event"); } // 79% chance: Normal Inference Request @@ -245,7 +254,10 @@ async fn simulate_engine_traffic(sender: EventSender) { #[tokio::main] async fn main() -> Result<(), Box> { tracing_subscriber::fmt() - .with_env_filter(tracing_subscriber::EnvFilter::from_default_env().add_directive("mofa_observability=info".parse().unwrap())) + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive("mofa_observability=info".parse().unwrap()), + ) .init(); println!("Starting MoFA Engine Observability Mock Harness..."); @@ -275,7 +287,10 @@ async fn main() -> Result<(), Box> { println!("======================================================="); println!("Mock Harness Running!"); println!("Prometheus Endpoint: http://localhost:{}/metrics", port); - println!("Try running: curl -s http://localhost:{}/metrics | grep \"^mofa_\"", port); + println!( + "Try running: curl -s http://localhost:{}/metrics | grep \"^mofa_\"", + port + ); println!("======================================================="); axum::serve(listener, app).await?; diff --git a/mofa-observability/src/collector.rs b/mofa-observability/src/collector.rs index 7e9539f..fc8917e 100644 --- a/mofa-observability/src/collector.rs +++ b/mofa-observability/src/collector.rs @@ -14,7 +14,7 @@ use crate::events::*; use std::collections::HashMap; use std::sync::Arc; use std::time::{Duration, Instant}; -use tokio::sync::{mpsc, RwLock}; +use tokio::sync::{RwLock, mpsc}; // ─── Labels ────────────────────────────────────────────────────────────────── @@ -118,7 +118,8 @@ impl CounterFamily { true } }); - self.last_seen.retain(|_, v| now.saturating_duration_since(*v) < max_idle); + self.last_seen + .retain(|_, v| now.saturating_duration_since(*v) < max_idle); } } @@ -160,7 +161,8 @@ impl HistogramFamily { true } }); - self.last_seen.retain(|_, v| now.saturating_duration_since(*v) < max_idle); + self.last_seen + .retain(|_, v| now.saturating_duration_since(*v) < max_idle); } } @@ -217,7 +219,8 @@ impl GaugeFamily { true } }); - self.last_seen.retain(|_, v| now.saturating_duration_since(*v) < max_idle); + self.last_seen + .retain(|_, v| now.saturating_duration_since(*v) < max_idle); } } @@ -258,10 +261,7 @@ impl MetricsState { pub fn new() -> Self { Self { // Counters - requests_total: CounterFamily::new( - "mofa_requests_total", - "Total inference requests", - ), + requests_total: CounterFamily::new("mofa_requests_total", "Total inference requests"), model_loads_total: CounterFamily::new( "mofa_model_loads_total", "Total model load operations", @@ -270,10 +270,7 @@ impl MetricsState { "mofa_model_unloads_total", "Total model unload operations", ), - failovers_total: CounterFamily::new( - "mofa_failovers_total", - "Total failover events", - ), + failovers_total: CounterFamily::new("mofa_failovers_total", "Total failover events"), evictions_total: CounterFamily::new( "mofa_evictions_total", "Total memory pressure evictions", @@ -401,16 +398,12 @@ impl MetricsState { // Counter: tokens if let Some(tokens_in) = e.tokens_in { - self.tokens_input_total.inc_by( - Labels::new().add("model", &e.model_id), - tokens_in, - ); + self.tokens_input_total + .inc_by(Labels::new().add("model", &e.model_id), tokens_in); } if let Some(tokens_out) = e.tokens_out { - self.tokens_output_total.inc_by( - Labels::new().add("model", &e.model_id), - tokens_out, - ); + self.tokens_output_total + .inc_by(Labels::new().add("model", &e.model_id), tokens_out); } // Gauge: active requests down @@ -473,14 +466,15 @@ impl MetricsState { // Reconcile memory gauge against the absolute post-eviction value. // This corrects any cumulative drift caused by dropped ModelUnloaded // the authoritative value from the eviction subsystem. - self.memory_used_bytes.set(Labels::new(), e.memory_after_bytes as f64); - self.memory_budget_bytes.set(Labels::new(), e.budget_bytes as f64); + self.memory_used_bytes + .set(Labels::new(), e.memory_after_bytes as f64); + self.memory_budget_bytes + .set(Labels::new(), e.budget_bytes as f64); } EngineEvent::PreflightSignal(e) => { - self.preflight_predictions_total.inc( - Labels::new().add("source", e.source.to_string()), - ); + self.preflight_predictions_total + .inc(Labels::new().add("source", e.source.to_string())); } EngineEvent::PreflightHit(_) => { @@ -567,14 +561,20 @@ impl EventSender { /// Send a critical event asynchronously. If the channel is full, this will await /// and apply backpressure to the caller instead of dropping the event. /// Use for rare, critical events like `FailoverTriggered` and `EvictionTriggered`. - pub async fn send_critical(&self, event: EventEnvelope) -> Result<(), mpsc::error::SendError> { + pub async fn send_critical( + &self, + event: EventEnvelope, + ) -> Result<(), mpsc::error::SendError> { self.tx.send(event).await } /// Send a critical event synchronously. If the channel is full, this will block /// the current thread. Must not be called from within an async runtime context. #[allow(clippy::result_large_err)] - pub fn send_blocking(&self, event: EventEnvelope) -> Result<(), mpsc::error::SendError> { + pub fn send_blocking( + &self, + event: EventEnvelope, + ) -> Result<(), mpsc::error::SendError> { self.tx.blocking_send(event) } @@ -681,7 +681,7 @@ pub fn init_pipeline(_otlp_endpoint: Option<&str>) -> (Arc> let (sender, receiver) = create_event_channel(10000); let collector_loop = MetricsCollector::new(receiver); let state = collector_loop.state(); - + tokio::spawn(async move { collector_loop.run().await; }); @@ -1037,10 +1037,7 @@ mod tests { let history_labels = Labels::new().add("source", "history"); assert_eq!(state.preflight_predictions_total.values[&hint_labels], 2); - assert_eq!( - state.preflight_predictions_total.values[&history_labels], - 1 - ); + assert_eq!(state.preflight_predictions_total.values[&history_labels], 1); } // ── Token counting tests ───────────────────────────────────────────── @@ -1149,18 +1146,17 @@ mod tests { // Try to send critical event. It should block because the channel is full. // We use tokio::time::timeout to prove it blocks. - let critical_event = EventEnvelope::now(EngineEvent::FailoverTriggered( - FailoverTriggered { + let critical_event = + EventEnvelope::now(EngineEvent::FailoverTriggered(FailoverTriggered { failed_model: "failed".into(), failed_backend: "backend".into(), fallback_model: "fallback".into(), fallback_backend: "backend".into(), - }, - )); + })); let send_future = sender.send_critical(critical_event.clone()); let result = tokio::time::timeout(Duration::from_millis(50), send_future).await; - + // It should timeout because it's blocking assert!(result.is_err()); @@ -1224,7 +1220,10 @@ mod tests { // Override the last_seen for the stale label to be 20 minutes ago let twenty_mins_ago = Instant::now() - Duration::from_secs(1200); - state.requests_total.last_seen.insert(labels_stale.clone(), twenty_mins_ago); + state + .requests_total + .last_seen + .insert(labels_stale.clone(), twenty_mins_ago); // Evict labels older than 10 minutes state.evict_stale_labels(Duration::from_secs(600)); diff --git a/mofa-observability/src/events.rs b/mofa-observability/src/events.rs index 1541c74..1140ea6 100644 --- a/mofa-observability/src/events.rs +++ b/mofa-observability/src/events.rs @@ -6,7 +6,7 @@ //! Design principles: //! - Zero side effects: events are data, not actions. //! - Privacy: no prompt text, file contents, API keys, or user-identifying info. Ever. -//! - Bounded enums: capability, reason, source, status use enums, not free-form strings. +//! - Bounded enums: capability, unload reason, and signal source use enums (status is a bool). use serde::{Deserialize, Serialize}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -576,11 +576,25 @@ mod tests { #[test] fn test_privacy_contract_no_forbidden_fields() { let forbidden_fields = [ - "prompt", "text", "generated_text", "response_text", - "file_path", "file_content", "file_contents", - "api_key", "api_secret", "token", "auth_token", "access_token", - "password", "credential", "credentials", - "user_id", "username", "email", "ip_address", + "prompt", + "text", + "generated_text", + "response_text", + "file_path", + "file_content", + "file_contents", + "api_key", + "api_secret", + "token", + "auth_token", + "access_token", + "password", + "credential", + "credentials", + "user_id", + "username", + "email", + "ip_address", ]; let events = vec![ diff --git a/mofa-observability/src/lib.rs b/mofa-observability/src/lib.rs index 9105b77..7492d26 100644 --- a/mofa-observability/src/lib.rs +++ b/mofa-observability/src/lib.rs @@ -1,3 +1,3 @@ -pub mod events; pub mod collector; +pub mod events; pub mod prometheus; diff --git a/mofa-observability/src/prometheus.rs b/mofa-observability/src/prometheus.rs index b62c487..1fac81a 100644 --- a/mofa-observability/src/prometheus.rs +++ b/mofa-observability/src/prometheus.rs @@ -93,7 +93,14 @@ fn render_gauge(buf: &mut String, family: &GaugeFamily) { ) .unwrap(); } else { - writeln!(buf, "{}{} {}", family.name, format_labels(labels), format_float(*value)).unwrap(); + writeln!( + buf, + "{}{} {}", + family.name, + format_labels(labels), + format_float(*value) + ) + .unwrap(); } } buf.push('\n'); @@ -302,13 +309,25 @@ mod tests { // Must have TYPE histogram. assert!(output.contains("# TYPE mofa_model_load_seconds histogram")); // Must have bucket lines with le labels. - assert!(output.contains("mofa_model_load_seconds_bucket{backend=\"ollama\",model=\"qwen2.5:7b\",le=\"0.5\"} 0")); - assert!(output.contains("mofa_model_load_seconds_bucket{backend=\"ollama\",model=\"qwen2.5:7b\",le=\"2.0\"} 1")); + assert!(output.contains( + "mofa_model_load_seconds_bucket{backend=\"ollama\",model=\"qwen2.5:7b\",le=\"0.5\"} 0" + )); + assert!(output.contains( + "mofa_model_load_seconds_bucket{backend=\"ollama\",model=\"qwen2.5:7b\",le=\"2.0\"} 1" + )); // Must have +Inf bucket. - assert!(output.contains("mofa_model_load_seconds_bucket{backend=\"ollama\",model=\"qwen2.5:7b\",le=\"+Inf\"} 1")); + assert!(output.contains( + "mofa_model_load_seconds_bucket{backend=\"ollama\",model=\"qwen2.5:7b\",le=\"+Inf\"} 1" + )); // Must have sum and count. - assert!(output.contains("mofa_model_load_seconds_sum{backend=\"ollama\",model=\"qwen2.5:7b\"} 1.532")); - assert!(output.contains("mofa_model_load_seconds_count{backend=\"ollama\",model=\"qwen2.5:7b\"} 1")); + assert!(output.contains( + "mofa_model_load_seconds_sum{backend=\"ollama\",model=\"qwen2.5:7b\"} 1.532" + )); + assert!( + output.contains( + "mofa_model_load_seconds_count{backend=\"ollama\",model=\"qwen2.5:7b\"} 1" + ) + ); } #[test] @@ -497,10 +516,21 @@ mod tests { #[test] fn test_float_formatting_spec_compliance() { let mut state = MetricsState::new(); - state.memory_used_bytes.values.insert(Labels::new(), f64::INFINITY); + state + .memory_used_bytes + .values + .insert(Labels::new(), f64::INFINITY); state.active_requests.values.insert(Labels::new(), f64::NAN); let output = render(&state); - assert!(output.contains("mofa_memory_used_bytes +Inf"), "Failed to render +Inf: {}", output); - assert!(output.contains("mofa_active_requests NaN"), "Failed to render NaN: {}", output); + assert!( + output.contains("mofa_memory_used_bytes +Inf"), + "Failed to render +Inf: {}", + output + ); + assert!( + output.contains("mofa_active_requests NaN"), + "Failed to render NaN: {}", + output + ); } } From 4cff2dc8394b2547a44b94120cb62c33c80019fc Mon Sep 17 00:00:00 2001 From: ashum9 Date: Fri, 17 Jul 2026 11:20:27 +0530 Subject: [PATCH 26/75] handle invalid JSON with proper 400 response --- kokoro_tts_server.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/kokoro_tts_server.py b/kokoro_tts_server.py index 77266b9..b05e71f 100644 --- a/kokoro_tts_server.py +++ b/kokoro_tts_server.py @@ -159,7 +159,17 @@ def do_POST(self): return content_length = int(self.headers.get("Content-Length", 0)) - body = json.loads(self.rfile.read(content_length)) if content_length else {} + raw = self.rfile.read(content_length) if content_length else b"{}" + try: + body = json.loads(raw) + except json.JSONDecodeError: + self.send_response(400) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"error":"invalid_json"}') + return + if not isinstance(body, dict): + body = {} text = body.get("input", "Hello from Kokoro") raw_voice = body.get("voice", "xiaoxiao") voice = resolve_voice(raw_voice) From 8b39b2c9332b5c06859bd7bea88eef90c336d10d Mon Sep 17 00:00:00 2001 From: ashum9 Date: Thu, 16 Jul 2026 16:38:08 +0530 Subject: [PATCH 27/75] setup base react vite and tailwind configuration --- mofa-frontend/.gitignore | 5 + mofa-frontend/README.md | 37 + mofa-frontend/eslint.config.js | 27 + mofa-frontend/index.html | 15 + mofa-frontend/package-lock.json | 3217 ++++++++++++++++++++++++++++ mofa-frontend/package.json | 43 + mofa-frontend/postcss.config.mjs | 5 + mofa-frontend/public/favicon.svg | 15 + mofa-frontend/public/mofa-logo.png | Bin 0 -> 103807 bytes mofa-frontend/tsconfig.json | 25 + mofa-frontend/vite.config.ts | 17 + 11 files changed, 3406 insertions(+) create mode 100644 mofa-frontend/.gitignore create mode 100644 mofa-frontend/README.md create mode 100644 mofa-frontend/eslint.config.js create mode 100644 mofa-frontend/index.html create mode 100644 mofa-frontend/package-lock.json create mode 100644 mofa-frontend/package.json create mode 100644 mofa-frontend/postcss.config.mjs create mode 100644 mofa-frontend/public/favicon.svg create mode 100644 mofa-frontend/public/mofa-logo.png create mode 100644 mofa-frontend/tsconfig.json create mode 100644 mofa-frontend/vite.config.ts diff --git a/mofa-frontend/.gitignore b/mofa-frontend/.gitignore new file mode 100644 index 0000000..fd7d79d --- /dev/null +++ b/mofa-frontend/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.env +.DS_Store +*.log diff --git a/mofa-frontend/README.md b/mofa-frontend/README.md new file mode 100644 index 0000000..d2bdace --- /dev/null +++ b/mofa-frontend/README.md @@ -0,0 +1,37 @@ +# MoFA FM + +MoFA FM is a local AI podcast studio. It turns any article into a high-quality podcast script and synthesizes it into natural-sounding audio — running entirely on your machine. No cloud, no API keys, full privacy. + +## Features + +- **Offline-First**: Operates seamlessly against a local inference engine. +- **Visual Intelligence**: Real-time observability into the model's inner workings via the Theater view and Engine Monitor. +- **Design Excellence**: Highly polished interface with considered motion, sound design, and typography. +- **Robust Pipeline**: Hardened against intermittent network failures with intelligent retries. +- **Accessibility**: Full keyboard and screen reader support, honoring reduced-motion preferences. + +## Quick Start + +\`\`\`bash +npm install +npm run dev +\`\`\` + +## Architecture + +- **React + Vite**: Fast, modern frontend build. +- **Tailwind CSS**: Utility-first styling tied to a strict design token system. +- **Framer Motion**: Smooth, intentional animations and state transitions. + +### Mock vs. Real Engine + +MoFA FM is designed to run against the **MoFA Core Engine** (at `127.0.0.1:8420`). +However, for development and demonstration without the heavy backend, the app can run against a simulated local mock engine. + +To use the mock engine, create a `.env` file or set the environment variable: +\`\`\`env +VITE_USE_MOCK=true +VITE_ENGINE_URL=http://localhost:8420 +\`\`\` + +If `VITE_USE_MOCK=false`, the application will make real HTTP and SSE connections to your local Engine. diff --git a/mofa-frontend/eslint.config.js b/mofa-frontend/eslint.config.js new file mode 100644 index 0000000..bdcc475 --- /dev/null +++ b/mofa-frontend/eslint.config.js @@ -0,0 +1,27 @@ +import js from '@eslint/js'; +import globals from 'globals'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config({ + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }] + }, +}); diff --git a/mofa-frontend/index.html b/mofa-frontend/index.html new file mode 100644 index 0000000..916e967 --- /dev/null +++ b/mofa-frontend/index.html @@ -0,0 +1,15 @@ + + + + + + + + MoFA Engine + + + +
+ + + diff --git a/mofa-frontend/package-lock.json b/mofa-frontend/package-lock.json new file mode 100644 index 0000000..fe26f84 --- /dev/null +++ b/mofa-frontend/package-lock.json @@ -0,0 +1,3217 @@ +{ + "name": "applet", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "applet", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@tanstack/react-query": "^5.101.2", + "clsx": "^2.1.1", + "framer-motion": "^12.42.2", + "lucide-react": "^1.23.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "tailwind-merge": "^3.6.0", + "wavesurfer.js": "^7.12.10" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@tailwindcss/postcss": "^4.3.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@typescript-eslint/eslint-plugin": "^8.63.0", + "@typescript-eslint/parser": "^8.63.0", + "@vitejs/plugin-react": "^6.0.3", + "eslint": "^10.6.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.3", + "globals": "^17.7.0", + "postcss": "^8.5.16", + "tailwindcss": "^4.3.2", + "typescript": "^6.0.3", + "typescript-eslint": "^8.63.0", + "vite": "^8.1.4" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.2.tgz", + "integrity": "sha512-rjVWYCa7Ngbi5AarT6k8TkxUG3Wl1QKzHdIZVsjZSzf36Jmo2IKZt/NHRAwly8oDkbBOH0YTu+CHuf9jPxMc+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "postcss": "^8.5.15", + "tailwindcss": "4.3.2" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz", + "integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.2.tgz", + "integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz", + "integrity": "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/type-utils": "8.63.0", + "@typescript-eslint/utils": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.63.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.63.0.tgz", + "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz", + "integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.63.0", + "@typescript-eslint/types": "^8.63.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz", + "integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz", + "integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.63.0.tgz", + "integrity": "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/utils": "8.63.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz", + "integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz", + "integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.63.0", + "@typescript-eslint/tsconfig-utils": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz", + "integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz", + "integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.63.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.42", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", + "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", + "integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001800", + "electron-to-chromium": "^1.5.387", + "node-releases": "^2.0.50", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001803", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz", + "integrity": "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.389", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", + "integrity": "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz", + "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.3.tgz", + "integrity": "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/framer-motion": { + "version": "12.42.2", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.42.2.tgz", + "integrity": "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.42.2", + "motion-utils": "^12.39.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.23.0.tgz", + "integrity": "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/motion-dom": { + "version": "12.42.2", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.42.2.tgz", + "integrity": "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.39.0" + } + }, + "node_modules/motion-utils": { + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", + "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.63.0.tgz", + "integrity": "sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.63.0", + "@typescript-eslint/parser": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/utils": "8.63.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/wavesurfer.js": { + "version": "7.12.10", + "resolved": "https://registry.npmjs.org/wavesurfer.js/-/wavesurfer.js-7.12.10.tgz", + "integrity": "sha512-M3AC5biFmvfy7Oxe8FN6I88H0z1c8Vuz81N6Oq2CY4kOumNDb6U1/0qg4pomjP619vJqdWADlgUcpOKvfGifng==", + "license": "BSD-3-Clause" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/mofa-frontend/package.json b/mofa-frontend/package.json new file mode 100644 index 0000000..560804b --- /dev/null +++ b/mofa-frontend/package.json @@ -0,0 +1,43 @@ +{ + "name": "applet", + "version": "1.0.0", + "type": "module", + "description": "", + "main": "index.js", + "scripts": { + "dev": "vite --port 3000 --host 0.0.0.0", + "build": "tsc && vite build", + "lint": "eslint ." + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@tanstack/react-query": "^5.101.2", + "clsx": "^2.1.1", + "framer-motion": "^12.42.2", + "lucide-react": "^1.23.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "tailwind-merge": "^3.6.0", + "wavesurfer.js": "^7.12.10" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@tailwindcss/postcss": "^4.3.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@typescript-eslint/eslint-plugin": "^8.63.0", + "@typescript-eslint/parser": "^8.63.0", + "@vitejs/plugin-react": "^6.0.3", + "eslint": "^10.6.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.3", + "globals": "^17.7.0", + "postcss": "^8.5.16", + "tailwindcss": "^4.3.2", + "typescript": "^6.0.3", + "typescript-eslint": "^8.63.0", + "vite": "^8.1.4" + } +} diff --git a/mofa-frontend/postcss.config.mjs b/mofa-frontend/postcss.config.mjs new file mode 100644 index 0000000..ef6af3e --- /dev/null +++ b/mofa-frontend/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + } +} diff --git a/mofa-frontend/public/favicon.svg b/mofa-frontend/public/favicon.svg new file mode 100644 index 0000000..95eaf7b --- /dev/null +++ b/mofa-frontend/public/favicon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/mofa-frontend/public/mofa-logo.png b/mofa-frontend/public/mofa-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..8eeb2cb5685ee74beb84a9dfbfe9573bab9324c7 GIT binary patch literal 103807 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 JQbQN)e*t9Ir@#OJ literal 0 HcmV?d00001 diff --git a/mofa-frontend/tsconfig.json b/mofa-frontend/tsconfig.json new file mode 100644 index 0000000..fae33a3 --- /dev/null +++ b/mofa-frontend/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "ignoreDeprecations": "6.0", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/mofa-frontend/vite.config.ts b/mofa-frontend/vite.config.ts new file mode 100644 index 0000000..3994565 --- /dev/null +++ b/mofa-frontend/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + server: { + hmr: process.env.DISABLE_HMR !== 'true', + host: '0.0.0.0', + port: 3000, + }, +}); From d28d617cde3707fca839aaa6612d0abae0f43e15 Mon Sep 17 00:00:00 2001 From: ashum9 Date: Thu, 16 Jul 2026 16:38:08 +0530 Subject: [PATCH 28/75] add shared ui components and styling utilities --- mofa-frontend/src/index.css | 36 ++++++++++++++++++ mofa-frontend/src/lib/audio.ts | 23 ++++++++++++ mofa-frontend/src/lib/motion.ts | 26 +++++++++++++ mofa-frontend/src/lib/utils.ts | 11 ++++++ mofa-frontend/src/shared/Badge.tsx | 41 +++++++++++++++++++++ mofa-frontend/src/shared/Button.tsx | 31 ++++++++++++++++ mofa-frontend/src/shared/Card.tsx | 24 ++++++++++++ mofa-frontend/src/shared/Skeleton.tsx | 12 ++++++ mofa-frontend/src/shared/StatusDot.tsx | 51 ++++++++++++++++++++++++++ mofa-frontend/src/vite-env.d.ts | 1 + 10 files changed, 256 insertions(+) create mode 100644 mofa-frontend/src/index.css create mode 100644 mofa-frontend/src/lib/audio.ts create mode 100644 mofa-frontend/src/lib/motion.ts create mode 100644 mofa-frontend/src/lib/utils.ts create mode 100644 mofa-frontend/src/shared/Badge.tsx create mode 100644 mofa-frontend/src/shared/Button.tsx create mode 100644 mofa-frontend/src/shared/Card.tsx create mode 100644 mofa-frontend/src/shared/Skeleton.tsx create mode 100644 mofa-frontend/src/shared/StatusDot.tsx create mode 100644 mofa-frontend/src/vite-env.d.ts diff --git a/mofa-frontend/src/index.css b/mofa-frontend/src/index.css new file mode 100644 index 0000000..bc14f65 --- /dev/null +++ b/mofa-frontend/src/index.css @@ -0,0 +1,36 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap'); +@import "tailwindcss"; + +@theme { + --color-background-primary: #ffffff; + --color-background-secondary: #f4f4f5; + + --color-text-primary: #09090b; + --color-text-secondary: #52525b; + --color-text-dim: #a1a1aa; + + --color-accent-blue: #2563eb; + --color-accent-cyan: #0891b2; + --color-accent-green: #059669; + --color-accent-yellow: #d97706; + --color-accent-red: #dc2626; + --color-accent-purple: #7c3aed; + + --radius-card: 10px; + --radius-small: 6px; + + --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; +} + +@layer base { + body { + @apply bg-background-primary text-text-primary font-sans antialiased; + margin: 0; + } + + *:focus-visible { + @apply outline-none ring-2 ring-accent-cyan ring-offset-2 ring-offset-background-primary; + } +} + diff --git a/mofa-frontend/src/lib/audio.ts b/mofa-frontend/src/lib/audio.ts new file mode 100644 index 0000000..ff3af24 --- /dev/null +++ b/mofa-frontend/src/lib/audio.ts @@ -0,0 +1,23 @@ +export function playChime() { + try { + const ctx = new (window.AudioContext || (window as any).webkitAudioContext)(); + const osc = ctx.createOscillator(); + const gainNode = ctx.createGain(); + + osc.type = 'sine'; + osc.frequency.setValueAtTime(523.25, ctx.currentTime); // C5 + osc.frequency.exponentialRampToValueAtTime(1046.50, ctx.currentTime + 0.1); // C6 + + gainNode.gain.setValueAtTime(0, ctx.currentTime); + gainNode.gain.linearRampToValueAtTime(0.3, ctx.currentTime + 0.05); + gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 1.5); + + osc.connect(gainNode); + gainNode.connect(ctx.destination); + + osc.start(); + osc.stop(ctx.currentTime + 1.5); + } catch { + // Ignore if audio context fails + } +} diff --git a/mofa-frontend/src/lib/motion.ts b/mofa-frontend/src/lib/motion.ts new file mode 100644 index 0000000..600eb1f --- /dev/null +++ b/mofa-frontend/src/lib/motion.ts @@ -0,0 +1,26 @@ +export const motionVariants: any = { + enter: { + initial: { opacity: 0 }, + animate: { opacity: 1, transition: { duration: 0.4, ease: [0.25, 0.1, 0.25, 1] } }, + exit: { opacity: 0, transition: { duration: 0.3, ease: 'easeIn' } } + }, + fade: { + initial: { opacity: 0 }, + animate: { opacity: 1, transition: { duration: 0.3, ease: [0.25, 0.1, 0.25, 1] } }, + exit: { opacity: 0, transition: { duration: 0.2, ease: 'easeIn' } } + }, + staggerContainer: { + animate: { + transition: { + staggerChildren: 0.04 + } + } + }, + staggerItem: { + initial: { opacity: 0 }, + animate: { opacity: 1, transition: { duration: 0.3, ease: 'easeOut' } } + }, + buttonTap: { + scale: 0.98 + } +}; diff --git a/mofa-frontend/src/lib/utils.ts b/mofa-frontend/src/lib/utils.ts new file mode 100644 index 0000000..ce9a19e --- /dev/null +++ b/mofa-frontend/src/lib/utils.ts @@ -0,0 +1,11 @@ +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + + +export function formatMs(ms: number) { + return `${(ms / 1000).toFixed(1)}s`; +} diff --git a/mofa-frontend/src/shared/Badge.tsx b/mofa-frontend/src/shared/Badge.tsx new file mode 100644 index 0000000..d24d38e --- /dev/null +++ b/mofa-frontend/src/shared/Badge.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { cn } from '../lib/utils'; +import { Capability } from '../engine/types'; + +interface BadgeProps extends React.HTMLAttributes { + variant?: 'blue' | 'purple' | 'cyan' | 'yellow' | 'green' | 'default'; + capability?: Capability; +} + +export function Badge({ className, variant = 'default', capability, children, ...props }: BadgeProps) { + let finalVariant = variant; + + if (capability) { + switch (capability) { + case 'Chat': finalVariant = 'blue'; break; + case 'Tts': finalVariant = 'purple'; break; + case 'Asr': finalVariant = 'cyan'; break; + case 'ImageGen': finalVariant = 'yellow'; break; + case 'Embedding': finalVariant = 'green'; break; + default: finalVariant = 'default'; + } + } + + return ( + + {capability || children} + + ); +} diff --git a/mofa-frontend/src/shared/Button.tsx b/mofa-frontend/src/shared/Button.tsx new file mode 100644 index 0000000..9886349 --- /dev/null +++ b/mofa-frontend/src/shared/Button.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { cn } from '../lib/utils'; +import { motion, HTMLMotionProps } from 'framer-motion'; +import { motionVariants } from '../lib/motion'; + +interface ButtonProps extends HTMLMotionProps<'button'> { + variant?: 'primary' | 'secondary' | 'ghost'; +} + +export const Button = React.forwardRef( + ({ className, variant = 'primary', ...props }, ref) => { + return ( + + {props.children as React.ReactNode} + + ); + } +); +Button.displayName = 'Button'; diff --git a/mofa-frontend/src/shared/Card.tsx b/mofa-frontend/src/shared/Card.tsx new file mode 100644 index 0000000..e2451bb --- /dev/null +++ b/mofa-frontend/src/shared/Card.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { cn } from '../lib/utils'; +import { motion, HTMLMotionProps } from 'framer-motion'; + +interface CardProps extends HTMLMotionProps<'div'> { + interactive?: boolean; +} + +export const Card = React.forwardRef( + ({ className, interactive, ...props }, ref) => { + return ( + + ); + } +); +Card.displayName = 'Card'; diff --git a/mofa-frontend/src/shared/Skeleton.tsx b/mofa-frontend/src/shared/Skeleton.tsx new file mode 100644 index 0000000..c232444 --- /dev/null +++ b/mofa-frontend/src/shared/Skeleton.tsx @@ -0,0 +1,12 @@ +import { cn } from '../lib/utils'; + +type SkeletonProps = React.HTMLAttributes; + +export function Skeleton({ className, ...props }: SkeletonProps) { + return ( +

+ ); +} diff --git a/mofa-frontend/src/shared/StatusDot.tsx b/mofa-frontend/src/shared/StatusDot.tsx new file mode 100644 index 0000000..1c9452d --- /dev/null +++ b/mofa-frontend/src/shared/StatusDot.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { cn } from '../lib/utils'; +import { motion } from 'framer-motion'; + +export type StatusType = 'Hot' | 'Healthy' | 'Closed' | 'Warming' | 'HalfOpen' | 'Busy' | 'Cold' | 'Unloaded' | 'Failed' | 'Open' | 'Unhealthy' | 'Connecting'; + +interface StatusDotProps { + status: StatusType; + className?: string; +} + +export function StatusDot({ status, className }: StatusDotProps) { + let colorClass = 'bg-text-dim'; + let animatePulse = false; + + switch (status) { + case 'Hot': + case 'Healthy': + case 'Closed': + colorClass = 'bg-accent-green shadow-[0_0_8px_rgba(16,185,129,0.6)]'; + break; + case 'Warming': + case 'HalfOpen': + case 'Connecting': + colorClass = 'bg-accent-yellow shadow-[0_0_8px_rgba(245,158,11,0.6)]'; + animatePulse = true; + break; + case 'Busy': + colorClass = 'bg-accent-blue shadow-[0_0_8px_rgba(59,130,246,0.6)]'; + animatePulse = true; + break; + case 'Cold': + case 'Unloaded': + break; + case 'Failed': + case 'Open': + case 'Unhealthy': + colorClass = 'bg-accent-red shadow-[0_0_8px_rgba(239,68,68,0.6)]'; + break; + default: + break; + } + + return ( + + ); +} diff --git a/mofa-frontend/src/vite-env.d.ts b/mofa-frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/mofa-frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// From 5cfa985f3681e7a82dcf4ef3fbdb6ec757640956 Mon Sep 17 00:00:00 2001 From: ashum9 Date: Thu, 16 Jul 2026 16:38:08 +0530 Subject: [PATCH 29/75] implement rust engine sdk and sse connection hooks --- mofa-frontend/src/engine/client.ts | 115 ++++++++++++++++++ mofa-frontend/src/engine/index.ts | 43 +++++++ mofa-frontend/src/engine/types.ts | 96 +++++++++++++++ .../src/engine/useEngineConnection.ts | 28 +++++ mofa-frontend/src/engine/useEngineStatus.ts | 15 +++ 5 files changed, 297 insertions(+) create mode 100644 mofa-frontend/src/engine/client.ts create mode 100644 mofa-frontend/src/engine/index.ts create mode 100644 mofa-frontend/src/engine/types.ts create mode 100644 mofa-frontend/src/engine/useEngineConnection.ts create mode 100644 mofa-frontend/src/engine/useEngineStatus.ts diff --git a/mofa-frontend/src/engine/client.ts b/mofa-frontend/src/engine/client.ts new file mode 100644 index 0000000..eec03da --- /dev/null +++ b/mofa-frontend/src/engine/client.ts @@ -0,0 +1,115 @@ +import { + HealthResponse, + EngineStatus, + ModelCard, + InferenceRequest, + InferenceResponse, + EngineResult, + EngineEvent +} from './types'; +import { IEngineClient } from './index'; + +export class RealEngineClient implements IEngineClient { + private baseUrl: string; + + constructor(baseUrl: string) { + this.baseUrl = baseUrl.replace(/\/$/, ''); + } + + setBaseUrl(url: string) { + this.baseUrl = url.replace(/\/$/, ''); + } + + getBaseUrl() { + return this.baseUrl; + } + + private async request(path: string, options?: RequestInit): Promise> { + try { + const response = await fetch(`${this.baseUrl}${path}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(options?.headers || {}) + } + }); + + if (!response.ok) { + let detail = response.statusText; + let error = `HTTP ${response.status}`; + try { + const body = await response.json(); + if (body.error) error = body.error; + if (body.detail) detail = body.detail; + } catch { + // ignore parsing error if it's not JSON + } + return { success: false, type: 'http', error, detail }; + } + + const data = await response.json(); + return { success: true, data }; + } catch (e) { + return { success: false, type: 'network', error: 'Engine unreachable', detail: (e as Error).message }; + } + } + + async getHealth() { + return this.request('/health'); + } + + async getStatus() { + return this.request('/v1/status'); + } + + async getCapabilities() { + return this.request('/v1/capabilities'); + } + + async invoke(payload: InferenceRequest) { + return this.request('/v1/invoke', { + method: 'POST', + body: JSON.stringify(payload) + }); + } + + async refreshDiscovery() { + return this.request('/v1/discovery/refresh', { + method: 'POST' + }); + } + + subscribeEvents(handler: (e: EngineEvent) => void): () => void { + const eventSource = new EventSource(`${this.baseUrl}/v1/events`); + + eventSource.onmessage = (event) => { + try { + const raw = JSON.parse(event.data); + // Real engine uses serde tag="type" rename_all="snake_case" + // so type comes as "request_started", "model_status_changed", etc. + const snakeType: string = raw.type || ''; + // Convert snake_case to PascalCase: "request_started" → "RequestStarted" + const pascalType = snakeType.replace(/(^|_)([a-z])/g, (_: string, __: string, c: string) => c.toUpperCase()); + // Everything except 'type' goes into data + const { type: _, ...data } = raw; + handler({ type: pascalType as any, data, timestamp: Date.now() }); + } catch (e) { + console.error("Failed to parse SSE", e); + } + }; + + return () => { + eventSource.close(); + }; + } + + getAudioUrl(filename: string): string { + return `${this.baseUrl}/v1/files/${filename}`; + } + + async fetchAudio(filename: string): Promise { + const res = await fetch(this.getAudioUrl(filename)); + if (!res.ok) throw new Error(`Failed to fetch audio: ${res.statusText}`); + return res.blob(); + } +} diff --git a/mofa-frontend/src/engine/index.ts b/mofa-frontend/src/engine/index.ts new file mode 100644 index 0000000..c994705 --- /dev/null +++ b/mofa-frontend/src/engine/index.ts @@ -0,0 +1,43 @@ +import { HealthResponse, EngineStatus, ModelCard, InferenceRequest, InferenceResponse, EngineResult, EngineEvent } from './types'; +import { RealEngineClient } from './client'; +export interface IEngineClient { + getBaseUrl(): string; + setBaseUrl(url: string): void; + getHealth(): Promise>; + getStatus(): Promise>; + getCapabilities(): Promise>; + invoke(req: InferenceRequest): Promise>; + refreshDiscovery(): Promise>; + subscribeEvents(handler: (e: EngineEvent) => void): () => void; + getAudioUrl(filename: string): string; + fetchAudio(filename: string): Promise; +} + +export const defaultEngineUrl = import.meta.env.VITE_ENGINE_URL || 'http://127.0.0.1:8420'; + +export const getStoredEngineUrl = () => { + return localStorage.getItem('mofa_engine_url') || defaultEngineUrl; +}; + +import { useState, useEffect } from 'react'; + +type Listener = () => void; +const listeners = new Set(); + +export const setStoredEngineUrl = (url: string) => { + localStorage.setItem('mofa_engine_url', url); + engine.setBaseUrl(url); + listeners.forEach(l => l()); +}; + +export const useEngineUrl = () => { + const [url, setUrl] = useState(getStoredEngineUrl()); + useEffect(() => { + const listener = () => setUrl(getStoredEngineUrl()); + listeners.add(listener); + return () => { listeners.delete(listener); }; + }, []); + return url; +}; + +export const engine: IEngineClient = new RealEngineClient(getStoredEngineUrl()); diff --git a/mofa-frontend/src/engine/types.ts b/mofa-frontend/src/engine/types.ts new file mode 100644 index 0000000..dc2c09a --- /dev/null +++ b/mofa-frontend/src/engine/types.ts @@ -0,0 +1,96 @@ +export type Capability = 'Chat' | 'Tts' | 'Asr' | 'ImageGen' | 'VideoGen' | 'Vlm' | 'Embedding'; +export type ModelStatus = 'Cold' | 'Warming' | 'Hot' | 'Busy' | 'Failed'; +export type ModelResidency = 'Unknown' | 'Unloaded' | 'Loading' | 'Loaded' | 'Unloading' | 'Remote'; +export type ModelAvailability = 'Discovered' | 'Configured' | 'Unavailable'; +export type ExecutionState = 'Idle' | 'Active' | 'Overloaded'; +export type CostTier = 'Free' | 'Low' | 'Medium' | 'High'; +export type CircuitState = 'Closed' | 'Open' | 'HalfOpen'; +export type BackendHealth = 'Healthy' | 'Degraded' | 'Unhealthy'; +export type FallbackPolicy = 'capability_only' | 'disabled' | 'allow_named'; + +export interface HealthResponse { + status: string; + version: string; + uptime_secs: number; +} + +export interface ProviderHealth { + name: string; + healthy: boolean; + circuit_state: CircuitState; +} + +export interface Backend { + name: string; + kind: string; + health: BackendHealth; + circuit_state: CircuitState; + features: string[]; +} + +export interface EngineStatus { + total_models: number; + loaded_models: number; + providers: number; + memory_used_bytes: number; + memory_budget_bytes: number; + uptime_secs: number; + provider_health: ProviderHealth[]; + backends: Backend[]; +} + +export interface ModelCard { + id: string; + name: string; + provider: string; + capability: Capability; + capabilities: Capability[]; + status: ModelStatus; + availability: ModelAvailability; + residency: ModelResidency; + execution: ExecutionState; + cost_tier: CostTier; + context_window?: number; + memory_estimate_bytes?: number; +} + +export interface InferenceRequest { + capability: Capability; + model: string | null; + messages: Array<{ role: string; content: string }>; + hint_next?: string; + params: Record; + app_id: string; + session_id: string | null; + fallback_policy: FallbackPolicy; +} + +export interface InferenceResponse { + text: string; + file: string | null; + model_used: string; + provider: string; + duration_ms: number; + request_id: string; + tokens_used: number; + fallback_used: boolean; + routing_reason: string; + candidates_considered?: number; +} + +export interface EngineError { + error: string; + detail: string; +} + +export type EngineResult = + | { success: true; data: T } + | { success: false; type: 'network' | 'http'; error: string; detail?: string }; + +export type EngineEventType = 'RequestStarted' | 'RequestCompleted' | 'ModelStatusChanged' | 'ModelResidencyChanged' | 'MemoryChanged' | 'ProviderHealthChanged' | 'DiscoveryCompleted'; + +export interface EngineEvent { + type: EngineEventType; + data: any; + timestamp: number; +} diff --git a/mofa-frontend/src/engine/useEngineConnection.ts b/mofa-frontend/src/engine/useEngineConnection.ts new file mode 100644 index 0000000..305142a --- /dev/null +++ b/mofa-frontend/src/engine/useEngineConnection.ts @@ -0,0 +1,28 @@ +import { useQuery } from '@tanstack/react-query'; +import { engine, useEngineUrl } from './index'; + +export type ConnectionState = 'connecting' | 'connected' | 'disconnected'; + +export function useEngineConnection() { + const url = useEngineUrl(); + const { data, error, isFetching } = useQuery({ + queryKey: ['engine_health', url], + queryFn: () => engine.getHealth(), + refetchInterval: 3000, + retry: false, + }); + + let state: ConnectionState = 'connecting'; + if (data?.success) { + state = 'connected'; + } else if (data?.success === false || error) { + state = 'disconnected'; + } + + return { + state, + version: data?.success ? data.data.version : null, + uptime_secs: data?.success ? data.data.uptime_secs : null, + isFetching + }; +} diff --git a/mofa-frontend/src/engine/useEngineStatus.ts b/mofa-frontend/src/engine/useEngineStatus.ts new file mode 100644 index 0000000..3328fc9 --- /dev/null +++ b/mofa-frontend/src/engine/useEngineStatus.ts @@ -0,0 +1,15 @@ +import { useQuery } from '@tanstack/react-query'; +import { engine, useEngineUrl } from './index'; +import { useEngineConnection } from './useEngineConnection'; + +export function useEngineStatus() { + const { state } = useEngineConnection(); + const url = useEngineUrl(); + + return useQuery({ + queryKey: ['engine_status', url], + queryFn: () => engine.getStatus(), + refetchInterval: 2000, + enabled: state === 'connected', + }); +} From fbecebc210bff1b8fa550ef610a1883209a1ceaa Mon Sep 17 00:00:00 2001 From: ashum9 Date: Thu, 16 Jul 2026 16:38:08 +0530 Subject: [PATCH 30/75] build real time observability and metrics dashboard --- mofa-frontend/src/monitor/EventFeed.tsx | 131 ++++++++++++++ mofa-frontend/src/monitor/MemoryGauge.tsx | 80 +++++++++ mofa-frontend/src/monitor/ModelGrid.tsx | 82 +++++++++ mofa-frontend/src/monitor/MonitorSidebar.tsx | 58 ++++++ .../src/monitor/PreflightIndicator.tsx | 66 +++++++ mofa-frontend/src/monitor/ProviderHealth.tsx | 42 +++++ .../src/observability/MetricsStrip.tsx | 96 ++++++++++ .../src/observability/ObservabilityView.tsx | 170 ++++++++++++++++++ 8 files changed, 725 insertions(+) create mode 100644 mofa-frontend/src/monitor/EventFeed.tsx create mode 100644 mofa-frontend/src/monitor/MemoryGauge.tsx create mode 100644 mofa-frontend/src/monitor/ModelGrid.tsx create mode 100644 mofa-frontend/src/monitor/MonitorSidebar.tsx create mode 100644 mofa-frontend/src/monitor/PreflightIndicator.tsx create mode 100644 mofa-frontend/src/monitor/ProviderHealth.tsx create mode 100644 mofa-frontend/src/observability/MetricsStrip.tsx create mode 100644 mofa-frontend/src/observability/ObservabilityView.tsx diff --git a/mofa-frontend/src/monitor/EventFeed.tsx b/mofa-frontend/src/monitor/EventFeed.tsx new file mode 100644 index 0000000..785dea9 --- /dev/null +++ b/mofa-frontend/src/monitor/EventFeed.tsx @@ -0,0 +1,131 @@ +import React, { useEffect, useState } from 'react'; +import { engine } from '../engine'; +import { EngineEvent } from '../engine/types'; +import { Activity, CheckCircle2, Cpu, Loader, Box, RotateCw } from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; + +export function EventFeed() { + const [events, setEvents] = useState<(EngineEvent & { _id: string })[]>([]); + + useEffect(() => { + let counter = 0; + const handleEvent = (evt: EngineEvent) => { + setEvents(prev => { + const newEvt = { ...evt, _id: `${evt.timestamp}-${counter++}` }; + const next = [newEvt, ...prev]; // Prepend for reverse chronological + if (next.length > 15) return next.slice(0, 15); + return next; + }); + }; + + const unsubscribe = engine.subscribeEvents(handleEvent); + return () => unsubscribe(); + }, []); + + return ( +
+
+ + {events.map((evt) => ( + + ))} + + {events.length === 0 && ( +
Waiting for engine events...
+ )} +
+
+
+
+ ); +} + +function EventCard({ evt }: { evt: EngineEvent }) { + const { icon, color, title, desc } = formatEvent(evt); + + return ( + +
+ {icon} +
+
+
+ {title} + + {new Date(evt.timestamp).toLocaleTimeString([], { hour12: false, hour: '2-digit', minute: '2-digit', second:'2-digit' })} + +
+
+ {desc} +
+
+
+ ); +} + +function formatEvent(evt: EngineEvent) { + switch (evt.type) { + case 'RequestStarted': + return { + icon: , + color: 'accent-blue', + title: 'Request Started', + desc: `${evt.data.capability || 'inference'} → ${evt.data.model_id}` + }; + case 'RequestCompleted': + return { + icon: , + color: evt.data.success ? 'accent-green' : 'accent-red', + title: evt.data.success ? 'Request Completed' : 'Request Failed', + desc: `${evt.data.request_id?.slice(0, 8)}… ${evt.data.duration_ms}ms` + }; + case 'ModelStatusChanged': + return { + icon: , + color: 'accent-yellow', + title: 'Model Status', + desc: `${evt.data.model_id}: ${evt.data.old} → ${evt.data.new}` + }; + case 'ModelResidencyChanged': + return { + icon: , + color: 'accent-cyan', + title: 'Model Residency', + desc: `${evt.data.model_id}: ${evt.data.old} → ${evt.data.new}` + }; + case 'MemoryChanged': + return { + icon: , + color: 'accent-purple', + title: 'Memory Changed', + desc: `${(evt.data.used_bytes / 1024 / 1024 / 1024).toFixed(2)} GB / ${(evt.data.total_bytes / 1024 / 1024 / 1024).toFixed(1)} GB` + }; + case 'ProviderHealthChanged': + return { + icon: , + color: 'accent-yellow', + title: 'Provider Health', + desc: `${evt.data.provider}: ${evt.data.health}` + }; + case 'DiscoveryCompleted': + return { + icon: , + color: evt.data.success ? 'accent-green' : 'accent-red', + title: 'Discovery', + desc: `${evt.data.provider}: ${evt.data.models} model(s)` + }; + default: + return { + icon: , + color: 'text-dim', + title: evt.type || 'Event', + desc: JSON.stringify(evt.data) + }; + } +} diff --git a/mofa-frontend/src/monitor/MemoryGauge.tsx b/mofa-frontend/src/monitor/MemoryGauge.tsx new file mode 100644 index 0000000..04320c2 --- /dev/null +++ b/mofa-frontend/src/monitor/MemoryGauge.tsx @@ -0,0 +1,80 @@ +import React, { useEffect, useState, useRef } from 'react'; +import { engine } from '../engine'; + +export function MemoryGauge() { + const [memory, setMemory] = useState(0); + const [maxMemory, setMaxMemory] = useState(16 * 1024 * 1024 * 1024); + const [delta, setDelta] = useState(null); + const deltaTimeout = useRef(null); + + useEffect(() => { + // Initial fetch + engine.getStatus().then(s => { + if (s.success) { + setMemory(s.data.memory_used_bytes || 0); + setMaxMemory(s.data.memory_budget_bytes || 16 * 1024 * 1024 * 1024); + } + }); + + const handleEvent = (evt: any) => { + if (evt.type === 'MemoryChanged') { + setMemory(prev => { + const newMem = evt.data.used_bytes; + const diff = newMem - prev; + if (Math.abs(diff) > 1024 * 1024) { // only show significant delta + setDelta(diff); + if (deltaTimeout.current) clearTimeout(deltaTimeout.current); + deltaTimeout.current = setTimeout(() => setDelta(null), 2000); + } + return newMem; + }); + } + }; + const unsubscribe = engine.subscribeEvents(handleEvent); + return () => { + unsubscribe(); + if (deltaTimeout.current) clearTimeout(deltaTimeout.current); + }; + }, []); + + const percentage = Math.min(100, Math.max(0, (memory / maxMemory) * 100)); + const memoryGb = (memory / 1024 / 1024 / 1024).toFixed(2); + const maxGb = (maxMemory / 1024 / 1024 / 1024).toFixed(1); + + let colorClass = 'bg-accent-green'; + let textColorClass = 'text-accent-green'; + if (percentage >= 90) { + colorClass = 'bg-accent-red'; + textColorClass = 'text-accent-red'; + } else if (percentage >= 80) { + colorClass = 'bg-accent-yellow'; + textColorClass = 'text-accent-yellow'; + } + + return ( +
+
+
+ {memoryGb} + / {maxGb} GB +
+
+ {percentage.toFixed(1)}% +
+
+
+
+
+
+ {delta !== null && ( + 0 ? 'text-accent-red' : 'text-accent-green'}`}> + {delta > 0 ? '+' : ''}{(delta / 1024 / 1024 / 1024).toFixed(2)} GB + + )} +
+
+ ); +} diff --git a/mofa-frontend/src/monitor/ModelGrid.tsx b/mofa-frontend/src/monitor/ModelGrid.tsx new file mode 100644 index 0000000..1598021 --- /dev/null +++ b/mofa-frontend/src/monitor/ModelGrid.tsx @@ -0,0 +1,82 @@ +import React, { useEffect, useState } from 'react'; +import { engine } from '../engine'; +import { ModelCard } from '../engine/types'; +import { StatusDot } from '../shared/StatusDot'; +import { Badge } from '../shared/Badge'; +import { Cpu } from 'lucide-react'; + +export function ModelGrid() { + const [models, setModels] = useState([]); + + useEffect(() => { + // Initial load + engine.getCapabilities().then(c => { + if (c.success) setModels(c.data); + }); + + const handleEvent = (evt: any) => { + if (evt.type === 'ModelStatusChanged' || evt.type === 'ModelResidencyChanged') { + engine.getCapabilities().then(c => { + if (c.success) setModels(c.data); + }); + } + }; + + const unsubscribe = engine.subscribeEvents(handleEvent); + return () => unsubscribe(); + }, []); + + const activeModels = models.filter(m => m.status !== 'Cold' || m.residency === 'Remote'); + + if (activeModels.length === 0) { + return ( +
+ + All models cold on disk +
+ ); + } + + return ( +
+ {activeModels.map(model => { + const memGb = model.memory_estimate_bytes ? (model.memory_estimate_bytes / 1024 / 1024 / 1024).toFixed(1) + 'GB' : '0GB'; + + return ( +
+
+ +
+
{model.id}
+
+ {model.provider} + {model.residency !== 'Remote' && ( + <> + · + {memGb} + + )} +
+
+
+
+ + {model.residency === 'Remote' ? 'REMOTE' : model.status.toUpperCase()} + +
+
+ ); + })} +
+ ); +} diff --git a/mofa-frontend/src/monitor/MonitorSidebar.tsx b/mofa-frontend/src/monitor/MonitorSidebar.tsx new file mode 100644 index 0000000..d1ca99d --- /dev/null +++ b/mofa-frontend/src/monitor/MonitorSidebar.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { Activity, Shield, Box, Zap } from 'lucide-react'; +import { MemoryGauge } from './MemoryGauge'; +import { ModelGrid } from './ModelGrid'; +import { ProviderHealth } from './ProviderHealth'; +import { PreflightIndicator } from './PreflightIndicator'; + +export function MonitorSidebar() { + return ( + + ); +} diff --git a/mofa-frontend/src/monitor/PreflightIndicator.tsx b/mofa-frontend/src/monitor/PreflightIndicator.tsx new file mode 100644 index 0000000..86d299f --- /dev/null +++ b/mofa-frontend/src/monitor/PreflightIndicator.tsx @@ -0,0 +1,66 @@ +import React, { useEffect, useState } from 'react'; +import { engine } from '../engine'; +import { Zap, RotateCw, CheckCircle2 } from 'lucide-react'; + +export function PreflightIndicator() { + const [status, setStatus] = useState<{ type: 'idle' | 'warming' | 'evicting'; model?: string; loadedCount: number }>({ + type: 'idle', + loadedCount: 0 + }); + + useEffect(() => { + // Initial fetch to get loaded count + engine.getStatus().then(s => { + if (s.success) { + setStatus(prev => ({ ...prev, loadedCount: s.data.loaded_models })); + } + }); + + const handleEvent = (evt: any) => { + if (evt.type === 'ModelResidencyChanged') { + const { model, from, to, reason } = evt.data; + if (to === 'Loading') { + setStatus(prev => ({ ...prev, type: 'warming', model })); + } else if (to === 'Unloaded' && reason === 'eviction') { + setStatus(prev => ({ ...prev, type: 'evicting', model })); + } else if (to === 'Loaded' || to === 'Unloaded') { + // Re-fetch loaded count + engine.getStatus().then(s => { + if (s.success) { + setStatus({ type: 'idle', loadedCount: s.data.loaded_models }); + } + }); + } + } else if (evt.type === 'ModelStatusChanged') { + const { model, from, to } = evt.data; + if (to === 'Warming') { + setStatus(prev => ({ ...prev, type: 'warming', model })); + } + } + }; + + const unsubscribe = engine.subscribeEvents(handleEvent); + return () => unsubscribe(); + }, []); + + return ( +
+ {status.type === 'idle' ? ( +
+ + Idle · {status.loadedCount} Loaded +
+ ) : status.type === 'warming' ? ( +
+ + Pre-warming {status.model} +
+ ) : ( +
+ + Evicting {status.model} +
+ )} +
+ ); +} diff --git a/mofa-frontend/src/monitor/ProviderHealth.tsx b/mofa-frontend/src/monitor/ProviderHealth.tsx new file mode 100644 index 0000000..cf44eec --- /dev/null +++ b/mofa-frontend/src/monitor/ProviderHealth.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { ShieldAlert, ShieldCheck } from 'lucide-react'; +import { useEngineStatus } from '../engine/useEngineStatus'; + +export function ProviderHealth() { + const { data: statusRes } = useEngineStatus(); + + const providers = (statusRes?.success ? statusRes.data.provider_health : null) || [ + { name: 'Ollama (Local)', healthy: true, circuit_state: 'closed' }, + { name: 'Kokoro (Local)', healthy: true, circuit_state: 'closed' } + ]; + + return ( +
+ {providers.map(p => { + const state = (p.circuit_state || '').toLowerCase(); + const isClosed = state === 'closed'; + const isHalfOpen = state === 'halfopen' || state === 'half_open'; + + return ( +
+
+ {isClosed ? ( + + ) : isHalfOpen ? ( + + ) : ( + + )} + {p.name} +
+
+ + {isClosed ? 'CLOSED' : isHalfOpen ? 'HALF_OPEN' : 'OPEN'} + +
+
+ ); + })} +
+ ); +} diff --git a/mofa-frontend/src/observability/MetricsStrip.tsx b/mofa-frontend/src/observability/MetricsStrip.tsx new file mode 100644 index 0000000..aef5b15 --- /dev/null +++ b/mofa-frontend/src/observability/MetricsStrip.tsx @@ -0,0 +1,96 @@ +import React, { useEffect, useState } from 'react'; +import { engine } from '../engine'; +import { EngineStatus } from '../engine/types'; +import { Card } from '../shared/Card'; +import { Activity, Clock, Cpu, HardDrive } from 'lucide-react'; + +export function MetricsStrip() { + const [status, setStatus] = useState(null); + const [requestCount, setRequestCount] = useState(0); + const [avgLatency, setAvgLatency] = useState(0); + + useEffect(() => { + // Initial fetch + engine.getStatus().then(res => { + if (res.success) setStatus(res.data); + }); + + const handleEvent = (evt: any) => { + if (evt.type === 'RequestStarted') { + setRequestCount(prev => prev + 1); + } else if (evt.type === 'RequestCompleted') { + if (evt.data && evt.data.duration_ms) { + setAvgLatency(prev => { + if (prev === 0) return evt.data.duration_ms; + return prev * 0.8 + evt.data.duration_ms * 0.2; + }); + } + } else if ( + evt.type === 'ModelStatusChanged' || + evt.type === 'ModelResidencyChanged' || + evt.type === 'MemoryChanged' + ) { + engine.getStatus().then(res => { + if (res.success) setStatus(res.data); + }); + } + }; + + const unsubscribe = engine.subscribeEvents(handleEvent); + return () => unsubscribe(); + }, []); + + const memoryPercent = status + ? ((status.memory_used_bytes / status.memory_budget_bytes) * 100).toFixed(1) + : 0; + + return ( +
+ +
+ +
+
+
Total Requests
+
{requestCount}
+
+
+ + +
+ +
+
+
Avg Latency
+
+ {avgLatency > 0 ? `${(avgLatency / 1000).toFixed(2)}s` : '--'} +
+
+
+ + +
+ +
+
+
Models Loaded
+
+ {status?.loaded_models ?? '--'} / {status?.total_models ?? '--'} +
+
+
+ + +
+ +
+
+
Memory Usage
+
+ {memoryPercent}% +
+
+
+
+ ); +} diff --git a/mofa-frontend/src/observability/ObservabilityView.tsx b/mofa-frontend/src/observability/ObservabilityView.tsx new file mode 100644 index 0000000..fd09348 --- /dev/null +++ b/mofa-frontend/src/observability/ObservabilityView.tsx @@ -0,0 +1,170 @@ +import React, { useEffect, useState } from 'react'; +import { motion } from 'framer-motion'; +import { Card } from '../shared/Card'; +import { Button } from '../shared/Button'; +import { MetricsStrip } from './MetricsStrip'; +import { Activity, LayoutDashboard, Cpu, Network, ExternalLink } from 'lucide-react'; + +const GRAFANA_URL = import.meta.env.VITE_GRAFANA_URL || 'http://localhost:3000'; + +type TabId = 'overview' | 'memory' | 'routing'; + +interface TabConfig { + id: TabId; + label: string; + icon: React.ReactNode; + dashboardPath: string; + description: string; +} + +const TABS: TabConfig[] = [ + { + id: 'overview', + label: 'Engine Overview', + icon: , + dashboardPath: '/d/engine-overview/mofa-engine-overview', + description: 'Request rate, error rate, P95 latency, TTFT, and tokens/sec.' + }, + { + id: 'memory', + label: 'Memory & Lifecycle', + icon: , + dashboardPath: '/d/engine-memory/mofa-memory-and-lifecycle', + description: 'Memory vs budget, model loads/unloads, eviction rate, and cold-load heatmap.' + }, + { + id: 'routing', + label: 'Preflight & Routing', + icon: , + dashboardPath: '/d/engine-routing/mofa-preflight-and-routing', + description: 'Fallback routing, circuit breakers, and capability matching.' + } +]; + +export function ObservabilityView() { + const [activeTab, setActiveTab] = useState('overview'); + const [grafanaAvailable, setGrafanaAvailable] = useState(null); + + useEffect(() => { + let mounted = true; + + // Lightweight check for Grafana availability using a known public static asset + const checkGrafana = () => { + const img = new Image(); + img.onload = () => { + if (mounted) setGrafanaAvailable(true); + }; + img.onerror = () => { + if (mounted) setGrafanaAvailable(false); + }; + // Prevent caching + img.src = `${GRAFANA_URL}/public/img/grafana_icon.svg?t=${Date.now()}`; + }; + + checkGrafana(); + + return () => { mounted = false; }; + }, []); + + const activeConfig = TABS.find(t => t.id === activeTab)!; + + return ( + +
+ {/* Header */} +
+
+

+ + Engine Observability +

+

+ Powered by Prometheus + Grafana +

+
+ {grafanaAvailable && ( + + )} +
+ + + + {/* Tab Navigation */} +
+ {TABS.map(tab => ( + + ))} +
+ +
+ {grafanaAvailable === null ? ( +
+
Checking observability stack...
+
+ ) : grafanaAvailable ? ( + +
+
{activeConfig.label}
+
{activeConfig.description}
+
+
+