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 @@ + + Ollama + + 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 @@ + + Ollama + + 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"