runtime zmeny
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -12,3 +12,4 @@ notes/.compile.lock
|
|||||||
cron/runs/
|
cron/runs/
|
||||||
wiki/*
|
wiki/*
|
||||||
!wiki/config.yaml
|
!wiki/config.yaml
|
||||||
|
.env
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ Pravidlo: vynechaný den není selhání. Pásmo: podlaha 2×/týden, strop denn
|
|||||||
| 2026-09-11 | ✅ | první trénink po fyzio (10.9.) |
|
| 2026-09-11 | ✅ | první trénink po fyzio (10.9.) |
|
||||||
| 2026-09-12 | ✅ | opakování |
|
| 2026-09-12 | ✅ | opakování |
|
||||||
| 2026-09-13 | ❌ | |
|
| 2026-09-13 | ❌ | |
|
||||||
| 2026-09-14 | ✅ | |
|
| 2026-09-14 | ✅ | |
|
||||||
|
| 2026-09-15 | ✅ | + další cviky z vlastní sestavy; pravá kyčel táhne víc, kolena ztuha |
|
||||||
@@ -24,3 +24,6 @@
|
|||||||
- 2026-09-12: Opakování fyzio cviků (kyčel/pánev) — další trénink po zahájení 11.9., adherence drží v rámci zvoleného pásma (podlaha 2×/týden, cíl denně).
|
- 2026-09-12: Opakování fyzio cviků (kyčel/pánev) — další trénink po zahájení 11.9., adherence drží v rámci zvoleného pásma (podlaha 2×/týden, cíl denně).
|
||||||
- 2026-09-14: Založen tréninkový log (artifacts/trenink-log.md) — tabulka datum/trénink/poznámka pro fyzio cviky. Vynechaný den 13.9. bez komentáře (v rámci tolerance), 14.9. cvičil.
|
- 2026-09-14: Založen tréninkový log (artifacts/trenink-log.md) — tabulka datum/trénink/poznámka pro fyzio cviky. Vynechaný den 13.9. bez komentáře (v rámci tolerance), 14.9. cvičil.
|
||||||
- 2026-09-14: Doplněno do cílů projektu (prompt.md): dobrá mobilita hlavně kyčle, pohyb bez bolesti, ideálně pravidelné cvičení — vedle změny váhy a kondice.
|
- 2026-09-14: Doplněno do cílů projektu (prompt.md): dobrá mobilita hlavně kyčle, pohyb bez bolesti, ideálně pravidelné cvičení — vedle změny váhy a kondice.
|
||||||
|
- 2026-09-15: Cvičil fyzio cviky — třetí tréninkový den za čtyři dny (11., 12., 14., 15.9.), v rámci zvoleného pásma.
|
||||||
|
- 2026-09-15: Stav kyčle po 4 dnech cvičení: asymetrie přetrvává — pravá strana táhne o dost víc, přitahování kolen jde obecně ztuha. K fyzio sestavě zařadil i další cviky ze své obvyklé sestavy. Rozhodnutí: nová návštěva fyzioterapeutky cca 3–4 týdny po první (10.9.), tedy začátek října — v souladu s klinickou praxí (reassessment 2–3 týdny po zahájení, pak 3–4 týdny; ROM adaptace trvá týdny). Na kontrole doladit počty opakování a výdrže u cviků.
|
||||||
|
- 2026-09-15: Upřesnění k rozšíření sestavy: další přidané cviky jsou taky protahovací — celý trénink tak zůstává čistě mobilizační, žádná zátěž navíc.
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ Denní vážení, vnímaná kondice, jestli mě něco bolí.
|
|||||||
- Cviky uložené v `artifacts/fyzio-cviky-kycele.md` (rekonstrukce z paměti).
|
- Cviky uložené v `artifacts/fyzio-cviky-kycele.md` (rekonstrukce z paměti).
|
||||||
- **Chybí**: počty opakování a délky výdrží — doladit s fyzioterapeutem při
|
- **Chybí**: počty opakování a délky výdrží — doladit s fyzioterapeutem při
|
||||||
další návštěvě.
|
další návštěvě.
|
||||||
|
- **Kontrola naplánovaná**: ~3–4 týdny po první návštěvě, tedy začátek října.
|
||||||
|
- **Stav (15.9.)**: asymetrie přetrvává — pravá strana táhne víc, přitahování
|
||||||
|
kolen ztuha. ROM adaptace trvá týdny, čekat zlepšení.
|
||||||
- **Frekvence — rozhodnuto (2026-09-11)**: denní snaha s tolerancí — vynechaný
|
- **Frekvence — rozhodnuto (2026-09-11)**: denní snaha s tolerancí — vynechaný
|
||||||
den není selhání. Pásmo: podlaha 2× týdně (pevná), strop denně, cokoli
|
den není selhání. Pásmo: podlaha 2× týdně (pevná), strop denně, cokoli
|
||||||
mezi = úspěch. Fyzicky stačilo i obden, denní cíl je kvůli návyku, ne
|
mezi = úspěch. Fyzicky stačilo i obden, denní cíl je kvůli návyku, ne
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
---
|
---
|
||||||
name: usage
|
name: usage
|
||||||
description: >
|
description: >
|
||||||
Show Ollama Cloud credit/usage spent via the ollama.com usage API, and
|
How much of the Ollama Cloud plan has been spent — current session and weekly
|
||||||
report continuously sampled usage history. Triggers on: "/usage",
|
usage per model, and reports over the continuously sampled history.
|
||||||
"ollama usage", "how much credit", "how much have I used up",
|
Triggers on: "ollama usage", "usage history".
|
||||||
"credits left", "quota", "usage report", "usage history".
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Usage
|
# 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
|
order; translate the labels into the user's language, keep the numbers
|
||||||
exact; no extra model info on the Session/Weekly lines):
|
exact; no extra model info on the Session/Weekly lines):
|
||||||
|
|
||||||
```
|
```text
|
||||||
Ollama Cloud usage
|
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
|
Weekly: <pct> %, resets in Y days
|
||||||
Models (request count, weekly window):
|
Models (request count, weekly window):
|
||||||
<model>: <count>
|
<model>: <count>
|
||||||
@@ -38,14 +37,53 @@ Models (request count, weekly window):
|
|||||||
The per-model breakdown lives only in the "Models" section — never inline
|
The per-model breakdown lives only in the "Models" section — never inline
|
||||||
on the Session/Weekly lines.
|
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
|
## Reset times
|
||||||
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
|
`/api/usage` carries **no reset timestamps**, neither in the body nor in the
|
||||||
UTC. Rationale: session usage climbed in real time during testing (rolling
|
response headers (re-checked 2026-09-15). Both are derived.
|
||||||
window), weekly changes slowly — consistent with hourly/weekly windows.
|
|
||||||
If the window turns out not to be calendar-based, fix `until_next_*`.
|
**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:00–05:00, then 06:00–11: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
|
## 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
|
figure there — `limits.*.usage` has a resolution of 0.1 %, so short-interval
|
||||||
percentage deltas are noise.
|
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
|
## Notes
|
||||||
|
|
||||||
- Endpoint: `GET https://ollama.com/api/usage`, header
|
- Endpoint: `GET https://ollama.com/api/usage`, header
|
||||||
|
|||||||
@@ -1,25 +1,48 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import itertools
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import UTC, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
API_URL = "https://ollama.com/api/usage"
|
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:
|
def load_api_key() -> str:
|
||||||
env = os.environ.get("OLLAMA_API_KEY")
|
env = os.environ.get("OLLAMA_API_KEY")
|
||||||
if env:
|
if env:
|
||||||
return env.strip()
|
return env.strip()
|
||||||
# workspace root = three levels above this script
|
env_file = WORKSPACE / ".env"
|
||||||
env_file = Path(__file__).resolve().parents[3] / ".env"
|
|
||||||
if env_file.is_file():
|
if env_file.is_file():
|
||||||
for line in env_file.read_text().splitlines():
|
for line in env_file.read_text().splitlines():
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
@@ -28,17 +51,10 @@ def load_api_key() -> str:
|
|||||||
sys.exit("OLLAMA_API_KEY not found (neither env nor workspace/.env)")
|
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:
|
def until_next_monday(now: datetime) -> timedelta:
|
||||||
# days until Monday (weekday(): Mon == 0)
|
# days until Monday (weekday(): Mon == 0)
|
||||||
days = (7 - now.weekday()) % 7 or 7
|
days = (7 - now.weekday()) % 7 or 7
|
||||||
nxt = (now.replace(hour=0, minute=0, second=0, microsecond=0)
|
nxt = now.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=days)
|
||||||
+ timedelta(days=days))
|
|
||||||
return nxt - now
|
return nxt - now
|
||||||
|
|
||||||
|
|
||||||
@@ -48,18 +64,92 @@ def fmt_delta_short(td: timedelta) -> str:
|
|||||||
if total >= 86400:
|
if total >= 86400:
|
||||||
return f"{int(total // 86400)} days"
|
return f"{int(total // 86400)} days"
|
||||||
if total >= 3600:
|
if total >= 3600:
|
||||||
return f"{round(td.total_seconds() / 3600)} hours"
|
return f"{round(total / 3600)} hours"
|
||||||
return f"{max(1, round(td.total_seconds() / 60))} minutes"
|
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:
|
def main() -> None:
|
||||||
req = urllib.request.Request(API_URL, headers={
|
|
||||||
"Authorization": f"Bearer {load_api_key()}",
|
|
||||||
"Accept": "application/json",
|
|
||||||
})
|
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
data = fetch_usage()
|
||||||
data = json.load(resp)
|
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
sys.exit(f"ollama.com API error: HTTP {e.code}")
|
sys.exit(f"ollama.com API error: HTTP {e.code}")
|
||||||
except urllib.error.URLError as e:
|
except urllib.error.URLError as e:
|
||||||
@@ -68,24 +158,18 @@ def main() -> None:
|
|||||||
limits = data.get("limits", {})
|
limits = data.get("limits", {})
|
||||||
session = limits.get("session", {})
|
session = limits.get("session", {})
|
||||||
weekly = limits.get("weekly", {})
|
weekly = limits.get("weekly", {})
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
print("Ollama Cloud usage")
|
print("Ollama Cloud usage")
|
||||||
print(
|
print_session(session.get("usage", 0.0), now)
|
||||||
f'Session: {session.get("usage", 0) * 100:.1f} %, '
|
print(f"Weekly: {weekly.get('usage', 0) * 100:.1f} %, resets in {fmt_delta_short(until_next_monday(now))}")
|
||||||
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))}'
|
|
||||||
)
|
|
||||||
|
|
||||||
models = weekly.get("models", [])
|
models = weekly.get("models", [])
|
||||||
if models:
|
if models:
|
||||||
print("Models (request count, weekly window):")
|
print("Models (request count, weekly window):")
|
||||||
for m in models:
|
for m in models:
|
||||||
print(f' {m["name"]}: {m["request_count"]}')
|
print(f" {m['name']}: {m['request_count']}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -11,14 +11,9 @@ import json
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from ollama_usage import API_URL, load_api_key
|
from ollama_usage import DB_PATH, fetch_usage
|
||||||
|
|
||||||
WORKSPACE = Path(__file__).resolve().parents[3]
|
|
||||||
DB_PATH = WORKSPACE / "db" / "ollama_usage.sqlite"
|
|
||||||
|
|
||||||
SCHEMA = """
|
SCHEMA = """
|
||||||
CREATE TABLE IF NOT EXISTS samples (
|
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:
|
def canonical_models(models: list[dict]) -> str:
|
||||||
"""Stable JSON for change detection — the API returns an unordered list."""
|
"""Stable JSON for change detection — the API returns an unordered list."""
|
||||||
return json.dumps(
|
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:
|
def latest_sample(conn: sqlite3.Connection) -> tuple | None:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT session_usage, weekly_usage, session_models, weekly_models "
|
"SELECT session_usage, weekly_usage, session_models, weekly_models FROM samples ORDER BY ts DESC LIMIT 1"
|
||||||
"FROM samples ORDER BY ts DESC LIMIT 1"
|
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,16 @@ import json
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
WORKSPACE = Path(__file__).resolve().parents[3]
|
from ollama_usage import (
|
||||||
DB_PATH = WORKSPACE / "db" / "ollama_usage.sqlite"
|
DB_PATH,
|
||||||
|
SESSION_BLOCK,
|
||||||
|
Sample,
|
||||||
|
fmt_countdown,
|
||||||
|
is_window_reset,
|
||||||
|
load_samples,
|
||||||
|
window_rollovers,
|
||||||
|
)
|
||||||
|
|
||||||
DEFAULT_WINDOW = timedelta(hours=24)
|
DEFAULT_WINDOW = timedelta(hours=24)
|
||||||
GAP_MINUTES = 15
|
GAP_MINUTES = 15
|
||||||
@@ -37,14 +43,6 @@ def resolve_window(args: argparse.Namespace) -> tuple[str, str]:
|
|||||||
return since, until
|
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]:
|
def count_deltas(previous: dict[str, int], current: dict[str, int]) -> dict[str, int]:
|
||||||
names = set(previous) | set(current)
|
names = set(previous) | set(current)
|
||||||
deltas = {n: current.get(n, 0) - previous.get(n, 0) for n in names}
|
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
|
return delta.total_seconds() / 60
|
||||||
|
|
||||||
|
|
||||||
def is_window_reset(previous: tuple, current: tuple) -> bool:
|
def print_rows(samples: list[Sample]) -> dict[str, int]:
|
||||||
"""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] = {}
|
total: dict[str, int] = {}
|
||||||
for previous, current in itertools.pairwise(samples):
|
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:
|
if gap > GAP_MINUTES:
|
||||||
print(f" … {gap:.0f} min with no recorded change (idle or poller down)")
|
print(f" … {gap:.0f} min with no recorded change (idle or poller down)")
|
||||||
if is_window_reset(previous, current):
|
if is_window_reset(previous, current):
|
||||||
print(" ── session window reset ──")
|
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():
|
for name, count in deltas.items():
|
||||||
total[name] = total.get(name, 0) + count
|
total[name] = total.get(name, 0) + count
|
||||||
print(
|
print(
|
||||||
f"{current[0]} session {current[1] * 100:5.1f} % "
|
f"{current.ts} session {current.session_usage * 100:5.1f} % "
|
||||||
f"weekly {current[2] * 100:5.1f} % {format_deltas(deltas)}"
|
f"weekly {current.weekly_usage * 100:5.1f} % {format_deltas(deltas)}"
|
||||||
)
|
)
|
||||||
return total
|
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]
|
first, last = samples[0], samples[-1]
|
||||||
print()
|
print()
|
||||||
print(f"Window: {first[0]} → {last[0]} ({len(samples)} samples)")
|
print(f"Window: {first.ts} → {last.ts} ({len(samples)} samples)")
|
||||||
print(f"Weekly usage: {first[2] * 100:.1f} % → {last[2] * 100:.1f} %")
|
print(f"Weekly usage: {first.weekly_usage * 100:.1f} % → {last.weekly_usage * 100:.1f} %")
|
||||||
print(f"Requests: {format_deltas(total)}")
|
print(f"Requests: {format_deltas(total)}")
|
||||||
|
|
||||||
|
|
||||||
@@ -116,6 +133,7 @@ def main() -> None:
|
|||||||
|
|
||||||
total = print_rows(samples)
|
total = print_rows(samples)
|
||||||
print_summary(samples, total)
|
print_summary(samples, total)
|
||||||
|
print_window(conn)
|
||||||
print_poll_status(conn)
|
print_poll_status(conn)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user