governer 1.0.2__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.
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: governer
|
|
3
|
+
Version: 1.0.2
|
|
4
|
+
Summary: Hard spend breaker for AI agents — prices cache reads, bills real usage, and refuses the request that would cross your budget.
|
|
5
|
+
Author-email: Kaiser0733 <amardeepkumar0733@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Kaiser0733/governer
|
|
8
|
+
Project-URL: Issues, https://github.com/Kaiser0733/governer/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/Kaiser0733/governer/blob/main/CHANGELOG.md
|
|
10
|
+
Keywords: ai,agents,llm,cost,finops,budget,tokens,openai,anthropic
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
18
|
+
Classifier: Topic :: System :: Monitoring
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# governer
|
|
25
|
+
|
|
26
|
+
[](https://github.com/Kaiser0733/governer/actions/workflows/test.yml)
|
|
27
|
+
|
|
28
|
+
**Your agent spent $37,901 while you slept. The budget alert told you afterwards.**
|
|
29
|
+
|
|
30
|
+
`governer` prices every request the way the provider actually bills it — cache
|
|
31
|
+
reads and writes included — then **refuses the request that would cross your
|
|
32
|
+
line**. Not a warning. Not a dashboard. A kill switch.
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
$ governer box
|
|
36
|
+
wallet $10.00 · gpt-6-astra · 200,000 tok/turn · CACHE MISSING
|
|
37
|
+
|
|
38
|
+
turn 1 $ 2.02 / $10.00
|
|
39
|
+
turn 2 $ 4.05 / $10.00
|
|
40
|
+
turn 3 $ 6.07 / $10.00
|
|
41
|
+
turn 4 $ 8.10 / $10.00
|
|
42
|
+
turn 5 BLOCKED (would have cost $2.0250)
|
|
43
|
+
|
|
44
|
+
🛑 BREAKER TRIPPED at $8.10 of $10.00 — agent stopped
|
|
45
|
+
|
|
46
|
+
paid $ 8.10 (5 turns)
|
|
47
|
+
same run, cached $ 0.90
|
|
48
|
+
you paid 9x the cached price — this is the $38k bug
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
No API key, no network, ninety seconds.
|
|
52
|
+
|
|
53
|
+
## Install
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pipx install governer # or: pip install governer
|
|
57
|
+
governer box
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
No dependencies either way. Prefer a file? Two curls and you're done:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
curl -O https://raw.githubusercontent.com/Kaiser0733/governer/main/governer.py
|
|
64
|
+
curl -O https://raw.githubusercontent.com/Kaiser0733/governer/main/prices.json
|
|
65
|
+
python3 governer.py box
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Python 3.9+.
|
|
69
|
+
|
|
70
|
+
## Commands
|
|
71
|
+
|
|
72
|
+
| command | what it does |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `governer proxy --limit 5` | OpenAI-compatible shim: point your agent at it, it refuses anything past $5 |
|
|
75
|
+
| `governer box` | simulated agent in a virtual wallet — watch the breaker trip, no key needed |
|
|
76
|
+
| `governer check usage.json --limit 20` | true cost of a real log, plus where a $20 cap would have stopped it |
|
|
77
|
+
| `governer prices` | what every model costs right now, with its verified date |
|
|
78
|
+
| `governer prices --refresh` | pull live prices from the gateway — 459 models, cache pricing included |
|
|
79
|
+
| `governer prices --live` | every known model, cheapest first |
|
|
80
|
+
| `governer prices --upcoming` | announced price changes — Gemini 3.8 Flash doubles 2027-01-01 |
|
|
81
|
+
| `governer free` | which models are free *right now*, fetched live |
|
|
82
|
+
| `governer status spend.jsonl --limit 20` | the meter: spent, burn rate, projection, per-agent split |
|
|
83
|
+
| `governer verify ledger.jsonl` | checks a spend ledger for tampering |
|
|
84
|
+
|
|
85
|
+
## Put it in front of your agent
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
$ governer proxy --limit 5 --ledger spend.jsonl
|
|
89
|
+
|
|
90
|
+
governer http://127.0.0.1:8787 → https://openrouter.ai/api/v1
|
|
91
|
+
wallet $5.00 · unknown models billed $0.0/$0.0 per M
|
|
92
|
+
|
|
93
|
+
export OPENAI_BASE_URL=http://127.0.0.1:8787/v1
|
|
94
|
+
export OPENAI_API_KEY=<your key> # governer never stores it
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Your agent keeps working unchanged. Every request is priced at its worst case
|
|
98
|
+
*before it leaves the machine*, forwarded with your own key, then billed at its
|
|
99
|
+
real usage. Cross the limit and the request comes back `402` with
|
|
100
|
+
`type: governer_breaker_tripped` — and your provider never sees it.
|
|
101
|
+
|
|
102
|
+
Streaming passes straight through — governer adds `stream_options.include_usage`
|
|
103
|
+
so the final usage block still arrives and the stream can be billed. Unpriced models
|
|
104
|
+
fall back to `--default-in/--default-out`, which is also how you *shadow-price* a
|
|
105
|
+
free model to see what it would have cost:
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
✓ 200 nvidia/nemotron-3.5-lightning:free $0.001230 $0.0012/$5.00
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
When the provider reports its own `cost` field, governer compares it against
|
|
112
|
+
its own number and warns when `prices.json` has gone stale.
|
|
113
|
+
|
|
114
|
+
### Budgets per agent, per day
|
|
115
|
+
|
|
116
|
+
One proxy, several caps. Copy `governer.example.json` and pass it:
|
|
117
|
+
|
|
118
|
+
```json
|
|
119
|
+
{
|
|
120
|
+
"limit": 20.0,
|
|
121
|
+
"ledger": "spend.jsonl",
|
|
122
|
+
"notify": "https://ntfy.sh/your-topic",
|
|
123
|
+
"agents": {
|
|
124
|
+
"claude-code": { "limit": 5.0, "per": "day" },
|
|
125
|
+
"ci": { "limit": 2.0, "per": "run" }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
governer proxy --config governer.example.json
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The agent names itself with a header — `X-Governer-Agent: claude-code` — and the
|
|
135
|
+
cap applies to it alone. `day` resets at UTC midnight, `run` lasts the process.
|
|
136
|
+
Both are rebuilt from the ledger on restart, so bouncing the proxy cannot reset a
|
|
137
|
+
budget. `notify` receives a POST when the breaker trips.
|
|
138
|
+
|
|
139
|
+
### Watch for a week before you enforce
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
governer proxy --limit 20 --dry-run --ledger spend.jsonl
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Dry run books the true spend and refuses nothing, so you learn what a normal week
|
|
146
|
+
costs before a cap starts costing you work. Anything it would have refused is
|
|
147
|
+
marked `◌` in the console.
|
|
148
|
+
|
|
149
|
+
## Audit a log you already have
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
$ governer check usage.json --limit 20
|
|
153
|
+
requests 100
|
|
154
|
+
true cost $52.00
|
|
155
|
+
cache miss 100 request(s) billed uncached while sending ≥10k tokens
|
|
156
|
+
$43.20 of that was avoidable — ~83% of the bill
|
|
157
|
+
at $20.00 cap: stopped at request 39, $32.24 never spent (62% saved)
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Reads Anthropic (`input_tokens`), OpenAI (`prompt_tokens`) and OpenRouter
|
|
161
|
+
(`cached_tokens`) field names, as a JSON array or JSONL.
|
|
162
|
+
|
|
163
|
+
## It prices models nobody told it about
|
|
164
|
+
|
|
165
|
+
```
|
|
166
|
+
$ governer prices --refresh
|
|
167
|
+
459 models refreshed from https://openrouter.ai/api/v1
|
|
168
|
+
free right now: 20
|
|
169
|
+
vendor-confirmed cache pricing: 313
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Curated entries are hand-verified, each with a date and a source. Everything
|
|
173
|
+
else is pulled live, so an agent that switches to a model released last week
|
|
174
|
+
still gets priced — instead of silently costing nothing in your ledger because
|
|
175
|
+
nobody typed it into a table.
|
|
176
|
+
|
|
177
|
+
## Gate a pull request on cost
|
|
178
|
+
|
|
179
|
+
```yaml
|
|
180
|
+
- uses: Kaiser0733/governer@main
|
|
181
|
+
with:
|
|
182
|
+
usage-file: ci/agent-usage.json
|
|
183
|
+
budget: "2.50"
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
`governer check --fail-over 2.50` exits `1` when the run costs more. Tests
|
|
187
|
+
should not be the only thing a PR can fail.
|
|
188
|
+
|
|
189
|
+
## The meter, not just the wall
|
|
190
|
+
|
|
191
|
+
A hard cap alone does not fix behaviour — it just moves the spending to the last week
|
|
192
|
+
of the month. The number has to be in front of you while you decide whether to kick off
|
|
193
|
+
another run.
|
|
194
|
+
|
|
195
|
+
```
|
|
196
|
+
$ governer status spend.jsonl --limit 20
|
|
197
|
+
spent $2.40 of $20.00 (12% used)
|
|
198
|
+
burn rate $0.40/h over 6.0h
|
|
199
|
+
projected $9.60/day at this rate
|
|
200
|
+
runs out in 44.0h at this rate
|
|
201
|
+
agent claude-code $2.10
|
|
202
|
+
agent ci $0.30
|
|
203
|
+
chain OK — 3 records · $2.4000 · chain intact
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
It refuses to project from a window shorter than 30 minutes, because extrapolating three
|
|
207
|
+
records in the same minute produces confident nonsense.
|
|
208
|
+
|
|
209
|
+
## Why this instead of the other options
|
|
210
|
+
|
|
211
|
+
| option | what it covers | what it misses |
|
|
212
|
+
|---|---|---|
|
|
213
|
+
| provider credit / key limits | a hard stop per account | not per agent, not cross-vendor, and it is exactly the layer that failed the $38k case |
|
|
214
|
+
| an in-process library (`budget-guard`, npm) | a cap inside one Node app | one language, needs a code change, no help for a CLI agent you did not write |
|
|
215
|
+
| an agent runtime with budgets (`cx`, Claude Extender) | caps for agents inside that runtime | requires adopting the whole runtime |
|
|
216
|
+
| gateway budgets (litellm, OmniRoute) | budget fields in the gateway | enforcement is soft, and litellm's was silently bypassed (#26672) |
|
|
217
|
+
| **governer** | any client in any language, no code change, cross-vendor, cache-aware pricing, tamper-evident ledger, CI gate, audit of logs you already have | one process per ledger — do not point two proxies at the same file |
|
|
218
|
+
|
|
219
|
+
## Why the bill explodes at night
|
|
220
|
+
|
|
221
|
+
- **1M-token contexts are table stakes**, so the cached prefix is replayed on
|
|
222
|
+
every call. Cache reads dominate agent bills — and they are billed even when
|
|
223
|
+
that content is truncated before the model attends to it.
|
|
224
|
+
- **"Prompt caching is supported" is not "your stack is caching."** The
|
|
225
|
+
expensive failure is silent: status 200, normal-looking output, invoice 100×.
|
|
226
|
+
- **Alerts are not limits.** Email after the money is gone is not a safety
|
|
227
|
+
mechanism, and neither are credits or a dashboard you check on Mondays.
|
|
228
|
+
|
|
229
|
+
## Receipts
|
|
230
|
+
|
|
231
|
+
The spend ledger is append-only and hash-chained: every record carries the hash
|
|
232
|
+
of the one before it, so editing a number breaks the chain and `verify` says so.
|
|
233
|
+
Tamper-evident bookkeeping, because a budget you can quietly rewrite is not a
|
|
234
|
+
budget.
|
|
235
|
+
|
|
236
|
+
## Prices are data, not truth
|
|
237
|
+
|
|
238
|
+
Every model in `prices.json` carries a `verified` date and a source. Cache
|
|
239
|
+
prices marked `~` are derived from the vendor convention (0.1× read, 1.25×
|
|
240
|
+
write) rather than confirmed. Re-verify before betting a number that matters,
|
|
241
|
+
and open a PR when a vendor moves.
|
|
242
|
+
|
|
243
|
+
## Roadmap
|
|
244
|
+
|
|
245
|
+
- ~~v0.2~~ proxy, live `free` check, CI gate — shipped
|
|
246
|
+
- ~~v0.5~~ price table refreshed from the gateway instead of hand-maintained — shipped
|
|
247
|
+
- **v0.3** per-agent and per-workflow budgets on one proxy
|
|
248
|
+
- **v0.4** phone push the second a breaker trips
|
|
249
|
+
|
|
250
|
+
## License
|
|
251
|
+
|
|
252
|
+
**Docs and demo:** https://kaiser0733.github.io/governer/
|
|
253
|
+
|
|
254
|
+
MIT. Built by [Kaiser0733](https://github.com/Kaiser0733) at Vastraa Labs.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
governer.py,sha256=sE4ZwQ5l-iI_-d_XQ7bq3uv8wfU7dCqFH7IvzKfXeC8,42332
|
|
2
|
+
governer-1.0.2.dist-info/licenses/LICENSE,sha256=2wuy3Fcc1Kn_Ge9CoKYJEEoNQziyevjcGf7m9RpJqOE,1082
|
|
3
|
+
governer-1.0.2.dist-info/METADATA,sha256=Rf3U_j3Q4JVzK28e1H4sz7KL9IHFsUTg21EDOhkMiPQ,9784
|
|
4
|
+
governer-1.0.2.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
5
|
+
governer-1.0.2.dist-info/entry_points.txt,sha256=tpeBQzvwjylcfZal4KmeVjgKgzLLmYcXuPzNnr6tTL4,43
|
|
6
|
+
governer-1.0.2.dist-info/top_level.txt,sha256=bVhCGwx3kpj2_XyJSyngRnNrB8IMTqyUilikR1P_Y50,9
|
|
7
|
+
governer-1.0.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kaiser0733 / Vastraa Labs
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
governer
|
governer.py
ADDED
|
@@ -0,0 +1,983 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""governer — hard spend governor for AI agents.
|
|
3
|
+
|
|
4
|
+
Budget alerts are not kill switches. governer prices every request the way
|
|
5
|
+
the provider actually bills it (cache reads and writes included), then stops
|
|
6
|
+
the agent at the line you set.
|
|
7
|
+
|
|
8
|
+
Zero dependencies. One file. Python 3.9+.
|
|
9
|
+
|
|
10
|
+
governer prices [--upcoming] [--json]
|
|
11
|
+
governer free
|
|
12
|
+
governer box [--limit 10] [--model gpt-6-astra] [--context 200000]
|
|
13
|
+
governer proxy [--limit 5] [--port 8787] [--upstream URL]
|
|
14
|
+
governer check usage.json [--limit 200] [--fail-over 50]
|
|
15
|
+
governer verify ledger.jsonl
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import hashlib
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
import threading
|
|
25
|
+
import urllib.error
|
|
26
|
+
import urllib.request
|
|
27
|
+
from datetime import datetime, timezone
|
|
28
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
29
|
+
|
|
30
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
31
|
+
PRICES_PATH = os.environ.get("GOVERNER_PRICES", os.path.join(HERE, "prices.json"))
|
|
32
|
+
LIVE_PATH = os.environ.get("GOVERNER_LIVE", os.path.join(HERE, "prices.live.json"))
|
|
33
|
+
|
|
34
|
+
__version__ = "1.0.2"
|
|
35
|
+
|
|
36
|
+
# Provider logs spell the same field three ways. Accept all of them.
|
|
37
|
+
ALIASES = {
|
|
38
|
+
"input": ("input", "input_tokens", "prompt_tokens"),
|
|
39
|
+
"output": ("output", "output_tokens", "completion_tokens"),
|
|
40
|
+
"cache_read": ("cache_read", "cache_read_input_tokens", "cached_tokens"),
|
|
41
|
+
"cache_write": ("cache_write", "cache_creation_input_tokens"),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Installed copies (pipx, site-packages) have no prices.json next to the module,
|
|
46
|
+
# so the tool still works out of the box. Keep in sync with prices.json —
|
|
47
|
+
# test_embedded_prices_match_the_file fails the moment these drift.
|
|
48
|
+
_FALLBACK = {
|
|
49
|
+
"claude-fable-5-1": {"in": 10.0, "out": 50.0},
|
|
50
|
+
"claude-opus-5-5": {"in": 4.0, "out": 20.0},
|
|
51
|
+
"claude-sonnet-5-5": {"in": 2.0, "out": 10.0},
|
|
52
|
+
"claude-haiku-5-5": {"in": 0.10, "out": 0.50},
|
|
53
|
+
"gpt-6-astra": {"in": 10.0, "out": 50.0},
|
|
54
|
+
"gpt-6-1-sol": {"in": 2.0, "out": 10.0},
|
|
55
|
+
"gpt-6-luna": {"in": 0.10, "out": 0.50},
|
|
56
|
+
"gemini-3-8-flash": {"in": 0.75, "out": 3.75},
|
|
57
|
+
"gemini-4-argon": {"in": 2.0, "out": 10.0},
|
|
58
|
+
"grok-4-7": {"in": 2.0, "out": 6.0, "tiered_above": 200000},
|
|
59
|
+
"deepseek-v4-pro": {"in": 1.32, "out": 3.96, "peak_pricing": True},
|
|
60
|
+
"qwen3-5-plus": {"in": 0.40, "out": 2.40, "tiered_above": 256000},
|
|
61
|
+
"llama-5-hosted": {"in": 0.35, "out": 0.35},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_curated(path=PRICES_PATH):
|
|
66
|
+
try:
|
|
67
|
+
with open(path) as f:
|
|
68
|
+
return json.load(f)["models"]
|
|
69
|
+
except OSError:
|
|
70
|
+
return dict(_FALLBACK)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def load_prices(path=PRICES_PATH):
|
|
74
|
+
"""Curated table first (hand-verified), then anything a live refresh found."""
|
|
75
|
+
models = dict(load_curated(path))
|
|
76
|
+
if os.path.exists(LIVE_PATH):
|
|
77
|
+
with open(LIVE_PATH) as f:
|
|
78
|
+
for name, row in json.load(f)["models"].items():
|
|
79
|
+
models.setdefault(name, row)
|
|
80
|
+
return models
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def fetch_live(api):
|
|
84
|
+
"""Everything the gateway knows, with per-token prices turned into per-million."""
|
|
85
|
+
with urllib.request.urlopen(api.rstrip("/") + "/models", timeout=60) as r:
|
|
86
|
+
data = json.load(r)["data"]
|
|
87
|
+
models = {}
|
|
88
|
+
for m in data:
|
|
89
|
+
pr = m.get("pricing") or {}
|
|
90
|
+
|
|
91
|
+
def per_m(key):
|
|
92
|
+
try:
|
|
93
|
+
return round(float(pr[key]) * 1e6, 6)
|
|
94
|
+
except (KeyError, TypeError, ValueError):
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
row = {"in": per_m("prompt"), "out": per_m("completion"),
|
|
98
|
+
"cache_read": per_m("input_cache_read"),
|
|
99
|
+
"cache_write": per_m("input_cache_write"),
|
|
100
|
+
"ctx_in": m.get("context_length"), "name": m.get("name"),
|
|
101
|
+
"expires": m.get("expiration_date"),
|
|
102
|
+
"verified": now()[:10], "source": "openrouter.ai/api/v1/models"}
|
|
103
|
+
# routers report -1 when they cannot quote a price; skip them, they are not models
|
|
104
|
+
if row["in"] is not None and row["out"] is not None \
|
|
105
|
+
and row["in"] >= 0 and row["out"] >= 0:
|
|
106
|
+
models[m["id"]] = row
|
|
107
|
+
return models
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def now():
|
|
111
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------- money math
|
|
115
|
+
|
|
116
|
+
def price_of(model, prices):
|
|
117
|
+
try:
|
|
118
|
+
return prices[model]
|
|
119
|
+
except KeyError:
|
|
120
|
+
names = sorted(prices)
|
|
121
|
+
sample = ", ".join(names[:12]) + (" …" if len(names) > 12 else "")
|
|
122
|
+
raise SystemExit(f"unknown model: {model}\nknown: {sample}\n"
|
|
123
|
+
f"`governer prices --refresh` adds the rest")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def unit_cost(model, prices):
|
|
127
|
+
"""USD per token: (input, output, cache_read, cache_write) + derived flag."""
|
|
128
|
+
p = price_of(model, prices)
|
|
129
|
+
derived = False
|
|
130
|
+
cr, cw = p.get("cache_read"), p.get("cache_write")
|
|
131
|
+
if cr is None: # ponytail: vendor convention (10%) when unverified, flagged in output
|
|
132
|
+
cr, derived = p["in"] * 0.1, True
|
|
133
|
+
if cw is None:
|
|
134
|
+
cw, derived = p["in"] * 1.25, True
|
|
135
|
+
return (p["in"] / 1e6, p["out"] / 1e6, cr / 1e6, cw / 1e6), derived
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def normalize(record):
|
|
139
|
+
"""Map provider field spellings onto our counters.
|
|
140
|
+
|
|
141
|
+
Returns (usage, inclusive). `inclusive=True` means the provider already
|
|
142
|
+
counted cached tokens inside `input` — OpenAI and OpenRouter report
|
|
143
|
+
prompt_tokens that way, so charging input AND cache_read for the same
|
|
144
|
+
tokens bills them twice. Anthropic reports cache reads on top of
|
|
145
|
+
input_tokens, which is exclusive.
|
|
146
|
+
"""
|
|
147
|
+
out, inclusive = {}, False
|
|
148
|
+
for key, names in ALIASES.items():
|
|
149
|
+
for n in names:
|
|
150
|
+
if n in record:
|
|
151
|
+
out[key] = record[n]
|
|
152
|
+
if key == "input" and n == "prompt_tokens":
|
|
153
|
+
inclusive = True
|
|
154
|
+
break
|
|
155
|
+
details = record.get("prompt_tokens_details")
|
|
156
|
+
if "cache_read" not in out and isinstance(details, dict):
|
|
157
|
+
for n in ("cached_tokens", "cache_read_tokens"):
|
|
158
|
+
if isinstance(details.get(n), (int, float)):
|
|
159
|
+
out["cache_read"] = details[n]
|
|
160
|
+
break
|
|
161
|
+
return out, inclusive
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def cost(model, usage, prices, inclusive=False, fallback=None):
|
|
165
|
+
"""USD for one call, using the model's real cache prices when we have them."""
|
|
166
|
+
if model in prices:
|
|
167
|
+
(i, o, cr, cw), _ = unit_cost(model, prices)
|
|
168
|
+
elif fallback:
|
|
169
|
+
i, o = fallback
|
|
170
|
+
cr, cw = i * 0.1, i * 1.25 # ponytail: convention only when there is no price row
|
|
171
|
+
else:
|
|
172
|
+
price_of(model, prices) # raises with the unknown-model message
|
|
173
|
+
return 0.0
|
|
174
|
+
inp = usage.get("input", 0)
|
|
175
|
+
cached = usage.get("cache_read", 0)
|
|
176
|
+
if inclusive:
|
|
177
|
+
inp = max(0, inp - cached) # those tokens were already inside input
|
|
178
|
+
return (inp * i + usage.get("output", 0) * o
|
|
179
|
+
+ cached * cr + usage.get("cache_write", 0) * cw)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def sanity(model, usage, prices, inclusive=False):
|
|
183
|
+
"""Warnings for what the number might be missing."""
|
|
184
|
+
out, p = [], price_of(model, prices)
|
|
185
|
+
above = p.get("tiered_above")
|
|
186
|
+
context = usage.get("input", 0) + (0 if inclusive else usage.get("cache_read", 0))
|
|
187
|
+
if above and context > above:
|
|
188
|
+
out.append(f"above {above:,}-token tier boundary — cost shown is a lower bound")
|
|
189
|
+
if p.get("peak_pricing"):
|
|
190
|
+
out.append("vendor bills peak/off-peak — cost shown assumes peak")
|
|
191
|
+
return out
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ---------------------------------------------------------------- the breaker
|
|
195
|
+
|
|
196
|
+
class Tripped(Exception):
|
|
197
|
+
def __init__(self, spent, limit, would_be, reason=""):
|
|
198
|
+
self.spent, self.limit, self.would_be = spent, limit, would_be
|
|
199
|
+
self.reason = reason
|
|
200
|
+
super().__init__(
|
|
201
|
+
f"tripped at ${spent:.4f} of ${limit:.2f}; "
|
|
202
|
+
f"request blocked (would have cost ${would_be - spent:.4f})"
|
|
203
|
+
+ (f" — {reason}" if reason else ""))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _hash(rec):
|
|
207
|
+
body = json.dumps({k: v for k, v in rec.items() if k != "hash"}, sort_keys=True)
|
|
208
|
+
return hashlib.sha256(body.encode()).hexdigest()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class Wallet:
|
|
212
|
+
"""Append-only, hash-chained spend ledger with a hard limit.
|
|
213
|
+
|
|
214
|
+
charge() raises Tripped *before* the money is spent — that is the whole
|
|
215
|
+
point. An alert tells you after; this refuses.
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
def __init__(self, limit, path=None):
|
|
219
|
+
self.limit = float(limit)
|
|
220
|
+
self.spent = 0.0
|
|
221
|
+
self.path = path
|
|
222
|
+
self.prev = "genesis"
|
|
223
|
+
if path and os.path.exists(path):
|
|
224
|
+
for rec in read_ledger(path):
|
|
225
|
+
self.spent += rec["cost"]
|
|
226
|
+
self.prev = rec["hash"]
|
|
227
|
+
|
|
228
|
+
def charge(self, amount, **meta):
|
|
229
|
+
if self.spent + amount > self.limit:
|
|
230
|
+
raise Tripped(self.spent, self.limit, self.spent + amount)
|
|
231
|
+
return self.record(amount, **meta)
|
|
232
|
+
|
|
233
|
+
def record(self, amount, **meta):
|
|
234
|
+
"""Book spend that already happened, even past the limit. Reporting only."""
|
|
235
|
+
self.spent += amount
|
|
236
|
+
rec = {"ts": now(), "cost": round(amount, 8), "total": round(self.spent, 8),
|
|
237
|
+
"prev": self.prev, **meta}
|
|
238
|
+
rec["hash"] = _hash(rec)
|
|
239
|
+
self.prev = rec["hash"]
|
|
240
|
+
if self.path:
|
|
241
|
+
with open(self.path, "a") as f:
|
|
242
|
+
f.write(json.dumps(rec, sort_keys=True) + "\n")
|
|
243
|
+
f.flush()
|
|
244
|
+
os.fsync(f.fileno())
|
|
245
|
+
return rec
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def read_ledger(path):
|
|
249
|
+
with open(path) as f:
|
|
250
|
+
return [json.loads(line) for line in f if line.strip()]
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def verify_ledger(path):
|
|
254
|
+
prev, n, total = "genesis", 0, 0.0
|
|
255
|
+
for rec in read_ledger(path):
|
|
256
|
+
if rec.get("prev") != prev:
|
|
257
|
+
return False, n, f"chain broken at record {n + 1} (prev hash mismatch)"
|
|
258
|
+
if rec.get("hash") != _hash(rec):
|
|
259
|
+
return False, n, f"record {n + 1} was edited after it was written"
|
|
260
|
+
prev, n, total = rec["hash"], n + 1, total + rec.get("cost", 0)
|
|
261
|
+
return True, n, f"{n} records · ${total:.4f} · chain intact"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class Treasury:
|
|
265
|
+
"""One total budget plus per-agent caps, all checked before a request leaves.
|
|
266
|
+
|
|
267
|
+
Agents are identified by the `X-Governer-Agent` header. A cap with
|
|
268
|
+
`"per": "day"` resets at UTC midnight; `"per": "run"` lasts for the process
|
|
269
|
+
and survives a restart by re-reading today's ledger records.
|
|
270
|
+
"""
|
|
271
|
+
|
|
272
|
+
def __init__(self, limit, path=None, agents=None):
|
|
273
|
+
self.total = Wallet(limit, path)
|
|
274
|
+
self.agents = {name: {"limit": float(cfg.get("limit", 0)),
|
|
275
|
+
"spent": 0.0,
|
|
276
|
+
"per": cfg.get("per", "run")}
|
|
277
|
+
for name, cfg in (agents or {}).items()}
|
|
278
|
+
self.today = now()[:10]
|
|
279
|
+
self.held = 0.0 # estimates currently in flight, not yet billed
|
|
280
|
+
self.agent_held = {}
|
|
281
|
+
self._rehydrate()
|
|
282
|
+
|
|
283
|
+
@property
|
|
284
|
+
def spent(self):
|
|
285
|
+
return self.total.spent
|
|
286
|
+
|
|
287
|
+
@property
|
|
288
|
+
def limit(self):
|
|
289
|
+
return self.total.limit
|
|
290
|
+
|
|
291
|
+
def _rehydrate(self):
|
|
292
|
+
"""Rebuild per-agent counters from the ledger so restarts cannot reset a cap."""
|
|
293
|
+
if not self.total.path or not os.path.exists(self.total.path):
|
|
294
|
+
return
|
|
295
|
+
try:
|
|
296
|
+
records = read_ledger(self.total.path)
|
|
297
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
298
|
+
# starting is better than not starting, but the caps are now blind —
|
|
299
|
+
# say so loudly instead of quietly letting spend through
|
|
300
|
+
print(f" ⚠ cannot read {self.total.path} ({e}); agent caps start from zero",
|
|
301
|
+
file=sys.stderr)
|
|
302
|
+
return
|
|
303
|
+
for rec in records:
|
|
304
|
+
a = self.agents.get(rec.get("agent"))
|
|
305
|
+
if not a:
|
|
306
|
+
continue
|
|
307
|
+
if a["per"] == "run" or str(rec.get("ts", ""))[:10] == self.today:
|
|
308
|
+
a["spent"] += rec.get("cost", 0)
|
|
309
|
+
|
|
310
|
+
def _roll_day(self):
|
|
311
|
+
today = now()[:10]
|
|
312
|
+
if today != self.today:
|
|
313
|
+
self.today = today
|
|
314
|
+
for a in self.agents.values():
|
|
315
|
+
if a["per"] == "day":
|
|
316
|
+
a["spent"] = 0.0
|
|
317
|
+
|
|
318
|
+
def blocker(self, agent, amount):
|
|
319
|
+
"""Why this charge would be refused, or None. Counts in-flight holds."""
|
|
320
|
+
if self.total.spent + self.held + amount > self.total.limit:
|
|
321
|
+
flight = f" ({money(self.held)} in flight)" if self.held else ""
|
|
322
|
+
return (f"total budget {money(self.total.spent)} of "
|
|
323
|
+
f"{money(self.total.limit)}{flight}")
|
|
324
|
+
a = self.agents.get(agent)
|
|
325
|
+
if a and a["limit"] > 0 and \
|
|
326
|
+
a["spent"] + self.agent_held.get(agent, 0.0) + amount > a["limit"]:
|
|
327
|
+
return f"agent '{agent}' {money(a['spent'])} of {money(a['limit'])} per {a['per']}"
|
|
328
|
+
return None
|
|
329
|
+
|
|
330
|
+
def reserve(self, agent, amount):
|
|
331
|
+
"""Hold an estimate. Ten concurrent requests must not each see the same budget."""
|
|
332
|
+
self._roll_day()
|
|
333
|
+
why = self.blocker(agent, amount)
|
|
334
|
+
if why:
|
|
335
|
+
raise Tripped(self.total.spent, self.total.limit,
|
|
336
|
+
self.total.spent + amount, why)
|
|
337
|
+
self.held += amount
|
|
338
|
+
self.agent_held[agent] = self.agent_held.get(agent, 0.0) + amount
|
|
339
|
+
|
|
340
|
+
def release(self, agent, amount):
|
|
341
|
+
self.held = max(0.0, self.held - amount)
|
|
342
|
+
if agent in self.agent_held:
|
|
343
|
+
self.agent_held[agent] = max(0.0, self.agent_held[agent] - amount)
|
|
344
|
+
|
|
345
|
+
def charge(self, agent, amount, **meta):
|
|
346
|
+
self._roll_day()
|
|
347
|
+
why = self.blocker(agent, amount)
|
|
348
|
+
if why:
|
|
349
|
+
raise Tripped(self.total.spent, self.total.limit,
|
|
350
|
+
self.total.spent + amount, why)
|
|
351
|
+
return self._book(agent, amount, meta)
|
|
352
|
+
|
|
353
|
+
def force_charge(self, agent, amount, **meta):
|
|
354
|
+
"""Book spend that already left the account, or a dry-run overrun."""
|
|
355
|
+
self._roll_day()
|
|
356
|
+
return self._book(agent, amount, meta)
|
|
357
|
+
|
|
358
|
+
def _book(self, agent, amount, meta):
|
|
359
|
+
rec = self.total.record(amount, agent=agent, **meta)
|
|
360
|
+
if agent in self.agents:
|
|
361
|
+
self.agents[agent]["spent"] += amount
|
|
362
|
+
return rec
|
|
363
|
+
|
|
364
|
+
def status(self):
|
|
365
|
+
return {"version": __version__,
|
|
366
|
+
"spent": round(self.spent, 6), "limit": self.limit,
|
|
367
|
+
"remaining": round(self.limit - self.spent, 6),
|
|
368
|
+
"tripped": self.spent >= self.limit,
|
|
369
|
+
"agents": {name: {"spent": round(a["spent"], 6),
|
|
370
|
+
"limit": a["limit"], "per": a["per"]}
|
|
371
|
+
for name, a in self.agents.items()}}
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def notify(url, payload, timeout=5):
|
|
375
|
+
"""Fire-and-forget webhook. A broken webhook must never break the breaker."""
|
|
376
|
+
def send():
|
|
377
|
+
try:
|
|
378
|
+
req = urllib.request.Request(url, data=json.dumps(payload).encode(), method="POST")
|
|
379
|
+
req.add_header("content-type", "application/json")
|
|
380
|
+
urllib.request.urlopen(req, timeout=timeout).read()
|
|
381
|
+
except Exception:
|
|
382
|
+
pass
|
|
383
|
+
|
|
384
|
+
threading.Thread(target=send, daemon=True).start()
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def load_config(path):
|
|
388
|
+
if not path:
|
|
389
|
+
return {}
|
|
390
|
+
try:
|
|
391
|
+
with open(path) as f:
|
|
392
|
+
conf = json.load(f)
|
|
393
|
+
except OSError as e:
|
|
394
|
+
raise SystemExit(f"cannot read config {path}: {e.strerror or e}")
|
|
395
|
+
except json.JSONDecodeError as e:
|
|
396
|
+
raise SystemExit(f"{path} is not valid JSON (line {e.lineno}, column {e.colno}): {e.msg}")
|
|
397
|
+
if not isinstance(conf, dict):
|
|
398
|
+
raise SystemExit(f"{path}: config must be a JSON object")
|
|
399
|
+
return conf
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
# ---------------------------------------------------------------- commands
|
|
403
|
+
|
|
404
|
+
def cmd_prices(args):
|
|
405
|
+
if args.refresh:
|
|
406
|
+
models = fetch_live(args.api)
|
|
407
|
+
with open(LIVE_PATH, "w") as f:
|
|
408
|
+
json.dump({"refreshed": now(), "source": args.api, "models": models},
|
|
409
|
+
f, indent=1, sort_keys=True)
|
|
410
|
+
free = sum(1 for m in models.values() if m["in"] == 0 and m["out"] == 0)
|
|
411
|
+
cached = sum(1 for m in models.values() if m["cache_read"])
|
|
412
|
+
print(f"{len(models)} models refreshed from {args.api}")
|
|
413
|
+
print(f" free right now: {free}")
|
|
414
|
+
print(f" vendor-confirmed cache pricing: {cached}")
|
|
415
|
+
print(f" saved to {LIVE_PATH}")
|
|
416
|
+
return
|
|
417
|
+
prices = load_curated()
|
|
418
|
+
if args.live:
|
|
419
|
+
prices = load_prices()
|
|
420
|
+
rows = sorted((p for p in prices.values() if p.get("in") is not None),
|
|
421
|
+
key=lambda p: (p["in"], p["out"]))
|
|
422
|
+
if not args.all:
|
|
423
|
+
rows = rows[:40]
|
|
424
|
+
print(f"{'model':<50} {'in/M':>9} {'out/M':>9} {'cache r':>9} ctx")
|
|
425
|
+
for p in rows:
|
|
426
|
+
cr = f"{p['cache_read']:.4f}" if p.get("cache_read") is not None else "-"
|
|
427
|
+
print(f"{(p.get('name') or '?')[:50]:<50} {p['in']:>9.4f} {p['out']:>9.4f} "
|
|
428
|
+
f"{cr:>9} {p.get('ctx_in') or '?':>9}")
|
|
429
|
+
print(f"\n{len(rows)} of {len(prices)} models — "
|
|
430
|
+
f"`governer prices --refresh` pulls what the gateway knows today")
|
|
431
|
+
return
|
|
432
|
+
if args.upcoming:
|
|
433
|
+
rows = [(m, c) for m, p in prices.items() for c in p.get("changes", [])]
|
|
434
|
+
if not rows:
|
|
435
|
+
print("no announced price changes in the dataset")
|
|
436
|
+
return
|
|
437
|
+
print(f"{'model':<22} {'when':<12} {'now':<16} {'after':<16} change")
|
|
438
|
+
for m, c in rows:
|
|
439
|
+
p = prices[m]
|
|
440
|
+
after = f"${c['in']}/${c['out']}"
|
|
441
|
+
change = f"+{(c['in'] / p['in'] - 1) * 100:.0f}%"
|
|
442
|
+
print(f"{m:<22} {c.get('date') or 'TBA':<12} "
|
|
443
|
+
f"${p['in']}/{p['out']:<10} {after:<16} {change} {c.get('why', '')}")
|
|
444
|
+
return
|
|
445
|
+
if args.json:
|
|
446
|
+
print(json.dumps(prices, indent=2, sort_keys=True))
|
|
447
|
+
return
|
|
448
|
+
print(f"{'model':<22} {'in/M':>8} {'out/M':>8} {'cache r':>8} {'cache w':>8} verified")
|
|
449
|
+
for m, p in sorted(prices.items()):
|
|
450
|
+
(_, _, cr, cw), derived = unit_cost(m, prices)
|
|
451
|
+
flag = "~" if derived else " "
|
|
452
|
+
print(f"{m:<22} {p['in']:>8.2f} {p['out']:>8.2f} "
|
|
453
|
+
f"{cr * 1e6:>7.2f}{flag} {cw * 1e6:>7.2f}{flag} {p.get('verified', '?')}")
|
|
454
|
+
print("\n~ = cache price from vendor convention (0.1x read, 1.25x write), not confirmed")
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def cmd_box(args):
|
|
458
|
+
prices = load_prices()
|
|
459
|
+
if args.model not in prices:
|
|
460
|
+
raise SystemExit(f"unknown model: {args.model}")
|
|
461
|
+
w = Wallet(args.limit, args.ledger)
|
|
462
|
+
paid = tin = tout = turns = 0.0
|
|
463
|
+
hit_cache = not args.cache_miss
|
|
464
|
+
print(f"wallet ${args.limit:.2f} · {args.model} · {args.context:,} tok/turn · "
|
|
465
|
+
f"{'cache working' if hit_cache else 'CACHE MISSING'}")
|
|
466
|
+
print()
|
|
467
|
+
try:
|
|
468
|
+
while turns < args.max_turns:
|
|
469
|
+
turns += 1
|
|
470
|
+
usage = {"input": 0 if hit_cache else args.context,
|
|
471
|
+
"cache_read": args.context if hit_cache else 0,
|
|
472
|
+
"output": 500}
|
|
473
|
+
c = cost(args.model, usage, prices)
|
|
474
|
+
w.charge(c, turn=int(turns), model=args.model, usage=usage)
|
|
475
|
+
paid += c
|
|
476
|
+
tin += usage["input"] + usage["cache_read"]
|
|
477
|
+
tout += usage["output"]
|
|
478
|
+
if turns <= args.max_turns - 1:
|
|
479
|
+
print(f" turn {int(turns):>4} ${w.spent:>8.2f} / ${args.limit:.2f}")
|
|
480
|
+
except Tripped as t:
|
|
481
|
+
print(f"\n turn {int(turns):>4} BLOCKED (would have cost "
|
|
482
|
+
f"${t.would_be - t.spent:.4f})")
|
|
483
|
+
print(f"\n🛑 BREAKER TRIPPED at ${t.spent:.2f} of ${t.limit:.2f} — agent stopped")
|
|
484
|
+
else:
|
|
485
|
+
print(f"\nno trip: ${w.spent:.2f} of ${args.limit:.2f} after {int(turns)} turns")
|
|
486
|
+
|
|
487
|
+
(_, _, cr, _), _ = unit_cost(args.model, prices)
|
|
488
|
+
if_cache = tin * cr + tout * price_of(args.model, prices)["out"] / 1e6
|
|
489
|
+
print(f"\n paid ${paid:>10.2f} ({int(turns)} turns)")
|
|
490
|
+
print(f" same run, cached ${if_cache:>10.2f}")
|
|
491
|
+
if if_cache > 0 and paid / if_cache > 1.05:
|
|
492
|
+
print(f" you paid {paid / if_cache:.0f}x the cached price — this is the $38k bug")
|
|
493
|
+
print(f" last receipt {w.prev[:16]}…")
|
|
494
|
+
if args.ledger:
|
|
495
|
+
ok, n, msg = verify_ledger(args.ledger)
|
|
496
|
+
print(f" ledger {args.ledger}: {'OK' if ok else 'BROKEN'} — {msg}")
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def iter_records(path):
|
|
500
|
+
"""Stream records so a 2GB log does not have to fit in memory."""
|
|
501
|
+
with open(path) as f:
|
|
502
|
+
if f.read(1) == "[":
|
|
503
|
+
f.seek(0)
|
|
504
|
+
yield from json.load(f)
|
|
505
|
+
return
|
|
506
|
+
f.seek(0)
|
|
507
|
+
for line in f:
|
|
508
|
+
line = line.strip()
|
|
509
|
+
if line:
|
|
510
|
+
yield json.loads(line)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def cmd_check(args):
|
|
514
|
+
prices = load_prices()
|
|
515
|
+
wallet = Wallet(args.limit) if args.limit else None
|
|
516
|
+
total, trip_at, waste, misses, warnings = 0.0, None, 0.0, 0, set()
|
|
517
|
+
requests = unpriced = 0
|
|
518
|
+
unknown = set()
|
|
519
|
+
for rec in iter_records(args.file):
|
|
520
|
+
requests += 1
|
|
521
|
+
model = rec.get("model") or args.model
|
|
522
|
+
usage, inclusive = normalize(rec)
|
|
523
|
+
if model not in prices:
|
|
524
|
+
unpriced += 1
|
|
525
|
+
unknown.add(model)
|
|
526
|
+
continue # an unpriced model must not kill the audit
|
|
527
|
+
c = cost(model, usage, prices, inclusive)
|
|
528
|
+
total += c
|
|
529
|
+
warnings.update(sanity(model, usage, prices, inclusive))
|
|
530
|
+
# the silent killer: a big prompt billed with no cache reads at all
|
|
531
|
+
if usage.get("input", 0) >= 10_000 and usage.get("cache_read", 0) == 0:
|
|
532
|
+
misses += 1
|
|
533
|
+
(_, _, cr, _), _ = unit_cost(model, prices)
|
|
534
|
+
waste += usage["input"] * (prices[model]["in"] / 1e6 - cr)
|
|
535
|
+
if wallet and trip_at is None:
|
|
536
|
+
try:
|
|
537
|
+
wallet.charge(c, request=requests, model=model)
|
|
538
|
+
except Tripped:
|
|
539
|
+
trip_at = (requests, wallet.spent) # spent, not total: the blocked request never billed
|
|
540
|
+
print(f"requests {requests}")
|
|
541
|
+
print(f"true cost ${total:.2f}")
|
|
542
|
+
if unpriced:
|
|
543
|
+
sample = ", ".join(sorted(unknown)[:3])
|
|
544
|
+
print(f"unpriced {unpriced} request(s) skipped — {sample}"
|
|
545
|
+
f"{' …' if len(unknown) > 3 else ''} (run `governer prices --refresh`)")
|
|
546
|
+
if misses:
|
|
547
|
+
print(f"cache miss {misses} request(s) billed uncached while sending ≥10k tokens")
|
|
548
|
+
if total > 0:
|
|
549
|
+
print(f" ${waste:.2f} of that was avoidable — ~{waste / total * 100:.0f}% of the bill")
|
|
550
|
+
for w in sorted(warnings):
|
|
551
|
+
print(f"warning {w}")
|
|
552
|
+
if wallet:
|
|
553
|
+
if trip_at:
|
|
554
|
+
kept = total - trip_at[1]
|
|
555
|
+
saved = f" ({(1 - trip_at[1] / total) * 100:.0f}% saved)" if total > 0 else ""
|
|
556
|
+
print(f"at ${wallet.limit:.2f} cap: stopped at request {trip_at[0]}, "
|
|
557
|
+
f"${kept:.2f} never spent{saved}")
|
|
558
|
+
else:
|
|
559
|
+
print(f"at ${wallet.limit:.2f} cap: would not have tripped")
|
|
560
|
+
if args.fail_over is not None and total > args.fail_over:
|
|
561
|
+
print(f"FAIL — ${total:.2f} is over the ${args.fail_over:.2f} gate")
|
|
562
|
+
sys.exit(1)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def cmd_status(args):
|
|
566
|
+
"""The meter. A hard cap alone just moves spend to the last week of the month."""
|
|
567
|
+
records = read_ledger(args.file)
|
|
568
|
+
if not records:
|
|
569
|
+
print(f"{args.file} is empty — nothing spent yet")
|
|
570
|
+
return
|
|
571
|
+
total = sum(r.get("cost", 0) for r in records)
|
|
572
|
+
by_agent = {}
|
|
573
|
+
for r in records:
|
|
574
|
+
name = r.get("agent") or "unknown"
|
|
575
|
+
by_agent[name] = by_agent.get(name, 0.0) + r.get("cost", 0)
|
|
576
|
+
print(f"spent {money(total)}"
|
|
577
|
+
+ (f" of {money(args.limit)} ({total / args.limit * 100:.0f}% used)"
|
|
578
|
+
if args.limit else ""))
|
|
579
|
+
try:
|
|
580
|
+
first = datetime.fromisoformat(records[0]["ts"])
|
|
581
|
+
last = datetime.fromisoformat(records[-1]["ts"])
|
|
582
|
+
hours = max((last - first).total_seconds() / 3600, 1 / 60)
|
|
583
|
+
except (KeyError, ValueError):
|
|
584
|
+
hours = None
|
|
585
|
+
if hours is not None and hours < 0.5:
|
|
586
|
+
# a handful of records in the same minute extrapolates to nonsense; say so
|
|
587
|
+
print(f"burn rate needs a longer window — this ledger spans {hours * 60:.0f}m")
|
|
588
|
+
elif hours and total > 0:
|
|
589
|
+
rate = total / hours
|
|
590
|
+
print(f"burn rate {money(rate)}/h over {hours:.1f}h")
|
|
591
|
+
print(f"projected {money(rate * 24)}/day at this rate")
|
|
592
|
+
if args.limit:
|
|
593
|
+
left = args.limit - total
|
|
594
|
+
print(f"runs out in {left / rate:.1f}h at this rate" if left > 0
|
|
595
|
+
else "runs out already over the line")
|
|
596
|
+
for name, amount in sorted(by_agent.items(), key=lambda kv: -kv[1]):
|
|
597
|
+
print(f"agent {name:<22} {money(amount)}")
|
|
598
|
+
ok, _, msg = verify_ledger(args.file)
|
|
599
|
+
print(f"chain {'OK' if ok else 'BROKEN'} — {msg}")
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def cmd_verify(args):
|
|
603
|
+
ok, n, msg = verify_ledger(args.file)
|
|
604
|
+
print(f"{'OK' if ok else 'BROKEN'} — {msg}")
|
|
605
|
+
sys.exit(0 if ok else 1)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def cmd_free(args):
|
|
609
|
+
"""Live check: which models are free right now (OpenRouter's public catalogue)."""
|
|
610
|
+
with urllib.request.urlopen(args.api.rstrip("/") + "/models", timeout=30) as r:
|
|
611
|
+
data = json.load(r)["data"]
|
|
612
|
+
free = [m for m in data
|
|
613
|
+
if str(m.get("pricing", {}).get("prompt")) in ("0", "0.0", "0.00")
|
|
614
|
+
and str(m.get("pricing", {}).get("completion")) in ("0", "0.0", "0.00")]
|
|
615
|
+
print(f"{len(free)} of {len(data)} models are free right now ({now()[:10]})")
|
|
616
|
+
for m in sorted(free, key=lambda m: -(m.get("context_length") or 0)):
|
|
617
|
+
print(f" {m['id']:<52} ctx {m.get('context_length') or '?':>9}")
|
|
618
|
+
print("\nfree tiers expire without notice — re-run this before trusting one")
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
# ---------------------------------------------------------------- the proxy
|
|
622
|
+
|
|
623
|
+
_lock = threading.Lock()
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def money(x):
|
|
627
|
+
return f"${x:.4f}" if 0 < x < 0.01 else f"${x:.2f}"
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def estimate_tokens(body):
|
|
631
|
+
"""~4 chars per token. ponytail: no tokenizer, so pre-flight is an estimate.
|
|
632
|
+
|
|
633
|
+
Counts characters instead of building one giant string — a 64MB body used
|
|
634
|
+
to be copied dozens of times. No tokenizer means governer never spends a
|
|
635
|
+
token of your budget on counting.
|
|
636
|
+
"""
|
|
637
|
+
chars = 0
|
|
638
|
+
|
|
639
|
+
def count(value):
|
|
640
|
+
nonlocal chars
|
|
641
|
+
if isinstance(value, str):
|
|
642
|
+
chars += len(value)
|
|
643
|
+
elif isinstance(value, list):
|
|
644
|
+
for item in value:
|
|
645
|
+
count(item)
|
|
646
|
+
elif isinstance(value, dict):
|
|
647
|
+
for item in value.values():
|
|
648
|
+
count(item)
|
|
649
|
+
|
|
650
|
+
for key in ("messages", "system", "prompt", "tools"):
|
|
651
|
+
count(body.get(key))
|
|
652
|
+
return max(1, chars // 4) if chars else 0
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def make_handler(tr, cfg):
|
|
656
|
+
class Handler(BaseHTTPRequestHandler):
|
|
657
|
+
protocol_version = "HTTP/1.1"
|
|
658
|
+
|
|
659
|
+
def _out(self, code, payload, extra=()):
|
|
660
|
+
self.send_response(code)
|
|
661
|
+
self.send_header("content-type", "application/json")
|
|
662
|
+
self.send_header("content-length", str(len(payload)))
|
|
663
|
+
for k, v in extra:
|
|
664
|
+
self.send_header(k, v)
|
|
665
|
+
self.end_headers()
|
|
666
|
+
self.wfile.write(payload)
|
|
667
|
+
|
|
668
|
+
def _json(self, code, obj):
|
|
669
|
+
self._out(code, json.dumps(obj).encode())
|
|
670
|
+
|
|
671
|
+
def do_GET(self):
|
|
672
|
+
if self.path.startswith("/__governer"):
|
|
673
|
+
self._json(200, tr.status())
|
|
674
|
+
else:
|
|
675
|
+
self._json(404, {"error": {"message": "governer proxies POST only"}})
|
|
676
|
+
|
|
677
|
+
def _relay_stream(self, resp, tr, agent):
|
|
678
|
+
"""Pass the provider's SSE straight through, remembering the usage block."""
|
|
679
|
+
self.send_response(resp.status)
|
|
680
|
+
self.send_header("content-type",
|
|
681
|
+
resp.headers.get("content-type", "text/event-stream"))
|
|
682
|
+
self.send_header("transfer-encoding", "chunked")
|
|
683
|
+
self.send_header("x-governer-spent", f"{tr.spent:.6f}")
|
|
684
|
+
self.send_header("x-governer-limit", f"{tr.limit:.2f}")
|
|
685
|
+
self.send_header("x-governer-agent", agent)
|
|
686
|
+
self.end_headers()
|
|
687
|
+
usage = {}
|
|
688
|
+
try:
|
|
689
|
+
for line in resp:
|
|
690
|
+
self.wfile.write(b"%X\r\n" % len(line) + line + b"\r\n")
|
|
691
|
+
text = line.strip()
|
|
692
|
+
if text.startswith(b"data:"):
|
|
693
|
+
chunk = text[5:].strip()
|
|
694
|
+
if chunk and chunk != b"[DONE]":
|
|
695
|
+
try:
|
|
696
|
+
obj = json.loads(chunk)
|
|
697
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
698
|
+
continue
|
|
699
|
+
if isinstance(obj, dict) and obj.get("usage"):
|
|
700
|
+
usage = obj["usage"]
|
|
701
|
+
finally:
|
|
702
|
+
resp.close()
|
|
703
|
+
self.wfile.write(b"0\r\n\r\n")
|
|
704
|
+
return usage
|
|
705
|
+
|
|
706
|
+
def _bill(self, agent, model, raw_usage, code, i_rate, o_rate, estimate, held):
|
|
707
|
+
"""Release the hold, then book what really happened."""
|
|
708
|
+
usage, inclusive = normalize(raw_usage)
|
|
709
|
+
with _lock:
|
|
710
|
+
if held:
|
|
711
|
+
tr.release(agent, estimate)
|
|
712
|
+
if not usage: # no usage block: assume the estimate rather than zero
|
|
713
|
+
tr.force_charge(agent, estimate, model=model, status=code, assumed=True)
|
|
714
|
+
print(f" ⚠ no usage block from provider — booked the ${estimate:.4f} estimate")
|
|
715
|
+
return
|
|
716
|
+
actual = cost(model, usage, cfg.get("prices") or {}, inclusive,
|
|
717
|
+
fallback=(i_rate, o_rate))
|
|
718
|
+
vendor = raw_usage.get("cost")
|
|
719
|
+
if isinstance(vendor, (int, float)) and vendor > 0 \
|
|
720
|
+
and abs(vendor - actual) > max(1e-6, actual * 0.02):
|
|
721
|
+
print(f" ⚠ price mismatch: governer ${actual:.6f} vs vendor ${vendor:.6f} "
|
|
722
|
+
f"— prices.json is stale for {model}")
|
|
723
|
+
with _lock:
|
|
724
|
+
why = tr.blocker(agent, actual)
|
|
725
|
+
if why and cfg["dry_run"]:
|
|
726
|
+
print(f" ◌ would overrun — {why}")
|
|
727
|
+
tr.force_charge(agent, actual, model=model, usage=usage, status=code,
|
|
728
|
+
vendor_cost=vendor, dry_run=True)
|
|
729
|
+
elif why: # the estimate was low and the money is already gone
|
|
730
|
+
print(f" ⚠ estimate was low — {why}")
|
|
731
|
+
tr.force_charge(agent, actual, model=model, usage=usage, status=code,
|
|
732
|
+
vendor_cost=vendor)
|
|
733
|
+
cfg["alert"]({"event": "overrun", "agent": agent, "model": model,
|
|
734
|
+
"reason": why, **tr.status()})
|
|
735
|
+
else:
|
|
736
|
+
tr.charge(agent, actual, model=model, usage=usage, status=code,
|
|
737
|
+
vendor_cost=vendor)
|
|
738
|
+
print(f" ✓ {code} {agent}/{model[:34]:<34} ${actual:.6f} "
|
|
739
|
+
f"${tr.spent:.4f}/${tr.limit:.2f}")
|
|
740
|
+
|
|
741
|
+
def do_POST(self):
|
|
742
|
+
try:
|
|
743
|
+
length = int(self.headers.get("content-length") or 0)
|
|
744
|
+
except ValueError:
|
|
745
|
+
return self._json(400, {"error": {"message":
|
|
746
|
+
"governer: content-length is not a number"}})
|
|
747
|
+
if length > 64 * 1024 * 1024:
|
|
748
|
+
return self._json(413, {"error": {"message": "governer: body over 64MB"}})
|
|
749
|
+
raw = self.rfile.read(length)
|
|
750
|
+
try:
|
|
751
|
+
body = json.loads(raw or b"{}")
|
|
752
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
753
|
+
return self._json(400, {"error": {"message":
|
|
754
|
+
"governer: body is not JSON (chunked uploads without a "
|
|
755
|
+
"content-length are not supported)"}})
|
|
756
|
+
if not isinstance(body, dict):
|
|
757
|
+
return self._json(400, {"error": {"message":
|
|
758
|
+
"governer: body must be a JSON object"}})
|
|
759
|
+
model = body.get("model") or ""
|
|
760
|
+
agent = (self.headers.get("x-governer-agent") or "default")[:64]
|
|
761
|
+
prices = cfg.get("prices") or {}
|
|
762
|
+
|
|
763
|
+
if model not in prices:
|
|
764
|
+
warned = cfg.setdefault("warned", set())
|
|
765
|
+
if model not in warned:
|
|
766
|
+
warned.add(model)
|
|
767
|
+
i_r, o_r = cfg["rate"](model)
|
|
768
|
+
print(f" ⚠ {model or '(no model)'} has no price row — billed at "
|
|
769
|
+
f"${i_r * 1e6:g}/${o_r * 1e6:g} per M. `governer prices --refresh` "
|
|
770
|
+
f"fixes that")
|
|
771
|
+
|
|
772
|
+
# 1. hold the worst case BEFORE the request leaves the machine, so parallel
|
|
773
|
+
# requests cannot each be shown the same remaining budget
|
|
774
|
+
est_in = estimate_tokens(body)
|
|
775
|
+
try:
|
|
776
|
+
est_out = int(body.get("max_tokens") or body.get("max_completion_tokens") or 1024)
|
|
777
|
+
except (TypeError, ValueError):
|
|
778
|
+
est_out = 1024
|
|
779
|
+
i_rate, o_rate = cfg["rate"](model)
|
|
780
|
+
estimate = est_in * i_rate + est_out * o_rate
|
|
781
|
+
held = False
|
|
782
|
+
with _lock:
|
|
783
|
+
why = tr.blocker(agent, estimate)
|
|
784
|
+
if why is None:
|
|
785
|
+
tr.reserve(agent, estimate)
|
|
786
|
+
held = True
|
|
787
|
+
if why:
|
|
788
|
+
if cfg["dry_run"]:
|
|
789
|
+
print(f" ◌ would refuse {agent}/{model or '(unnamed)'} — {why}")
|
|
790
|
+
else:
|
|
791
|
+
print(f" 🛑 refused {agent}/{model or '(unnamed)'} — {why}")
|
|
792
|
+
cfg["alert"]({"event": "breaker_tripped", "agent": agent, "model": model,
|
|
793
|
+
"reason": why, "estimate": round(estimate, 6), **tr.status()})
|
|
794
|
+
return self._json(402, {"error": {
|
|
795
|
+
"type": "governer_breaker_tripped",
|
|
796
|
+
"message": f"governer refused this request: {why}; "
|
|
797
|
+
f"worst case here is ${estimate:.4f}",
|
|
798
|
+
"agent": agent, "spent": round(tr.spent, 6), "limit": tr.limit}})
|
|
799
|
+
|
|
800
|
+
stream = bool(body.get("stream"))
|
|
801
|
+
if stream: # without this, most providers never send the usage block
|
|
802
|
+
opts = dict(body.get("stream_options") or {})
|
|
803
|
+
opts["include_usage"] = True
|
|
804
|
+
body["stream_options"] = opts
|
|
805
|
+
raw = json.dumps(body).encode()
|
|
806
|
+
|
|
807
|
+
# 2. forward, passing the caller's auth through
|
|
808
|
+
path = self.path
|
|
809
|
+
up = cfg["upstream"].rstrip("/")
|
|
810
|
+
if up.endswith("/v1") and path.startswith("/v1"):
|
|
811
|
+
path = path[3:] # upstream already carries /v1
|
|
812
|
+
req = urllib.request.Request(up + path, data=raw, method="POST")
|
|
813
|
+
for h in ("authorization", "content-type", "x-api-key", "anthropic-version",
|
|
814
|
+
"http-referer", "x-title"):
|
|
815
|
+
v = self.headers.get(h)
|
|
816
|
+
if v:
|
|
817
|
+
req.add_header(h, v)
|
|
818
|
+
if not self.headers.get("authorization") and cfg.get("key"):
|
|
819
|
+
req.add_header("authorization", "Bearer " + cfg["key"])
|
|
820
|
+
try:
|
|
821
|
+
resp = urllib.request.urlopen(req, timeout=cfg["timeout"])
|
|
822
|
+
except urllib.error.HTTPError as e:
|
|
823
|
+
payload, code, resp = e.read(), e.code, None
|
|
824
|
+
except Exception as e:
|
|
825
|
+
if held:
|
|
826
|
+
with _lock:
|
|
827
|
+
tr.release(agent, estimate)
|
|
828
|
+
if "timed out" in str(e).lower() or "reset" in str(e).lower():
|
|
829
|
+
# the provider may have run and billed it: assume the money is gone
|
|
830
|
+
with _lock:
|
|
831
|
+
tr.force_charge(agent, estimate, model=model, assumed=True,
|
|
832
|
+
note=f"upstream {type(e).__name__}")
|
|
833
|
+
print(f" ⚠ upstream {type(e).__name__} — booked the ${estimate:.4f} estimate")
|
|
834
|
+
return self._json(502, {"error": {"message": f"governer: upstream failed: {e}"}})
|
|
835
|
+
|
|
836
|
+
if stream and resp is not None and resp.status < 400:
|
|
837
|
+
raw_usage = self._relay_stream(resp, tr, agent)
|
|
838
|
+
self._bill(agent, model, raw_usage, resp.status, i_rate, o_rate, estimate, held)
|
|
839
|
+
return
|
|
840
|
+
|
|
841
|
+
try:
|
|
842
|
+
payload, code = (resp.read(), resp.status) if resp is not None else (payload, code)
|
|
843
|
+
finally:
|
|
844
|
+
if resp is not None:
|
|
845
|
+
resp.close()
|
|
846
|
+
|
|
847
|
+
# 3. bill what actually happened
|
|
848
|
+
try:
|
|
849
|
+
raw_usage = json.loads(payload).get("usage") or {}
|
|
850
|
+
except (json.JSONDecodeError, AttributeError, TypeError):
|
|
851
|
+
raw_usage = {}
|
|
852
|
+
self._bill(agent, model, raw_usage, code, i_rate, o_rate, estimate, held)
|
|
853
|
+
self._out(code, payload, [("x-governer-spent", f"{tr.spent:.6f}"),
|
|
854
|
+
("x-governer-limit", f"{tr.limit:.2f}"),
|
|
855
|
+
("x-governer-agent", agent)])
|
|
856
|
+
|
|
857
|
+
def log_message(self, *a):
|
|
858
|
+
pass
|
|
859
|
+
|
|
860
|
+
return Handler
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def cmd_proxy(args):
|
|
864
|
+
conf = load_config(args.config)
|
|
865
|
+
limit = args.limit if args.limit is not None else conf.get("limit", 5.0)
|
|
866
|
+
upstream = args.upstream or conf.get("upstream") or "https://openrouter.ai/api/v1"
|
|
867
|
+
ledger = args.ledger or conf.get("ledger")
|
|
868
|
+
d_in = args.default_in if args.default_in is not None else conf.get("default_in", 0.0)
|
|
869
|
+
d_out = args.default_out if args.default_out is not None else conf.get("default_out", 0.0)
|
|
870
|
+
dry = bool(args.dry_run or conf.get("dry_run"))
|
|
871
|
+
webhook = args.notify or conf.get("notify")
|
|
872
|
+
prices = load_prices()
|
|
873
|
+
tr = Treasury(limit, ledger, conf.get("agents") or {})
|
|
874
|
+
|
|
875
|
+
def rate(model):
|
|
876
|
+
if model in prices:
|
|
877
|
+
(i, o, _, _), _ = unit_cost(model, prices)
|
|
878
|
+
return i, o
|
|
879
|
+
return d_in / 1e6, d_out / 1e6 # ponytail: unknown model = flat rate
|
|
880
|
+
def alert(payload):
|
|
881
|
+
if webhook:
|
|
882
|
+
notify(webhook, payload)
|
|
883
|
+
|
|
884
|
+
cfg = {"upstream": upstream, "rate": rate, "timeout": args.timeout,
|
|
885
|
+
"prices": prices, "dry_run": dry, "alert": alert,
|
|
886
|
+
"key": args.key or os.environ.get("GOVENER_KEY")}
|
|
887
|
+
srv = ThreadingHTTPServer((args.host, args.port), make_handler(tr, cfg))
|
|
888
|
+
base = f"http://{args.host}:{args.port}"
|
|
889
|
+
print(f"governer {__version__} {base} → {upstream}")
|
|
890
|
+
print(f"budget {money(tr.limit)}"
|
|
891
|
+
f"{' (dry run: counts, never refuses)' if dry else ''}"
|
|
892
|
+
f" · unknown models ${d_in}/${d_out} per M")
|
|
893
|
+
for name, a in tr.agents.items():
|
|
894
|
+
print(f" agent {name:<22} {money(a['limit'])} per {a['per']}")
|
|
895
|
+
if ledger:
|
|
896
|
+
print(f"ledger {ledger}")
|
|
897
|
+
if webhook:
|
|
898
|
+
print(f"alerts {webhook}")
|
|
899
|
+
print(f"\n export OPENAI_BASE_URL={base}/v1")
|
|
900
|
+
print( " export OPENAI_API_KEY=<your key> # governer never stores it")
|
|
901
|
+
print(f" curl {base}/__governer\n")
|
|
902
|
+
try:
|
|
903
|
+
srv.serve_forever()
|
|
904
|
+
except KeyboardInterrupt:
|
|
905
|
+
msg = verify_ledger(ledger)[2] if ledger else "no ledger"
|
|
906
|
+
print(f"\nstopped · spent {money(tr.spent)} of {money(tr.limit)} · {msg}")
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def main(argv=None):
|
|
910
|
+
try: # a proxied log file must show lines as they happen, not at exit
|
|
911
|
+
sys.stdout.reconfigure(line_buffering=True)
|
|
912
|
+
except (AttributeError, OSError):
|
|
913
|
+
pass
|
|
914
|
+
ap = argparse.ArgumentParser(prog="governer", description="hard spend governor for AI agents")
|
|
915
|
+
ap.add_argument("--version", action="version", version=f"governer {__version__}")
|
|
916
|
+
sub = ap.add_subparsers(dest="cmd")
|
|
917
|
+
|
|
918
|
+
p = sub.add_parser("prices", help="what models cost right now")
|
|
919
|
+
p.add_argument("--upcoming", action="store_true", help="announced price changes only")
|
|
920
|
+
p.add_argument("--refresh", action="store_true", help="pull live prices from a gateway")
|
|
921
|
+
p.add_argument("--live", action="store_true", help="list every known model, cheapest first")
|
|
922
|
+
p.add_argument("--all", action="store_true", help="do not truncate --live")
|
|
923
|
+
p.add_argument("--api", default="https://openrouter.ai/api/v1")
|
|
924
|
+
p.add_argument("--json", action="store_true")
|
|
925
|
+
p.set_defaults(fn=cmd_prices)
|
|
926
|
+
|
|
927
|
+
b = sub.add_parser("box", help="simulated agent in a virtual wallet (no API key)")
|
|
928
|
+
b.add_argument("--limit", type=float, default=10.0)
|
|
929
|
+
b.add_argument("--model", default="gpt-6-astra")
|
|
930
|
+
b.add_argument("--context", type=int, default=200_000)
|
|
931
|
+
b.add_argument("--cache-miss", dest="cache_miss", action="store_true", default=True)
|
|
932
|
+
b.add_argument("--cached", dest="cache_miss", action="store_false")
|
|
933
|
+
b.add_argument("--ledger")
|
|
934
|
+
b.add_argument("--max-turns", type=int, default=5000)
|
|
935
|
+
b.set_defaults(fn=cmd_box)
|
|
936
|
+
|
|
937
|
+
c = sub.add_parser("check", help="audit real usage records")
|
|
938
|
+
c.add_argument("file")
|
|
939
|
+
c.add_argument("--model", default="gpt-6-astra")
|
|
940
|
+
c.add_argument("--limit", type=float)
|
|
941
|
+
c.add_argument("--fail-over", dest="fail_over", type=float,
|
|
942
|
+
help="exit 1 if true cost exceeds this (CI gate)")
|
|
943
|
+
c.set_defaults(fn=cmd_check)
|
|
944
|
+
|
|
945
|
+
s = sub.add_parser("status", help="the meter: spent, burn rate, per-agent split")
|
|
946
|
+
s.add_argument("file")
|
|
947
|
+
s.add_argument("--limit", type=float)
|
|
948
|
+
s.set_defaults(fn=cmd_status)
|
|
949
|
+
|
|
950
|
+
v = sub.add_parser("verify", help="check a ledger for tampering")
|
|
951
|
+
v.add_argument("file")
|
|
952
|
+
v.set_defaults(fn=cmd_verify)
|
|
953
|
+
|
|
954
|
+
f = sub.add_parser("free", help="which models are free right now (live)")
|
|
955
|
+
f.add_argument("--api", default="https://openrouter.ai/api/v1")
|
|
956
|
+
f.set_defaults(fn=cmd_free)
|
|
957
|
+
|
|
958
|
+
x = sub.add_parser("proxy", help="OpenAI-compatible shim that enforces the wallet")
|
|
959
|
+
x.add_argument("--config", help="JSON config: limit, upstream, ledger, agents, notify, dry_run")
|
|
960
|
+
x.add_argument("--limit", type=float)
|
|
961
|
+
x.add_argument("--port", type=int, default=8787)
|
|
962
|
+
x.add_argument("--host", default="127.0.0.1")
|
|
963
|
+
x.add_argument("--upstream")
|
|
964
|
+
x.add_argument("--ledger")
|
|
965
|
+
x.add_argument("--notify", help="webhook URL to POST to when the breaker trips")
|
|
966
|
+
x.add_argument("--dry-run", dest="dry_run", action="store_true",
|
|
967
|
+
help="count everything, refuse nothing (watch before you enforce)")
|
|
968
|
+
x.add_argument("--key", help="upstream key, if the client does not send one")
|
|
969
|
+
x.add_argument("--default-in", dest="default_in", type=float,
|
|
970
|
+
help="USD per M for models missing from the price tables (0 = assume free)")
|
|
971
|
+
x.add_argument("--default-out", dest="default_out", type=float)
|
|
972
|
+
x.add_argument("--timeout", type=float, default=120)
|
|
973
|
+
x.set_defaults(fn=cmd_proxy)
|
|
974
|
+
|
|
975
|
+
args = ap.parse_args(argv)
|
|
976
|
+
if not getattr(args, "cmd", None):
|
|
977
|
+
ap.print_help()
|
|
978
|
+
sys.exit(2)
|
|
979
|
+
args.fn(args)
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
if __name__ == "__main__":
|
|
983
|
+
main()
|