Skip to main content

mesh_llm_events/
lib.rs

1#![forbid(unsafe_code)]
2
3use clap::ValueEnum;
4use serde_json::Value;
5use std::future::Future;
6use std::io::{self, IsTerminal};
7use std::pin::Pin;
8use std::sync::{Arc, OnceLock, RwLock};
9
10pub mod terminal_progress;
11
12#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)]
13pub enum LogFormat {
14    #[default]
15    Pretty,
16    Json,
17}
18
19#[allow(dead_code)]
20#[derive(Clone, Debug, Eq, PartialEq)]
21pub enum RuntimeStatus {
22    NotReady,
23    Starting,
24    Loading,
25    Ready,
26    ShuttingDown,
27    Stopped,
28    Exited,
29    Warning,
30    Error,
31}
32
33impl RuntimeStatus {
34    pub fn as_str(&self) -> &'static str {
35        match self {
36            RuntimeStatus::NotReady => "NOT READY",
37            RuntimeStatus::Starting => "starting",
38            RuntimeStatus::Loading => "loading",
39            RuntimeStatus::Ready => "ready",
40            RuntimeStatus::ShuttingDown => "shutting down",
41            RuntimeStatus::Stopped => "stopped",
42            RuntimeStatus::Exited => "exited",
43            RuntimeStatus::Warning => "warning",
44            RuntimeStatus::Error => "error",
45        }
46    }
47}
48
49#[allow(dead_code)]
50#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51pub enum ConsoleSessionMode {
52    InteractiveDashboard,
53    Fallback,
54    None,
55}
56
57#[allow(dead_code)]
58#[derive(Clone, Debug, Eq, PartialEq)]
59pub struct DashboardProcessRow {
60    pub name: String,
61    pub backend: String,
62    pub status: RuntimeStatus,
63    pub port: u16,
64    pub pid: u32,
65}
66
67#[allow(dead_code)]
68#[derive(Clone, Debug, Eq, PartialEq)]
69pub struct DashboardEndpointRow {
70    pub label: String,
71    pub status: RuntimeStatus,
72    pub url: String,
73    pub port: u16,
74    pub pid: Option<u32>,
75}
76
77#[allow(dead_code)]
78#[derive(Clone, Debug, PartialEq)]
79pub struct DashboardModelRow {
80    pub name: String,
81    pub role: Option<String>,
82    pub status: RuntimeStatus,
83    pub port: Option<u16>,
84    pub device: Option<String>,
85    pub slots: Option<usize>,
86    pub quantization: Option<String>,
87    pub ctx_size: Option<u32>,
88    pub ctx_used_tokens: Option<u64>,
89    pub lanes: Option<Vec<DashboardModelLane>>,
90    pub file_size_gb: Option<f64>,
91}
92
93#[allow(dead_code)]
94#[derive(Clone, Debug, Eq, PartialEq)]
95pub struct DashboardModelLane {
96    pub index: usize,
97    pub active: bool,
98}
99
100#[allow(dead_code)]
101#[derive(Clone, Debug, Eq, PartialEq)]
102pub struct DashboardAcceptedRequestBucket {
103    pub second_offset: u32,
104    pub accepted_count: u64,
105}
106
107#[derive(Clone, Debug, Eq, PartialEq)]
108pub enum ModelProgressStatus {
109    Ensuring,
110    Downloading,
111    Ready,
112}
113
114impl ModelProgressStatus {
115    pub fn as_str(&self) -> &'static str {
116        match self {
117            ModelProgressStatus::Ensuring => "ensuring",
118            ModelProgressStatus::Downloading => "downloading",
119            ModelProgressStatus::Ready => "ready",
120        }
121    }
122}
123
124#[allow(dead_code)]
125#[derive(Clone, Debug, PartialEq)]
126pub struct DashboardSnapshot {
127    pub llama_process_rows: Vec<DashboardProcessRow>,
128    pub webserver_rows: Vec<DashboardEndpointRow>,
129    pub loaded_model_rows: Vec<DashboardModelRow>,
130    pub current_inflight_requests: u64,
131    pub accepted_request_buckets: Vec<DashboardAcceptedRequestBucket>,
132    pub latency_samples_ms: Vec<u64>,
133}
134
135impl Default for DashboardSnapshot {
136    fn default() -> Self {
137        Self {
138            llama_process_rows: Vec::new(),
139            webserver_rows: Vec::new(),
140            loaded_model_rows: Vec::new(),
141            current_inflight_requests: 0,
142            accepted_request_buckets: (0..30)
143                .map(|second_offset| DashboardAcceptedRequestBucket {
144                    second_offset,
145                    accepted_count: 0,
146                })
147                .collect(),
148            latency_samples_ms: Vec::new(),
149        }
150    }
151}
152
153#[allow(dead_code)]
154#[derive(Clone, Debug, Default, PartialEq)]
155pub struct DashboardLaunchPlan {
156    pub llama_process_rows: Vec<DashboardProcessRow>,
157    pub webserver_rows: Vec<DashboardEndpointRow>,
158    pub loaded_model_rows: Vec<DashboardModelRow>,
159}
160
161#[allow(dead_code)]
162pub type DashboardSnapshotFuture<'a> = Pin<Box<dyn Future<Output = DashboardSnapshot> + Send + 'a>>;
163
164#[allow(dead_code)]
165pub trait DashboardSnapshotProvider: Send + Sync {
166    fn snapshot(&self) -> DashboardSnapshotFuture<'_>;
167}
168
169pub type OutputSinkFuture<'a, T> = Pin<Box<dyn Future<Output = io::Result<T>> + Send + 'a>>;
170
171pub trait OutputSink: Send + Sync {
172    fn emit_event(&self, event: OutputEvent) -> io::Result<()>;
173
174    fn schedule_ready_prompt(&self) -> io::Result<()> {
175        Ok(())
176    }
177
178    fn write_ready_prompt(&self) -> io::Result<()> {
179        Ok(())
180    }
181
182    fn ready_prompt_active(&self) -> bool {
183        false
184    }
185
186    fn flush(&self) -> OutputSinkFuture<'_, ()> {
187        Box::pin(async { Ok(()) })
188    }
189
190    fn mode(&self) -> LogFormat {
191        LogFormat::Pretty
192    }
193
194    fn console_session_mode(&self) -> Option<ConsoleSessionMode> {
195        None
196    }
197
198    fn register_dashboard_snapshot_provider(&self, _provider: Arc<dyn DashboardSnapshotProvider>) {}
199
200    fn enter_tui(&self) -> OutputSinkFuture<'_, ()> {
201        Box::pin(async { Ok(()) })
202    }
203
204    fn exit_tui(&self) -> OutputSinkFuture<'_, ()> {
205        Box::pin(async { Ok(()) })
206    }
207
208    fn dispatch_tui_event(&self, _event: TuiEvent) -> OutputSinkFuture<'_, TuiControlFlow> {
209        Box::pin(async { Ok(TuiControlFlow::Continue) })
210    }
211
212    fn render_tui_if_dirty(&self) -> OutputSinkFuture<'_, bool> {
213        Box::pin(async { Ok(false) })
214    }
215
216    fn force_restore_tui_terminal(&self) -> io::Result<()> {
217        Ok(())
218    }
219}
220
221static OUTPUT_SINK: OnceLock<RwLock<Option<Arc<dyn OutputSink>>>> = OnceLock::new();
222
223fn output_sink_slot() -> &'static RwLock<Option<Arc<dyn OutputSink>>> {
224    OUTPUT_SINK.get_or_init(|| RwLock::new(None))
225}
226
227pub fn set_output_sink(sink: Arc<dyn OutputSink>) {
228    if let Ok(mut slot) = output_sink_slot().write() {
229        *slot = Some(sink);
230    }
231}
232
233pub fn clear_output_sink() {
234    if let Ok(mut slot) = output_sink_slot().write() {
235        *slot = None;
236    }
237}
238
239pub fn output_sink() -> Option<Arc<dyn OutputSink>> {
240    output_sink_slot()
241        .read()
242        .ok()
243        .and_then(|slot| slot.as_ref().cloned())
244}
245
246pub fn emit_event(event: OutputEvent) -> io::Result<()> {
247    match output_sink() {
248        Some(sink) => sink.emit_event(event),
249        None => Ok(()),
250    }
251}
252
253pub async fn flush_output() -> io::Result<()> {
254    match output_sink() {
255        Some(sink) => sink.flush().await,
256        None => Ok(()),
257    }
258}
259
260pub fn schedule_ready_prompt() -> io::Result<()> {
261    match output_sink() {
262        Some(sink) => sink.schedule_ready_prompt(),
263        None => Ok(()),
264    }
265}
266
267pub fn json_mode_enabled() -> bool {
268    output_sink().is_some_and(|sink| matches!(sink.mode(), LogFormat::Json))
269}
270
271pub fn interactive_tui_active() -> bool {
272    output_sink().is_some_and(|sink| {
273        matches!(sink.mode(), LogFormat::Pretty)
274            && matches!(
275                sink.console_session_mode(),
276                Some(ConsoleSessionMode::InteractiveDashboard)
277            )
278    })
279}
280
281pub fn current_console_session_mode() -> ConsoleSessionMode {
282    console_session_mode(
283        std::io::stdin().is_terminal(),
284        std::io::stderr().is_terminal(),
285    )
286}
287
288pub fn console_session_mode(stdin_is_tty: bool, stderr_is_tty: bool) -> ConsoleSessionMode {
289    console_session_mode_for_term(
290        stdin_is_tty,
291        stderr_is_tty,
292        std::env::var("TERM").ok().as_deref(),
293    )
294}
295
296pub fn console_session_mode_for_term(
297    stdin_is_tty: bool,
298    stderr_is_tty: bool,
299    term: Option<&str>,
300) -> ConsoleSessionMode {
301    if stdin_is_tty && stderr_is_tty && terminal_supports_dashboard(term) {
302        ConsoleSessionMode::InteractiveDashboard
303    } else {
304        ConsoleSessionMode::Fallback
305    }
306}
307
308fn terminal_supports_dashboard(term: Option<&str>) -> bool {
309    match term.map(str::trim).filter(|term| !term.is_empty()) {
310        Some(term) => term != "dumb",
311        None => false,
312    }
313}
314
315pub fn sort_dashboard_endpoint_rows(rows: &mut [DashboardEndpointRow]) {
316    rows.sort_by(|left, right| {
317        dashboard_endpoint_sort_bucket(left)
318            .cmp(&dashboard_endpoint_sort_bucket(right))
319            .then_with(|| left.label.cmp(&right.label))
320    });
321}
322
323fn dashboard_endpoint_sort_bucket(row: &DashboardEndpointRow) -> u8 {
324    if row.label.starts_with("Plugin: ") {
325        1
326    } else {
327        0
328    }
329}
330
331#[derive(Clone, Copy, Debug, Eq, PartialEq)]
332pub enum TuiKeyEvent {
333    Tab,
334    BackTab,
335    Backspace,
336    Enter,
337    Escape,
338    Left,
339    Right,
340    Up,
341    Down,
342    PageUp,
343    PageDown,
344    Interrupt,
345    Char(char),
346}
347
348#[derive(Clone, Copy, Debug, Eq, PartialEq)]
349pub enum TuiEvent {
350    Key(TuiKeyEvent),
351    Resize { columns: u16, rows: u16 },
352    MouseDown { column: u16, row: u16 },
353}
354
355#[derive(Clone, Copy, Debug, Eq, PartialEq)]
356pub enum TuiControlFlow {
357    Continue,
358    Quit,
359}
360
361#[derive(Clone, Debug, Eq, PartialEq)]
362pub enum OutputLevel {
363    Debug,
364    Info,
365    Warn,
366    Error,
367    Fatal,
368}
369
370impl OutputLevel {
371    pub fn as_str(&self) -> &'static str {
372        match self {
373            OutputLevel::Debug => "debug",
374            OutputLevel::Info => "info",
375            OutputLevel::Warn => "warn",
376            OutputLevel::Error => "error",
377            OutputLevel::Fatal => "fatal",
378        }
379    }
380}
381
382#[derive(Clone, Debug, Eq, PartialEq)]
383pub enum LlamaInstanceKind {
384    LlamaServer,
385}
386
387impl LlamaInstanceKind {
388    pub fn as_str(&self) -> &'static str {
389        match self {
390            LlamaInstanceKind::LlamaServer => "llama-server",
391        }
392    }
393
394    pub fn sort_key(&self) -> u8 {
395        match self {
396            LlamaInstanceKind::LlamaServer => 0,
397        }
398    }
399}
400
401#[allow(dead_code)]
402#[derive(Clone, Debug, PartialEq)]
403pub enum OutputEvent {
404    Info {
405        message: String,
406        context: Option<String>,
407    },
408    Startup {
409        version: String,
410        message: Option<String>,
411    },
412    LaunchPlan {
413        plan: DashboardLaunchPlan,
414    },
415    NodeIdentity {
416        node_id: String,
417        mesh_id: Option<String>,
418    },
419    InviteToken {
420        token: String,
421        mesh_id: String,
422        mesh_name: Option<String>,
423    },
424    DiscoveryStarting {
425        source: String,
426    },
427    MeshFound {
428        mesh: String,
429        peers: usize,
430        region: Option<String>,
431    },
432    DiscoveryJoined {
433        mesh: String,
434    },
435    DiscoveryFailed {
436        message: String,
437        detail: Option<String>,
438    },
439    WaitingForPeers {
440        detail: Option<String>,
441    },
442    PassiveMode {
443        role: String,
444        status: RuntimeStatus,
445        capacity_gb: Option<f64>,
446        models_on_disk: Option<Vec<String>>,
447        detail: Option<String>,
448    },
449    PeerJoined {
450        peer_id: String,
451        label: Option<String>,
452    },
453    PeerLeft {
454        peer_id: String,
455        reason: Option<String>,
456    },
457    ModelQueued {
458        model: String,
459    },
460    ModelLoading {
461        model: String,
462        source: Option<String>,
463    },
464    ModelLoaded {
465        model: String,
466        bytes: Option<u64>,
467    },
468    ModelUnloading {
469        model: String,
470    },
471    ModelUnloaded {
472        model: String,
473    },
474    HostElected {
475        model: String,
476        host: String,
477        role: Option<String>,
478        capacity_gb: Option<f64>,
479    },
480    RpcServerStarting {
481        port: u16,
482        device: String,
483        log_path: Option<String>,
484    },
485    RpcReady {
486        port: u16,
487        device: String,
488        log_path: Option<String>,
489    },
490    RpcStartupFailed {
491        port: u16,
492        device: String,
493        log_path: Option<String>,
494        detail: String,
495    },
496    LlamaStarting {
497        model: Option<String>,
498        http_port: u16,
499        ctx_size: Option<u32>,
500        log_path: Option<String>,
501    },
502    LlamaReady {
503        model: Option<String>,
504        port: u16,
505        ctx_size: Option<u32>,
506        log_path: Option<String>,
507    },
508    LlamaStartupFailed {
509        model: Option<String>,
510        http_port: u16,
511        ctx_size: Option<u32>,
512        log_path: Option<String>,
513        detail: String,
514    },
515    ModelReady {
516        model: String,
517        internal_port: Option<u16>,
518        role: Option<String>,
519    },
520    MultiModelMode {
521        count: usize,
522        models: Vec<String>,
523    },
524    WebserverStarting {
525        url: String,
526    },
527    WebserverReady {
528        url: String,
529    },
530    ApiStarting {
531        url: String,
532    },
533    ApiReady {
534        url: String,
535    },
536    RuntimeReady {
537        api_url: String,
538        console_url: Option<String>,
539        api_port: u16,
540        console_port: Option<u16>,
541        models_count: Option<usize>,
542        pi_command: Option<String>,
543        goose_command: Option<String>,
544    },
545    ModelDownloadProgress {
546        label: String,
547        file: Option<String>,
548        downloaded_bytes: Option<u64>,
549        total_bytes: Option<u64>,
550        status: ModelProgressStatus,
551    },
552    RequestRouted {
553        model: String,
554        target: String,
555    },
556    Warning {
557        message: String,
558        context: Option<String>,
559    },
560    Error {
561        message: String,
562        context: Option<String>,
563    },
564    Fatal {
565        message: String,
566        context: Option<String>,
567    },
568    ShutdownRequested {
569        signal: &'static str,
570    },
571    Shutdown {
572        reason: Option<String>,
573    },
574    LlamaNativeLog {
575        message: String,
576        category: &'static str,
577        params: Vec<(String, Value)>,
578    },
579}
580
581impl OutputEvent {
582    pub fn event_name(&self) -> &'static str {
583        match self {
584            OutputEvent::Info { .. } => "info",
585            OutputEvent::Startup { .. } => "startup",
586            OutputEvent::LaunchPlan { .. } => "launch_plan",
587            OutputEvent::NodeIdentity { .. } => "node_identity",
588            OutputEvent::InviteToken { .. } => "invite_token",
589            OutputEvent::DiscoveryStarting { .. } => "discovery_starting",
590            OutputEvent::MeshFound { .. } => "mesh_found",
591            OutputEvent::DiscoveryJoined { .. } => "discovery_joined",
592            OutputEvent::DiscoveryFailed { .. } => "discovery_failed",
593            OutputEvent::WaitingForPeers { .. } => "waiting_for_peers",
594            OutputEvent::PassiveMode { .. } => "passive_mode",
595            OutputEvent::PeerJoined { .. } => "peer_joined",
596            OutputEvent::PeerLeft { .. } => "peer_left",
597            OutputEvent::ModelQueued { .. } => "model_queued",
598            OutputEvent::ModelLoading { .. } => "model_loading",
599            OutputEvent::ModelLoaded { .. } => "model_loaded",
600            OutputEvent::ModelUnloading { .. } => "model_unloading",
601            OutputEvent::ModelUnloaded { .. } => "model_unloaded",
602            OutputEvent::HostElected { .. } => "host_elected",
603            OutputEvent::RpcServerStarting { .. } => "rpc_server_starting",
604            OutputEvent::RpcReady { .. } => "rpc_ready",
605            OutputEvent::RpcStartupFailed { .. } => "rpc_startup_failed",
606            OutputEvent::LlamaStarting { .. } => "llama_starting",
607            OutputEvent::LlamaReady { .. } => "llama_ready",
608            OutputEvent::LlamaStartupFailed { .. } => "llama_startup_failed",
609            OutputEvent::ModelReady { .. } => "model_ready",
610            OutputEvent::MultiModelMode { .. } => "multi_model_mode",
611            OutputEvent::WebserverStarting { .. } => "webserver_starting",
612            OutputEvent::WebserverReady { .. } => "webserver_ready",
613            OutputEvent::ApiStarting { .. } => "api_starting",
614            OutputEvent::ApiReady { .. } => "api_ready",
615            OutputEvent::RuntimeReady { .. } => "ready",
616            OutputEvent::ModelDownloadProgress { .. } => "model_download_progress",
617            OutputEvent::RequestRouted { .. } => "request_routed",
618            OutputEvent::Warning { .. } => "warning",
619            OutputEvent::Error { .. } => "error",
620            OutputEvent::Fatal { .. } => "fatal",
621            OutputEvent::ShutdownRequested { signal } => signal,
622            OutputEvent::Shutdown { .. } => "shutdown",
623            OutputEvent::LlamaNativeLog { category, .. } => category,
624        }
625    }
626
627    pub fn level(&self) -> OutputLevel {
628        match self {
629            OutputEvent::RpcStartupFailed { .. } | OutputEvent::LlamaStartupFailed { .. } => {
630                OutputLevel::Error
631            }
632            OutputEvent::LlamaNativeLog { .. } => OutputLevel::Debug,
633            OutputEvent::Warning { .. } => OutputLevel::Warn,
634            OutputEvent::Error { .. } => OutputLevel::Error,
635            OutputEvent::Fatal { .. } => OutputLevel::Fatal,
636            _ => OutputLevel::Info,
637        }
638    }
639
640    pub fn message(&self) -> String {
641        match self {
642            OutputEvent::Info { message, .. } => message.clone(),
643            OutputEvent::Startup { message, .. } => message
644                .clone()
645                .unwrap_or_else(|| "mesh-llm starting".to_string()),
646            OutputEvent::LaunchPlan { plan } => format!(
647                "startup plan ready ({} process(es), {} endpoint(s), {} model(s))",
648                plan.llama_process_rows.len(),
649                plan.webserver_rows.len(),
650                plan.loaded_model_rows.len()
651            ),
652            OutputEvent::NodeIdentity { node_id, mesh_id } => match mesh_id {
653                Some(mesh_id) => format!("node {node_id} joined mesh {mesh_id}"),
654                None => format!("node {node_id} initialized"),
655            },
656            OutputEvent::InviteToken {
657                mesh_id, mesh_name, ..
658            } => {
659                let mesh_label = format_invite_mesh_label(mesh_name.as_deref(), mesh_id);
660                format!("invite token ready for mesh {mesh_label}")
661            }
662            OutputEvent::DiscoveryStarting { source } => format!("discovering mesh via {source}"),
663            OutputEvent::MeshFound { mesh, peers, .. } => {
664                format!("discovered mesh {mesh} ({peers} peer(s))")
665            }
666            OutputEvent::DiscoveryJoined { mesh } => format!("joined mesh {mesh}"),
667            OutputEvent::DiscoveryFailed { message, detail } => match detail {
668                Some(detail) => format!("{message}: {detail}"),
669                None => message.clone(),
670            },
671            OutputEvent::WaitingForPeers { detail } => detail
672                .clone()
673                .unwrap_or_else(|| "waiting for peers".to_string()),
674            OutputEvent::PassiveMode {
675                role,
676                status,
677                capacity_gb,
678                models_on_disk,
679                detail,
680            } => {
681                let mut line = detail
682                    .clone()
683                    .unwrap_or_else(|| format!("{role} {}", status.as_str()));
684                if let Some(capacity_gb) = capacity_gb {
685                    line.push_str(&format!(" ({capacity_gb:.1}GB capacity)"));
686                }
687                if let Some(models_on_disk) = models_on_disk
688                    && !models_on_disk.is_empty()
689                {
690                    line.push_str(&format!(" models={}", models_on_disk.join(", ")));
691                }
692                line
693            }
694            OutputEvent::PeerJoined { peer_id, .. } => format!("peer {peer_id} joined"),
695            OutputEvent::PeerLeft { peer_id, .. } => format!("peer {peer_id} left"),
696            OutputEvent::ModelQueued { model } => format!("queued model {model}"),
697            OutputEvent::ModelLoading { model, .. } => format!("loading model {model}"),
698            OutputEvent::ModelLoaded { model, .. } => format!("loaded model {model}"),
699            OutputEvent::ModelUnloading { model } => format!("unloading model {model}"),
700            OutputEvent::ModelUnloaded { model } => format!("unloaded model {model}"),
701            OutputEvent::HostElected {
702                model, host, role, ..
703            } => match role {
704                Some(role) => format!("{model} elected {host} as {role}"),
705                None => format!("{model} elected {host} as host"),
706            },
707            OutputEvent::RpcServerStarting { port, log_path, .. } => {
708                let msg = format!("rpc-server starting on port {port}");
709                append_log_path(msg, log_path)
710            }
711            OutputEvent::RpcReady { port, log_path, .. } => {
712                let msg = format!("rpc-server ready on port {port}");
713                append_log_path(msg, log_path)
714            }
715            OutputEvent::RpcStartupFailed {
716                port,
717                detail,
718                log_path,
719                ..
720            } => {
721                let msg = format!("rpc-server failed to start on port {port}: {detail}");
722                append_log_path(msg, log_path)
723            }
724            OutputEvent::LlamaStarting {
725                http_port,
726                log_path,
727                ..
728            } => {
729                let msg = format!("llama-server starting on port {http_port}");
730                append_log_path(msg, log_path)
731            }
732            OutputEvent::LlamaReady { port, log_path, .. } => {
733                let msg = format!("llama-server ready on port {port}");
734                append_log_path(msg, log_path)
735            }
736            OutputEvent::LlamaStartupFailed {
737                model,
738                http_port,
739                detail,
740                log_path,
741                ..
742            } => {
743                let msg = match model {
744                    Some(model) => {
745                        format!(
746                            "llama-server failed to start for {model} on port {http_port}: {detail}"
747                        )
748                    }
749                    None => format!("llama-server failed to start on port {http_port}: {detail}"),
750                };
751                append_log_path(msg, log_path)
752            }
753            OutputEvent::ModelReady {
754                model,
755                internal_port,
756                ..
757            } => match internal_port {
758                Some(port) => format!("model {model} ready on port {port}"),
759                None => format!("model {model} ready"),
760            },
761            OutputEvent::WebserverStarting { url } => format!("web console starting at {url}"),
762            OutputEvent::WebserverReady { url } => format!("web console ready at {url}"),
763            OutputEvent::ApiStarting { url } => format!("api starting at {url}"),
764            OutputEvent::ApiReady { url } => format!("api ready at {url}"),
765            OutputEvent::RuntimeReady { .. } => "mesh-llm runtime ready".to_string(),
766            OutputEvent::ModelDownloadProgress {
767                label,
768                file,
769                downloaded_bytes,
770                total_bytes,
771                status,
772            } => format_model_download_progress_message(
773                label,
774                file.as_deref(),
775                *downloaded_bytes,
776                *total_bytes,
777                status,
778            ),
779            OutputEvent::MultiModelMode { count, models } => {
780                if models.is_empty() {
781                    format!("Multi-model mode: {count} model(s)")
782                } else {
783                    format!("Multi-model mode: {count} model(s): {}", models.join(", "))
784                }
785            }
786            OutputEvent::RequestRouted { model, target } => {
787                format!("routed request for {model} to {target}")
788            }
789            OutputEvent::Warning { message, .. } => message.clone(),
790            OutputEvent::Error { message, .. } => message.clone(),
791            OutputEvent::Fatal { message, .. } => message.clone(),
792            OutputEvent::ShutdownRequested { signal } => format!("shutdown requested ({signal})"),
793            OutputEvent::Shutdown { reason } => reason
794                .clone()
795                .unwrap_or_else(|| "mesh-llm shutting down".to_string()),
796            OutputEvent::LlamaNativeLog { message, .. } => message.clone(),
797        }
798    }
799}
800
801fn append_log_path(message: String, log_path: &Option<String>) -> String {
802    if let Some(path) = log_path {
803        format!("{message}\n  ↳ log={path}")
804    } else {
805        message
806    }
807}
808
809fn format_invite_mesh_label(mesh_name: Option<&str>, mesh_id: &str) -> String {
810    match mesh_name.map(str::trim).filter(|name| !name.is_empty()) {
811        Some(name) => format!("{name} ({mesh_id})"),
812        None => mesh_id.to_string(),
813    }
814}
815
816pub fn format_model_download_progress_message(
817    label: &str,
818    file: Option<&str>,
819    downloaded_bytes: Option<u64>,
820    total_bytes: Option<u64>,
821    status: &ModelProgressStatus,
822) -> String {
823    let target = file.unwrap_or(label);
824    if let Some(package) = label.strip_prefix("layer package ") {
825        return match status {
826            ModelProgressStatus::Ensuring => {
827                format!("ensuring layer package artifact {target} for {package}")
828            }
829            ModelProgressStatus::Downloading => match (downloaded_bytes, total_bytes) {
830                (Some(downloaded), Some(total)) if total > 0 => format!(
831                    "downloading layer package artifact {target} for {package} {}/{}",
832                    format_display_bytes(downloaded),
833                    format_display_bytes(total)
834                ),
835                (Some(downloaded), _) if downloaded > 0 => format!(
836                    "downloading layer package artifact {target} for {package} {}",
837                    format_display_bytes(downloaded)
838                ),
839                _ => format!("downloading layer package artifact {target} for {package}"),
840            },
841            ModelProgressStatus::Ready => match total_bytes {
842                Some(total) if total > 0 => format!(
843                    "layer package artifact {target} ready for {package} ({})",
844                    format_display_bytes(total)
845                ),
846                _ => format!("layer package artifact {target} ready for {package}"),
847            },
848        };
849    }
850    match status {
851        ModelProgressStatus::Ensuring => format!("ensuring model {target}"),
852        ModelProgressStatus::Downloading => match (downloaded_bytes, total_bytes) {
853            (Some(downloaded), Some(total)) if total > 0 => format!(
854                "downloading model {target} {}/{}",
855                format_display_bytes(downloaded),
856                format_display_bytes(total)
857            ),
858            (Some(downloaded), _) if downloaded > 0 => {
859                format!(
860                    "downloading model {target} {}",
861                    format_display_bytes(downloaded)
862                )
863            }
864            _ => format!("downloading model {target}"),
865        },
866        ModelProgressStatus::Ready => match total_bytes {
867            Some(total) if total > 0 => {
868                format!("model {target} ready ({})", format_display_bytes(total))
869            }
870            _ => format!("model {target} ready"),
871        },
872    }
873}
874
875fn format_display_bytes(bytes: u64) -> String {
876    if bytes >= 1_000_000_000 {
877        format!("{:.1}GB", bytes as f64 / 1e9)
878    } else if bytes >= 1_000_000 {
879        format!("{:.0}MB", bytes as f64 / 1e6)
880    } else if bytes >= 1_000 {
881        format!("{:.0}KB", bytes as f64 / 1e3)
882    } else {
883        format!("{bytes}B")
884    }
885}