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.
- obey_cli/__init__.py +3 -0
- obey_cli/__main__.py +3 -0
- obey_cli/_data/demo/README.md +12 -0
- obey_cli/_data/demo/obey.yaml +287 -0
- obey_cli/_data/demo/repo/.gitignore +8 -0
- obey_cli/_data/demo/repo/AGENTS.md +38 -0
- obey_cli/_data/demo/repo/README.md +36 -0
- obey_cli/_data/demo/repo/eslint.config.js +24 -0
- obey_cli/_data/demo/repo/index.html +21 -0
- obey_cli/_data/demo/repo/openapi.yaml +86 -0
- obey_cli/_data/demo/repo/package.json +26 -0
- obey_cli/_data/demo/repo/pnpm-lock.yaml +1439 -0
- obey_cli/_data/demo/repo/scripts/gen-types.mjs +109 -0
- obey_cli/_data/demo/repo/src/api/client.test.ts +60 -0
- obey_cli/_data/demo/repo/src/api/client.ts +71 -0
- obey_cli/_data/demo/repo/src/api/response-guards.test.ts +21 -0
- obey_cli/_data/demo/repo/src/api/response-guards.ts +38 -0
- obey_cli/_data/demo/repo/src/date-utils.test.ts +51 -0
- obey_cli/_data/demo/repo/src/date-utils.ts +60 -0
- obey_cli/_data/demo/repo/src/generated/api-types.ts +35 -0
- obey_cli/_data/demo/repo/src/logger.test.ts +24 -0
- obey_cli/_data/demo/repo/src/logger.ts +36 -0
- obey_cli/_data/demo/repo/src/main.ts +30 -0
- obey_cli/_data/demo/repo/src/render-table.test.ts +34 -0
- obey_cli/_data/demo/repo/src/render-table.ts +48 -0
- obey_cli/_data/demo/repo/src/shift-planner.test.ts +66 -0
- obey_cli/_data/demo/repo/src/shift-planner.ts +101 -0
- obey_cli/_data/demo/repo/tsconfig.json +20 -0
- obey_cli/_data/demo/repo/vitest.config.ts +8 -0
- obey_cli/_data/demo/runs/after.jsonl.gz +0 -0
- obey_cli/_data/demo/runs/before.jsonl.gz +0 -0
- obey_cli/_data/demo/runs/meta.json +10 -0
- obey_cli/_data/schemas/obey-config-v1.json +116 -0
- obey_cli/_data/schemas/report-v1.json +55 -0
- obey_cli/_data/schemas/survey-record-v1.json +30 -0
- obey_cli/checkers/__init__.py +30 -0
- obey_cli/checkers/samples.py +95 -0
- obey_cli/cli.py +201 -0
- obey_cli/commands.py +676 -0
- obey_cli/config.py +90 -0
- obey_cli/discover.py +232 -0
- obey_cli/enforce/__init__.py +114 -0
- obey_cli/enforce/engine/__init__.py +0 -0
- obey_cli/enforce/engine/obey_engine.py +2005 -0
- obey_cli/enforce/merge.py +196 -0
- obey_cli/enforce/targets/__init__.py +354 -0
- obey_cli/enforce/templates/obey_hook.py +452 -0
- obey_cli/enforce/templates/pre_commit.py +116 -0
- obey_cli/extract.py +1342 -0
- obey_cli/lint.py +125 -0
- obey_cli/llm.py +129 -0
- obey_cli/parse_md.py +260 -0
- obey_cli/probes/__init__.py +0 -0
- obey_cli/probes/facts.py +162 -0
- obey_cli/probes/templates.py +326 -0
- obey_cli/py.typed +0 -0
- obey_cli/report/__init__.py +314 -0
- obey_cli/resources.py +35 -0
- obey_cli/rules.py +75 -0
- obey_cli/runner/__init__.py +0 -0
- obey_cli/runner/agents/__init__.py +22 -0
- obey_cli/runner/agents/base.py +96 -0
- obey_cli/runner/agents/claude_code.py +203 -0
- obey_cli/runner/agents/codex.py +152 -0
- obey_cli/runner/agents/copilot.py +142 -0
- obey_cli/runner/agents/gemini.py +125 -0
- obey_cli/runner/agents/opencode.py +156 -0
- obey_cli/runner/core.py +140 -0
- obey_cli/runner/egress_proxy.py +166 -0
- obey_cli/runner/gateway.py +529 -0
- obey_cli/runner/ledger.py +132 -0
- obey_cli/runner/local.py +176 -0
- obey_cli/runner/orchestrate.py +227 -0
- obey_cli/runner/sandbox/Dockerfile +41 -0
- obey_cli/runner/sandbox/entrypoint.sh +38 -0
- obey_cli/runner/sandbox.py +341 -0
- obey_cli/runner/sidecar.py +38 -0
- obey_cli/runner/workspace.py +187 -0
- obey_cli/score.py +150 -0
- obey_cli/study/__init__.py +0 -0
- obey_cli/study/analysis.py +277 -0
- obey_cli/study/design.py +98 -0
- obey_cli/study/experiment.py +198 -0
- obey_cli/study/report.py +519 -0
- obey_cli/study/survey.py +438 -0
- obey_cli-0.1.0.dist-info/METADATA +393 -0
- obey_cli-0.1.0.dist-info/RECORD +90 -0
- obey_cli-0.1.0.dist-info/WHEEL +4 -0
- obey_cli-0.1.0.dist-info/entry_points.txt +2 -0
- 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()
|