Skip to main content

mermaid_cli/ollama/
observe.rs

1//! What Ollama models exist, answered read-only.
2//!
3//! The one entry point every enumeration surface shares (`mermaid list`,
4//! `/model`, `status`, `doctor`, and the startup default probe). Autostart is
5//! hard-off on this path — observing must never mutate, so a server the user
6//! deliberately stopped stays stopped — and the on-disk store
7//! ([`super::store`]) fills the blind spot that rule used to create: a
8//! stopped server no longer hides what is installed.
9
10use std::sync::Arc;
11
12use mermaid_domain::Config;
13
14/// The installed-model answer, tagged with where it came from — the surfaces
15/// phrase "running" and "will start on use" differently.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum LocalModelListing {
18    /// The daemon answered `/api/tags`: authoritative, including whatever a
19    /// custom server-side store location would serve.
20    Live(Vec<String>),
21    /// The daemon was unreachable; names come from the on-disk manifest
22    /// store. Produced only for a loopback host with the Ollama binary
23    /// installed — the combination in which "starts automatically on use"
24    /// is actually true.
25    FromDisk(Vec<String>),
26    /// The daemon was unreachable and disk had no answer: a remote host, no
27    /// binary to start, or no populated store to read.
28    Unreachable,
29}
30
31impl LocalModelListing {
32    /// The names regardless of source, when there are any to show.
33    #[must_use]
34    pub fn models(&self) -> Option<&[String]> {
35        match self {
36            Self::Live(models) | Self::FromDisk(models) => Some(models),
37            Self::Unreachable => None,
38        }
39    }
40}
41
42/// Answer "what Ollama models exist" without mutating anything.
43///
44/// Asks the daemon first, and when it is unreachable falls back to the
45/// manifest store on disk. The disk is consulted lazily — a running server
46/// never pays for the walk — and only when [`host_is_loopback`] and the
47/// binary is installed.
48pub async fn observe_models(config: &Config) -> LocalModelListing {
49    let live = live_models(config).await;
50    combine(live, || {
51        (host_is_loopback(config) && super::is_installed())
52            .then(super::store::installed_models)
53            .flatten()
54    })
55}
56
57/// The pure decision: a live answer wins outright (including a truthful
58/// "running with nothing pulled"), disk answers only when live failed and
59/// the walk found something, and everything else is unreachable.
60fn combine(
61    live: Option<Vec<String>>,
62    disk: impl FnOnce() -> Option<Vec<String>>,
63) -> LocalModelListing {
64    live.map_or_else(
65        || match disk() {
66            Some(models) if !models.is_empty() => LocalModelListing::FromDisk(models),
67            _ => LocalModelListing::Unreachable,
68        },
69        LocalModelListing::Live,
70    )
71}
72
73/// `/api/tags` with autostart hard-off. `None` when the server could not be
74/// reached — distinct from `Some(vec![])`, a running server with nothing
75/// pulled. No recovery hook is ever attached here, so this path *cannot*
76/// start a server (see `LocalServerRecovery`) — that absence is the
77/// read-only guarantee, not a flag someone remembers to pass.
78async fn live_models(config: &Config) -> Option<Vec<String>> {
79    use mermaid_model::models::adapters::ollama::OllamaAdapter;
80    use mermaid_model::models::{BackendConfig, Model};
81    let backend = BackendConfig {
82        ollama_url: config.ollama.base_url(),
83        timeout_secs: 5,
84        max_idle_per_host: 2,
85        ollama_autostart: false,
86    };
87    match OllamaAdapter::new("__list__", Arc::new(backend)).await {
88        Ok(adapter) => adapter.list_models().await.ok(),
89        Err(_) => None,
90    }
91}
92
93/// Whether the configured Ollama host is this machine. The disk may only
94/// answer for a server this machine would itself run — a remote Ollama's
95/// store lives on the remote machine, and listing our own disk for it would
96/// be confidently wrong. Same host classification the autostart gate uses.
97fn host_is_loopback(config: &Config) -> bool {
98    let authority = config.ollama.base_url();
99    let host = super::server::host_of(super::server::authority_of(&authority));
100    mermaid_model::utils::classify_host(host).is_loopback()
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    fn names(list: &[&str]) -> Vec<String> {
108        list.iter().copied().map(String::from).collect()
109    }
110
111    /// The precedence table, exhaustively: live wins even when empty (a
112    /// running server with nothing pulled is a truthful empty, not a reason
113    /// to consult disk), disk answers only a dead server with a non-empty
114    /// walk, and the rest is unreachable.
115    #[test]
116    fn combine_prefers_live_then_nonempty_disk() {
117        assert_eq!(
118            combine(Some(names(&["a"])), || Some(names(&["b"]))),
119            LocalModelListing::Live(names(&["a"]))
120        );
121        assert_eq!(
122            combine(Some(Vec::new()), || Some(names(&["b"]))),
123            LocalModelListing::Live(Vec::new())
124        );
125        assert_eq!(
126            combine(None, || Some(names(&["b"]))),
127            LocalModelListing::FromDisk(names(&["b"]))
128        );
129        assert_eq!(combine(None, || None), LocalModelListing::Unreachable);
130        assert_eq!(
131            combine(None, || Some(Vec::new())),
132            LocalModelListing::Unreachable
133        );
134    }
135
136    /// The disk gate: loopback hosts (with or without an explicit scheme in
137    /// the configured host) may read the local store; anything remote —
138    /// public or LAN — must not, because the store that answers for that
139    /// server is on that machine.
140    #[test]
141    fn disk_fallback_is_loopback_only() {
142        let mut config = Config::default();
143        assert!(host_is_loopback(&config), "default localhost is loopback");
144        config.ollama.host = "http://127.0.0.1".to_string();
145        assert!(host_is_loopback(&config));
146        config.ollama.host = "ollama.example.com".to_string();
147        assert!(!host_is_loopback(&config));
148        config.ollama.host = "http://192.168.1.50".to_string();
149        assert!(!host_is_loopback(&config), "LAN is not this machine");
150    }
151}