runlayer-hooks-sdk 0.2.1__tar.gz → 0.2.2__tar.gz

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 (25) hide show
  1. {runlayer_hooks_sdk-0.2.1 → runlayer_hooks_sdk-0.2.2}/.gitignore +10 -0
  2. {runlayer_hooks_sdk-0.2.1 → runlayer_hooks_sdk-0.2.2}/PKG-INFO +1 -1
  3. {runlayer_hooks_sdk-0.2.1 → runlayer_hooks_sdk-0.2.2}/pyproject.toml +1 -1
  4. {runlayer_hooks_sdk-0.2.1 → runlayer_hooks_sdk-0.2.2}/runlayer_sdk/hook_transport.py +48 -11
  5. {runlayer_hooks_sdk-0.2.1 → runlayer_hooks_sdk-0.2.2}/runlayer_sdk/tool_enforcement.py +20 -2
  6. runlayer_hooks_sdk-0.2.2/tests/test_hook_transport.py +121 -0
  7. {runlayer_hooks_sdk-0.2.1 → runlayer_hooks_sdk-0.2.2}/tests/test_tool_enforcement.py +48 -0
  8. {runlayer_hooks_sdk-0.2.1 → runlayer_hooks_sdk-0.2.2}/uv.lock +2 -2
  9. {runlayer_hooks_sdk-0.2.1 → runlayer_hooks_sdk-0.2.2}/LICENSE +0 -0
  10. {runlayer_hooks_sdk-0.2.1 → runlayer_hooks_sdk-0.2.2}/Makefile +0 -0
  11. {runlayer_hooks_sdk-0.2.1 → runlayer_hooks_sdk-0.2.2}/README.md +0 -0
  12. {runlayer_hooks_sdk-0.2.1 → runlayer_hooks_sdk-0.2.2}/runlayer_sdk/__init__.py +0 -0
  13. {runlayer_hooks_sdk-0.2.1 → runlayer_hooks_sdk-0.2.2}/runlayer_sdk/claude_agent_sdk.py +0 -0
  14. {runlayer_hooks_sdk-0.2.1 → runlayer_hooks_sdk-0.2.2}/runlayer_sdk/client.py +0 -0
  15. {runlayer_hooks_sdk-0.2.1 → runlayer_hooks_sdk-0.2.2}/runlayer_sdk/google_adk.py +0 -0
  16. {runlayer_hooks_sdk-0.2.1 → runlayer_hooks_sdk-0.2.2}/runlayer_sdk/openai_agents_sdk.py +0 -0
  17. {runlayer_hooks_sdk-0.2.1 → runlayer_hooks_sdk-0.2.2}/runlayer_sdk/preflight.py +0 -0
  18. {runlayer_hooks_sdk-0.2.1 → runlayer_hooks_sdk-0.2.2}/runlayer_sdk/tool_adapter.py +0 -0
  19. {runlayer_hooks_sdk-0.2.1 → runlayer_hooks_sdk-0.2.2}/runlayer_sdk/types.py +0 -0
  20. {runlayer_hooks_sdk-0.2.1 → runlayer_hooks_sdk-0.2.2}/runlayer_sdk/vercel_ai_sdk.py +0 -0
  21. {runlayer_hooks_sdk-0.2.1 → runlayer_hooks_sdk-0.2.2}/tests/http_mock.py +0 -0
  22. {runlayer_hooks_sdk-0.2.1 → runlayer_hooks_sdk-0.2.2}/tests/test_adapters.py +0 -0
  23. {runlayer_hooks_sdk-0.2.1 → runlayer_hooks_sdk-0.2.2}/tests/test_claude_agent_sdk.py +0 -0
  24. {runlayer_hooks_sdk-0.2.1 → runlayer_hooks_sdk-0.2.2}/tests/test_client.py +0 -0
  25. {runlayer_hooks_sdk-0.2.1 → runlayer_hooks_sdk-0.2.2}/tests/test_license.py +0 -0
@@ -4,6 +4,12 @@
4
4
  .worktrees
5
5
  node_modules/
6
6
  .pnpm-store
7
+
8
+ # Any stray `pnpm` invocation in a bun subproject writes these into the working
9
+ # tree. Ignore by default; re-allow the subprojects that are still on pnpm.
10
+ pnpm-lock.yaml
11
+ pnpm-workspace.yaml
12
+ !/llm-gateway/test/clients/pnpm-lock.yaml
7
13
  /test-results/
8
14
  /playwright-report/
9
15
  /blob-report/
@@ -110,3 +116,7 @@ docs/superpowers/
110
116
  # SDD (subagent-driven-development) task reports — internal artifacts
111
117
  sdd/
112
118
  tmp/
119
+
120
+ # Extractor-emitted load models are artifacts; the committed fixture stays hand-built.
121
+ /load-testing/load-model.json
122
+ /load-testing/model/load-model.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runlayer-hooks-sdk
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Runlayer Hooks SDK for Python agent runtimes
5
5
  Project-URL: Homepage, https://runlayer.com
6
6
  Project-URL: Documentation, https://docs.runlayer.com/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "runlayer-hooks-sdk"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  description = "Runlayer Hooks SDK for Python agent runtimes"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -1,9 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import gzip
3
4
  from collections.abc import Callable
4
5
  from contextlib import AbstractContextManager
5
6
  from dataclasses import dataclass
6
- from typing import Any, Protocol
7
+ from typing import Any, Protocol, TypedDict
7
8
 
8
9
  import httpx
9
10
  from runlayer_sdk.types import FetchLike, FetchResponse
@@ -12,6 +13,38 @@ API_KEY_HEADER_NAME = "x-runlayer-api-key"
12
13
  ENFORCEMENT_HOOK_TIMEOUT_SECONDS = 30
13
14
  EVENT_HOOK_TIMEOUT_SECONDS = 5
14
15
 
16
+ # Compress only bodies past this size: small hook bodies gain nothing from
17
+ # gzip, and the compress call would just add CPU to the latency-critical path.
18
+ GZIP_MIN_PAYLOAD_BYTES = 16 * 1024
19
+ # Speed over ratio: hook POSTs are latency-critical, and level 1 already
20
+ # collapses JSON transcript bodies dramatically.
21
+ GZIP_COMPRESS_LEVEL = 1
22
+
23
+
24
+ class WireBody(TypedDict):
25
+ """What actually goes on the wire: body + any transport headers it needs."""
26
+
27
+ content: str | bytes
28
+ headers: dict[str, str]
29
+
30
+
31
+ def encode_wire_body(payload: str | bytes, *, compress: bool = False) -> WireBody:
32
+ """Gzip the payload when opted in and larger than ``GZIP_MIN_PAYLOAD_BYTES``.
33
+
34
+ ``bytes`` input is treated as an already-encoded wire body and passed
35
+ through untouched, so a caller that pre-encoded (to measure the wire size)
36
+ can post the result without double-compressing.
37
+ """
38
+ if not compress or isinstance(payload, bytes):
39
+ return {"content": payload, "headers": {}}
40
+ raw = payload.encode("utf-8")
41
+ if len(raw) <= GZIP_MIN_PAYLOAD_BYTES:
42
+ return {"content": payload, "headers": {}}
43
+ return {
44
+ "content": gzip.compress(raw, compresslevel=GZIP_COMPRESS_LEVEL),
45
+ "headers": {"Content-Encoding": "gzip"},
46
+ }
47
+
15
48
 
16
49
  @dataclass(frozen=True)
17
50
  class HookTarget:
@@ -22,6 +55,7 @@ class HookTarget:
22
55
  HOOK_RELAY_TARGETS: dict[str, HookTarget] = {
23
56
  "enforce": HookTarget("/api/v1/hooks/cursor", ENFORCEMENT_HOOK_TIMEOUT_SECONDS),
24
57
  "event": HookTarget("/api/v1/hooks/events", EVENT_HOOK_TIMEOUT_SECONDS),
58
+ "mcp-usage": HookTarget("/api/v1/hooks/mcp-usage", EVENT_HOOK_TIMEOUT_SECONDS),
25
59
  # Tool lifecycle endpoints normalize into LocalToolPre/PostRequest for
26
60
  # scanning. Routing them to /events would silently downgrade enforcement.
27
61
  "tool-pre": HookTarget("/api/v1/hooks/tool/pre", ENFORCEMENT_HOOK_TIMEOUT_SECONDS),
@@ -44,9 +78,9 @@ class HookHTTPClient(Protocol):
44
78
  self,
45
79
  url: str,
46
80
  *,
47
- content: str,
81
+ content: str | bytes,
48
82
  headers: dict[str, str],
49
- timeout: int | float,
83
+ timeout: int | float | httpx.Timeout,
50
84
  ) -> HookHTTPResponse: ...
51
85
 
52
86
 
@@ -67,9 +101,9 @@ class FetchLikeHookHTTPClient:
67
101
  self,
68
102
  url: str,
69
103
  *,
70
- content: str,
104
+ content: str | bytes,
71
105
  headers: dict[str, str],
72
- timeout: int | float,
106
+ timeout: int | float | httpx.Timeout,
73
107
  ) -> FetchResponse:
74
108
  return self.fetch_impl(
75
109
  url,
@@ -86,11 +120,14 @@ def post_hook_endpoint(
86
120
  http_client: HookHTTPClient,
87
121
  base_url: str,
88
122
  endpoint: str,
89
- payload: str,
123
+ payload: str | bytes,
90
124
  *,
91
125
  headers: dict[str, str],
92
- timeout: int | float,
126
+ timeout: int | float | httpx.Timeout,
93
127
  ) -> HookHTTPResponse:
128
+ # Compression happens upstream via encode_wire_body: callers that opt in
129
+ # pre-encode the payload (and its Content-Encoding header) so they can
130
+ # also measure the wire size without re-compressing. bytes pass through.
94
131
  return http_client.post(
95
132
  f"{base_url.rstrip('/')}{endpoint}",
96
133
  content=payload,
@@ -117,9 +154,9 @@ class HookAPIClient:
117
154
  def post_target(
118
155
  self,
119
156
  target: str,
120
- payload: str,
157
+ payload: str | bytes,
121
158
  *,
122
- timeout: int | float | None = None,
159
+ timeout: int | float | httpx.Timeout | None = None,
123
160
  ) -> HookHTTPResponse:
124
161
  spec = HOOK_RELAY_TARGETS[target]
125
162
  effective_timeout = timeout if timeout is not None else spec.timeout
@@ -128,9 +165,9 @@ class HookAPIClient:
128
165
  def post_endpoint(
129
166
  self,
130
167
  endpoint: str,
131
- payload: str,
168
+ payload: str | bytes,
132
169
  *,
133
- timeout: int | float,
170
+ timeout: int | float | httpx.Timeout,
134
171
  ) -> HookHTTPResponse:
135
172
  with self.http_client_factory() as http_client:
136
173
  return post_hook_endpoint(
@@ -1,6 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- import re
4
3
  from collections.abc import Sequence
5
4
  from typing import cast
6
5
  from urllib.parse import ParseResult, urljoin, urlparse
@@ -186,7 +185,26 @@ def _option_bool(
186
185
 
187
186
 
188
187
  def _has_explicit_origin_reference(value: str) -> bool:
189
- return bool(re.match(r"^[a-zA-Z][a-zA-Z\d+\-.]*:", value)) or value.startswith("//")
188
+ # Detects an RFC-3986 scheme prefix (``scheme:``) or a protocol-relative
189
+ # ``//`` origin. Pure string scan: a published SDK must not carry a
190
+ # regex-engine dependency for one prefix check. Semantics are exactly
191
+ # ``^[a-zA-Z][a-zA-Z\d+\-.]*:`` with Unicode-aware ``\d`` — first char an
192
+ # ASCII letter, then a run of scheme chars (ASCII letter, any Unicode
193
+ # decimal digit per ``str.isdecimal()`` == the ``Nd`` category, or one of
194
+ # ``+ - .``), then a colon.
195
+ if value.startswith("//"):
196
+ return True
197
+ if not value or not ("a" <= value[0] <= "z" or "A" <= value[0] <= "Z"):
198
+ return False
199
+ i = 1
200
+ while i < len(value) and (
201
+ "a" <= value[i] <= "z"
202
+ or "A" <= value[i] <= "Z"
203
+ or value[i] in "+-."
204
+ or value[i].isdecimal()
205
+ ):
206
+ i += 1
207
+ return i < len(value) and value[i] == ":"
190
208
 
191
209
 
192
210
  def _has_valid_explicit_host(value: str) -> bool:
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+
3
+ import gzip
4
+ import json
5
+
6
+ from runlayer_sdk.hook_transport import (
7
+ GZIP_MIN_PAYLOAD_BYTES,
8
+ HookAPIClient,
9
+ encode_wire_body,
10
+ post_hook_endpoint,
11
+ )
12
+
13
+ _LARGE_PAYLOAD = json.dumps({"transcript": "x" * (GZIP_MIN_PAYLOAD_BYTES + 1024)})
14
+ _SMALL_PAYLOAD = json.dumps({"k": "v"})
15
+
16
+
17
+ class _Response:
18
+ text = "{}"
19
+ status_code = 200
20
+ is_success = True
21
+
22
+ def json(self):
23
+ return {}
24
+
25
+
26
+ class _CapturingHTTPClient:
27
+ def __init__(self) -> None:
28
+ self.calls: list[dict] = []
29
+
30
+ def __enter__(self) -> _CapturingHTTPClient:
31
+ return self
32
+
33
+ def __exit__(self, *_args: object) -> None:
34
+ pass
35
+
36
+ def post(self, url, *, content, headers, timeout):
37
+ self.calls.append(
38
+ {"url": url, "content": content, "headers": headers, "timeout": timeout}
39
+ )
40
+ return _Response()
41
+
42
+
43
+ class TestEncodeWireBody:
44
+ def test_default_is_identity(self):
45
+ wire = encode_wire_body(_LARGE_PAYLOAD)
46
+ assert wire["content"] == _LARGE_PAYLOAD
47
+ assert wire["headers"] == {}
48
+
49
+ def test_small_payload_not_compressed_even_when_opted_in(self):
50
+ wire = encode_wire_body(_SMALL_PAYLOAD, compress=True)
51
+ assert wire["content"] == _SMALL_PAYLOAD
52
+ assert wire["headers"] == {}
53
+
54
+ def test_payload_at_threshold_not_compressed(self):
55
+ payload = "x" * GZIP_MIN_PAYLOAD_BYTES
56
+ wire = encode_wire_body(payload, compress=True)
57
+ assert wire["content"] == payload
58
+ assert wire["headers"] == {}
59
+
60
+ def test_large_payload_compressed_with_header_and_round_trips(self):
61
+ wire = encode_wire_body(_LARGE_PAYLOAD, compress=True)
62
+ content = wire["content"]
63
+ assert isinstance(content, bytes)
64
+ assert len(content) < len(_LARGE_PAYLOAD.encode("utf-8"))
65
+ assert wire["headers"] == {"Content-Encoding": "gzip"}
66
+ assert gzip.decompress(content).decode("utf-8") == _LARGE_PAYLOAD
67
+
68
+ def test_bytes_input_passes_through_without_double_compression(self):
69
+ pre_encoded = encode_wire_body(_LARGE_PAYLOAD, compress=True)["content"]
70
+ assert isinstance(pre_encoded, bytes)
71
+ wire = encode_wire_body(pre_encoded, compress=True)
72
+ assert wire["content"] is pre_encoded
73
+ assert wire["headers"] == {}
74
+
75
+
76
+ class TestPostHookEndpoint:
77
+ def test_str_payload_sent_identity(self):
78
+ http_client = _CapturingHTTPClient()
79
+ post_hook_endpoint(
80
+ http_client,
81
+ "https://api.example.test",
82
+ "/api/v1/hooks/events",
83
+ _LARGE_PAYLOAD,
84
+ headers={},
85
+ timeout=5,
86
+ )
87
+ (call,) = http_client.calls
88
+ assert call["content"] == _LARGE_PAYLOAD
89
+ assert "Content-Encoding" not in call["headers"]
90
+
91
+ def test_pre_encoded_bytes_and_headers_pass_through_untouched(self):
92
+ """Compression lives in encode_wire_body; the transport posts exactly
93
+ the bytes + headers the caller pre-encoded."""
94
+ wire = encode_wire_body(_LARGE_PAYLOAD, compress=True)
95
+ http_client = _CapturingHTTPClient()
96
+ post_hook_endpoint(
97
+ http_client,
98
+ "https://api.example.test",
99
+ "/api/v1/hooks/events",
100
+ wire["content"],
101
+ headers={"x-runlayer-api-key": "sk", **wire["headers"]},
102
+ timeout=5,
103
+ )
104
+ (call,) = http_client.calls
105
+ assert call["content"] is wire["content"]
106
+ assert call["headers"]["Content-Encoding"] == "gzip"
107
+ assert call["headers"]["x-runlayer-api-key"] == "sk"
108
+ assert gzip.decompress(call["content"]).decode("utf-8") == _LARGE_PAYLOAD
109
+
110
+ def test_post_target_sends_payload_as_given(self):
111
+ http_client = _CapturingHTTPClient()
112
+ client = HookAPIClient(
113
+ "https://api.example.test",
114
+ headers={},
115
+ http_client_factory=lambda: http_client,
116
+ )
117
+ client.post_target("event", _LARGE_PAYLOAD)
118
+ (call,) = http_client.calls
119
+ assert call["url"].endswith("/api/v1/hooks/events")
120
+ assert call["content"] == _LARGE_PAYLOAD
121
+ assert "Content-Encoding" not in call["headers"]
@@ -225,3 +225,51 @@ def test_client_sends_tool_url_to_enforcement_filter_only():
225
225
  "/api/v1/hooks/events",
226
226
  ]
227
227
  assert "tool_url" not in request_json(requests[1])
228
+
229
+
230
+ # regex-engine: stdlib-parity
231
+ def test_has_explicit_origin_reference_matches_old_regex():
232
+ """Pins the pure-string scheme detector to the pre-RE2 regex exactly,
233
+ including the Unicode-decimal-digit behavior of the old ``\\d`` (ENG-4056)."""
234
+ import random
235
+ import re as stdlib_re
236
+
237
+ from runlayer_sdk.tool_enforcement import _has_explicit_origin_reference
238
+
239
+ old = stdlib_re.compile(r"^[a-zA-Z][a-zA-Z\d+\-.]*:")
240
+
241
+ def old_impl(value: str) -> bool:
242
+ return bool(old.match(value)) or value.startswith("//")
243
+
244
+ corpus = [
245
+ "",
246
+ "http://x",
247
+ "https://x",
248
+ "mailto:a@b",
249
+ "a+b-c.d:rest",
250
+ "//protocol-relative",
251
+ "/single-slash",
252
+ "no-scheme-here",
253
+ "1http:", # leading digit -> not a scheme
254
+ "+bad:", # leading + -> not a scheme
255
+ ".bad:",
256
+ "a b:", # space breaks the run before the colon
257
+ "scheme", # no colon
258
+ "scheme:",
259
+ "x:",
260
+ "HTTP://X",
261
+ "a:",
262
+ "://",
263
+ "a²:", # superscript-2 (category No) — NOT \d, breaks the run
264
+ "a٠:", # Arabic-Indic digit (category Nd) — IS \d
265
+ "a\n:",
266
+ "urn:isbn:0451450523",
267
+ ]
268
+ for v in corpus:
269
+ assert _has_explicit_origin_reference(v) == old_impl(v), repr(v)
270
+
271
+ rng = random.Random(4056)
272
+ alphabet = "abAB01+-.: /²٠\t"
273
+ for _ in range(2000):
274
+ v = "".join(rng.choices(alphabet, k=rng.randint(0, 8)))
275
+ assert _has_explicit_origin_reference(v) == old_impl(v), repr(v)
@@ -3,7 +3,7 @@ revision = 3
3
3
  requires-python = ">=3.10"
4
4
 
5
5
  [options]
6
- exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
6
+ exclude-newer = "2026-08-04T20:02:56.085185Z"
7
7
  exclude-newer-span = "P7D"
8
8
 
9
9
  [[package]]
@@ -177,7 +177,7 @@ wheels = [
177
177
 
178
178
  [[package]]
179
179
  name = "runlayer-hooks-sdk"
180
- version = "0.2.1"
180
+ version = "0.2.2"
181
181
  source = { editable = "." }
182
182
  dependencies = [
183
183
  { name = "httpx" },