keymeter 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- keymeter/__init__.py +3 -0
- keymeter/__main__.py +5 -0
- keymeter/cli.py +114 -0
- keymeter/gateways/__init__.py +37 -0
- keymeter/gateways/base.py +95 -0
- keymeter/gateways/litellm.py +42 -0
- keymeter/gateways/openrouter.py +57 -0
- keymeter/monitor.py +336 -0
- keymeter/static/app.js +908 -0
- keymeter/static/index.html +100 -0
- keymeter/static/styles.css +362 -0
- keymeter/tui.py +316 -0
- keymeter/util.py +67 -0
- keymeter/web.py +207 -0
- keymeter-0.1.0.dist-info/METADATA +214 -0
- keymeter-0.1.0.dist-info/RECORD +19 -0
- keymeter-0.1.0.dist-info/WHEEL +4 -0
- keymeter-0.1.0.dist-info/entry_points.txt +2 -0
- keymeter-0.1.0.dist-info/licenses/LICENSE +21 -0
keymeter/__init__.py
ADDED
keymeter/__main__.py
ADDED
keymeter/cli.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Command line: `keymeter web`, `keymeter tui` and `keymeter json`.
|
|
2
|
+
|
|
3
|
+
Settings come from the environment, then from a .env file (the current directory's, or --env FILE):
|
|
4
|
+
KEYMETER_KEY the API key to watch (required)
|
|
5
|
+
KEYMETER_URL gateway base URL (default depends on the gateway)
|
|
6
|
+
KEYMETER_GATEWAY auto, litellm or openrouter (default auto)
|
|
7
|
+
"""
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from keymeter import __version__, gateways
|
|
15
|
+
from keymeter.monitor import Monitor
|
|
16
|
+
from keymeter.util import tidy
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_env(path, required):
|
|
20
|
+
"""Minimal .env reader (KEY=VALUE lines); real environment variables win."""
|
|
21
|
+
if not path.is_file():
|
|
22
|
+
if required:
|
|
23
|
+
sys.exit(f"--env file not found: {path}")
|
|
24
|
+
return
|
|
25
|
+
raw = path.read_bytes()
|
|
26
|
+
try:
|
|
27
|
+
text = raw.decode("utf-16") if raw[:2] in (b"\xff\xfe", b"\xfe\xff") else raw.decode("utf-8-sig")
|
|
28
|
+
except UnicodeDecodeError:
|
|
29
|
+
sys.exit(f"can't read {path}: save it as UTF-8")
|
|
30
|
+
for line in text.splitlines():
|
|
31
|
+
line = line.strip()
|
|
32
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
33
|
+
continue
|
|
34
|
+
k, v = line.split("=", 1)
|
|
35
|
+
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build_parser():
|
|
39
|
+
conn = argparse.ArgumentParser(add_help=False)
|
|
40
|
+
g = conn.add_argument_group("gateway")
|
|
41
|
+
g.add_argument("--gateway", choices=["auto", *gateways.GATEWAYS],
|
|
42
|
+
help="gateway type (default: $KEYMETER_GATEWAY or auto: openrouter for openrouter.ai URLs "
|
|
43
|
+
"and sk-or- keys, litellm otherwise)")
|
|
44
|
+
g.add_argument("--url", help="gateway base URL (default: $KEYMETER_URL, else http://localhost:4000 for "
|
|
45
|
+
"litellm or https://openrouter.ai/api for openrouter)")
|
|
46
|
+
g.add_argument("--env", metavar="FILE", help="read settings from this file (default: .env in the current directory)")
|
|
47
|
+
|
|
48
|
+
watch = argparse.ArgumentParser(add_help=False)
|
|
49
|
+
w = watch.add_argument_group("monitoring")
|
|
50
|
+
w.add_argument("-i", "--interval", type=float, default=15, help="seconds between polls (default 15)")
|
|
51
|
+
w.add_argument("--window", type=float, default=15, help="burn-rate window in minutes (default 15)")
|
|
52
|
+
w.add_argument("--warn", type=float, default=0.8, help="warn at this fraction of a budget (default 0.8)")
|
|
53
|
+
w.add_argument("--crit", type=float, default=0.95, help="critical at this fraction (default 0.95)")
|
|
54
|
+
w.add_argument("--log", metavar="CSV", help="also append every poll to this CSV file")
|
|
55
|
+
|
|
56
|
+
p = argparse.ArgumentParser(
|
|
57
|
+
prog="keymeter",
|
|
58
|
+
description="Live spend, budget and burn rate for one LiteLLM or OpenRouter API key.",
|
|
59
|
+
epilog="The key is read from KEYMETER_KEY (environment or .env). Run `keymeter COMMAND -h` for a command's options.")
|
|
60
|
+
p.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}")
|
|
61
|
+
sub = p.add_subparsers(dest="command", required=True, metavar="COMMAND")
|
|
62
|
+
|
|
63
|
+
web = sub.add_parser("web", parents=[conn, watch], help="browser dashboard",
|
|
64
|
+
description="Serve a live dashboard for the key in the browser.")
|
|
65
|
+
web.add_argument("--host", default="127.0.0.1",
|
|
66
|
+
help="address to listen on (default 127.0.0.1, this machine only; 0.0.0.0 = all interfaces, no login)")
|
|
67
|
+
web.add_argument("--port", type=int, default=8765, help="port (default 8765)")
|
|
68
|
+
|
|
69
|
+
tui = sub.add_parser("tui", parents=[conn, watch], help="terminal dashboard",
|
|
70
|
+
description="Show a live dashboard for the key in the terminal.")
|
|
71
|
+
tui.add_argument("--once", action="store_true", help="print one snapshot and exit (exit 1 if not OK)")
|
|
72
|
+
tui.add_argument("--no-team", action="store_true", help="don't query the team (LiteLLM /team/info)")
|
|
73
|
+
tui.add_argument("--no-bell", action="store_true", help="don't beep on alerts")
|
|
74
|
+
|
|
75
|
+
js = sub.add_parser("json", parents=[conn], help="print one snapshot as JSON and exit",
|
|
76
|
+
description="Poll once and print the summary as JSON. Exits 1 if the status is not OK.")
|
|
77
|
+
js.add_argument("--no-team", action="store_true", help="don't query the team (LiteLLM /team/info)")
|
|
78
|
+
js.set_defaults(interval=15, window=15, warn=0.8, crit=0.95, log=None)
|
|
79
|
+
return p
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def main(argv=None):
|
|
83
|
+
# write UTF-8 even when output goes to a pipe or file on Windows (Python's default from 3.15 on, PEP 686)
|
|
84
|
+
for stream in (sys.stdout, sys.stderr):
|
|
85
|
+
if hasattr(stream, "reconfigure"):
|
|
86
|
+
stream.reconfigure(encoding="utf-8")
|
|
87
|
+
args = build_parser().parse_args(argv)
|
|
88
|
+
args.interval = max(args.interval, 1)
|
|
89
|
+
if args.env:
|
|
90
|
+
load_env(Path(args.env), required=True)
|
|
91
|
+
elif not os.environ.get("KEYMETER_KEY"):
|
|
92
|
+
# ./.env is only a fallback for the key itself: a .env in whatever directory you are in (a cloned
|
|
93
|
+
# repo, say) must not point a key from your environment at another server
|
|
94
|
+
load_env(Path(".env"), required=False)
|
|
95
|
+
key = os.environ.get("KEYMETER_KEY", "").strip().strip('"')
|
|
96
|
+
if not key:
|
|
97
|
+
sys.exit("KEYMETER_KEY is not set. Put it in a .env file in this directory, pass --env FILE, or export it.")
|
|
98
|
+
name = (args.gateway or os.environ.get("KEYMETER_GATEWAY") or "auto").strip().lower()
|
|
99
|
+
team = args.command != "web" and not args.no_team # the web dashboard covers the key only
|
|
100
|
+
try:
|
|
101
|
+
gw = gateways.create(name, args.url or os.environ.get("KEYMETER_URL"), key, team=team)
|
|
102
|
+
except ValueError as e:
|
|
103
|
+
sys.exit(str(e))
|
|
104
|
+
mon = Monitor(gw, args)
|
|
105
|
+
|
|
106
|
+
if args.command == "json":
|
|
107
|
+
mon.poll()
|
|
108
|
+
print(json.dumps(tidy(mon.summary()), indent=2, ensure_ascii=False))
|
|
109
|
+
return 0 if mon.snap.status == "OK" else 1
|
|
110
|
+
if args.command == "web":
|
|
111
|
+
from keymeter import web
|
|
112
|
+
return web.serve(mon, args)
|
|
113
|
+
from keymeter import tui
|
|
114
|
+
return tui.run(mon, args)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Gateway adapters.
|
|
2
|
+
|
|
3
|
+
An adapter polls one gateway with the key and returns a Snapshot whose `key` dict uses LiteLLM's
|
|
4
|
+
/key/info field names, so the monitor and both dashboards treat every gateway the same way. Each
|
|
5
|
+
adapter has `name`, `default_url`, `url`, `key`, `fetch() -> Snapshot`, and `features`: the optional
|
|
6
|
+
parts of the dashboard it can fill ("models" = spend per model, "limits" = rate limits and allowed
|
|
7
|
+
models, "team" = team budget and keys).
|
|
8
|
+
"""
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
from keymeter.gateways.base import Snapshot, base_url
|
|
12
|
+
from keymeter.gateways.litellm import LiteLLM
|
|
13
|
+
from keymeter.gateways.openrouter import OpenRouter
|
|
14
|
+
|
|
15
|
+
GATEWAYS = {"litellm": LiteLLM, "openrouter": OpenRouter}
|
|
16
|
+
|
|
17
|
+
__all__ = ["GATEWAYS", "LiteLLM", "OpenRouter", "Snapshot", "create", "detect"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def detect(url, key):
|
|
21
|
+
"""Pick a gateway from the URL's host, or from the key's prefix when no URL is set."""
|
|
22
|
+
if url:
|
|
23
|
+
host = urlparse(url).hostname or ""
|
|
24
|
+
return "openrouter" if host == "openrouter.ai" or host.endswith(".openrouter.ai") else "litellm"
|
|
25
|
+
return "openrouter" if key.startswith("sk-or-") else "litellm"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def create(name, url, key, team=True):
|
|
29
|
+
"""Adapter for gateway `name` ("auto", "litellm" or "openrouter"); `url` falls back to its default."""
|
|
30
|
+
if name == "auto":
|
|
31
|
+
name = detect(url, key)
|
|
32
|
+
if name not in GATEWAYS:
|
|
33
|
+
raise ValueError(f"unknown gateway {name!r}: use auto, {' or '.join(GATEWAYS)}")
|
|
34
|
+
url = base_url(url or GATEWAYS[name].default_url)
|
|
35
|
+
if urlparse(url).scheme not in ("http", "https") or not urlparse(url).netloc:
|
|
36
|
+
raise ValueError(f"the gateway URL must start with http:// or https:// (got {url!r})")
|
|
37
|
+
return LiteLLM(url, key, team=team) if name == "litellm" else OpenRouter(url, key)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""What every gateway adapter shares: the snapshot it returns and a small read-only HTTP client."""
|
|
2
|
+
import http.client
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import urllib.request
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
from keymeter import __version__
|
|
11
|
+
|
|
12
|
+
USER_AGENT = f"keymeter/{__version__}"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Snapshot:
|
|
17
|
+
"""One poll. `key` and `team` use LiteLLM's /key/info and /team/info field names; other adapters translate into them."""
|
|
18
|
+
ts: float
|
|
19
|
+
status: str
|
|
20
|
+
message: str = ""
|
|
21
|
+
latency_ms: float = 0.0
|
|
22
|
+
key: dict = field(default_factory=dict) # spend, max_budget, budget_reset_at, expires, ...
|
|
23
|
+
team: dict = field(default_factory=dict)
|
|
24
|
+
team_keys: list = field(default_factory=list)
|
|
25
|
+
team_error: str = ""
|
|
26
|
+
notices: list = field(default_factory=list) # info lines for the monitor's event log
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def base_url(url):
|
|
30
|
+
"""Trim a trailing slash and a trailing /v1, so an OpenAI-style base URL works too."""
|
|
31
|
+
return url.rstrip("/").removesuffix("/v1")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def http_get(base, key, path, params=None, timeout=15):
|
|
35
|
+
"""GET a gateway endpoint -> (status, body, latency_ms); status 0 means a network error."""
|
|
36
|
+
url = base.rstrip("/") + path
|
|
37
|
+
if params:
|
|
38
|
+
url += "?" + urllib.parse.urlencode(params)
|
|
39
|
+
t0 = time.monotonic()
|
|
40
|
+
try:
|
|
41
|
+
req = urllib.request.Request(url, headers={
|
|
42
|
+
"Authorization": f"Bearer {key}",
|
|
43
|
+
"Accept": "application/json",
|
|
44
|
+
"User-Agent": USER_AGENT,
|
|
45
|
+
})
|
|
46
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
47
|
+
status, raw = resp.status, resp.read()
|
|
48
|
+
except urllib.error.HTTPError as e:
|
|
49
|
+
status, raw = e.code, e.read()
|
|
50
|
+
except (urllib.error.URLError, http.client.HTTPException, OSError, ValueError) as e:
|
|
51
|
+
return 0, str(getattr(e, "reason", e)), (time.monotonic() - t0) * 1000
|
|
52
|
+
latency = (time.monotonic() - t0) * 1000
|
|
53
|
+
try:
|
|
54
|
+
return status, json.loads(raw), latency
|
|
55
|
+
except ValueError:
|
|
56
|
+
return status, raw.decode("utf-8", "replace").strip()[:300], latency
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def unwrap(body, inner):
|
|
60
|
+
"""LiteLLM wraps some responses ({"info": {...}}, {"team_info": {...}}); accept both."""
|
|
61
|
+
if isinstance(body, dict) and isinstance(body.get(inner), dict):
|
|
62
|
+
return body[inner]
|
|
63
|
+
return body if isinstance(body, dict) else {}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def error_text(body):
|
|
67
|
+
if isinstance(body, dict):
|
|
68
|
+
err = body.get("error", body.get("detail", body))
|
|
69
|
+
if isinstance(err, dict):
|
|
70
|
+
return str(err.get("message", err))
|
|
71
|
+
return str(err)
|
|
72
|
+
return str(body)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def classify(status, msg):
|
|
76
|
+
m = msg.lower()
|
|
77
|
+
if status == 0:
|
|
78
|
+
return "OFFLINE"
|
|
79
|
+
if "blocked" in m:
|
|
80
|
+
return "BLOCKED"
|
|
81
|
+
if "budget" in m:
|
|
82
|
+
return "OVER BUDGET"
|
|
83
|
+
if "expired" in m:
|
|
84
|
+
return "EXPIRED"
|
|
85
|
+
if status == 429:
|
|
86
|
+
return "RATE LIMITED"
|
|
87
|
+
if status in (401, 403):
|
|
88
|
+
return "AUTH ERROR"
|
|
89
|
+
return f"HTTP {status}"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def error_snapshot(status, body, latency_ms):
|
|
93
|
+
"""Snapshot for a poll that returned no key data."""
|
|
94
|
+
msg = error_text(body) if status else f"network error: {body}"
|
|
95
|
+
return Snapshot(time.time(), classify(status, msg), msg, latency_ms)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""LiteLLM proxy: GET /key/info for the calling key, plus /team/info when the key is allowed to read it.
|
|
2
|
+
|
|
3
|
+
Both endpoints are read-only and cost no tokens.
|
|
4
|
+
"""
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from keymeter.gateways.base import Snapshot, error_snapshot, error_text, http_get, unwrap
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LiteLLM:
|
|
11
|
+
name = "litellm"
|
|
12
|
+
default_url = "http://localhost:4000"
|
|
13
|
+
features = frozenset({"models", "limits", "team"})
|
|
14
|
+
|
|
15
|
+
def __init__(self, url, key, team=True):
|
|
16
|
+
self.url, self.key = url, key
|
|
17
|
+
self.team_allowed = team
|
|
18
|
+
self.key_param = False # older LiteLLM versions need /key/info?key=...
|
|
19
|
+
|
|
20
|
+
def fetch(self):
|
|
21
|
+
params = {"key": self.key} if self.key_param else None
|
|
22
|
+
status, body, ms = http_get(self.url, self.key, "/key/info", params)
|
|
23
|
+
# the key goes into the URL (and so into access logs) only when LiteLLM asks for the parameter
|
|
24
|
+
if not self.key_param and (status == 422 or (status == 200 and "spend" not in unwrap(body, "info"))):
|
|
25
|
+
self.key_param = True
|
|
26
|
+
status, body, ms = http_get(self.url, self.key, "/key/info", {"key": self.key})
|
|
27
|
+
if status != 200 or not isinstance(body, dict):
|
|
28
|
+
return error_snapshot(status, body, ms)
|
|
29
|
+
|
|
30
|
+
snap = Snapshot(time.time(), "OK", latency_ms=ms, key=unwrap(body, "info"))
|
|
31
|
+
team_id = snap.key.get("team_id")
|
|
32
|
+
if team_id and self.team_allowed:
|
|
33
|
+
ts, tbody, _ = http_get(self.url, self.key, "/team/info", {"team_id": team_id})
|
|
34
|
+
if ts == 200 and isinstance(tbody, dict):
|
|
35
|
+
snap.team = unwrap(tbody, "team_info")
|
|
36
|
+
snap.team_keys = tbody.get("keys") or []
|
|
37
|
+
else:
|
|
38
|
+
snap.team_error = f"HTTP {ts}: {error_text(tbody)}"[:160]
|
|
39
|
+
if ts in (401, 403):
|
|
40
|
+
self.team_allowed = False
|
|
41
|
+
snap.notices.append("this key can't read /team/info — showing key data only")
|
|
42
|
+
return snap
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""OpenRouter: GET /api/v1/key, translated into the LiteLLM field names the monitor works with.
|
|
2
|
+
|
|
3
|
+
OpenRouter reports the key's credit limit, what is left of it and how often it resets (daily, weekly
|
|
4
|
+
or monthly, on UTC boundaries). It has no per-model spend, rate limits or teams.
|
|
5
|
+
"""
|
|
6
|
+
import time
|
|
7
|
+
from datetime import datetime, timedelta, timezone
|
|
8
|
+
|
|
9
|
+
from keymeter.gateways.base import Snapshot, error_snapshot, http_get
|
|
10
|
+
from keymeter.util import num
|
|
11
|
+
|
|
12
|
+
PERIODS = {"daily": "day", "weekly": "week", "monthly": "month"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def next_reset(period, now):
|
|
16
|
+
"""Start of the next UTC day, week (Monday) or month after `now`, or None if the limit never resets."""
|
|
17
|
+
d = datetime.fromtimestamp(now, timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
18
|
+
if period == "daily":
|
|
19
|
+
return d + timedelta(days=1)
|
|
20
|
+
if period == "weekly":
|
|
21
|
+
return d + timedelta(days=7 - d.weekday())
|
|
22
|
+
if period == "monthly":
|
|
23
|
+
return d.replace(year=d.year + d.month // 12, month=d.month % 12 + 1, day=1)
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def translate(d, now):
|
|
28
|
+
"""/api/v1/key "data" -> the key fields the monitor reads."""
|
|
29
|
+
limit, remaining = num(d.get("limit")), num(d.get("limit_remaining"))
|
|
30
|
+
reset = next_reset(d.get("limit_reset"), now) if limit is not None else None
|
|
31
|
+
label = d.get("label") or ""
|
|
32
|
+
return {
|
|
33
|
+
# the default label is just the masked key, which the header already shows
|
|
34
|
+
"key_alias": None if label.startswith("sk-or-") else label or None,
|
|
35
|
+
# spend that counts against the limit (this period's when the limit resets)
|
|
36
|
+
"spend": max(limit - remaining, 0.0) if limit is not None and remaining is not None else num(d.get("usage")) or 0.0,
|
|
37
|
+
"max_budget": limit,
|
|
38
|
+
"budget_duration": PERIODS.get(d.get("limit_reset")) if limit is not None else None,
|
|
39
|
+
"budget_reset_at": reset.isoformat() if reset else None,
|
|
40
|
+
"expires": d.get("expires_at"),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class OpenRouter:
|
|
45
|
+
name = "openrouter"
|
|
46
|
+
default_url = "https://openrouter.ai/api"
|
|
47
|
+
features = frozenset()
|
|
48
|
+
|
|
49
|
+
def __init__(self, url, key):
|
|
50
|
+
self.url, self.key = url, key
|
|
51
|
+
|
|
52
|
+
def fetch(self):
|
|
53
|
+
status, body, ms = http_get(self.url, self.key, "/v1/key")
|
|
54
|
+
if status != 200 or not isinstance(body, dict) or not isinstance(body.get("data"), dict):
|
|
55
|
+
return error_snapshot(status, body, ms)
|
|
56
|
+
now = time.time()
|
|
57
|
+
return Snapshot(now, "OK", latency_ms=ms, key=translate(body["data"], now))
|