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}