runtime zmeny

This commit is contained in:
lachtan
2026-09-15 10:15:24 +02:00
parent a98c07ba82
commit ea70c10ea9
8 changed files with 225 additions and 88 deletions

View File

@@ -1,10 +1,9 @@
---
name: usage
description: >
Show Ollama Cloud credit/usage spent via the ollama.com usage API, and
report continuously sampled usage history. Triggers on: "/usage",
"ollama usage", "how much credit", "how much have I used up",
"credits left", "quota", "usage report", "usage history".
How much of the Ollama Cloud plan has been spent — current session and weekly
usage per model, and reports over the continuously sampled history.
Triggers on: "ollama usage", "usage history".
---
# Usage
@@ -27,9 +26,9 @@ Format (script prints it, present it to the user as-is — same lines, same
order; translate the labels into the user's language, keep the numbers
exact; no extra model info on the Session/Weekly lines):
```
```text
Ollama Cloud usage
Session: <pct> %, resets in X hours
Session: <pct> %, resets HH:MM TZ (in H h M min)
Weekly: <pct> %, resets in Y days
Models (request count, weekly window):
<model>: <count>
@@ -38,14 +37,53 @@ Models (request count, weekly window):
The per-model breakdown lives only in the "Models" section — never inline
on the Session/Weekly lines.
## Reset times — derived, not from the API
Times are printed in the **server's local zone** (`Europe/Prague`), taken from
the system — no zone is hardcoded. The session line loses its reset clause when
the history holds no rollover to anchor the window; that is correct output,
not a failure.
`/api/usage` contains **no reset timestamps** (they exist only in the HTML
UI). The script computes them: session = until the next full hour UTC (the
dashboard shows "Resets in 1 hour"), weekly = until the next Monday 00:00
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_*`.
## Reset times
`/api/usage` carries **no reset timestamps**, neither in the body nor in the
response headers (re-checked 2026-09-15). Both are derived.
**Weekly:** next Monday 00:00 UTC, `until_next_monday()`. Matches the dashboard.
**Session: a 5-hour window anchored by the first request after the previous one
ran out** — not a fixed grid. The length comes from
[ollama.com/blog/transparent-pricing](https://ollama.com/blog/transparent-pricing):
the new plans dropped the "5-hour or weekly limits" this key still has.
How the anchoring was established on 2026-09-15: usage sat unchanged at
0.077/21 requests through 05:00 UTC — a fixed grid would have zeroed it there
and the poller would have recorded it — and only reset when a request arrived
at 06:00, after a 93-minute pause. Reconstructing the agent's activity gives a
consistent chain: window 00:0005:00, then 06:0011:00, each opened by the
first request after the previous expiry. A fixed grid would additionally
require that request to land exactly on a boundary by chance.
So `session_window_end()` takes the **newest rollover in `samples`** and adds
5 h. A rollover sample marks the start of a new window, not a boundary that was
due anyway, which is why nothing is ever extrapolated past it: once the window
runs out, the output says the next one starts with the next request rather than
naming a time.
That it is a window and not a rolling counter was measured too — usage dropped
from 0.077/21 to 0.0/`{}` at once; a rolling counter decays gradually.
**The reset is never guessed.** An earlier version assumed a calendar hour and
printed "resets in 31 minutes" while the dashboard said "Resets in 2 hours" —
a confident wrong number is worse than none. With no rollover in the history,
the Session line carries the percentage alone.
Do not compare our countdown against the dashboard's to the hour: the dashboard
rounds an unknown way (it showed "4 hours" and "3 hours" seven minutes apart),
which is why the output prints the wall-clock time too.
**If the model is wrong, the report shows it.** `Rollover gap:` lines compare
consecutive rollovers against the 5 h window — gaps longer than the block
confirm request-anchoring, a gap exactly equal to it across a long idle stretch
would point back to a fixed grid.
## Continuous sampling
@@ -63,6 +101,10 @@ 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.
The report ends with `Rollover gap:` lines and a `Session window:` line —
when the window in progress started, when it ends, and how the observed
rollovers line up against the 5 h length.
## Notes
- Endpoint: `GET https://ollama.com/api/usage`, header

View File

@@ -1,25 +1,48 @@
#!/usr/bin/env python3
"""Ollama Cloud usage — GET https://ollama.com/api/usage with Bearer key."""
"""Ollama Cloud usage — GET https://ollama.com/api/usage with Bearer key.
Also the shared base for the poller and the report: DB location, the sample
row shape, and the session-window arithmetic described below.
"""
from __future__ import annotations
import itertools
import json
import os
import sqlite3
import sys
import urllib.error
import urllib.request
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import NamedTuple
API_URL = "https://ollama.com/api/usage"
WORKSPACE = Path(__file__).resolve().parents[3]
DB_PATH = WORKSPACE / "db" / "ollama_usage.sqlite"
SAMPLE_COLUMNS = "ts, session_usage, weekly_usage, session_models, weekly_models"
# Legacy plans bill in 5-hour session windows; see ollama.com/blog/transparent-pricing
# ("no 5-hour or weekly limits" is what the *new* plans dropped).
SESSION_BLOCK = timedelta(hours=5)
class Sample(NamedTuple):
ts: str
session_usage: float
weekly_usage: float
session_models: str
weekly_models: str
def load_api_key() -> str:
env = os.environ.get("OLLAMA_API_KEY")
if env:
return env.strip()
# workspace root = three levels above this script
env_file = Path(__file__).resolve().parents[3] / ".env"
env_file = WORKSPACE / ".env"
if env_file.is_file():
for line in env_file.read_text().splitlines():
line = line.strip()
@@ -28,17 +51,10 @@ def load_api_key() -> str:
sys.exit("OLLAMA_API_KEY not found (neither env nor workspace/.env)")
def until_next_full_hour(now: datetime) -> timedelta:
nxt = (now.replace(minute=0, second=0, microsecond=0)
+ timedelta(hours=1))
return nxt - now
def until_next_monday(now: datetime) -> timedelta:
# days until Monday (weekday(): Mon == 0)
days = (7 - now.weekday()) % 7 or 7
nxt = (now.replace(hour=0, minute=0, second=0, microsecond=0)
+ timedelta(days=days))
nxt = now.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=days)
return nxt - now
@@ -48,18 +64,92 @@ def fmt_delta_short(td: timedelta) -> str:
if total >= 86400:
return f"{int(total // 86400)} days"
if total >= 3600:
return f"{round(td.total_seconds() / 3600)} hours"
return f"{max(1, round(td.total_seconds() / 60))} minutes"
return f"{round(total / 3600)} hours"
return f"{max(1, round(total / 60))} minutes"
def fmt_countdown(td: timedelta) -> str:
"""Exact countdown: '3 h 55 min'. The dashboard rounds; we do not."""
hours, minutes = divmod(int(td.total_seconds() // 60), 60)
return f"{hours} h {minutes} min" if hours else f"{minutes} min"
def is_window_reset(previous: Sample, current: Sample) -> bool:
"""Session window rolled over: its usage or its request total went down."""
previous_total = sum(json.loads(previous.session_models).values())
current_total = sum(json.loads(current.session_models).values())
return current.session_usage < previous.session_usage or current_total < previous_total
def load_samples(conn: sqlite3.Connection, since: str | None = None, until: str | None = None) -> list[Sample]:
if since is None and until is None:
rows = conn.execute(f"SELECT {SAMPLE_COLUMNS} FROM samples ORDER BY ts")
else:
rows = conn.execute(
f"SELECT {SAMPLE_COLUMNS} FROM samples WHERE ts >= ? AND ts <= ? ORDER BY ts",
(since, until),
)
return [Sample(*row) for row in rows]
def window_rollovers(samples: list[Sample]) -> list[datetime]:
"""Every sample where the session window rolled over, oldest first.
The window is not on a fixed grid: it starts with the first request after
the previous one ran out. On 2026-09-15 usage sat unchanged through 05:00
UTC and only reset once a request arrived at 06:00 — so a rollover sample
marks the *start of a new window*, not a boundary that was due anyway.
"""
return [
datetime.fromisoformat(current.ts)
for previous, current in itertools.pairwise(samples)
if is_window_reset(previous, current)
]
def session_window_end() -> datetime | None:
"""End of the window in progress, or None when the history cannot show it."""
if not DB_PATH.is_file():
return None
conn = sqlite3.connect(DB_PATH)
try:
rollovers = window_rollovers(load_samples(conn))
finally:
conn.close()
return rollovers[-1] + SESSION_BLOCK if rollovers else None
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 print_session(usage: float, now: datetime) -> None:
percent = f"Session: {usage * 100:.1f} %"
end = session_window_end()
if end is None:
# No rollover recorded yet — the window's start is simply unknown.
print(percent)
elif end <= now:
# The window ran out; the next one only begins with the next request,
# so there is no time to count down to.
print(f"{percent}, window expired — the next one starts with the next request")
else:
local = end.astimezone()
print(f"{percent}, resets {local:%H:%M %Z} (in {fmt_countdown(end - now)})")
def main() -> None:
req = urllib.request.Request(API_URL, headers={
"Authorization": f"Bearer {load_api_key()}",
"Accept": "application/json",
})
try:
with urllib.request.urlopen(req, timeout=15) as resp:
data = json.load(resp)
data = fetch_usage()
except urllib.error.HTTPError as e:
sys.exit(f"ollama.com API error: HTTP {e.code}")
except urllib.error.URLError as e:
@@ -68,24 +158,18 @@ def main() -> None:
limits = data.get("limits", {})
session = limits.get("session", {})
weekly = limits.get("weekly", {})
now = datetime.now(timezone.utc)
now = datetime.now(UTC)
print("Ollama Cloud usage")
print(
f'Session: {session.get("usage", 0) * 100:.1f} %, '
f'resets in {fmt_delta_short(until_next_full_hour(now))}'
)
print(
f'Weekly: {weekly.get("usage", 0) * 100:.1f} %, '
f'resets in {fmt_delta_short(until_next_monday(now))}'
)
print_session(session.get("usage", 0.0), now)
print(f"Weekly: {weekly.get('usage', 0) * 100:.1f} %, resets in {fmt_delta_short(until_next_monday(now))}")
models = weekly.get("models", [])
if models:
print("Models (request count, weekly window):")
for m in models:
print(f' {m["name"]}: {m["request_count"]}')
print(f" {m['name']}: {m['request_count']}")
if __name__ == "__main__":
main()
main()

View File

@@ -11,14 +11,9 @@ 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"
from ollama_usage import DB_PATH, fetch_usage
SCHEMA = """
CREATE TABLE IF NOT EXISTS samples (
@@ -36,15 +31,6 @@ CREATE TABLE IF NOT EXISTS meta (
"""
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(
@@ -72,8 +58,7 @@ def record_poll(conn: sqlite3.Connection, ts: str, status: str) -> None:
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"
"SELECT session_usage, weekly_usage, session_models, weekly_models FROM samples ORDER BY ts DESC LIMIT 1"
).fetchone()
return row

View File

@@ -14,10 +14,16 @@ 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"
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
@@ -37,14 +43,6 @@ def resolve_window(args: argparse.Namespace) -> tuple[str, str]:
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}
@@ -60,37 +58,56 @@ def minutes_between(earlier: str, later: str) -> float:
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]:
def print_rows(samples: list[Sample]) -> dict[str, int]:
total: dict[str, int] = {}
for previous, current in itertools.pairwise(samples):
gap = minutes_between(previous[0], current[0])
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[4]), json.loads(current[4]))
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[0]} session {current[1] * 100:5.1f} % "
f"weekly {current[2] * 100:5.1f} % {format_deltas(deltas)}"
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_summary(samples: list[tuple], total: dict[str, int]) -> None:
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[0]}{last[0]} ({len(samples)} samples)")
print(f"Weekly usage: {first[2] * 100:.1f} % → {last[2] * 100:.1f} %")
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)}")
@@ -116,6 +133,7 @@ def main() -> None:
total = print_rows(samples)
print_summary(samples, total)
print_window(conn)
print_poll_status(conn)