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
This commit is contained in:
nanobot
2026-09-15 06:22:53 +02:00
parent b0ad79afc1
commit a970805fdc
3 changed files with 251 additions and 0 deletions

View File

@@ -46,6 +46,22 @@ UTC. Rationale: session usage climbed in real time during testing (rolling
window), weekly changes slowly — consistent with hourly/weekly windows. window), weekly changes slowly — consistent with hourly/weekly windows.
If the window turns out not to be calendar-based, fix `until_next_*`. 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 ## Notes
- Endpoint: `GET https://ollama.com/api/usage`, header - Endpoint: `GET https://ollama.com/api/usage`, header

View File

@@ -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()

View File

@@ -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()