keymeter 0.1.0__tar.gz
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-0.1.0/.gitignore +26 -0
- keymeter-0.1.0/CHANGELOG.md +14 -0
- keymeter-0.1.0/LICENSE +21 -0
- keymeter-0.1.0/PKG-INFO +214 -0
- keymeter-0.1.0/README.md +186 -0
- keymeter-0.1.0/pyproject.toml +57 -0
- keymeter-0.1.0/src/keymeter/__init__.py +3 -0
- keymeter-0.1.0/src/keymeter/__main__.py +5 -0
- keymeter-0.1.0/src/keymeter/cli.py +114 -0
- keymeter-0.1.0/src/keymeter/gateways/__init__.py +37 -0
- keymeter-0.1.0/src/keymeter/gateways/base.py +95 -0
- keymeter-0.1.0/src/keymeter/gateways/litellm.py +42 -0
- keymeter-0.1.0/src/keymeter/gateways/openrouter.py +57 -0
- keymeter-0.1.0/src/keymeter/monitor.py +336 -0
- keymeter-0.1.0/src/keymeter/static/app.js +908 -0
- keymeter-0.1.0/src/keymeter/static/index.html +100 -0
- keymeter-0.1.0/src/keymeter/static/styles.css +362 -0
- keymeter-0.1.0/src/keymeter/tui.py +316 -0
- keymeter-0.1.0/src/keymeter/util.py +67 -0
- keymeter-0.1.0/src/keymeter/web.py +207 -0
- keymeter-0.1.0/tests/conftest.py +96 -0
- keymeter-0.1.0/tests/test_cli.py +102 -0
- keymeter-0.1.0/tests/test_gateways.py +43 -0
- keymeter-0.1.0/tests/test_litellm.py +103 -0
- keymeter-0.1.0/tests/test_monitor.py +209 -0
- keymeter-0.1.0/tests/test_openrouter.py +70 -0
- keymeter-0.1.0/tests/test_tui.py +21 -0
- keymeter-0.1.0/tests/test_web.py +97 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# secrets
|
|
2
|
+
.env
|
|
3
|
+
.env.*
|
|
4
|
+
!.env.example
|
|
5
|
+
|
|
6
|
+
# python
|
|
7
|
+
__pycache__/
|
|
8
|
+
*.py[cod]
|
|
9
|
+
*.egg-info/
|
|
10
|
+
.venv/
|
|
11
|
+
venv/
|
|
12
|
+
build/
|
|
13
|
+
dist/
|
|
14
|
+
.pytest_cache/
|
|
15
|
+
.ruff_cache/
|
|
16
|
+
.coverage
|
|
17
|
+
htmlcov/
|
|
18
|
+
|
|
19
|
+
# poll logs written with --log
|
|
20
|
+
*.csv
|
|
21
|
+
|
|
22
|
+
# editors / OS
|
|
23
|
+
.vscode/
|
|
24
|
+
.idea/
|
|
25
|
+
.DS_Store
|
|
26
|
+
Thumbs.db
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-10-07)
|
|
4
|
+
|
|
5
|
+
First public release.
|
|
6
|
+
|
|
7
|
+
- `keymeter web`: browser dashboard with spend vs. budget, burn rate, run-out time, projected spend at
|
|
8
|
+
reset, spend-over-time charts, spend per model, limits, an event log, and optional sound and desktop
|
|
9
|
+
alerts. Listens on localhost only unless `--host` says otherwise.
|
|
10
|
+
- `keymeter tui`: the same numbers as a live terminal dashboard, plus the team's budget and keys on
|
|
11
|
+
LiteLLM when the key may read them.
|
|
12
|
+
- `keymeter json`: one snapshot as JSON, exit code 1 when the key is not usable.
|
|
13
|
+
- Gateways: LiteLLM proxy (`/key/info`) and OpenRouter (`/api/v1/key`), picked automatically from the
|
|
14
|
+
URL or the key.
|
keymeter-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Do Pham Bao Hoang
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
keymeter-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: keymeter
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Live spend, budget and burn-rate dashboard for one LiteLLM or OpenRouter API key, in the browser or the terminal.
|
|
5
|
+
Project-URL: Homepage, https://github.com/hoanghero125/keymeter
|
|
6
|
+
Project-URL: Issues, https://github.com/hoanghero125/keymeter/issues
|
|
7
|
+
Project-URL: Changelog, https://github.com/hoanghero125/keymeter/blob/main/CHANGELOG.md
|
|
8
|
+
Author: Do Pham Bao Hoang
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: api-key,budget,dashboard,litellm,llm,monitoring,openrouter,quota,spend
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Environment :: Web Environment
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Topic :: System :: Monitoring
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Requires-Dist: rich>=13
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# keymeter
|
|
30
|
+
|
|
31
|
+
[](https://github.com/hoanghero125/keymeter/actions/workflows/ci.yml)
|
|
32
|
+
[](https://pypi.org/project/keymeter/)
|
|
33
|
+
|
|
34
|
+
A live dashboard for one LLM gateway API key: how much of its budget is spent, how fast it is going,
|
|
35
|
+
and when it will run out. It works with a [LiteLLM](https://github.com/BerriAI/litellm) proxy or
|
|
36
|
+
[OpenRouter](https://openrouter.ai), in the browser or in the terminal.
|
|
37
|
+
|
|
38
|
+

|
|
39
|
+
|
|
40
|
+
LiteLLM's admin UI needs an admin key. keymeter only needs the key you were handed, which is often all
|
|
41
|
+
you have: a team key at work, a key for a hackathon or a course. It polls the gateway's read-only key
|
|
42
|
+
endpoint (no tokens spent) and shows:
|
|
43
|
+
|
|
44
|
+
- spend vs. budget, what is left, and when the budget resets
|
|
45
|
+
- the burn rate over the last 15 minutes, when the budget runs out at that rate, and the projected
|
|
46
|
+
spend at reset
|
|
47
|
+
- charts of spend over time and spend per interval, with a table view
|
|
48
|
+
- spend per model and per-model budgets, rate limits, allowed models, expiry and blocked state (LiteLLM)
|
|
49
|
+
- an event log, and optional sound and desktop alerts when the status changes or a budget passes 80%
|
|
50
|
+
or 95%
|
|
51
|
+
|
|
52
|
+
The key stays in the keymeter process. The browser only ever sees a masked version of it.
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pipx install keymeter # or: uv tool install keymeter
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
keymeter needs Python 3.10 or newer.
|
|
61
|
+
|
|
62
|
+
## Quick start
|
|
63
|
+
|
|
64
|
+
**LiteLLM proxy**
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
export KEYMETER_URL=https://llm.example.com # your proxy
|
|
68
|
+
export KEYMETER_KEY=sk-...
|
|
69
|
+
keymeter web
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**OpenRouter**
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
export KEYMETER_KEY=sk-or-v1-...
|
|
76
|
+
keymeter web
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Then open <http://localhost:8765>. keymeter recognises OpenRouter from the `sk-or-` key or an
|
|
80
|
+
`openrouter.ai` URL, so it needs no URL there.
|
|
81
|
+
|
|
82
|
+
On Windows PowerShell, set the variables with `$env:KEYMETER_KEY = "sk-..."`. You can also put
|
|
83
|
+
them in a `.env` file instead (see [Configuration](#configuration)).
|
|
84
|
+
|
|
85
|
+
## Commands
|
|
86
|
+
|
|
87
|
+
| Command | |
|
|
88
|
+
|---|---|
|
|
89
|
+
| `keymeter web` | browser dashboard on <http://localhost:8765> |
|
|
90
|
+
| `keymeter tui` | live dashboard in the terminal; on LiteLLM it also shows the team's budget and keys when the key may read them |
|
|
91
|
+
| `keymeter tui --once` | print one snapshot and exit |
|
|
92
|
+
| `keymeter json` | print one snapshot as JSON and exit with code 1 when the key is not usable, for scripts and cron jobs |
|
|
93
|
+
|
|
94
|
+
Options (`keymeter COMMAND -h` lists them per command):
|
|
95
|
+
|
|
96
|
+
| Option | Default | |
|
|
97
|
+
|---|---|---|
|
|
98
|
+
| `--gateway` | `auto` | `litellm`, `openrouter`, or `auto` to pick from the URL and key |
|
|
99
|
+
| `--url` | depends on the gateway | gateway base URL; `http://localhost:4000` for LiteLLM, `https://openrouter.ai/api` for OpenRouter. A trailing `/v1` is fine. |
|
|
100
|
+
| `--env FILE` | `.env` | where to read settings from |
|
|
101
|
+
| `-i`, `--interval` | `15` | seconds between polls (`web`, `tui`) |
|
|
102
|
+
| `--window` | `15` | burn-rate window in minutes (`web`, `tui`) |
|
|
103
|
+
| `--warn` / `--crit` | `0.8` / `0.95` | alert thresholds, as a fraction of a budget (`web`, `tui`) |
|
|
104
|
+
| `--log FILE.csv` | | also append every poll to a CSV file (`web`, `tui`) |
|
|
105
|
+
| `--host` / `--port` | `127.0.0.1` / `8765` | where the dashboard listens (`web`) |
|
|
106
|
+
| `--no-team` | | skip LiteLLM's `/team/info` (`tui`, `json`) |
|
|
107
|
+
| `--no-bell` | | don't beep on alerts (`tui`) |
|
|
108
|
+
|
|
109
|
+
## Configuration
|
|
110
|
+
|
|
111
|
+
keymeter reads three settings, from the environment first and then from a `.env` file in the
|
|
112
|
+
current directory (or the file given with `--env`):
|
|
113
|
+
|
|
114
|
+
| Variable | |
|
|
115
|
+
|---|---|
|
|
116
|
+
| `KEYMETER_KEY` | the API key to watch (required) |
|
|
117
|
+
| `KEYMETER_URL` | gateway base URL |
|
|
118
|
+
| `KEYMETER_GATEWAY` | `auto`, `litellm` or `openrouter` |
|
|
119
|
+
|
|
120
|
+
Copy [`.env.example`](.env.example) to `.env` to start. There is no `--key` option, because a key
|
|
121
|
+
typed on the command line ends up in your shell history.
|
|
122
|
+
|
|
123
|
+
When `KEYMETER_KEY` is already set in the environment, keymeter ignores `./.env` (but still reads a
|
|
124
|
+
file you name with `--env`). That way a `.env` in whatever directory you happen to be in, such as a
|
|
125
|
+
cloned repository, can't send your key to another server.
|
|
126
|
+
|
|
127
|
+
## What each gateway reports
|
|
128
|
+
|
|
129
|
+
| | LiteLLM | OpenRouter |
|
|
130
|
+
|---|---|---|
|
|
131
|
+
| Spend, budget, reset time, burn rate, projections | yes | yes |
|
|
132
|
+
| Key expiry | yes | yes |
|
|
133
|
+
| Spend per model, per-model budgets | yes | no |
|
|
134
|
+
| Rate limits, allowed models, blocked state | yes | no |
|
|
135
|
+
| Team budget and team keys (`tui`, `json`) | yes | no |
|
|
136
|
+
|
|
137
|
+
On OpenRouter the budget is the key's credit limit. The spend shown is what counts against that limit
|
|
138
|
+
in the current period. Limits reset at 00:00 UTC: daily, weekly on Monday, or on the 1st of the month.
|
|
139
|
+
A key without a limit shows its all-time usage and no budget.
|
|
140
|
+
|
|
141
|
+
## Running it on a server
|
|
142
|
+
|
|
143
|
+
`keymeter web` listens on `127.0.0.1` only. To watch it from your laptop, forward the port over SSH:
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
# on the server
|
|
147
|
+
keymeter web
|
|
148
|
+
|
|
149
|
+
# on your laptop
|
|
150
|
+
ssh -N -L 8765:localhost:8765 you@server
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Then open <http://localhost:8765> on the laptop. Because the browser sees `localhost`, desktop
|
|
154
|
+
notifications work as well (browsers only allow them over HTTPS or on localhost).
|
|
155
|
+
|
|
156
|
+
On `127.0.0.1`, keymeter answers only requests addressed to `localhost`, so other websites can't read
|
|
157
|
+
it through DNS rebinding. A reverse proxy in front of it has to send `Host: localhost`.
|
|
158
|
+
|
|
159
|
+
To keep it running after you log out, use `tmux`, a systemd service, or
|
|
160
|
+
`nohup keymeter web > keymeter.log 2>&1 &`.
|
|
161
|
+
|
|
162
|
+
`keymeter web --host 0.0.0.0` serves the dashboard on every interface instead. There is no login,
|
|
163
|
+
so anyone who can reach the port can see the key's spend, budget and model usage (not the key
|
|
164
|
+
itself) and press "Poll now". If you do this, allow only your own IP in the firewall.
|
|
165
|
+
|
|
166
|
+
## How it works
|
|
167
|
+
|
|
168
|
+
- keymeter calls `GET /key/info` on LiteLLM (plus `/team/info` for `tui` and `json`), or
|
|
169
|
+
`GET /api/v1/key` on OpenRouter. These endpoints are read-only and cost nothing.
|
|
170
|
+
- The burn rate is the spend added over the burn-rate window, per hour. When spend goes down,
|
|
171
|
+
keymeter takes it as a budget reset and starts measuring again.
|
|
172
|
+
- When the gateway stops answering, polls back off to at most once a minute. The dashboard keeps
|
|
173
|
+
showing the last numbers it got and says how old they are.
|
|
174
|
+
- The charts' history lives in memory for 24 hours and starts over when keymeter restarts. Use
|
|
175
|
+
`--log` to keep a permanent record.
|
|
176
|
+
- The bell button in the browser turns on alerts: a short sound, plus a desktop notification when
|
|
177
|
+
the browser allows it.
|
|
178
|
+
- The web UI fades things in and out as they change, and keeps still if your system asks for reduced
|
|
179
|
+
motion.
|
|
180
|
+
|
|
181
|
+
## Development
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
git clone https://github.com/hoanghero125/keymeter
|
|
185
|
+
cd keymeter
|
|
186
|
+
uv sync
|
|
187
|
+
uv run pytest
|
|
188
|
+
uv run ruff check
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Without uv, run `python -m venv .venv`, activate it, then `pip install -e . --group dev` (pip 25.1
|
|
192
|
+
or newer).
|
|
193
|
+
|
|
194
|
+
The web UI is plain HTML, CSS and JavaScript in `src/keymeter/static`, with no build step.
|
|
195
|
+
|
|
196
|
+
To support another gateway, add an adapter in `src/keymeter/gateways/`. It fetches the key's data
|
|
197
|
+
and translates it into the field names the rest of keymeter uses (LiteLLM's); see `openrouter.py`
|
|
198
|
+
for a small example.
|
|
199
|
+
|
|
200
|
+
### Releasing
|
|
201
|
+
|
|
202
|
+
1. Set `__version__` in `src/keymeter/__init__.py` and add the release to `CHANGELOG.md`.
|
|
203
|
+
2. Commit, then tag and push: `git tag v0.1.0 && git push origin v0.1.0`.
|
|
204
|
+
|
|
205
|
+
The `Release` workflow tests and builds the package, then publishes it to PyPI. Before the first
|
|
206
|
+
release, set up trusted publishing once:
|
|
207
|
+
|
|
208
|
+
- On PyPI, under *Your account → Publishing*, add a pending publisher with project `keymeter`,
|
|
209
|
+
owner `hoanghero125`, repository `keymeter`, workflow `release.yml` and environment `pypi`.
|
|
210
|
+
- On GitHub, under *Settings → Environments*, create an environment named `pypi`.
|
|
211
|
+
|
|
212
|
+
## License
|
|
213
|
+
|
|
214
|
+
[MIT](LICENSE). keymeter is not affiliated with LiteLLM (BerriAI) or OpenRouter.
|
keymeter-0.1.0/README.md
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# keymeter
|
|
2
|
+
|
|
3
|
+
[](https://github.com/hoanghero125/keymeter/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/keymeter/)
|
|
5
|
+
|
|
6
|
+
A live dashboard for one LLM gateway API key: how much of its budget is spent, how fast it is going,
|
|
7
|
+
and when it will run out. It works with a [LiteLLM](https://github.com/BerriAI/litellm) proxy or
|
|
8
|
+
[OpenRouter](https://openrouter.ai), in the browser or in the terminal.
|
|
9
|
+
|
|
10
|
+

|
|
11
|
+
|
|
12
|
+
LiteLLM's admin UI needs an admin key. keymeter only needs the key you were handed, which is often all
|
|
13
|
+
you have: a team key at work, a key for a hackathon or a course. It polls the gateway's read-only key
|
|
14
|
+
endpoint (no tokens spent) and shows:
|
|
15
|
+
|
|
16
|
+
- spend vs. budget, what is left, and when the budget resets
|
|
17
|
+
- the burn rate over the last 15 minutes, when the budget runs out at that rate, and the projected
|
|
18
|
+
spend at reset
|
|
19
|
+
- charts of spend over time and spend per interval, with a table view
|
|
20
|
+
- spend per model and per-model budgets, rate limits, allowed models, expiry and blocked state (LiteLLM)
|
|
21
|
+
- an event log, and optional sound and desktop alerts when the status changes or a budget passes 80%
|
|
22
|
+
or 95%
|
|
23
|
+
|
|
24
|
+
The key stays in the keymeter process. The browser only ever sees a masked version of it.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pipx install keymeter # or: uv tool install keymeter
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
keymeter needs Python 3.10 or newer.
|
|
33
|
+
|
|
34
|
+
## Quick start
|
|
35
|
+
|
|
36
|
+
**LiteLLM proxy**
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
export KEYMETER_URL=https://llm.example.com # your proxy
|
|
40
|
+
export KEYMETER_KEY=sk-...
|
|
41
|
+
keymeter web
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**OpenRouter**
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
export KEYMETER_KEY=sk-or-v1-...
|
|
48
|
+
keymeter web
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Then open <http://localhost:8765>. keymeter recognises OpenRouter from the `sk-or-` key or an
|
|
52
|
+
`openrouter.ai` URL, so it needs no URL there.
|
|
53
|
+
|
|
54
|
+
On Windows PowerShell, set the variables with `$env:KEYMETER_KEY = "sk-..."`. You can also put
|
|
55
|
+
them in a `.env` file instead (see [Configuration](#configuration)).
|
|
56
|
+
|
|
57
|
+
## Commands
|
|
58
|
+
|
|
59
|
+
| Command | |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `keymeter web` | browser dashboard on <http://localhost:8765> |
|
|
62
|
+
| `keymeter tui` | live dashboard in the terminal; on LiteLLM it also shows the team's budget and keys when the key may read them |
|
|
63
|
+
| `keymeter tui --once` | print one snapshot and exit |
|
|
64
|
+
| `keymeter json` | print one snapshot as JSON and exit with code 1 when the key is not usable, for scripts and cron jobs |
|
|
65
|
+
|
|
66
|
+
Options (`keymeter COMMAND -h` lists them per command):
|
|
67
|
+
|
|
68
|
+
| Option | Default | |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| `--gateway` | `auto` | `litellm`, `openrouter`, or `auto` to pick from the URL and key |
|
|
71
|
+
| `--url` | depends on the gateway | gateway base URL; `http://localhost:4000` for LiteLLM, `https://openrouter.ai/api` for OpenRouter. A trailing `/v1` is fine. |
|
|
72
|
+
| `--env FILE` | `.env` | where to read settings from |
|
|
73
|
+
| `-i`, `--interval` | `15` | seconds between polls (`web`, `tui`) |
|
|
74
|
+
| `--window` | `15` | burn-rate window in minutes (`web`, `tui`) |
|
|
75
|
+
| `--warn` / `--crit` | `0.8` / `0.95` | alert thresholds, as a fraction of a budget (`web`, `tui`) |
|
|
76
|
+
| `--log FILE.csv` | | also append every poll to a CSV file (`web`, `tui`) |
|
|
77
|
+
| `--host` / `--port` | `127.0.0.1` / `8765` | where the dashboard listens (`web`) |
|
|
78
|
+
| `--no-team` | | skip LiteLLM's `/team/info` (`tui`, `json`) |
|
|
79
|
+
| `--no-bell` | | don't beep on alerts (`tui`) |
|
|
80
|
+
|
|
81
|
+
## Configuration
|
|
82
|
+
|
|
83
|
+
keymeter reads three settings, from the environment first and then from a `.env` file in the
|
|
84
|
+
current directory (or the file given with `--env`):
|
|
85
|
+
|
|
86
|
+
| Variable | |
|
|
87
|
+
|---|---|
|
|
88
|
+
| `KEYMETER_KEY` | the API key to watch (required) |
|
|
89
|
+
| `KEYMETER_URL` | gateway base URL |
|
|
90
|
+
| `KEYMETER_GATEWAY` | `auto`, `litellm` or `openrouter` |
|
|
91
|
+
|
|
92
|
+
Copy [`.env.example`](.env.example) to `.env` to start. There is no `--key` option, because a key
|
|
93
|
+
typed on the command line ends up in your shell history.
|
|
94
|
+
|
|
95
|
+
When `KEYMETER_KEY` is already set in the environment, keymeter ignores `./.env` (but still reads a
|
|
96
|
+
file you name with `--env`). That way a `.env` in whatever directory you happen to be in, such as a
|
|
97
|
+
cloned repository, can't send your key to another server.
|
|
98
|
+
|
|
99
|
+
## What each gateway reports
|
|
100
|
+
|
|
101
|
+
| | LiteLLM | OpenRouter |
|
|
102
|
+
|---|---|---|
|
|
103
|
+
| Spend, budget, reset time, burn rate, projections | yes | yes |
|
|
104
|
+
| Key expiry | yes | yes |
|
|
105
|
+
| Spend per model, per-model budgets | yes | no |
|
|
106
|
+
| Rate limits, allowed models, blocked state | yes | no |
|
|
107
|
+
| Team budget and team keys (`tui`, `json`) | yes | no |
|
|
108
|
+
|
|
109
|
+
On OpenRouter the budget is the key's credit limit. The spend shown is what counts against that limit
|
|
110
|
+
in the current period. Limits reset at 00:00 UTC: daily, weekly on Monday, or on the 1st of the month.
|
|
111
|
+
A key without a limit shows its all-time usage and no budget.
|
|
112
|
+
|
|
113
|
+
## Running it on a server
|
|
114
|
+
|
|
115
|
+
`keymeter web` listens on `127.0.0.1` only. To watch it from your laptop, forward the port over SSH:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
# on the server
|
|
119
|
+
keymeter web
|
|
120
|
+
|
|
121
|
+
# on your laptop
|
|
122
|
+
ssh -N -L 8765:localhost:8765 you@server
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Then open <http://localhost:8765> on the laptop. Because the browser sees `localhost`, desktop
|
|
126
|
+
notifications work as well (browsers only allow them over HTTPS or on localhost).
|
|
127
|
+
|
|
128
|
+
On `127.0.0.1`, keymeter answers only requests addressed to `localhost`, so other websites can't read
|
|
129
|
+
it through DNS rebinding. A reverse proxy in front of it has to send `Host: localhost`.
|
|
130
|
+
|
|
131
|
+
To keep it running after you log out, use `tmux`, a systemd service, or
|
|
132
|
+
`nohup keymeter web > keymeter.log 2>&1 &`.
|
|
133
|
+
|
|
134
|
+
`keymeter web --host 0.0.0.0` serves the dashboard on every interface instead. There is no login,
|
|
135
|
+
so anyone who can reach the port can see the key's spend, budget and model usage (not the key
|
|
136
|
+
itself) and press "Poll now". If you do this, allow only your own IP in the firewall.
|
|
137
|
+
|
|
138
|
+
## How it works
|
|
139
|
+
|
|
140
|
+
- keymeter calls `GET /key/info` on LiteLLM (plus `/team/info` for `tui` and `json`), or
|
|
141
|
+
`GET /api/v1/key` on OpenRouter. These endpoints are read-only and cost nothing.
|
|
142
|
+
- The burn rate is the spend added over the burn-rate window, per hour. When spend goes down,
|
|
143
|
+
keymeter takes it as a budget reset and starts measuring again.
|
|
144
|
+
- When the gateway stops answering, polls back off to at most once a minute. The dashboard keeps
|
|
145
|
+
showing the last numbers it got and says how old they are.
|
|
146
|
+
- The charts' history lives in memory for 24 hours and starts over when keymeter restarts. Use
|
|
147
|
+
`--log` to keep a permanent record.
|
|
148
|
+
- The bell button in the browser turns on alerts: a short sound, plus a desktop notification when
|
|
149
|
+
the browser allows it.
|
|
150
|
+
- The web UI fades things in and out as they change, and keeps still if your system asks for reduced
|
|
151
|
+
motion.
|
|
152
|
+
|
|
153
|
+
## Development
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
git clone https://github.com/hoanghero125/keymeter
|
|
157
|
+
cd keymeter
|
|
158
|
+
uv sync
|
|
159
|
+
uv run pytest
|
|
160
|
+
uv run ruff check
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Without uv, run `python -m venv .venv`, activate it, then `pip install -e . --group dev` (pip 25.1
|
|
164
|
+
or newer).
|
|
165
|
+
|
|
166
|
+
The web UI is plain HTML, CSS and JavaScript in `src/keymeter/static`, with no build step.
|
|
167
|
+
|
|
168
|
+
To support another gateway, add an adapter in `src/keymeter/gateways/`. It fetches the key's data
|
|
169
|
+
and translates it into the field names the rest of keymeter uses (LiteLLM's); see `openrouter.py`
|
|
170
|
+
for a small example.
|
|
171
|
+
|
|
172
|
+
### Releasing
|
|
173
|
+
|
|
174
|
+
1. Set `__version__` in `src/keymeter/__init__.py` and add the release to `CHANGELOG.md`.
|
|
175
|
+
2. Commit, then tag and push: `git tag v0.1.0 && git push origin v0.1.0`.
|
|
176
|
+
|
|
177
|
+
The `Release` workflow tests and builds the package, then publishes it to PyPI. Before the first
|
|
178
|
+
release, set up trusted publishing once:
|
|
179
|
+
|
|
180
|
+
- On PyPI, under *Your account → Publishing*, add a pending publisher with project `keymeter`,
|
|
181
|
+
owner `hoanghero125`, repository `keymeter`, workflow `release.yml` and environment `pypi`.
|
|
182
|
+
- On GitHub, under *Settings → Environments*, create an environment named `pypi`.
|
|
183
|
+
|
|
184
|
+
## License
|
|
185
|
+
|
|
186
|
+
[MIT](LICENSE). keymeter is not affiliated with LiteLLM (BerriAI) or OpenRouter.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "keymeter"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Live spend, budget and burn-rate dashboard for one LiteLLM or OpenRouter API key, in the browser or the terminal."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "Do Pham Bao Hoang" }]
|
|
13
|
+
requires-python = ">=3.10"
|
|
14
|
+
dependencies = ["rich>=13"]
|
|
15
|
+
keywords = ["litellm", "openrouter", "llm", "api-key", "budget", "quota", "spend", "dashboard", "monitoring"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
"Environment :: Web Environment",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
24
|
+
"Programming Language :: Python :: 3.10",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Programming Language :: Python :: 3.13",
|
|
28
|
+
"Programming Language :: Python :: 3.14",
|
|
29
|
+
"Topic :: System :: Monitoring",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/hoanghero125/keymeter"
|
|
34
|
+
Issues = "https://github.com/hoanghero125/keymeter/issues"
|
|
35
|
+
Changelog = "https://github.com/hoanghero125/keymeter/blob/main/CHANGELOG.md"
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
keymeter = "keymeter.cli:main"
|
|
39
|
+
|
|
40
|
+
[dependency-groups]
|
|
41
|
+
dev = ["pytest>=8", "ruff>=0.6"]
|
|
42
|
+
|
|
43
|
+
[tool.hatch.version]
|
|
44
|
+
path = "src/keymeter/__init__.py"
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.sdist]
|
|
47
|
+
include = ["src", "tests", "CHANGELOG.md"]
|
|
48
|
+
|
|
49
|
+
[tool.pytest.ini_options]
|
|
50
|
+
testpaths = ["tests"]
|
|
51
|
+
|
|
52
|
+
[tool.ruff]
|
|
53
|
+
line-length = 140
|
|
54
|
+
target-version = "py310"
|
|
55
|
+
|
|
56
|
+
[tool.ruff.lint]
|
|
57
|
+
extend-select = ["I", "B", "UP"]
|
|
@@ -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)
|