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
+ [![test](https://github.com/Kaiser0733/governer/actions/workflows/test.yml/badge.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ governer = governer:main
@@ -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()