#!/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 ollama_usage import ( DB_PATH, SESSION_BLOCK, Sample, fmt_countdown, is_window_reset, load_samples, window_rollovers, ) 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 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 print_rows(samples: list[Sample]) -> dict[str, int]: total: dict[str, int] = {} for previous, current in itertools.pairwise(samples): gap = minutes_between(previous.ts, current.ts) 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.weekly_models), json.loads(current.weekly_models)) for name, count in deltas.items(): total[name] = total.get(name, 0) + count print( f"{current.ts} session {current.session_usage * 100:5.1f} % " f"weekly {current.weekly_usage * 100:5.1f} % {format_deltas(deltas)}" ) return total def print_window(conn: sqlite3.Connection) -> None: """The session window in progress, plus the gaps that test how it is anchored. Consecutive rollovers exactly SESSION_BLOCK apart would mean a fixed grid; longer gaps mean the window is anchored by the first request after the previous one ran out, which is what the data so far shows. """ rollovers = window_rollovers(load_samples(conn)) if not rollovers: print("Session window: no rollover recorded yet") return for previous, current in itertools.pairwise(rollovers): gap = current - previous verdict = "= block" if gap == SESSION_BLOCK else "> block (window is request-anchored)" print(f"Rollover gap: {previous:%m-%d %H:%M} → {current:%m-%d %H:%M} = {fmt_countdown(gap)} {verdict}") started = rollovers[-1].astimezone() ends = (rollovers[-1] + SESSION_BLOCK).astimezone() now = datetime.now(UTC) remaining = ( f"in {fmt_countdown(ends - now)}" if ends > now else "expired; next window starts with the next request" ) print(f"Session window: started {started:%m-%d %H:%M %Z}, ends {ends:%m-%d %H:%M %Z} ({remaining})") def print_summary(samples: list[Sample], total: dict[str, int]) -> None: first, last = samples[0], samples[-1] print() print(f"Window: {first.ts} → {last.ts} ({len(samples)} samples)") print(f"Weekly usage: {first.weekly_usage * 100:.1f} % → {last.weekly_usage * 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_window(conn) print_poll_status(conn) if __name__ == "__main__": main()