From a970805fdcf71a93976810ee268a407201ae2ae9 Mon Sep 17 00:00:00 2001 From: nanobot Date: Tue, 15 Sep 2026 06:22:53 +0200 Subject: [PATCH] usage: continuous Ollama Cloud usage sampling + delta report - scripts/ollama_usage_poll.py: per-minute cron sample into db/ollama_usage.sqlite, write-on-change; meta table records every poll so a data gap can be told apart from a failed or missed poll - scripts/ollama_usage_report.py: delta report keyed on per-model request_count (limits.*.usage has 0.1 % resolution, short-interval deltas are noise) - SKILL.md: Continuous sampling section --- skills/usage/SKILL.md | 16 +++ skills/usage/scripts/ollama_usage_poll.py | 112 ++++++++++++++++++ skills/usage/scripts/ollama_usage_report.py | 123 ++++++++++++++++++++ 3 files changed, 251 insertions(+) create mode 100644 skills/usage/scripts/ollama_usage_poll.py create mode 100644 skills/usage/scripts/ollama_usage_report.py diff --git a/skills/usage/SKILL.md b/skills/usage/SKILL.md index ed988a3..b69fec4 100644 --- a/skills/usage/SKILL.md +++ b/skills/usage/SKILL.md @@ -46,6 +46,22 @@ UTC. Rationale: session usage climbed in real time during testing (rolling window), weekly changes slowly — consistent with hourly/weekly windows. If the window turns out not to be calendar-based, fix `until_next_*`. +## Continuous sampling + +A cron job runs `scripts/ollama_usage_poll.py` every minute and appends to +`db/ollama_usage.sqlite` whenever anything changed (table `samples`; table `meta` +records every poll, so a gap can be told apart from a failed poll). + +For a delta report over that data: + +```bash +uv run skills/usage/scripts/ollama_usage_report.py [--since ISO] [--until ISO] +``` + +Default window is the last 24 hours. Per-model **request counts** are the exact +figure there — `limits.*.usage` has a resolution of 0.1 %, so short-interval +percentage deltas are noise. + ## Notes - Endpoint: `GET https://ollama.com/api/usage`, header diff --git a/skills/usage/scripts/ollama_usage_poll.py b/skills/usage/scripts/ollama_usage_poll.py new file mode 100644 index 0000000..2cfcb13 --- /dev/null +++ b/skills/usage/scripts/ollama_usage_poll.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Sample Ollama Cloud usage into db/ollama_usage.sqlite. Run from cron every minute. + +Writes a `samples` row only when something changed; `meta` records every poll so a +gap in `samples` can be told apart from a poll that failed or never ran. +""" + +from __future__ import annotations + +import json +import sqlite3 +import sys +import urllib.error +import urllib.request +from datetime import UTC, datetime +from pathlib import Path + +from ollama_usage import API_URL, load_api_key + +WORKSPACE = Path(__file__).resolve().parents[3] +DB_PATH = WORKSPACE / "db" / "ollama_usage.sqlite" + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS samples ( + ts TEXT PRIMARY KEY, + session_usage REAL NOT NULL, + weekly_usage REAL NOT NULL, + session_models TEXT NOT NULL, + weekly_models TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS meta ( + id INTEGER PRIMARY KEY CHECK (id = 1), + last_ts TEXT NOT NULL, + last_status TEXT NOT NULL +); +""" + + +def fetch_usage() -> dict: + request = urllib.request.Request(API_URL, headers={ + "Authorization": f"Bearer {load_api_key()}", + "Accept": "application/json", + }) + with urllib.request.urlopen(request, timeout=15) as response: + return json.load(response) + + +def canonical_models(models: list[dict]) -> str: + """Stable JSON for change detection — the API returns an unordered list.""" + return json.dumps( + {m["name"]: m["request_count"] for m in models}, + sort_keys=True, + ) + + +def connect() -> sqlite3.Connection: + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(DB_PATH) + conn.executescript(SCHEMA) + return conn + + +def record_poll(conn: sqlite3.Connection, ts: str, status: str) -> None: + conn.execute( + "INSERT INTO meta (id, last_ts, last_status) VALUES (1, ?, ?) " + "ON CONFLICT(id) DO UPDATE SET last_ts = excluded.last_ts, " + "last_status = excluded.last_status", + (ts, status), + ) + conn.commit() + + +def latest_sample(conn: sqlite3.Connection) -> tuple | None: + row = conn.execute( + "SELECT session_usage, weekly_usage, session_models, weekly_models " + "FROM samples ORDER BY ts DESC LIMIT 1" + ).fetchone() + return row + + +def main() -> None: + now = datetime.now(UTC).replace(microsecond=0).isoformat() + conn = connect() + + try: + data = fetch_usage() + except urllib.error.HTTPError as e: + record_poll(conn, now, f"http_{e.code}") + print(f"{now} ollama.com API error: HTTP {e.code}", file=sys.stderr) + return + except urllib.error.URLError as e: + record_poll(conn, now, "unreachable") + print(f"{now} ollama.com unreachable: {e.reason}", file=sys.stderr) + return + + limits = data.get("limits", {}) + session = limits.get("session", {}) + weekly = limits.get("weekly", {}) + sample = ( + session.get("usage", 0.0), + weekly.get("usage", 0.0), + canonical_models(session.get("models", [])), + canonical_models(weekly.get("models", [])), + ) + + if sample != latest_sample(conn): + conn.execute("INSERT INTO samples VALUES (?, ?, ?, ?, ?)", (now, *sample)) + record_poll(conn, now, "ok") + + +if __name__ == "__main__": + main() diff --git a/skills/usage/scripts/ollama_usage_report.py b/skills/usage/scripts/ollama_usage_report.py new file mode 100644 index 0000000..bc5f0d3 --- /dev/null +++ b/skills/usage/scripts/ollama_usage_report.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Delta report over db/ollama_usage.sqlite collected by ollama_usage_poll.py. + +Request counts are the exact axis; `limits.*.usage` has a resolution of 0.1 %, +so per-sample percentage deltas are mostly quantization noise and are shown only +as a running level, plus one aggregate for the whole window. +""" + +from __future__ import annotations + +import argparse +import itertools +import json +import sqlite3 +import sys +from datetime import UTC, datetime, timedelta +from pathlib import Path + +WORKSPACE = Path(__file__).resolve().parents[3] +DB_PATH = WORKSPACE / "db" / "ollama_usage.sqlite" + +DEFAULT_WINDOW = timedelta(hours=24) +GAP_MINUTES = 15 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--since", help="ISO 8601 UTC start (default: 24 h ago)") + parser.add_argument("--until", help="ISO 8601 UTC end (default: now)") + return parser.parse_args() + + +def resolve_window(args: argparse.Namespace) -> tuple[str, str]: + now = datetime.now(UTC).replace(microsecond=0) + since = args.since or (now - DEFAULT_WINDOW).isoformat() + until = args.until or now.isoformat() + return since, until + + +def load_samples(conn: sqlite3.Connection, since: str, until: str) -> list[tuple]: + return conn.execute( + "SELECT ts, session_usage, weekly_usage, session_models, weekly_models " + "FROM samples WHERE ts >= ? AND ts <= ? ORDER BY ts", + (since, until), + ).fetchall() + + +def count_deltas(previous: dict[str, int], current: dict[str, int]) -> dict[str, int]: + names = set(previous) | set(current) + deltas = {n: current.get(n, 0) - previous.get(n, 0) for n in names} + return {n: d for n, d in sorted(deltas.items()) if d} + + +def format_deltas(deltas: dict[str, int]) -> str: + return ", ".join(f"{name} +{count}" for name, count in deltas.items()) or "-" + + +def minutes_between(earlier: str, later: str) -> float: + delta = datetime.fromisoformat(later) - datetime.fromisoformat(earlier) + return delta.total_seconds() / 60 + + +def is_window_reset(previous: tuple, current: tuple) -> bool: + """Session window rolled over: its usage or its request total went down.""" + previous_total = sum(json.loads(previous[3]).values()) + current_total = sum(json.loads(current[3]).values()) + return current[1] < previous[1] or current_total < previous_total + + +def print_rows(samples: list[tuple]) -> dict[str, int]: + total: dict[str, int] = {} + for previous, current in itertools.pairwise(samples): + gap = minutes_between(previous[0], current[0]) + if gap > GAP_MINUTES: + print(f" … {gap:.0f} min with no recorded change (idle or poller down)") + if is_window_reset(previous, current): + print(" ── session window reset ──") + + deltas = count_deltas(json.loads(previous[4]), json.loads(current[4])) + for name, count in deltas.items(): + total[name] = total.get(name, 0) + count + print( + f"{current[0]} session {current[1] * 100:5.1f} % " + f"weekly {current[2] * 100:5.1f} % {format_deltas(deltas)}" + ) + return total + + +def print_summary(samples: list[tuple], total: dict[str, int]) -> None: + first, last = samples[0], samples[-1] + print() + print(f"Window: {first[0]} → {last[0]} ({len(samples)} samples)") + print(f"Weekly usage: {first[2] * 100:.1f} % → {last[2] * 100:.1f} %") + print(f"Requests: {format_deltas(total)}") + + +def print_poll_status(conn: sqlite3.Connection) -> None: + row = conn.execute("SELECT last_ts, last_status FROM meta WHERE id = 1").fetchone() + if row: + print(f"Last poll: {row[0]} ({row[1]})") + + +def main() -> None: + if not DB_PATH.is_file(): + sys.exit(f"no samples yet: {DB_PATH} does not exist") + + args = parse_args() + since, until = resolve_window(args) + conn = sqlite3.connect(DB_PATH) + samples = load_samples(conn, since, until) + + if len(samples) < 2: + print(f"Not enough samples between {since} and {until}.") + print_poll_status(conn) + return + + total = print_rows(samples) + print_summary(samples, total) + print_poll_status(conn) + + +if __name__ == "__main__": + main()