142 lines
4.8 KiB
Python
142 lines
4.8 KiB
Python
#!/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()
|