obey-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. obey_cli/__init__.py +3 -0
  2. obey_cli/__main__.py +3 -0
  3. obey_cli/_data/demo/README.md +12 -0
  4. obey_cli/_data/demo/obey.yaml +287 -0
  5. obey_cli/_data/demo/repo/.gitignore +8 -0
  6. obey_cli/_data/demo/repo/AGENTS.md +38 -0
  7. obey_cli/_data/demo/repo/README.md +36 -0
  8. obey_cli/_data/demo/repo/eslint.config.js +24 -0
  9. obey_cli/_data/demo/repo/index.html +21 -0
  10. obey_cli/_data/demo/repo/openapi.yaml +86 -0
  11. obey_cli/_data/demo/repo/package.json +26 -0
  12. obey_cli/_data/demo/repo/pnpm-lock.yaml +1439 -0
  13. obey_cli/_data/demo/repo/scripts/gen-types.mjs +109 -0
  14. obey_cli/_data/demo/repo/src/api/client.test.ts +60 -0
  15. obey_cli/_data/demo/repo/src/api/client.ts +71 -0
  16. obey_cli/_data/demo/repo/src/api/response-guards.test.ts +21 -0
  17. obey_cli/_data/demo/repo/src/api/response-guards.ts +38 -0
  18. obey_cli/_data/demo/repo/src/date-utils.test.ts +51 -0
  19. obey_cli/_data/demo/repo/src/date-utils.ts +60 -0
  20. obey_cli/_data/demo/repo/src/generated/api-types.ts +35 -0
  21. obey_cli/_data/demo/repo/src/logger.test.ts +24 -0
  22. obey_cli/_data/demo/repo/src/logger.ts +36 -0
  23. obey_cli/_data/demo/repo/src/main.ts +30 -0
  24. obey_cli/_data/demo/repo/src/render-table.test.ts +34 -0
  25. obey_cli/_data/demo/repo/src/render-table.ts +48 -0
  26. obey_cli/_data/demo/repo/src/shift-planner.test.ts +66 -0
  27. obey_cli/_data/demo/repo/src/shift-planner.ts +101 -0
  28. obey_cli/_data/demo/repo/tsconfig.json +20 -0
  29. obey_cli/_data/demo/repo/vitest.config.ts +8 -0
  30. obey_cli/_data/demo/runs/after.jsonl.gz +0 -0
  31. obey_cli/_data/demo/runs/before.jsonl.gz +0 -0
  32. obey_cli/_data/demo/runs/meta.json +10 -0
  33. obey_cli/_data/schemas/obey-config-v1.json +116 -0
  34. obey_cli/_data/schemas/report-v1.json +55 -0
  35. obey_cli/_data/schemas/survey-record-v1.json +30 -0
  36. obey_cli/checkers/__init__.py +30 -0
  37. obey_cli/checkers/samples.py +95 -0
  38. obey_cli/cli.py +201 -0
  39. obey_cli/commands.py +676 -0
  40. obey_cli/config.py +90 -0
  41. obey_cli/discover.py +232 -0
  42. obey_cli/enforce/__init__.py +114 -0
  43. obey_cli/enforce/engine/__init__.py +0 -0
  44. obey_cli/enforce/engine/obey_engine.py +2005 -0
  45. obey_cli/enforce/merge.py +196 -0
  46. obey_cli/enforce/targets/__init__.py +354 -0
  47. obey_cli/enforce/templates/obey_hook.py +452 -0
  48. obey_cli/enforce/templates/pre_commit.py +116 -0
  49. obey_cli/extract.py +1342 -0
  50. obey_cli/lint.py +125 -0
  51. obey_cli/llm.py +129 -0
  52. obey_cli/parse_md.py +260 -0
  53. obey_cli/probes/__init__.py +0 -0
  54. obey_cli/probes/facts.py +162 -0
  55. obey_cli/probes/templates.py +326 -0
  56. obey_cli/py.typed +0 -0
  57. obey_cli/report/__init__.py +314 -0
  58. obey_cli/resources.py +35 -0
  59. obey_cli/rules.py +75 -0
  60. obey_cli/runner/__init__.py +0 -0
  61. obey_cli/runner/agents/__init__.py +22 -0
  62. obey_cli/runner/agents/base.py +96 -0
  63. obey_cli/runner/agents/claude_code.py +203 -0
  64. obey_cli/runner/agents/codex.py +152 -0
  65. obey_cli/runner/agents/copilot.py +142 -0
  66. obey_cli/runner/agents/gemini.py +125 -0
  67. obey_cli/runner/agents/opencode.py +156 -0
  68. obey_cli/runner/core.py +140 -0
  69. obey_cli/runner/egress_proxy.py +166 -0
  70. obey_cli/runner/gateway.py +529 -0
  71. obey_cli/runner/ledger.py +132 -0
  72. obey_cli/runner/local.py +176 -0
  73. obey_cli/runner/orchestrate.py +227 -0
  74. obey_cli/runner/sandbox/Dockerfile +41 -0
  75. obey_cli/runner/sandbox/entrypoint.sh +38 -0
  76. obey_cli/runner/sandbox.py +341 -0
  77. obey_cli/runner/sidecar.py +38 -0
  78. obey_cli/runner/workspace.py +187 -0
  79. obey_cli/score.py +150 -0
  80. obey_cli/study/__init__.py +0 -0
  81. obey_cli/study/analysis.py +277 -0
  82. obey_cli/study/design.py +98 -0
  83. obey_cli/study/experiment.py +198 -0
  84. obey_cli/study/report.py +519 -0
  85. obey_cli/study/survey.py +438 -0
  86. obey_cli-0.1.0.dist-info/METADATA +393 -0
  87. obey_cli-0.1.0.dist-info/RECORD +90 -0
  88. obey_cli-0.1.0.dist-info/WHEEL +4 -0
  89. obey_cli-0.1.0.dist-info/entry_points.txt +2 -0
  90. obey_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,529 @@
1
+ """Model gateway: holds the API key outside the agent's container (standard library only).
2
+
3
+ Agents get a dummy token and a base URL of the form http://<gateway>/r/<run_id>/<api>, where <api> is
4
+ `anthropic` (Messages API), `openai` (Chat Completions / Responses) or `gemini`. The gateway checks the dummy token,
5
+ swaps in the real key, pins the model, streams the response back, records token usage and cost per run in the
6
+ ledger file, and refuses calls once a run or the whole batch reaches its cap. Only the model endpoints of each
7
+ API are reachable, and only for run ids the host registered (when it registered any).
8
+
9
+ OBEY_UPSTREAM_KEY=... python3 gateway.py --listen 0.0.0.0:8787 --model vendor/model --ledger ledger.jsonl \
10
+ --price-prompt 3e-7 --price-completion 1.2e-6 --run-cap 0.5 --budget 20 --run-id r1 --run-id r2
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import contextlib
17
+ import hmac
18
+ import http.client
19
+ import json
20
+ import os
21
+ import re
22
+ import sys
23
+ import threading
24
+ import time
25
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
26
+ from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
27
+ from urllib.parse import parse_qs, quote, unquote, urlsplit
28
+
29
+ UPSTREAMS = {
30
+ "anthropic": "https://openrouter.ai/api",
31
+ "openai": "https://openrouter.ai/api/v1",
32
+ "gemini": "https://generativelanguage.googleapis.com",
33
+ }
34
+ DUMMY_TOKEN = "obey-dummy-token" # noqa: S105 - what agents send; same value as runner/agents/base.py (stand-alone file)
35
+ MAX_BODY = 64 * 1024 * 1024
36
+ _PATH_RE = re.compile(r"/r/([A-Za-z0-9._-]{1,80})/(anthropic|openai|gemini)(/.*)?")
37
+ _CHUNK_SIZE_RE = re.compile(rb"[0-9A-Fa-f]{1,8}")
38
+ # What an agent may call, per API, relative to the upstream base URL (the openai base already ends in /v1).
39
+ _ROUTES: Dict[str, List[Tuple[str, "re.Pattern[str]"]]] = {
40
+ "anthropic": [("POST", re.compile(r"/v1/messages(?:/count_tokens)?")), ("GET", re.compile(r"/v1/models"))],
41
+ "openai": [("POST", re.compile(r"/(?:chat/completions|responses)")), ("GET", re.compile(r"/models"))],
42
+ "gemini": [
43
+ (
44
+ "POST",
45
+ re.compile(
46
+ r"/v1beta/models/(?P<model>[A-Za-z0-9._-]+):(?P<verb>generateContent|streamGenerateContent|countTokens)"
47
+ ),
48
+ )
49
+ ],
50
+ }
51
+ _HOP_HEADERS = {
52
+ "connection",
53
+ "keep-alive",
54
+ "proxy-authenticate",
55
+ "proxy-authorization",
56
+ "te",
57
+ "trailers",
58
+ "transfer-encoding",
59
+ "upgrade",
60
+ "host",
61
+ "content-length",
62
+ "authorization",
63
+ "x-api-key",
64
+ "x-goog-api-key",
65
+ "accept-encoding",
66
+ }
67
+ _RESPONSE_SKIP = _HOP_HEADERS | {"server", "date"} # send_response adds our own
68
+
69
+
70
+ class Config:
71
+ def __init__(
72
+ self,
73
+ key: str,
74
+ model: Optional[str],
75
+ ledger: Optional[str],
76
+ price: Dict[str, float],
77
+ run_cap: Optional[float],
78
+ budget: Optional[float],
79
+ upstreams: Dict[str, str],
80
+ allowed_hosts: Optional[List[str]] = None,
81
+ token: str = DUMMY_TOKEN,
82
+ run_ids: Optional[Iterable[str]] = None,
83
+ ):
84
+ self.key = key
85
+ self.model = model
86
+ self.ledger = ledger
87
+ self.price = price
88
+ self.run_cap = run_cap
89
+ self.budget = budget
90
+ self.upstreams = upstreams
91
+ self.allowed_hosts = allowed_hosts
92
+ self.token = token
93
+ # Run ids the host registered for this batch; None accepts any id (and register_run() switches the check on).
94
+ self.run_ids: Optional[Set[str]] = set(run_ids) if run_ids is not None else None
95
+ self.lock = threading.Lock()
96
+ self.spent: Dict[str, float] = {}
97
+ self.total = 0.0
98
+ if ledger and os.path.exists(ledger):
99
+ with open(ledger, encoding="utf-8") as fh:
100
+ for line in fh:
101
+ if line.strip():
102
+ row = json.loads(line)
103
+ self._add(str(row.get("run_id")), float(row.get("cost_usd", 0.0)))
104
+
105
+ def _add(self, run_id: str, cost: float) -> None:
106
+ self.spent[run_id] = self.spent.get(run_id, 0.0) + cost
107
+ self.total += cost
108
+
109
+ def register_run(self, run_id: str) -> None:
110
+ with self.lock:
111
+ if self.run_ids is None:
112
+ self.run_ids = set()
113
+ self.run_ids.add(run_id)
114
+
115
+ def known_run(self, run_id: str) -> bool:
116
+ with self.lock:
117
+ return self.run_ids is None or run_id in self.run_ids
118
+
119
+ def over_cap(self, run_id: str) -> Optional[str]:
120
+ with self.lock:
121
+ if self.budget is not None and self.total >= self.budget:
122
+ return f"batch budget ${self.budget:.2f} reached"
123
+ if self.run_cap is not None and self.spent.get(run_id, 0.0) >= self.run_cap:
124
+ return f"per-run cap ${self.run_cap:.2f} reached"
125
+ return None
126
+
127
+ def record(self, run_id: str, requested: str, usage: Dict[str, int], provider_cost: Optional[float]) -> None:
128
+ if provider_cost is not None:
129
+ cost, source = provider_cost, "provider"
130
+ else:
131
+ cost, source = price_cost(self.price, usage), "price_table"
132
+ entry = {
133
+ "ts": round(time.time(), 3),
134
+ "run_id": run_id,
135
+ "kind": "agent",
136
+ "model": self.model or requested,
137
+ "input_tokens": usage.get("input_tokens", 0),
138
+ "output_tokens": usage.get("output_tokens", 0),
139
+ "cache_read_tokens": usage.get("cache_read_tokens", 0),
140
+ "cache_write_tokens": usage.get("cache_write_tokens", 0),
141
+ "cost_usd": cost,
142
+ "cost_source": source,
143
+ }
144
+ with self.lock:
145
+ self._add(run_id, cost)
146
+ if self.ledger:
147
+ with open(self.ledger, "a", encoding="utf-8") as fh:
148
+ fh.write(json.dumps(entry, sort_keys=True) + "\n")
149
+
150
+
151
+ def price_cost(price: Dict[str, float], usage: Dict[str, int]) -> float:
152
+ cached = usage.get("cache_read_tokens", 0)
153
+ written = usage.get("cache_write_tokens", 0)
154
+ fresh = max(0, usage.get("input_tokens", 0) - cached - written)
155
+ prompt = price.get("prompt", 0.0)
156
+ return (
157
+ fresh * prompt
158
+ + usage.get("output_tokens", 0) * price.get("completion", 0.0)
159
+ + cached * price.get("cache_read", prompt)
160
+ + written * price.get("cache_write", prompt)
161
+ )
162
+
163
+
164
+ class UsageMeter:
165
+ """Accumulates usage from a JSON body or from server-sent events, for both API styles."""
166
+
167
+ def __init__(self) -> None:
168
+ self.usage = {"input_tokens": 0, "output_tokens": 0, "cache_read_tokens": 0, "cache_write_tokens": 0}
169
+ self.cost: Optional[float] = None
170
+ self._buffer = b""
171
+
172
+ def feed_json(self, body: Any) -> None:
173
+ if not isinstance(body, dict):
174
+ return
175
+ if body.get("type") == "message_start" and isinstance(body.get("message"), dict):
176
+ body = body["message"]
177
+ if body.get("type") == "response.completed" and isinstance(body.get("response"), dict):
178
+ body = body["response"]
179
+ meta = body.get("usageMetadata")
180
+ if isinstance(meta, dict): # Gemini API
181
+ self.usage["input_tokens"] = max(self.usage["input_tokens"], int(meta.get("promptTokenCount") or 0))
182
+ self.usage["output_tokens"] = max(
183
+ self.usage["output_tokens"],
184
+ int(meta.get("candidatesTokenCount") or 0) + int(meta.get("thoughtsTokenCount") or 0),
185
+ )
186
+ self.usage["cache_read_tokens"] = max(
187
+ self.usage["cache_read_tokens"], int(meta.get("cachedContentTokenCount") or 0)
188
+ )
189
+ return
190
+ usage = body.get("usage")
191
+ if not isinstance(usage, dict):
192
+ return
193
+ if isinstance(usage.get("cost"), (int, float)):
194
+ self.cost = (self.cost or 0.0) + float(usage["cost"])
195
+ if "prompt_tokens" in usage or "completion_tokens" in usage:
196
+ details = usage.get("prompt_tokens_details") or {}
197
+ self.usage["input_tokens"] = max(self.usage["input_tokens"], int(usage.get("prompt_tokens") or 0))
198
+ self.usage["output_tokens"] = max(self.usage["output_tokens"], int(usage.get("completion_tokens") or 0))
199
+ self.usage["cache_read_tokens"] = max(
200
+ self.usage["cache_read_tokens"], int(details.get("cached_tokens") or 0)
201
+ )
202
+ return
203
+ if "input_tokens" in usage and "output_tokens" in usage and "input_tokens_details" in usage:
204
+ details = usage.get("input_tokens_details") or {} # Responses API
205
+ self.usage["input_tokens"] = max(self.usage["input_tokens"], int(usage.get("input_tokens") or 0))
206
+ self.usage["output_tokens"] = max(self.usage["output_tokens"], int(usage.get("output_tokens") or 0))
207
+ self.usage["cache_read_tokens"] = max(
208
+ self.usage["cache_read_tokens"], int(details.get("cached_tokens") or 0)
209
+ )
210
+ return
211
+ read = int(usage.get("cache_read_input_tokens") or 0)
212
+ write = int(usage.get("cache_creation_input_tokens") or 0)
213
+ if "input_tokens" in usage:
214
+ self.usage["input_tokens"] = max(
215
+ self.usage["input_tokens"], int(usage.get("input_tokens") or 0) + read + write
216
+ )
217
+ self.usage["cache_read_tokens"] = max(self.usage["cache_read_tokens"], read)
218
+ self.usage["cache_write_tokens"] = max(self.usage["cache_write_tokens"], write)
219
+ if "output_tokens" in usage:
220
+ self.usage["output_tokens"] = max(self.usage["output_tokens"], int(usage.get("output_tokens") or 0))
221
+
222
+ def feed_sse(self, chunk: bytes) -> None:
223
+ self._buffer += chunk
224
+ while b"\n" in self._buffer:
225
+ line, self._buffer = self._buffer.split(b"\n", 1)
226
+ line = line.strip()
227
+ if line.startswith(b"data:"):
228
+ data = line[5:].strip()
229
+ if data and data != b"[DONE]":
230
+ with contextlib.suppress(ValueError):
231
+ self.feed_json(json.loads(data))
232
+
233
+
234
+ def rewrite_body(api: str, raw: bytes, model: Optional[str]) -> Tuple[bytes, str]:
235
+ """Pin the model and ask the provider to report usage. Returns (body, model the agent asked for)."""
236
+ try:
237
+ body = json.loads(raw) if raw else None
238
+ except ValueError:
239
+ return raw, ""
240
+ if not isinstance(body, dict):
241
+ return raw, ""
242
+ requested = str(body.get("model", ""))
243
+ if model and "model" in body:
244
+ body["model"] = model
245
+ if api == "openai":
246
+ body["usage"] = {"include": True}
247
+ if body.get("stream") and "messages" in body:
248
+ options = body.get("stream_options")
249
+ merged: Dict[str, Any] = dict(options) if isinstance(options, dict) else {}
250
+ merged["include_usage"] = True
251
+ body["stream_options"] = merged
252
+ return json.dumps(body).encode(), requested
253
+
254
+
255
+ def clean_path(rest: str) -> Optional[str]:
256
+ """Percent-decode a request path once; None if it could climb out of its route (dot segments, encoded slashes)."""
257
+ low = rest.lower()
258
+ if "%2f" in low or "%5c" in low or "%25" in low or "\\" in rest:
259
+ return None
260
+ path = unquote(rest)
261
+ if any(ord(ch) < 32 or ord(ch) == 127 for ch in path) or "//" in path:
262
+ return None
263
+ if any(segment in (".", "..") for segment in path.split("/")):
264
+ return None
265
+ return path
266
+
267
+
268
+ def route(api: str, method: str, rest: str, pinned: Optional[str]) -> Optional[Tuple[str, str]]:
269
+ """Check a path against the API's allow-list. Returns (upstream path, model named in the path), or None to refuse.
270
+
271
+ Gemini names its model in the path, so the pin is applied here rather than in the body."""
272
+ path = clean_path(rest)
273
+ if path is None:
274
+ return None
275
+ for verb, pattern in _ROUTES[api]:
276
+ found = pattern.fullmatch(path)
277
+ if verb == method and found:
278
+ requested = found.groupdict().get("model") or ""
279
+ if requested and pinned:
280
+ path = f"/v1beta/models/{quote(pinned, safe='')}:{found.group('verb')}"
281
+ return path, requested
282
+ return None
283
+
284
+
285
+ class BadBody(Exception):
286
+ def __init__(self, status: int, message: str):
287
+ super().__init__(message)
288
+ self.status = status
289
+
290
+
291
+ class Handler(BaseHTTPRequestHandler):
292
+ server_version = "obey-gateway"
293
+ protocol_version = "HTTP/1.1"
294
+ config: Config
295
+
296
+ def log_message(self, format: str, *args: Any) -> None:
297
+ pass
298
+
299
+ def _reply(self, status: int, payload: Dict[str, Any], close: bool = False) -> None:
300
+ data = json.dumps(payload).encode()
301
+ self.send_response(status)
302
+ self.send_header("Content-Type", "application/json")
303
+ self.send_header("Content-Length", str(len(data)))
304
+ if close: # the request body was not read, so the connection cannot be reused
305
+ self.send_header("Connection", "close")
306
+ self.end_headers()
307
+ self.wfile.write(data)
308
+
309
+ def _guard(self) -> bool:
310
+ cfg = self.config
311
+ if self.headers.get("Origin"):
312
+ self._reply(403, {"error": {"message": "browser requests are not accepted"}}, close=True)
313
+ return False
314
+ host = (self.headers.get("Host") or "").lower()
315
+ if cfg.allowed_hosts is not None and host not in cfg.allowed_hosts:
316
+ self._reply(403, {"error": {"message": "unexpected Host header"}}, close=True)
317
+ return False
318
+ return True
319
+
320
+ def _authorised(self, api: str, query: str) -> bool:
321
+ """The agent must present the batch's dummy token (bearer, x-api-key, x-goog-api-key or Gemini's ?key=)."""
322
+ supplied = [self.headers.get("x-api-key") or "", self.headers.get("x-goog-api-key") or ""]
323
+ auth = self.headers.get("Authorization") or ""
324
+ if auth[:7].lower() == "bearer ":
325
+ supplied.append(auth[7:].strip())
326
+ if api == "gemini":
327
+ supplied += parse_qs(query).get("key", [])
328
+ token = self.config.token.encode()
329
+ return any(value and hmac.compare_digest(value.encode("utf-8", "replace"), token) for value in supplied)
330
+
331
+ def _read_body(self) -> bytes:
332
+ if "chunked" in (self.headers.get("Transfer-Encoding") or "").lower():
333
+ return self._read_chunked()
334
+ text = (self.headers.get("Content-Length") or "0").strip()
335
+ if not (text.isascii() and text.isdigit()):
336
+ raise BadBody(400, "bad Content-Length")
337
+ length = int(text)
338
+ if length > MAX_BODY:
339
+ raise BadBody(413, "request body too large")
340
+ data = self.rfile.read(length) if length else b""
341
+ if len(data) < length:
342
+ raise BadBody(400, "request body ended early")
343
+ return data
344
+
345
+ def _read_chunked(self) -> bytes:
346
+ body = bytearray()
347
+ while True:
348
+ size_text = self.rfile.readline(1024).split(b";", 1)[0].strip()
349
+ if not _CHUNK_SIZE_RE.fullmatch(size_text):
350
+ raise BadBody(400, "bad chunk size")
351
+ size = int(size_text, 16)
352
+ if size == 0:
353
+ break
354
+ if len(body) + size > MAX_BODY:
355
+ raise BadBody(413, "request body too large")
356
+ chunk = self.rfile.read(size)
357
+ if len(chunk) < size or self.rfile.read(2) != b"\r\n":
358
+ raise BadBody(400, "bad chunked body")
359
+ body += chunk
360
+ while self.rfile.readline(1024).strip(): # trailers
361
+ pass
362
+ return bytes(body)
363
+
364
+ def do_GET(self) -> None:
365
+ self._forward("GET")
366
+
367
+ def do_POST(self) -> None:
368
+ self._forward("POST")
369
+
370
+ def _forward(self, method: str) -> None:
371
+ if not self._guard():
372
+ return
373
+ raw_path, _, query = self.path.partition("?")
374
+ match = _PATH_RE.fullmatch(raw_path)
375
+ if not match:
376
+ self._reply(404, {"error": {"message": "use /r/<run_id>/<anthropic|openai|gemini>/..."}}, close=True)
377
+ return
378
+ run_id, api, rest = match.group(1), match.group(2), match.group(3) or "/"
379
+ if not self._authorised(api, query):
380
+ self._reply(401, {"error": {"message": "missing or wrong gateway token"}}, close=True)
381
+ return
382
+ try:
383
+ raw = self._read_body()
384
+ except BadBody as exc:
385
+ self._reply(exc.status, {"error": {"message": str(exc)}}, close=True)
386
+ return
387
+ if not self.config.known_run(run_id):
388
+ self._reply(403, {"error": {"message": "unknown run id"}})
389
+ return
390
+ routed = route(api, method, rest, self.config.model)
391
+ if routed is None:
392
+ self._reply(403, {"error": {"message": "path not allowed"}})
393
+ return
394
+ upath, path_model = routed
395
+ reason = self.config.over_cap(run_id)
396
+ if reason:
397
+ self._reply(402, {"type": "error", "error": {"type": "budget_exceeded", "message": reason}})
398
+ return
399
+ body, requested = rewrite_body(api, raw, self.config.model) if method == "POST" else (raw, "")
400
+ requested = requested or path_model
401
+ base = urlsplit(self.config.upstreams[api])
402
+ headers = {k: v for k, v in self.headers.items() if k.lower() not in _HOP_HEADERS}
403
+ if api == "gemini":
404
+ headers["x-goog-api-key"] = self.config.key
405
+ query = "&".join(q for q in query.split("&") if not q.startswith("key="))
406
+ else:
407
+ headers["Authorization"] = f"Bearer {self.config.key}"
408
+ path = base.path.rstrip("/") + upath + (f"?{query}" if query else "")
409
+ if method == "POST":
410
+ headers["Content-Length"] = str(len(body))
411
+ headers["Accept-Encoding"] = "identity"
412
+ conn_cls = http.client.HTTPSConnection if base.scheme == "https" else http.client.HTTPConnection
413
+ conn = conn_cls(base.netloc, timeout=600)
414
+ meter = UsageMeter()
415
+ started = False # response headers are on the wire, so a failure can no longer become a 502
416
+ streaming = False
417
+ collected = b""
418
+ settled = False
419
+
420
+ def settle() -> None:
421
+ # Record the call before the response ends, so the client's next request sees this cost in the caps.
422
+ nonlocal settled
423
+ if settled:
424
+ return
425
+ settled = True
426
+ if not streaming and collected:
427
+ with contextlib.suppress(ValueError):
428
+ meter.feed_json(json.loads(collected))
429
+ if method == "POST" and (any(meter.usage.values()) or meter.cost):
430
+ self.config.record(run_id, requested, meter.usage, meter.cost)
431
+
432
+ try:
433
+ conn.request(method, path, body=body if method == "POST" else None, headers=headers)
434
+ resp = conn.getresponse()
435
+ streaming = "text/event-stream" in (resp.getheader("Content-Type") or "")
436
+ self.send_response(resp.status)
437
+ for key, value in resp.getheaders():
438
+ if key.lower() not in _RESPONSE_SKIP:
439
+ self.send_header(key, value)
440
+ if resp.status in (204, 304): # no body, so no chunked framing either
441
+ self.end_headers()
442
+ return
443
+ self.send_header("Transfer-Encoding", "chunked")
444
+ self.end_headers()
445
+ started = True
446
+ while True:
447
+ chunk = resp.read1(65536) if hasattr(resp, "read1") else resp.read(65536)
448
+ if not chunk:
449
+ break
450
+ if streaming:
451
+ meter.feed_sse(chunk)
452
+ elif len(collected) < 8_000_000:
453
+ collected += chunk
454
+ self.wfile.write(f"{len(chunk):x}\r\n".encode() + chunk + b"\r\n")
455
+ self.wfile.flush()
456
+ if resp.length: # read1 does not notice an upstream that closed before its Content-Length
457
+ raise http.client.IncompleteRead(b"", resp.length)
458
+ settle()
459
+ self.wfile.write(b"0\r\n\r\n")
460
+ except (OSError, http.client.HTTPException) as exc:
461
+ self.close_connection = True
462
+ settle()
463
+ with contextlib.suppress(OSError):
464
+ if started: # end the chunked body here instead of writing a second response into it
465
+ self.wfile.write(b"0\r\n\r\n")
466
+ else:
467
+ self._reply(502, {"error": {"message": f"upstream error: {type(exc).__name__}"}})
468
+ finally:
469
+ conn.close()
470
+ settle()
471
+
472
+
473
+ def make_server(listen: str, config: Config) -> ThreadingHTTPServer:
474
+ host, _, port = listen.rpartition(":")
475
+ handler = type("BoundHandler", (Handler,), {"config": config})
476
+ server = ThreadingHTTPServer((host or "127.0.0.1", int(port)), handler)
477
+ server.daemon_threads = True
478
+ return server
479
+
480
+
481
+ def main(argv: Optional[List[str]] = None) -> int:
482
+ parser = argparse.ArgumentParser(description="obey model gateway")
483
+ parser.add_argument("--listen", default="127.0.0.1:8787")
484
+ parser.add_argument("--model", default=None, help="pin every request to this model")
485
+ parser.add_argument("--ledger", default=None)
486
+ parser.add_argument("--price-prompt", type=float, default=0.0)
487
+ parser.add_argument("--price-completion", type=float, default=0.0)
488
+ parser.add_argument("--price-cache-read", type=float, default=None)
489
+ parser.add_argument("--price-cache-write", type=float, default=None)
490
+ parser.add_argument("--run-cap", type=float, default=None, help="USD per run")
491
+ parser.add_argument("--budget", type=float, default=None, help="USD for everything in the ledger")
492
+ parser.add_argument("--upstream", action="append", default=[], help="api=https://base (repeatable)")
493
+ parser.add_argument("--allowed-host", action="append", default=None, help="accepted Host header (repeatable)")
494
+ parser.add_argument("--token", default=DUMMY_TOKEN, help="token agents must present instead of a real key")
495
+ parser.add_argument("--run-id", action="append", default=None, help="accepted run id (repeatable; default: any)")
496
+ args = parser.parse_args(argv)
497
+ key = os.environ.get("OBEY_UPSTREAM_KEY", "")
498
+ if not key:
499
+ print("OBEY_UPSTREAM_KEY is not set", file=sys.stderr)
500
+ return 2
501
+ upstreams = dict(UPSTREAMS)
502
+ for item in args.upstream:
503
+ api, _, url = item.partition("=")
504
+ upstreams[api] = url
505
+ price = {"prompt": args.price_prompt, "completion": args.price_completion}
506
+ if args.price_cache_read is not None:
507
+ price["cache_read"] = args.price_cache_read
508
+ if args.price_cache_write is not None:
509
+ price["cache_write"] = args.price_cache_write
510
+ config = Config(
511
+ key,
512
+ args.model,
513
+ args.ledger,
514
+ price,
515
+ args.run_cap,
516
+ args.budget,
517
+ upstreams,
518
+ [h.lower() for h in args.allowed_host] if args.allowed_host else None,
519
+ args.token,
520
+ args.run_id,
521
+ )
522
+ server = make_server(args.listen, config)
523
+ print(f"gateway listening on {args.listen}", flush=True)
524
+ server.serve_forever()
525
+ return 0
526
+
527
+
528
+ if __name__ == "__main__":
529
+ sys.exit(main())
@@ -0,0 +1,132 @@
1
+ """Spend ledger: every model call is recorded, and runs stop before the budget is exhausted.
2
+
3
+ The ledger is an append-only JSON-lines file guarded by an advisory lock, so the model gateway (which records
4
+ usage as responses stream through) and the runner (which checks the budget before starting a run) can share it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ import fcntl
11
+ import json
12
+ import time
13
+ from collections.abc import Iterator, Mapping
14
+ from dataclasses import asdict, dataclass
15
+ from pathlib import Path
16
+ from typing import IO, Any
17
+
18
+
19
+ class BudgetExceeded(RuntimeError):
20
+ pass
21
+
22
+
23
+ @dataclass
24
+ class Price:
25
+ """USD per token."""
26
+
27
+ prompt: float
28
+ completion: float
29
+ cache_read: float | None = None
30
+ cache_write: float | None = None
31
+
32
+ def cost(self, usage: Mapping[str, int]) -> float:
33
+ cached = int(usage.get("cache_read_tokens", 0))
34
+ written = int(usage.get("cache_write_tokens", 0))
35
+ fresh = max(0, int(usage.get("input_tokens", 0)) - cached - written)
36
+ total = fresh * self.prompt + int(usage.get("output_tokens", 0)) * self.completion
37
+ total += cached * (self.cache_read if self.cache_read is not None else self.prompt)
38
+ total += written * (self.cache_write if self.cache_write is not None else self.prompt)
39
+ return total
40
+
41
+
42
+ @dataclass
43
+ class Entry:
44
+ ts: float
45
+ run_id: str
46
+ kind: str # agent | llm | estimate
47
+ model: str
48
+ input_tokens: int = 0
49
+ output_tokens: int = 0
50
+ cache_read_tokens: int = 0
51
+ cache_write_tokens: int = 0
52
+ cost_usd: float = 0.0
53
+ cost_source: str = "price_table" # provider | price_table
54
+
55
+
56
+ @contextlib.contextmanager
57
+ def _locked(path: Path, mode: str) -> Iterator[IO[str]]:
58
+ path.parent.mkdir(parents=True, exist_ok=True)
59
+ with path.open(mode, encoding="utf-8") as fh:
60
+ fcntl.flock(fh.fileno(), fcntl.LOCK_EX if "a" in mode or "w" in mode else fcntl.LOCK_SH)
61
+ try:
62
+ yield fh
63
+ finally:
64
+ fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
65
+
66
+
67
+ class Ledger:
68
+ def __init__(self, path: Path, budget_usd: float | None = None):
69
+ self.path = path
70
+ self.budget_usd = budget_usd
71
+
72
+ def record(self, entry: Entry) -> None:
73
+ with _locked(self.path, "a") as fh:
74
+ fh.write(json.dumps(asdict(entry), sort_keys=True) + "\n")
75
+
76
+ def entries(self) -> list[Entry]:
77
+ if not self.path.exists():
78
+ return []
79
+ with _locked(self.path, "r") as fh:
80
+ rows = [json.loads(line) for line in fh if line.strip()]
81
+ return [Entry(**row) for row in rows]
82
+
83
+ def spent(self, run_id: str | None = None) -> float:
84
+ return sum(e.cost_usd for e in self.entries() if run_id is None or e.run_id == run_id)
85
+
86
+ def remaining(self) -> float | None:
87
+ return None if self.budget_usd is None else self.budget_usd - self.spent()
88
+
89
+ def check(self, next_cost_usd: float = 0.0) -> None:
90
+ """Raise BudgetExceeded when spending `next_cost_usd` more would cross the budget."""
91
+ if self.budget_usd is None:
92
+ return
93
+ spent = self.spent()
94
+ if spent + next_cost_usd > self.budget_usd:
95
+ raise BudgetExceeded(
96
+ f"budget ${self.budget_usd:.2f}: spent ${spent:.4f}, next run estimated at ${next_cost_usd:.4f}"
97
+ )
98
+
99
+
100
+ def usage_from_response(body: Mapping[str, Any]) -> dict[str, int]:
101
+ """Normalise OpenAI- and Anthropic-style usage blocks to input/output/cache token counts."""
102
+ usage = body.get("usage") or {}
103
+ if not isinstance(usage, Mapping):
104
+ return {}
105
+ if "prompt_tokens" in usage or "completion_tokens" in usage:
106
+ details = usage.get("prompt_tokens_details") or {}
107
+ return {
108
+ "input_tokens": int(usage.get("prompt_tokens") or 0),
109
+ "output_tokens": int(usage.get("completion_tokens") or 0),
110
+ "cache_read_tokens": int(details.get("cached_tokens") or 0),
111
+ "cache_write_tokens": int(details.get("cache_write_tokens") or 0),
112
+ }
113
+ read = int(usage.get("cache_read_input_tokens") or 0)
114
+ write = int(usage.get("cache_creation_input_tokens") or 0)
115
+ return {
116
+ # Anthropic counts cached tokens separately from input_tokens.
117
+ "input_tokens": int(usage.get("input_tokens") or 0) + read + write,
118
+ "output_tokens": int(usage.get("output_tokens") or 0),
119
+ "cache_read_tokens": read,
120
+ "cache_write_tokens": write,
121
+ }
122
+
123
+
124
+ def provider_cost(body: Mapping[str, Any]) -> float | None:
125
+ usage = body.get("usage") or {}
126
+ if isinstance(usage, Mapping) and isinstance(usage.get("cost"), int | float):
127
+ return float(usage["cost"])
128
+ return None
129
+
130
+
131
+ def now() -> float:
132
+ return time.time()