diff --git a/bin/omarchy-agent-usage-ollama b/bin/omarchy-agent-usage-ollama
new file mode 100755
index 00000000000..da5477abf0f
--- /dev/null
+++ b/bin/omarchy-agent-usage-ollama
@@ -0,0 +1,434 @@
+#!/usr/bin/python3
+# omarchy:summary=Print the Ollama Cloud usage record as JSON
+# omarchy:args=[--force] [--limits-only]
+# omarchy:hidden=true
+"""Collect Ollama Cloud usage into one display-ready JSON record.
+
+Everything the agents panel shows for Ollama Cloud comes from this one
+command: local token stats from pi and omp sessions that ran on the
+`ollama-cloud` provider, and the account's 5-hour session and 7-day weekly
+limits from Ollama's usage endpoint. The panel itself only ever reads the
+JSON this prints; it never talks to disk formats or endpoints.
+"""
+
+from __future__ import annotations
+
+import argparse
+import datetime as dt
+import fcntl
+import json
+import os
+import sys
+import tempfile
+import time
+import urllib.error
+import urllib.request
+from pathlib import Path
+from typing import Any
+
+AGENT_ID = "ollama"
+AGENT_NAME = "Ollama Cloud"
+USAGE_ENDPOINT = "https://ollama.com/api/usage"
+ME_ENDPOINT = "https://ollama.com/api/me"
+PROBE_MIN_INTERVAL_SECONDS = 15
+SESSION_WINDOW_SECONDS = 5 * 3600
+WEEKLY_WINDOW_SECONDS = 7 * 86400
+# Ollama's weekly windows end Monday 00:00 UTC, which is 4 days after the
+# epoch (a Thursday) — hence the offset in the reset computation. Session
+# windows are epoch-aligned 5-hour blocks. The usage endpoint reports the
+# fractions but not the reset times, and the resets are the same for every
+# account, so both are computed rather than fetched.
+WEEKLY_EPOCH_OFFSET_SECONDS = 4 * 86400
+
+
+def config_dir() -> Path:
+ return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "omarchy" / "agents"
+
+
+def cache_root() -> Path:
+ root = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "omarchy" / "agent-usage"
+ root.mkdir(parents=True, exist_ok=True)
+ return root
+
+
+def read_config() -> dict[str, Any]:
+ try:
+ data = json.loads((config_dir() / "ollama.json").read_text(encoding="utf-8"))
+ except Exception:
+ return {}
+ return data if isinstance(data, dict) else {}
+
+
+def api_key() -> str:
+ env = os.environ.get("OLLAMA_API_KEY", "").strip()
+ if env:
+ return env
+ configured = str(read_config().get("apiKey") or "").strip()
+ if configured:
+ return configured
+ # pi and omp store the Ollama Cloud key they signed in with; either one
+ # proves the account, and the usage endpoint accepts the same key.
+ for auth_path in (Path.home() / ".pi" / "agent" / "auth.json", Path.home() / ".omp" / "agent" / "auth.json"):
+ try:
+ data = json.loads(auth_path.read_text(encoding="utf-8"))
+ entry = data.get("ollama-cloud")
+ if isinstance(entry, dict) and str(entry.get("key") or "").strip():
+ return str(entry["key"]).strip()
+ except Exception:
+ continue
+ return ""
+
+
+def date_string(value: dt.date) -> str:
+ return value.strftime("%Y-%m-%d")
+
+
+def recent_date_strings() -> list[str]:
+ today = dt.datetime.now().date()
+ return [date_string(today - dt.timedelta(days=offset)) for offset in range(6, -1, -1)]
+
+
+def local_date_string() -> str:
+ return date_string(dt.datetime.now().date())
+
+
+def local_date_from_timestamp(value: Any) -> str:
+ if value is None:
+ return local_date_string()
+
+ if isinstance(value, (int, float)):
+ try:
+ seconds = float(value) / 1000.0 if float(value) > 10_000_000_000 else float(value)
+ return date_string(dt.datetime.fromtimestamp(seconds).date())
+ except Exception:
+ return local_date_string()
+
+ raw = str(value).strip()
+ if not raw:
+ return local_date_string()
+
+ try:
+ parsed = dt.datetime.fromisoformat(raw.replace("Z", "+00:00"))
+ if parsed.tzinfo is not None:
+ parsed = parsed.astimezone()
+ return date_string(parsed.date())
+ except Exception:
+ return local_date_string()
+
+
+def number(value: Any) -> int:
+ try:
+ n = float(value or 0)
+ return round(n) if n == n else 0
+ except Exception:
+ return 0
+
+
+def empty_bucket() -> dict[str, int]:
+ return {
+ "inputTokens": 0,
+ "outputTokens": 0,
+ "cacheReadInputTokens": 0,
+ "cacheCreationInputTokens": 0,
+ }
+
+
+def read_fresh_json(path: Path, max_age_seconds: float) -> dict[str, Any] | None:
+ if max_age_seconds <= 0 or not path.exists():
+ return None
+ try:
+ if time.time() - path.stat().st_mtime <= max_age_seconds:
+ return json.loads(path.read_text(encoding="utf-8"))
+ except Exception:
+ return None
+ return None
+
+
+def write_json(path: Path, payload: dict[str, Any]) -> None:
+ # A temp name unique to this writer, not derived from the target: several
+ # collectors can run at once (the update command backgrounds one per agent,
+ # the panel refreshes on its own), and a shared temp path means the second
+ # replace finds the first one's file already moved away.
+ handle_fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=path.name + ".", suffix=".tmp")
+ tmp = Path(tmp_name)
+ try:
+ with os.fdopen(handle_fd, "w", encoding="utf-8") as handle:
+ handle.write(json.dumps(payload, separators=(",", ":"), sort_keys=True) + "\n")
+ # mkstemp opens at 0600; these caches were world-readable before.
+ tmp.chmod(0o644)
+ tmp.replace(path)
+ except BaseException:
+ tmp.unlink(missing_ok=True)
+ raise
+
+
+# ---------------------------------------------------------------- local scan
+#
+# pi and omp record every assistant message with its provider, model, and
+# token usage in JSONL session files. The `ollama-cloud` provider is the
+# Ollama Cloud API, which serves Claude models alongside the open ones.
+
+
+def scan_sessions(max_age_seconds: float, model_prefix: str) -> dict[str, Any] | None:
+ roots = [
+ Path.home() / ".pi" / "agent" / "sessions",
+ Path.home() / ".omp" / "agent" / "sessions",
+ ]
+ cache_file = cache_root() / "ollama-pi-sessions.json"
+ cached = read_fresh_json(cache_file, max_age_seconds)
+ if cached is not None:
+ return cached.get("stats")
+
+ today = local_date_string()
+ recent_dates = recent_date_strings()
+ recent = {day: {"date": day, "messageCount": 0} for day in recent_dates}
+
+ sessions: set[str] = set()
+ active_days: set[str] = set()
+ today_sessions: set[str] = set()
+ today_tokens: dict[str, int] = {}
+ usage_by_model: dict[str, dict[str, int]] = {}
+ seen: set[str] = set()
+ prompts = 0
+ today_prompt_count = 0
+ today_token_total = 0
+
+ for root in roots:
+ files = root.rglob("*.jsonl") if root.is_dir() else []
+ for path in files:
+ try:
+ with path.open("r", encoding="utf-8", errors="replace") as handle:
+ for line_number, line in enumerate(handle, 1):
+ # Cheap pre-filter before JSON parsing keeps files with unrelated
+ # lines inexpensive.
+ if '"usage"' not in line or '"ollama-cloud"' not in line:
+ continue
+ try:
+ entry = json.loads(line)
+ message = entry.get("message") if isinstance(entry.get("message"), dict) else {}
+ if entry.get("type") != "message" or message.get("role") != "assistant":
+ continue
+ if str(message.get("provider") or "") != "ollama-cloud":
+ continue
+ unique_key = f"{path}:{entry.get('id') or line_number}"
+ if unique_key in seen:
+ continue
+ seen.add(unique_key)
+ usage = message.get("usage") or {}
+ input_tokens = number(usage.get("input"))
+ output_tokens = number(usage.get("output"))
+ cache_read = number(usage.get("cacheRead"))
+ cache_write = number(usage.get("cacheWrite"))
+ total = input_tokens + output_tokens + cache_read + cache_write
+ if total <= 0:
+ total = number(usage.get("totalTokens"))
+ input_tokens = total
+ if total <= 0:
+ continue
+ # Ollama model ids carry a version tag ("deepseek-v4-pro:0813");
+ # merge tags so the chart groups by model, not by release.
+ model = str(message.get("model") or "ollama").split(":", 1)[0]
+ if model_prefix and not model.startswith(model_prefix):
+ continue
+ day = local_date_from_timestamp(entry.get("timestamp") or message.get("timestamp"))
+ except Exception:
+ continue
+
+ session_key = str(path)
+ sessions.add(session_key)
+ active_days.add(day)
+ prompts += 1
+ bucket = usage_by_model.setdefault(model, empty_bucket())
+ bucket["inputTokens"] += input_tokens
+ bucket["outputTokens"] += output_tokens
+ bucket["cacheReadInputTokens"] += cache_read
+ bucket["cacheCreationInputTokens"] += cache_write
+ if day in recent:
+ # recentDays.messageCount is actually a token total, despite the
+ # legacy name shared with synced snapshots.
+ recent[day]["messageCount"] += total
+ if day == today:
+ today_prompt_count += 1
+ today_sessions.add(session_key)
+ today_token_total += total
+ today_tokens[model] = today_tokens.get(model, 0) + total
+ except OSError:
+ continue
+
+ stats = None
+ if prompts > 0:
+ stats = {
+ "todayPrompts": today_prompt_count,
+ "todaySessions": len(today_sessions),
+ "todayTotalTokens": today_token_total,
+ "todayTokensByModel": today_tokens,
+ "recentDays": [recent[day] for day in recent_dates],
+ "modelUsage": usage_by_model,
+ "totalPrompts": prompts,
+ "totalSessions": len(sessions),
+ "activeDays": len(active_days),
+ "activeDates": sorted(active_days),
+ }
+ write_json(cache_file, {"stats": stats})
+ return stats
+
+
+# ------------------------------------------------------------------- limits
+
+
+def clamp_fraction(value: Any) -> float:
+ try:
+ n = float(value)
+ except Exception:
+ return -1.0
+ if not (n >= 0):
+ return -1.0
+ return min(1.0, n)
+
+
+def reset_iso(window_seconds: int, epoch_offset_seconds: int = 0) -> str:
+ now = time.time()
+ nxt = now - ((now - epoch_offset_seconds) % window_seconds) + window_seconds
+ return dt.datetime.fromtimestamp(nxt, dt.timezone.utc).isoformat()
+
+
+def probe_limits(access_key: str) -> dict[str, Any]:
+ request = urllib.request.Request(
+ USAGE_ENDPOINT,
+ headers={"Authorization": "Bearer " + access_key, "Accept": "application/json"},
+ )
+ try:
+ with urllib.request.urlopen(request, timeout=10) as response:
+ payload = json.loads(response.read().decode("utf-8", errors="replace"))
+ except urllib.error.HTTPError as error:
+ if error.code in (401, 403):
+ return {"ok": False, "auth": True, "helpText": "The Ollama Cloud API key was rejected. Local stats are still shown."}
+ return {"ok": False, "helpText": f"Ollama's usage endpoint returned status {error.code}. Local stats are still shown."}
+ except Exception:
+ # A transport failure reached no server at all — no route, no DNS. Any
+ # real answer, including an error status, is a server we should stop
+ # pestering; this is not.
+ return {
+ "ok": False,
+ "transport": True,
+ "helpText": "Couldn't reach Ollama's usage endpoint. Retrying shortly. Local stats are still shown.",
+ }
+
+ limits = payload.get("limits") if isinstance(payload, dict) else None
+ if not isinstance(limits, dict):
+ return {"ok": False, "helpText": "Ollama's usage endpoint returned no limits. Local stats are still shown."}
+
+ out = []
+ session = limits.get("session")
+ weekly = limits.get("weekly")
+ if isinstance(session, dict):
+ percent = clamp_fraction(session.get("usage"))
+ if percent >= 0:
+ out.append({"label": "Session (5-hour)", "percent": percent, "resetsAt": reset_iso(SESSION_WINDOW_SECONDS)})
+ if isinstance(weekly, dict):
+ percent = clamp_fraction(weekly.get("usage"))
+ if percent >= 0:
+ out.append({"label": "Weekly (7-day)", "percent": percent, "resetsAt": reset_iso(WEEKLY_WINDOW_SECONDS, WEEKLY_EPOCH_OFFSET_SECONDS)})
+ if not out:
+ return {"ok": False, "helpText": "Ollama's usage endpoint returned no limits. Local stats are still shown."}
+ return {"ok": True, "limits": out}
+
+
+def probe_plan(access_key: str) -> str:
+ request = urllib.request.Request(
+ ME_ENDPOINT,
+ headers={"Authorization": "Bearer " + access_key, "Accept": "application/json"},
+ method="POST",
+ )
+ try:
+ with urllib.request.urlopen(request, timeout=10) as response:
+ payload = json.loads(response.read().decode("utf-8", errors="replace"))
+ except Exception:
+ return ""
+ plan = str(payload.get("Plan") or "").strip()
+ return plan[0].upper() + plan[1:] if plan else ""
+
+
+def collect_limits(access_key: str, force: bool) -> dict[str, Any]:
+ result = {"limits": [], "tierLabel": "", "usageStatusText": "", "authHelpText": ""}
+
+ # A panel that is opened and shut repeatedly must not turn into a request
+ # per flick, so recent probe results are reused for a short window — and
+ # kept as the answer of record when a later probe fails.
+ probe_cache = cache_root() / "ollama-limits.json"
+ cached = read_fresh_json(probe_cache, float("inf")) or {}
+ fallback = cached.get("limits") if isinstance(cached.get("limits"), list) else []
+ result["tierLabel"] = str(cached.get("plan") or "")
+
+ if access_key == "":
+ result["limits"] = fallback
+ result["usageStatusText"] = "Waiting for auth"
+ result["authHelpText"] = "Set OLLAMA_API_KEY, or sign in to pi with an Ollama Cloud key, to show the account limits."
+ return result
+
+ # --force is a person asking for fresh numbers, so it skips the reuse window
+ # entirely; the interval is there to absorb repeated panel opens, not to
+ # overrule someone who pressed refresh.
+ fetched_at = number(cached.get("fetchedAtMs")) / 1000
+ if fallback and not force and time.time() - fetched_at < PROBE_MIN_INTERVAL_SECONDS:
+ result["limits"] = fallback
+ return result
+
+ probe = probe_limits(access_key)
+ if probe["ok"]:
+ plan = probe_plan(access_key)
+ result["limits"] = probe["limits"]
+ result["tierLabel"] = plan
+ write_json(probe_cache, {"fetchedAtMs": round(time.time() * 1000), "limits": probe["limits"], "plan": plan})
+ return result
+
+ # The first probe after login often fires before DHCP has handed out a
+ # route. Ask the shell to try again sooner than its regular interval.
+ if probe.get("transport"):
+ result["retryAdvised"] = True
+ if fallback:
+ result["limits"] = fallback
+ else:
+ result["usageStatusText"] = "Ollama limits unavailable"
+ result["authHelpText"] = probe["helpText"]
+ return result
+
+
+# -------------------------------------------------------------------- record
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--force", action="store_true", help="rescan sessions and re-probe limits, ignoring caches")
+ parser.add_argument("--limits-only", action="store_true", help="reuse any recent session scan; only the limits probe must be fresh")
+ parser.add_argument("--cache-seconds", type=float, default=20)
+ args = parser.parse_args()
+
+ config = read_config()
+ model_prefix = str(config.get("modelPrefix") or "").strip().lower()
+ scan_age = 0 if args.force else (900 if args.limits_only else args.cache_seconds)
+ stats = scan_sessions(scan_age, model_prefix)
+
+ limits = collect_limits(api_key(), args.force)
+
+ record = {
+ "schemaVersion": 1,
+ "id": AGENT_ID,
+ "name": AGENT_NAME,
+ "updatedAt": dt.datetime.now(dt.timezone.utc).isoformat(),
+ "ready": number((stats or {}).get("totalPrompts")) > 0 or len(limits["limits"]) > 0,
+ "hasLocalStats": True,
+ "tierLabel": limits["tierLabel"],
+ "usageStatusText": limits["usageStatusText"],
+ "authHelpText": limits["authHelpText"],
+ "limits": limits["limits"],
+ }
+ if limits.get("retryAdvised"):
+ record["retryAdvised"] = True
+ record.update(stats or {})
+ print(json.dumps(record, separators=(",", ":"), sort_keys=True))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/shell/plugins/agents/README.md b/shell/plugins/agents/README.md
index 51ad4e43c2d..e11e6e30299 100644
--- a/shell/plugins/agents/README.md
+++ b/shell/plugins/agents/README.md
@@ -55,6 +55,7 @@ light surfaces — and the bar glyph stands in when there is none.
| `claude` | Anthropic's OAuth usage endpoint (5-hour session + 7-day weekly) | `~/.claude/projects` transcripts, opencode sessions on an Anthropic provider, plus `stats-cache.json` and `history.jsonl` as fallback |
| `codex` | The Codex app-server RPC | native Codex CLI session files (plus pi and opencode sessions) |
| `fireworks` | Estimated prepaid balance: configured funding minus rated account costs | Fireworks billing API, grouped by day and model for the last 30 days |
+| `ollama` | Ollama Cloud's usage endpoint (5-hour session + 7-day weekly) | pi and omp sessions on the `ollama-cloud` provider |
Claude limits need a signed-in CLI; without credentials the panel says so and
falls back to local stats only. A non-default Claude directory is honored via
@@ -62,7 +63,29 @@ falls back to local stats only. A non-default Claude directory is honored via
`FIREWORKS_API_KEY` and `FIREWORKS_ACCOUNT_ID` first, then
`~/.fireworks/auth.ini` (which `firectl set-api-key` creates), then the key
opencode stores in `~/.local/share/opencode/auth.json` when Fireworks is
-signed in there.
+signed in there. Ollama Cloud reads `OLLAMA_API_KEY` first, then the key pi
+or omp signed in with in `~/.pi/agent/auth.json` or `~/.omp/agent/auth.json`.
+
+### Ollama Cloud limits
+
+Ollama's usage endpoint reports the account's session and weekly usage as
+0..1 fractions but not the reset times; the resets are the same for every
+account (epoch-aligned 5-hour session windows, weekly windows ending Monday
+00:00 UTC), so the collector computes them. The limits cover the whole
+account — every model the key can reach — while the local stats count pi and
+omp sessions on the `ollama-cloud` provider. To count only the Claude models
+Ollama Cloud serves, set a model prefix in
+`~/.config/omarchy/agents/ollama.json`:
+
+```json
+{
+ "modelPrefix": "claude",
+ "apiKey": ""
+}
+```
+
+`modelPrefix` restricts the local stats to models whose name starts with the
+prefix (empty means every model); `apiKey` overrides the key lookup order.
### Fireworks balance
@@ -128,7 +151,8 @@ edit `shell.json` directly):
omarchy bar set omarchy.agents providers '{
"claude": { "enabled": true },
"codex": { "enabled": false },
- "fireworks": { "enabled": true }
+ "fireworks": { "enabled": true },
+ "ollama": { "enabled": true }
}' --json
```
diff --git a/shell/plugins/agents/assets/ollama-light.svg b/shell/plugins/agents/assets/ollama-light.svg
new file mode 100644
index 00000000000..9c267376af3
--- /dev/null
+++ b/shell/plugins/agents/assets/ollama-light.svg
@@ -0,0 +1,4 @@
+
diff --git a/shell/plugins/agents/assets/ollama.svg b/shell/plugins/agents/assets/ollama.svg
new file mode 100644
index 00000000000..3729abfd1d6
--- /dev/null
+++ b/shell/plugins/agents/assets/ollama.svg
@@ -0,0 +1,4 @@
+
diff --git a/shell/plugins/agents/manifest.json b/shell/plugins/agents/manifest.json
index 1743a798214..4808732930c 100644
--- a/shell/plugins/agents/manifest.json
+++ b/shell/plugins/agents/manifest.json
@@ -5,7 +5,7 @@
"version": "1.0.0",
"author": "Omarchy",
"license": "MIT",
- "description": "Claude Code, Codex, and Fireworks usage, limits, and pace in a native Omarchy bar panel.",
+ "description": "Claude Code, Codex, Fireworks, and Ollama Cloud usage, limits, and pace in a native Omarchy bar panel.",
"kinds": ["bar-widget"],
"activation": "on-demand",
"entryPoints": {
@@ -21,7 +21,8 @@
"providers": {
"claude": { "enabled": true },
"codex": { "enabled": true },
- "fireworks": { "enabled": true }
+ "fireworks": { "enabled": true },
+ "ollama": { "enabled": true }
},
"refreshIntervalSec": 900,
"syncMode": "Off",
diff --git a/test/shell.d/agent-usage-ollama-scanner-test.sh b/test/shell.d/agent-usage-ollama-scanner-test.sh
new file mode 100755
index 00000000000..d3cbad832d4
--- /dev/null
+++ b/test/shell.d/agent-usage-ollama-scanner-test.sh
@@ -0,0 +1,211 @@
+#!/bin/bash
+
+source "$(dirname "$0")/base-test.sh"
+
+require_command jq
+require_command python3
+
+TEST_HOME=$(mktemp -d)
+trap 'rm -rf "$TEST_HOME"' EXIT
+
+# Without credentials the collector must still print a full, hidden-by-default
+# record: the update runner writes whatever valid JSON appears on stdout.
+no_key=$(HOME="$TEST_HOME" XDG_CONFIG_HOME="$TEST_HOME/.config" XDG_CACHE_HOME="$TEST_HOME/.cache" \
+ OLLAMA_API_KEY="" "$ROOT/bin/omarchy-agent-usage-ollama" --force)
+
+[[ $(jq -r '.id + ":" + (.ready | tostring) + ":" + .usageStatusText' <<<"$no_key") == "ollama:false:Waiting for auth" ]] ||
+ fail "Ollama collector prints a valid record without credentials" "$no_key"
+pass "Ollama collector prints a valid record without credentials"
+
+# Pi and omp sessions on the ollama-cloud provider are the local stats; other
+# providers in the same files must not leak in, and model version tags merge.
+PI_HOME=$(mktemp -d)
+trap 'rm -rf "$TEST_HOME" "$PI_HOME"' EXIT
+mkdir -p "$PI_HOME/.pi/agent/sessions/project" "$PI_HOME/.omp/agent/sessions/project"
+
+timestamp="$(date +%Y-%m-%d)T12:00:00Z"
+cat >"$PI_HOME/.pi/agent/sessions/project/pi.jsonl" <"$PI_HOME/.omp/agent/sessions/project/omp.jsonl" <"$PI_HOME/.config/omarchy/agents/ollama.json" <<'EOF'
+{ "modelPrefix": "kimi" }
+EOF
+
+result=$(HOME="$PI_HOME" XDG_CONFIG_HOME="$PI_HOME/.config" XDG_CACHE_HOME="$PI_HOME/.cache" \
+ OLLAMA_API_KEY="" "$ROOT/bin/omarchy-agent-usage-ollama" --force)
+
+[[ $(jq -c '.modelUsage' <<<"$result") == '{"kimi-k3":{"cacheCreationInputTokens":1,"cacheReadInputTokens":4,"inputTokens":20,"outputTokens":5}}' ]] ||
+ fail "Ollama collector honors the configured model prefix" "$result"
+pass "Ollama collector honors the configured model prefix"
+
+# probe_limits reaches Ollama, so the reader that interprets its answer is
+# exercised on its own: the collector loads as a module, and a recorded
+# payload stands in for the response.
+read_limits() {
+ COLLECTOR="$ROOT/bin/omarchy-agent-usage-ollama" PAYLOAD="$1" python3 - <<'PY'
+import importlib.machinery, importlib.util, io, json, os
+
+loader = importlib.machinery.SourceFileLoader("collector", os.environ["COLLECTOR"])
+spec = importlib.util.spec_from_loader(loader.name, loader)
+collector = importlib.util.module_from_spec(spec)
+loader.exec_module(collector)
+
+collector.urllib.request.urlopen = lambda request, timeout=None: io.BytesIO(os.environ["PAYLOAD"].encode())
+
+print(json.dumps(collector.probe_limits("key")))
+PY
+}
+
+limits=$(read_limits '{
+ "activity": { "cost": "0.00" },
+ "limits": {
+ "session": { "usage": 0.434, "models": [{ "name": "deepseek-v4-pro:0813", "request_count": 340 }] },
+ "weekly": { "usage": 0.298, "models": [{ "name": "kimi-k3", "request_count": 377 }] }
+ }
+}')
+
+[[ $(jq -c '[.limits[].label]' <<<"$limits") == '["Session (5-hour)","Weekly (7-day)"]' ]] ||
+ fail "Ollama collector reads the session and weekly windows" "$limits"
+[[ $(jq -c '[.limits[].percent]' <<<"$limits") == "[0.434,0.298]" ]] ||
+ fail "Ollama collector keeps the endpoint's 0..1 fractions" "$limits"
+pass "Ollama collector reads the session and weekly fractions"
+
+# Reset times are computed, not fetched: session windows are epoch-aligned
+# 5-hour blocks and weekly windows end Monday 00:00 UTC.
+resets=$(python3 - "$ROOT/bin/omarchy-agent-usage-ollama" <<'PY'
+import datetime as dt
+import importlib.machinery, importlib.util, sys
+
+loader = importlib.machinery.SourceFileLoader("collector", sys.argv[1])
+spec = importlib.util.spec_from_loader(loader.name, loader)
+collector = importlib.util.module_from_spec(spec)
+loader.exec_module(collector)
+
+session = dt.datetime.fromisoformat(collector.reset_iso(collector.SESSION_WINDOW_SECONDS))
+weekly = dt.datetime.fromisoformat(collector.reset_iso(collector.WEEKLY_WINDOW_SECONDS, collector.WEEKLY_EPOCH_OFFSET_SECONDS))
+print(f"{session.timestamp() % collector.SESSION_WINDOW_SECONDS == 0}:{weekly.strftime('%A')}:{weekly.hour}:{weekly.minute}")
+PY
+)
+
+[[ "$resets" == "True:Monday:0:0" ]] ||
+ fail "Ollama collector computes epoch-aligned session and Monday-midnight weekly resets" "$resets"
+pass "Ollama collector computes epoch-aligned session and Monday-midnight weekly resets"
+
+# A payload with no usable windows explains itself instead of printing none.
+empty=$(read_limits '{"limits": {"session": {"usage": "unknown"}, "weekly": null}}')
+[[ $(jq -r '.ok' <<<"$empty") == "false" && $(jq -r '.helpText' <<<"$empty") == *"no limits"* ]] ||
+ fail "Ollama collector reports a payload without usable limits" "$empty"
+pass "Ollama collector reports a payload without usable limits"
+
+# collect_limits drives the probe, the plan lookup, and the cache. A mocked
+# urlopen answers both endpoints, and the cache decides reuse and fallback.
+CACHE_HOME=$(mktemp -d)
+trap 'rm -rf "$TEST_HOME" "$PI_HOME" "$CACHE_HOME"' EXIT
+
+collect_limits() {
+ COLLECTOR="$ROOT/bin/omarchy-agent-usage-ollama" FORCE="$1" CACHED="$2" MODE="$3" \
+ XDG_CACHE_HOME="$CACHE_HOME" python3 - <<'PY'
+import importlib.machinery, importlib.util, io, json, os
+
+loader = importlib.machinery.SourceFileLoader("collector", os.environ["COLLECTOR"])
+spec = importlib.util.spec_from_loader(loader.name, loader)
+collector = importlib.util.module_from_spec(spec)
+loader.exec_module(collector)
+
+cache = collector.cache_root() / "ollama-limits.json"
+cached = os.environ["CACHED"]
+if cached:
+ cache.write_text(cached, encoding="utf-8")
+elif cache.exists():
+ cache.unlink()
+
+probes = []
+
+def urlopen(request, timeout=None):
+ probes.append(request.full_url)
+ mode = os.environ["MODE"]
+ if mode == "transport":
+ raise OSError("no route to host")
+ if mode == "rejected":
+ raise collector.urllib.error.HTTPError(request.full_url, 401, "Unauthorized", {}, io.BytesIO(b""))
+ if request.full_url.endswith("/api/me"):
+ return io.BytesIO(b'{"Plan":"pro"}')
+ return io.BytesIO(b'{"limits":{"session":{"usage":0.44},"weekly":{"usage":0.11}}}')
+
+collector.urllib.request.urlopen = urlopen
+result = collector.collect_limits("key", os.environ["FORCE"] == "true")
+print(json.dumps({
+ "result": result,
+ "probes": probes,
+ "cached": json.loads(cache.read_text(encoding="utf-8")) if cache.exists() else None,
+}))
+PY
+}
+
+fresh=$(jq -nc --argjson now "$(python3 -c 'import time; print(round(time.time() * 1000))')" '{
+ fetchedAtMs: $now,
+ limits: [{ label: "Weekly (7-day)", percent: 0.11, resetsAt: "" }],
+ plan: "Pro"
+}')
+stale=$(jq -nc '{
+ fetchedAtMs: 1,
+ limits: [{ label: "Weekly (7-day)", percent: 0.11, resetsAt: "" }],
+ plan: "Pro"
+}')
+
+# A live key that cannot reach the endpoint keeps the cached window and asks
+# the shell to retry sooner than its regular interval.
+unreachable=$(collect_limits false "$stale" transport)
+[[ $(jq -c '[.result.limits[].label]' <<<"$unreachable") == '["Weekly (7-day)"]' ]] ||
+ fail "Ollama collector falls back to cache when the probe cannot connect" "$unreachable"
+[[ $(jq -r '.result.retryAdvised' <<<"$unreachable") == "true" ]] ||
+ fail "Ollama collector advises a retry after a transport failure" "$unreachable"
+pass "Ollama collector falls back to cache when the probe cannot connect"
+
+# A rejected key says so instead of pretending the endpoint is down.
+rejected=$(collect_limits false "" rejected)
+[[ $(jq -r '.result.usageStatusText' <<<"$rejected") == "Ollama limits unavailable" ]] ||
+ fail "Ollama collector reports a rejected key" "$rejected"
+[[ $(jq -r '.result.authHelpText' <<<"$rejected") == *"rejected"* ]] ||
+ fail "Ollama collector explains a rejected key" "$rejected"
+pass "Ollama collector reports a rejected key"
+
+# A fresh cache answers repeated panel opens without a request apiece.
+reused=$(collect_limits false "$fresh" normal)
+[[ $(jq -r '.probes | length' <<<"$reused") == "0" && $(jq -c '[.result.limits[].percent]' <<<"$reused") == "[0.11]" ]] ||
+ fail "Ollama collector reuses a cache younger than the probe interval" "$reused"
+pass "Ollama collector reuses a cache younger than the probe interval"
+
+# --force is someone pressing refresh, so the reuse window must not outrank it.
+forced=$(collect_limits true "$fresh" normal)
+[[ $(jq -r '.probes | length' <<<"$forced") == "2" ]] ||
+ fail "Ollama collector re-probes limits and plan on --force despite a fresh cache" "$forced"
+[[ $(jq -c '[.result.limits[].percent]' <<<"$forced") == "[0.44,0.11]" && $(jq -r '.result.tierLabel' <<<"$forced") == "Pro" ]] ||
+ fail "Ollama collector returns the forced probe's numbers and plan" "$forced"
+pass "Ollama collector re-probes on --force despite a fresh cache"
+
+# A probe that lands becomes the next run's fallback.
+[[ $(jq -c '[.cached.limits[].percent]' <<<"$forced") == "[0.44,0.11]" && $(jq -r '.cached.plan' <<<"$forced") == "Pro" ]] ||
+ fail "Ollama collector caches a successful probe" "$forced"
+pass "Ollama collector caches a successful probe"