usage_monitor_cli/provider/
ollama.rs1use async_trait::async_trait;
7
8use crate::error::SpendPanelError;
9use crate::model::{PlanInfo, RateWindow, UsageSnapshot};
10use crate::provider::{ProviderContext, ProviderMetadata, UsageProvider};
11
12fn percent_after(html: &str, label: &str) -> Option<f64> {
14 let start = html.find(label)? + label.len();
15 let end = (start + 800).min(html.len());
17 let window = &html[start..end];
18 let bytes = window.as_bytes();
19 let mut i = 0;
20 while i < bytes.len() {
21 if bytes[i] == b'%' {
22 let mut j = i;
24 while j > 0 {
25 let c = bytes[j - 1];
26 if c.is_ascii_digit() || c == b'.' {
27 j -= 1;
28 } else {
29 break;
30 }
31 }
32 let parsed = (j < i).then(|| window[j..i].parse::<f64>().ok()).flatten();
33 if let Some(value) = parsed {
34 return Some(value.clamp(0.0, 100.0));
35 }
36 }
37 i += 1;
38 }
39 None
40}
41
42fn span_after(html: &str, anchor: &str) -> Option<String> {
44 let start = html.find(anchor)? + anchor.len();
45 let rest = &html[start..];
46 let open = rest.find("<span")?;
47 let after_open = &rest[open..];
48 let gt = after_open.find('>')? + 1;
49 let inner = &after_open[gt..];
50 let close = inner.find('<')?;
51 let text = inner[..close].trim();
52 if text.is_empty() {
53 None
54 } else {
55 Some(text.to_string())
56 }
57}
58
59pub struct OllamaProvider {
61 metadata: ProviderMetadata,
62 base_url: Option<String>,
63}
64
65impl OllamaProvider {
66 pub fn new() -> Self {
67 Self {
68 metadata: ProviderMetadata {
69 id: "ollama",
70 name: "Ollama",
71 description: "Ollama cloud session/weekly usage monitor (browser cookie)",
72 auth_methods: &["cookie", "env"],
73 website: Some("https://ollama.com"),
74 },
75 base_url: None,
76 }
77 }
78
79 pub fn with_base_url(url: &str) -> Self {
80 let mut p = Self::new();
81 p.base_url = Some(url.to_string());
82 p
83 }
84
85 fn api_base(&self) -> &str {
86 self.base_url.as_deref().unwrap_or("https://ollama.com")
87 }
88
89 fn clean(raw: &str) -> String {
90 let mut v = raw.trim();
91 if v.len() >= 2
92 && ((v.starts_with('"') && v.ends_with('"'))
93 || (v.starts_with('\'') && v.ends_with('\'')))
94 {
95 v = &v[1..v.len() - 1];
96 }
97 v.trim().to_string()
98 }
99
100 fn resolve_cookie(ctx: &ProviderContext) -> Result<String, SpendPanelError> {
101 for key in ["cookie", "token"] {
102 if let Some(v) = ctx.config.get(key) {
103 let c = Self::clean(v);
104 if !c.is_empty() {
105 return Ok(c);
106 }
107 }
108 }
109 if let Ok(v) = std::env::var("OLLAMA_COOKIE") {
110 let c = Self::clean(&v);
111 if !c.is_empty() {
112 return Ok(c);
113 }
114 }
115 Err(SpendPanelError::AuthFailed(
116 "ollama".into(),
117 "no session cookie in cookie config or OLLAMA_COOKIE".into(),
118 ))
119 }
120
121 fn build_client(ctx: &ProviderContext) -> Result<reqwest::Client, SpendPanelError> {
122 reqwest::Client::builder()
123 .timeout(std::time::Duration::from_secs(ctx.timeout_secs))
124 .build()
125 .map_err(|e| SpendPanelError::NetworkError(e.to_string()))
126 }
127
128 fn parse(html: &str) -> Result<UsageSnapshot, SpendPanelError> {
129 let session =
130 percent_after(html, "Session usage").or_else(|| percent_after(html, "Hourly usage"));
131 let weekly = percent_after(html, "Weekly usage");
132
133 if session.is_none() && weekly.is_none() {
134 if html.contains("Sign in") || html.contains("sign in") {
135 return Err(SpendPanelError::AuthFailed(
136 "ollama".into(),
137 "not logged in to ollama.com (session cookie missing/expired)".into(),
138 ));
139 }
140 return Err(SpendPanelError::ParseError(
141 "ollama".into(),
142 "no usage data found on settings page".into(),
143 ));
144 }
145
146 let mut snapshot = UsageSnapshot::new("ollama");
147 if let Some(s) = session {
148 snapshot.primary_rate_window =
149 Some(RateWindow::new(s.round() as u64, 100, "Session", 5 * 60));
150 }
151 if let Some(w) = weekly {
152 snapshot.secondary_rate_window = Some(RateWindow::new(
153 w.round() as u64,
154 100,
155 "Weekly",
156 7 * 24 * 60,
157 ));
158 }
159 if let Some(plan) = span_after(html, "Cloud Usage") {
160 snapshot.plan = Some(PlanInfo {
161 name: plan,
162 tier: None,
163 features: Vec::new(),
164 price: None,
165 currency: None,
166 billing_period: None,
167 });
168 }
169 Ok(snapshot)
170 }
171}
172
173impl Default for OllamaProvider {
174 fn default() -> Self {
175 Self::new()
176 }
177}
178
179#[async_trait]
180impl UsageProvider for OllamaProvider {
181 fn metadata(&self) -> &ProviderMetadata {
182 &self.metadata
183 }
184
185 fn detect_credentials(&self) -> bool {
186 std::env::var("OLLAMA_COOKIE")
187 .map(|v| !v.trim().is_empty())
188 .unwrap_or(false)
189 }
190
191 async fn fetch_usage(&self, ctx: &ProviderContext) -> Result<UsageSnapshot, SpendPanelError> {
192 let cookie = Self::resolve_cookie(ctx)?;
193 let client = Self::build_client(ctx)?;
194 let url = format!("{}/settings", self.api_base().trim_end_matches('/'));
195 let resp = client
196 .get(url)
197 .header("Cookie", cookie)
198 .header("Accept", "text/html")
199 .send()
200 .await
201 .map_err(|e| SpendPanelError::NetworkError(e.to_string()))?;
202 let status = resp.status();
203 let body = resp
204 .text()
205 .await
206 .map_err(|e| SpendPanelError::NetworkError(e.to_string()))?;
207 if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
208 return Err(SpendPanelError::AuthFailed(
209 "ollama".into(),
210 format!("session cookie rejected (HTTP {})", status.as_u16()),
211 ));
212 }
213 if !status.is_success() {
214 return Err(SpendPanelError::ProviderError(
215 "ollama".into(),
216 format!("HTTP {}: {}", status, body),
217 ));
218 }
219 Self::parse(&body)
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226 use pretty_assertions::assert_eq;
227 use wiremock::matchers::{method, path};
228 use wiremock::{Mock, MockServer, ResponseTemplate};
229
230 const HTML: &str = r#"<html><body>
231 <div>Cloud Usage <span class="x">Pro</span></div>
232 <div>Session usage <div class="bar">42%</div> resets soon</div>
233 <div>Weekly usage <div class="bar">7.5%</div></div>
234 </body></html>"#;
235
236 #[test]
237 fn test_metadata() {
238 assert_eq!(OllamaProvider::new().metadata().id, "ollama");
239 }
240
241 #[test]
242 fn test_percent_after() {
243 assert_eq!(percent_after(HTML, "Session usage"), Some(42.0));
244 assert_eq!(percent_after(HTML, "Weekly usage"), Some(7.5));
245 }
246
247 #[test]
248 fn test_parse() {
249 let snap = OllamaProvider::parse(HTML).unwrap();
250 assert_eq!(snap.primary_rate_window.unwrap().used, Some(42));
251 assert_eq!(snap.secondary_rate_window.unwrap().used, Some(8));
252 assert_eq!(snap.plan.unwrap().name, "Pro");
253 }
254
255 #[test]
256 fn test_parse_signed_out() {
257 let err = OllamaProvider::parse("<html>Please Sign in</html>").unwrap_err();
258 assert!(matches!(err, SpendPanelError::AuthFailed(_, _)));
259 }
260
261 #[tokio::test]
262 async fn test_fetch_success() {
263 let server = MockServer::start().await;
264 Mock::given(method("GET"))
265 .and(path("/settings"))
266 .respond_with(ResponseTemplate::new(200).set_body_raw(HTML, "text/html"))
267 .mount(&server)
268 .await;
269 let provider = OllamaProvider::with_base_url(&server.uri());
270 let mut ctx = ProviderContext::new();
271 ctx.config.insert("cookie".into(), "sid=abc".into());
272 let snap = provider.fetch_usage(&ctx).await.unwrap();
273 assert_eq!(snap.primary_rate_window.unwrap().used, Some(42));
274 }
275
276 #[tokio::test]
277 async fn test_fetch_401() {
278 let server = MockServer::start().await;
279 Mock::given(method("GET"))
280 .and(path("/settings"))
281 .respond_with(ResponseTemplate::new(401))
282 .mount(&server)
283 .await;
284 let provider = OllamaProvider::with_base_url(&server.uri());
285 let mut ctx = ProviderContext::new();
286 ctx.config.insert("cookie".into(), "bad".into());
287 assert!(matches!(
288 provider.fetch_usage(&ctx).await.unwrap_err(),
289 SpendPanelError::AuthFailed(_, _)
290 ));
291 }
292}