mcp-web-engine 1.0.4 → 1.2.0
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.
- package/README.md +62 -103
- package/bin/cli.js +20 -88
- package/docs/operations.md +15 -0
- package/package.json +5 -3
- package/pyproject.toml +58 -0
- package/requirements.txt +1 -9
- package/src/mcp_web_engine/__init__.py +3 -0
- package/src/mcp_web_engine/config.py +43 -0
- package/src/mcp_web_engine/logging_obs.py +142 -0
- package/src/mcp_web_engine/main.py +281 -0
- package/{manage_beta_keys.py → src/mcp_web_engine/manage_beta_keys.py} +30 -9
- package/src/mcp_web_engine/mcp_protocol.py +166 -0
- package/{mcp_tools.py → src/mcp_web_engine/mcp_tools.py} +10 -7
- package/src/mcp_web_engine/resource_limits.py +45 -0
- package/src/mcp_web_engine/security.py +339 -0
- package/src/mcp_web_engine/state_store.py +133 -0
- package/src/mcp_web_engine/web_engine.py +315 -0
- package/config.py +0 -24
- package/logging_obs.py +0 -111
- package/main.py +0 -230
- package/mcp_protocol.py +0 -210
- package/security.py +0 -169
- package/web_engine.py +0 -181
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hardened Security Module: SSRF Protection, IP/DNS Validation, Multi-Key Auth (Master & Beta Keys) & Rate Limiting
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import ipaddress
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import socket
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
import urllib.parse
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
|
|
17
|
+
from fastapi import HTTPException, Security
|
|
18
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
19
|
+
|
|
20
|
+
from . import state_store
|
|
21
|
+
from .config import settings
|
|
22
|
+
|
|
23
|
+
security_bearer = HTTPBearer(auto_error=False)
|
|
24
|
+
_security_credentials = Security(security_bearer)
|
|
25
|
+
|
|
26
|
+
# Compatibility/debug mirror only; clearing it NEVER resets durable usage.
|
|
27
|
+
rate_limit_records = {}
|
|
28
|
+
_rate_lock = threading.RLock()
|
|
29
|
+
MAX_RATE_BUCKETS = 4096
|
|
30
|
+
BETA_KEYS_FILE = str(state_store.beta_keys_path())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _secret_matches(token, expected):
|
|
34
|
+
return (
|
|
35
|
+
isinstance(token, str)
|
|
36
|
+
and isinstance(expected, str)
|
|
37
|
+
and bool(token)
|
|
38
|
+
and bool(expected)
|
|
39
|
+
and len(token) <= 1024
|
|
40
|
+
and hmac.compare_digest(token.encode("utf-8"), expected.encode("utf-8"))
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _is_master_key(token):
|
|
45
|
+
return _secret_matches(token, settings.API_KEY)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _key_id(token):
|
|
49
|
+
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _beta_record(token):
|
|
53
|
+
"""Re-read operator state on every admission; malformed state fails closed."""
|
|
54
|
+
if not isinstance(token, str) or not token or len(token) > 1024:
|
|
55
|
+
return None
|
|
56
|
+
try:
|
|
57
|
+
with open(BETA_KEYS_FILE, "r", encoding="utf-8") as stream:
|
|
58
|
+
raw = stream.read(1024 * 1024 + 1)
|
|
59
|
+
if len(raw) > 1024 * 1024:
|
|
60
|
+
return None
|
|
61
|
+
keys = json.loads(raw)
|
|
62
|
+
if not isinstance(keys, dict):
|
|
63
|
+
return None
|
|
64
|
+
record = next(
|
|
65
|
+
(value for key, value in keys.items() if _secret_matches(token, key)), None
|
|
66
|
+
)
|
|
67
|
+
if not isinstance(record, dict) or record.get("status") != "active":
|
|
68
|
+
return None
|
|
69
|
+
limit = record.get("limit")
|
|
70
|
+
rate = record.get("rate_limit", settings.RATE_LIMIT_PER_MINUTE)
|
|
71
|
+
telemetry = record.get("telemetry", {})
|
|
72
|
+
if not isinstance(telemetry, dict):
|
|
73
|
+
return None
|
|
74
|
+
requests = telemetry.get("requests", 0)
|
|
75
|
+
if any(
|
|
76
|
+
type(value) is not int or value < 0 for value in (limit, rate, requests)
|
|
77
|
+
):
|
|
78
|
+
return None
|
|
79
|
+
if rate == 0:
|
|
80
|
+
return None
|
|
81
|
+
expires = record.get("expires_at")
|
|
82
|
+
if expires is not None:
|
|
83
|
+
if not isinstance(expires, str):
|
|
84
|
+
return None
|
|
85
|
+
expiry = datetime.fromisoformat(expires.replace("Z", "+00:00"))
|
|
86
|
+
if expiry.tzinfo is None or expiry <= datetime.now(timezone.utc):
|
|
87
|
+
return None
|
|
88
|
+
return record
|
|
89
|
+
except (OSError, ValueError, TypeError, AttributeError):
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def is_beta_key_valid(token: str) -> bool:
|
|
94
|
+
record = _beta_record(token)
|
|
95
|
+
if record is None:
|
|
96
|
+
return False
|
|
97
|
+
try:
|
|
98
|
+
usage = state_store.read_usage(_key_id(token))
|
|
99
|
+
except state_store.StateUnavailable:
|
|
100
|
+
return False
|
|
101
|
+
return max(record.get("telemetry", {}).get("requests", 0),
|
|
102
|
+
usage.get("admissions", 0)) < record["limit"]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# Stable special-purpose exclusions supplement ipaddress's versioned registry.
|
|
106
|
+
# Conservative policy also excludes globally reachable anycast exceptions here.
|
|
107
|
+
PRIVATE_NETWORKS = tuple(
|
|
108
|
+
ipaddress.ip_network(net)
|
|
109
|
+
for net in (
|
|
110
|
+
"0.0.0.0/8",
|
|
111
|
+
"10.0.0.0/8",
|
|
112
|
+
"100.64.0.0/10",
|
|
113
|
+
"127.0.0.0/8",
|
|
114
|
+
"169.254.0.0/16",
|
|
115
|
+
"172.16.0.0/12",
|
|
116
|
+
"192.0.0.0/24",
|
|
117
|
+
"192.0.2.0/24",
|
|
118
|
+
"192.88.99.0/24",
|
|
119
|
+
"192.168.0.0/16",
|
|
120
|
+
"198.18.0.0/15",
|
|
121
|
+
"198.51.100.0/24",
|
|
122
|
+
"203.0.113.0/24",
|
|
123
|
+
"224.0.0.0/4",
|
|
124
|
+
"240.0.0.0/4",
|
|
125
|
+
"::/128",
|
|
126
|
+
"::1/128",
|
|
127
|
+
"fc00::/7",
|
|
128
|
+
"fe80::/10",
|
|
129
|
+
"ff00::/8",
|
|
130
|
+
"2001::/23",
|
|
131
|
+
"2001:db8::/32",
|
|
132
|
+
"3fff::/20",
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
# IPv6 transition mechanisms can route a global-looking address into IPv4.
|
|
136
|
+
TRANSITION_NETWORKS = tuple(
|
|
137
|
+
ipaddress.ip_network(net)
|
|
138
|
+
for net in (
|
|
139
|
+
"64:ff9b::/96",
|
|
140
|
+
"64:ff9b:1::/48",
|
|
141
|
+
"2001::/32",
|
|
142
|
+
"2002::/16",
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
FORBIDDEN_HOSTNAMES = {"localhost", "loopback", "broadcasthost", "local"}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def is_ip_private(ip_str: str) -> bool:
|
|
149
|
+
"""Historical API name: True means *not a public unicast destination*."""
|
|
150
|
+
try:
|
|
151
|
+
ip = ipaddress.ip_address(int(ip_str) if ip_str.isdigit() else ip_str)
|
|
152
|
+
if isinstance(ip, ipaddress.IPv6Address):
|
|
153
|
+
if ip.ipv4_mapped is not None:
|
|
154
|
+
return is_ip_private(str(ip.ipv4_mapped))
|
|
155
|
+
if any(ip in net for net in TRANSITION_NETWORKS):
|
|
156
|
+
return True
|
|
157
|
+
if ip not in ipaddress.ip_network("2000::/3"):
|
|
158
|
+
return True
|
|
159
|
+
if any(ip.version == net.version and ip in net for net in PRIVATE_NETWORKS):
|
|
160
|
+
return True
|
|
161
|
+
return (
|
|
162
|
+
not ip.is_global or ip.is_multicast or ip.is_reserved or ip.is_unspecified
|
|
163
|
+
)
|
|
164
|
+
except ValueError:
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _url_error(
|
|
169
|
+
code="INVALID_URL", message="Malformed or ambiguous HTTP URL.", status_code=400
|
|
170
|
+
):
|
|
171
|
+
return HTTPException(
|
|
172
|
+
status_code=status_code, detail={"error": code, "message": message}
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass(frozen=True)
|
|
177
|
+
class ValidatedTarget:
|
|
178
|
+
url: str
|
|
179
|
+
hostname: str
|
|
180
|
+
port: int
|
|
181
|
+
addresses: tuple
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def resolve_entry(self):
|
|
185
|
+
# One validated address, no DNS fallback. URL retains Host and TLS SNI.
|
|
186
|
+
address = self.addresses[0]
|
|
187
|
+
if ":" in address:
|
|
188
|
+
address = f"[{address}]"
|
|
189
|
+
host = f"[{self.hostname}]" if ":" in self.hostname else self.hostname
|
|
190
|
+
return f"{host}:{self.port}:{address}"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _parse_http_url(url_str):
|
|
194
|
+
if not isinstance(url_str, str) or not url_str or len(url_str) > 16384:
|
|
195
|
+
raise _url_error()
|
|
196
|
+
# urlsplit silently removes controls/leading whitespace; libcurl need not.
|
|
197
|
+
if "\\" in url_str or any(
|
|
198
|
+
ord(c) <= 32 or ord(c) == 127 or c.isspace() for c in url_str
|
|
199
|
+
):
|
|
200
|
+
raise _url_error()
|
|
201
|
+
try:
|
|
202
|
+
parsed = urllib.parse.urlsplit(url_str)
|
|
203
|
+
if parsed.scheme not in ("http", "https"):
|
|
204
|
+
raise _url_error("INVALID_SCHEME", "Only http/https are supported.")
|
|
205
|
+
# Never decode authorities or accept userinfo/IPv6 zone identifiers.
|
|
206
|
+
if not parsed.netloc or any(c in parsed.netloc for c in ("%", "@")):
|
|
207
|
+
raise _url_error()
|
|
208
|
+
hostname = parsed.hostname
|
|
209
|
+
port = parsed.port
|
|
210
|
+
if not hostname or port == 0 or parsed.netloc.endswith(":"):
|
|
211
|
+
raise _url_error()
|
|
212
|
+
port = port or (443 if parsed.scheme == "https" else 80)
|
|
213
|
+
try:
|
|
214
|
+
ip = ipaddress.ip_address(hostname)
|
|
215
|
+
except ValueError:
|
|
216
|
+
hostname = hostname.encode("idna").decode("ascii").lower().rstrip(".")
|
|
217
|
+
labels = hostname.split(".")
|
|
218
|
+
if (
|
|
219
|
+
not hostname
|
|
220
|
+
or len(hostname) > 253
|
|
221
|
+
or any(
|
|
222
|
+
not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", label)
|
|
223
|
+
for label in labels
|
|
224
|
+
)
|
|
225
|
+
):
|
|
226
|
+
raise _url_error()
|
|
227
|
+
# libcurl interprets legacy numeric IPv4 as literals, ignoring DNS.
|
|
228
|
+
try:
|
|
229
|
+
socket.inet_aton(hostname)
|
|
230
|
+
except OSError:
|
|
231
|
+
pass
|
|
232
|
+
else:
|
|
233
|
+
raise _url_error()
|
|
234
|
+
else:
|
|
235
|
+
hostname = str(ip)
|
|
236
|
+
authority = f"[{hostname}]" if ":" in hostname else hostname
|
|
237
|
+
if parsed.port is not None:
|
|
238
|
+
authority += f":{port}"
|
|
239
|
+
# Encode Unicode path/query once. Never unquote URL data or authority.
|
|
240
|
+
path = urllib.parse.quote(parsed.path or "/", safe="/%:@!$&'()*+,;=-._~")
|
|
241
|
+
query = urllib.parse.quote(parsed.query, safe="/%?:@!$&'()*+,;=-._~[]")
|
|
242
|
+
canonical = urllib.parse.urlunsplit((parsed.scheme, authority, path, query, ""))
|
|
243
|
+
return canonical, hostname, port
|
|
244
|
+
except (ValueError, UnicodeError):
|
|
245
|
+
raise _url_error() from None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def resolve_ssrf_target(url_str: str) -> ValidatedTarget:
|
|
249
|
+
"""Resolve once; fetchers MUST pin these exact addresses into the transport."""
|
|
250
|
+
canonical, hostname, port = _parse_http_url(url_str)
|
|
251
|
+
if hostname in FORBIDDEN_HOSTNAMES or hostname.endswith((".local", ".localhost")):
|
|
252
|
+
raise _url_error("SSRF_BLOCKED", "Local hostname is blocked.", 403)
|
|
253
|
+
try:
|
|
254
|
+
literal = ipaddress.ip_address(hostname)
|
|
255
|
+
except ValueError:
|
|
256
|
+
try:
|
|
257
|
+
records = socket.getaddrinfo(hostname, port, type=socket.SOCK_STREAM)
|
|
258
|
+
except OSError:
|
|
259
|
+
raise _url_error(
|
|
260
|
+
"DNS_RESOLUTION_FAILED", "Could not resolve target hostname."
|
|
261
|
+
) from None
|
|
262
|
+
addresses = tuple(dict.fromkeys(record[4][0] for record in records))
|
|
263
|
+
else:
|
|
264
|
+
addresses = (str(literal),)
|
|
265
|
+
if not addresses:
|
|
266
|
+
raise _url_error("DNS_RESOLUTION_FAILED", "No destination addresses returned.")
|
|
267
|
+
for address in addresses:
|
|
268
|
+
try:
|
|
269
|
+
ipaddress.ip_address(address)
|
|
270
|
+
except ValueError:
|
|
271
|
+
raise _url_error(
|
|
272
|
+
"SSRF_BLOCKED", "Invalid destination address.", 403
|
|
273
|
+
) from None
|
|
274
|
+
if is_ip_private(address):
|
|
275
|
+
raise _url_error(
|
|
276
|
+
"SSRF_BLOCKED", "Non-public destination address is blocked.", 403
|
|
277
|
+
)
|
|
278
|
+
return ValidatedTarget(canonical, hostname, port, addresses)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def validate_ssrf_url(url_str: str) -> str:
|
|
282
|
+
"""Compatibility API; fetching requires resolve_ssrf_target, not this alone."""
|
|
283
|
+
return resolve_ssrf_target(url_str).url
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def verify_api_key(auth: HTTPAuthorizationCredentials = _security_credentials):
|
|
287
|
+
if (
|
|
288
|
+
not auth
|
|
289
|
+
or not auth.credentials
|
|
290
|
+
or not isinstance(auth.scheme, str)
|
|
291
|
+
or auth.scheme.lower() != "bearer"
|
|
292
|
+
):
|
|
293
|
+
raise HTTPException(
|
|
294
|
+
401,
|
|
295
|
+
detail={"error": "UNAUTHORIZED", "message": "Invalid or missing API key."},
|
|
296
|
+
)
|
|
297
|
+
token = auth.credentials
|
|
298
|
+
if _is_master_key(token) or is_beta_key_valid(token):
|
|
299
|
+
return token
|
|
300
|
+
raise HTTPException(
|
|
301
|
+
401, detail={"error": "UNAUTHORIZED", "message": "Invalid or revoked API key."}
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def verify_master_api_key(auth: HTTPAuthorizationCredentials = _security_credentials):
|
|
306
|
+
"""Dependency for operator-only telemetry/metrics. Wire at the route layer."""
|
|
307
|
+
token = verify_api_key(auth)
|
|
308
|
+
if not _is_master_key(token):
|
|
309
|
+
raise HTTPException(403, detail={"error": "ADMIN_REQUIRED"})
|
|
310
|
+
return token
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def check_rate_limit(api_key: str):
|
|
314
|
+
"""Common durable admission for HTTP transports; one call per request.
|
|
315
|
+
|
|
316
|
+
Quota counts admitted requests, not tools within a batch. No authorization
|
|
317
|
+
JSON is ever written. All local workers must share MCP_WEB_ENGINE_STATE_DB.
|
|
318
|
+
"""
|
|
319
|
+
master = _is_master_key(api_key)
|
|
320
|
+
record = None if master else _beta_record(api_key)
|
|
321
|
+
if not master and record is None:
|
|
322
|
+
raise HTTPException(401, detail={"error": "UNAUTHORIZED"})
|
|
323
|
+
key = _key_id(api_key)
|
|
324
|
+
rate = settings.RATE_LIMIT_PER_MINUTE
|
|
325
|
+
if record is not None:
|
|
326
|
+
rate = min(rate, record.get("rate_limit", rate))
|
|
327
|
+
try:
|
|
328
|
+
state_store.admit(
|
|
329
|
+
key, rate, quota=record["limit"] if record is not None else None,
|
|
330
|
+
baseline=record.get("telemetry", {}).get("requests", 0) if record is not None else 0,
|
|
331
|
+
capacity=MAX_RATE_BUCKETS, now=time.time(),
|
|
332
|
+
)
|
|
333
|
+
except state_store.AdmissionDenied as exc:
|
|
334
|
+
raise HTTPException(429, detail={"error": exc.code}) from None
|
|
335
|
+
except state_store.StateUnavailable:
|
|
336
|
+
raise HTTPException(503, detail={"error": "USAGE_STATE_UNAVAILABLE"}) from None
|
|
337
|
+
with _rate_lock:
|
|
338
|
+
if len(rate_limit_records) < MAX_RATE_BUCKETS or key in rate_limit_records:
|
|
339
|
+
rate_limit_records[key] = True
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Local-host durable admission/telemetry. Never reads or writes credentials.
|
|
2
|
+
|
|
3
|
+
Every operation owns a connection; BEGIN IMMEDIATE serializes worker admissions.
|
|
4
|
+
Use a local filesystem shared by workers, not a distributed-host SQLite mount.
|
|
5
|
+
Admission totals and completed tool telemetry are deliberately separate counters.
|
|
6
|
+
"""
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import sqlite3
|
|
10
|
+
import time
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AdmissionDenied(Exception):
|
|
17
|
+
def __init__(self, code):
|
|
18
|
+
self.code = code
|
|
19
|
+
super().__init__(code)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class StateUnavailable(Exception):
|
|
23
|
+
"""Storage failed; callers must fail closed without exposing paths/errors."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _state_dir():
|
|
27
|
+
return Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local/state") / "mcp-web-engine"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def state_db_path():
|
|
31
|
+
return Path(os.environ.get("MCP_WEB_ENGINE_STATE_DB") or _state_dir() / "usage.sqlite3")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def beta_keys_path():
|
|
35
|
+
return Path(os.environ.get("MCP_WEB_ENGINE_BETA_KEYS_FILE") or _state_dir() / "beta_keys.json")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@contextmanager
|
|
39
|
+
def _connection(path=None):
|
|
40
|
+
db = None
|
|
41
|
+
try:
|
|
42
|
+
target = Path(path) if path is not None else state_db_path()
|
|
43
|
+
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
44
|
+
# Create with private permissions without changing process-global umask.
|
|
45
|
+
try:
|
|
46
|
+
fd = os.open(target, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
47
|
+
except FileExistsError:
|
|
48
|
+
pass
|
|
49
|
+
else:
|
|
50
|
+
os.close(fd)
|
|
51
|
+
db = sqlite3.connect(str(target), timeout=10, isolation_level=None)
|
|
52
|
+
db.row_factory = sqlite3.Row
|
|
53
|
+
db.execute("PRAGMA busy_timeout=10000")
|
|
54
|
+
db.execute("BEGIN IMMEDIATE")
|
|
55
|
+
db.execute("""CREATE TABLE IF NOT EXISTS usage (
|
|
56
|
+
key_id TEXT PRIMARY KEY,
|
|
57
|
+
window_started REAL NOT NULL DEFAULT 0,
|
|
58
|
+
window_count INTEGER NOT NULL DEFAULT 0 CHECK(window_count >= 0),
|
|
59
|
+
admissions INTEGER NOT NULL DEFAULT 0 CHECK(admissions >= 0),
|
|
60
|
+
requests INTEGER NOT NULL DEFAULT 0 CHECK(requests >= 0),
|
|
61
|
+
errors INTEGER NOT NULL DEFAULT 0 CHECK(errors >= 0),
|
|
62
|
+
units INTEGER NOT NULL DEFAULT 0 CHECK(units >= 0),
|
|
63
|
+
latency_total REAL NOT NULL DEFAULT 0,
|
|
64
|
+
web_search INTEGER NOT NULL DEFAULT 0,
|
|
65
|
+
fetch_url INTEGER NOT NULL DEFAULT 0,
|
|
66
|
+
extract_markdown INTEGER NOT NULL DEFAULT 0,
|
|
67
|
+
last_seen REAL
|
|
68
|
+
)""")
|
|
69
|
+
yield db
|
|
70
|
+
db.commit()
|
|
71
|
+
except (OSError, sqlite3.Error, OverflowError) as exc:
|
|
72
|
+
if db is not None:
|
|
73
|
+
db.rollback()
|
|
74
|
+
raise StateUnavailable("Usage state unavailable") from exc
|
|
75
|
+
finally:
|
|
76
|
+
if db is not None:
|
|
77
|
+
db.close()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _ensure_row(db, key_id, capacity):
|
|
81
|
+
if db.execute("SELECT 1 FROM usage WHERE key_id=?", (key_id,)).fetchone():
|
|
82
|
+
return
|
|
83
|
+
if db.execute("SELECT COUNT(*) FROM usage").fetchone()[0] >= capacity:
|
|
84
|
+
raise AdmissionDenied("RATE_LIMIT_CAPACITY")
|
|
85
|
+
db.execute("INSERT INTO usage(key_id) VALUES (?)", (key_id,))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def admit(key_id, rate, *, quota=None, baseline=0, capacity=4096, now=None, path=None):
|
|
89
|
+
"""Atomically consume one HTTP admission or raise AdmissionDenied.
|
|
90
|
+
|
|
91
|
+
Baseline imports legacy JSON requests via max(), never adding tool counts to
|
|
92
|
+
admission quota. Wall-clock windows survive restart; clock rollback does not
|
|
93
|
+
reset a live window. Operators must rotate tokens rather than delete DB rows.
|
|
94
|
+
"""
|
|
95
|
+
now = time.time() if now is None else now
|
|
96
|
+
with _connection(path) as db:
|
|
97
|
+
_ensure_row(db, key_id, capacity)
|
|
98
|
+
row = db.execute("SELECT * FROM usage WHERE key_id=?", (key_id,)).fetchone()
|
|
99
|
+
used = max(row["admissions"], baseline)
|
|
100
|
+
if quota is not None and used >= quota:
|
|
101
|
+
raise AdmissionDenied("BETA_QUOTA_EXCEEDED")
|
|
102
|
+
start, count = row["window_started"], row["window_count"]
|
|
103
|
+
if now - start >= 60:
|
|
104
|
+
start, count = now, 0
|
|
105
|
+
if count >= rate:
|
|
106
|
+
raise AdmissionDenied("RATE_LIMIT_EXCEEDED")
|
|
107
|
+
db.execute("UPDATE usage SET window_started=?, window_count=?, admissions=? WHERE key_id=?",
|
|
108
|
+
(start, count + 1, used + 1, key_id))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def read_usage(key_id=None, *, path=None) -> dict[str, Any]:
|
|
112
|
+
"""Return hashed-id counters, never credentials; None returns all rows."""
|
|
113
|
+
with _connection(path) as db:
|
|
114
|
+
if key_id is not None:
|
|
115
|
+
row = db.execute("SELECT * FROM usage WHERE key_id=?", (key_id,)).fetchone()
|
|
116
|
+
return dict(row) if row else {}
|
|
117
|
+
return {row["key_id"]: dict(row) for row in db.execute("SELECT * FROM usage")}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def record_telemetry(key_id, tool_name, latency_ms, success, units=1, *, path=None):
|
|
121
|
+
"""Completed tool counters only; cannot authorize or restore a revoked key."""
|
|
122
|
+
if not isinstance(latency_ms, (int, float)) or not math.isfinite(latency_ms) or latency_ms < 0:
|
|
123
|
+
raise ValueError("Invalid latency")
|
|
124
|
+
if type(units) is not int or units < 0:
|
|
125
|
+
raise ValueError("Invalid units")
|
|
126
|
+
with _connection(path) as db:
|
|
127
|
+
_ensure_row(db, key_id, 4096)
|
|
128
|
+
db.execute("""UPDATE usage SET requests=requests+1, errors=errors+?,
|
|
129
|
+
units=units+?, latency_total=latency_total+?, last_seen=?,
|
|
130
|
+
web_search=web_search+?, fetch_url=fetch_url+?, extract_markdown=extract_markdown+?
|
|
131
|
+
WHERE key_id=?""",
|
|
132
|
+
(int(not success), units, latency_ms, time.time(), int(tool_name == "web_search"),
|
|
133
|
+
int(tool_name == "fetch_url"), int(tool_name == "extract_markdown"), key_id))
|