coding-sh 0.1.3__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.
coding_sh/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ """coding.sh Python SDK: send LLM calls, traces and tool spans to a coding.sh server.
2
+
3
+ import coding_sh
4
+ from openai import OpenAI
5
+
6
+ client = coding_sh.observe(OpenAI()) # also AsyncOpenAI, Anthropic, AsyncAnthropic
7
+
8
+ with coding_sh.trace("support-reply", session_id=conversation_id, input=question):
9
+ with coding_sh.span("tool", "lookup_order", input={"id": 42}) as s:
10
+ s.set_output(lookup(42))
11
+ client.chat.completions.create(model="gpt-4o-mini", messages=[...])
12
+
13
+ Set CODING_SH_URL and CODING_SH_INGEST_KEY. Without a URL the SDK does nothing. It never
14
+ changes return values, errors or stream semantics, and never raises into the app.
15
+ Stdlib only; one daemon thread sends batches, and an atexit flush is bounded to 2 s.
16
+ """
17
+
18
+ from ._client import __version__
19
+ from ._providers import observe
20
+ from ._trace import current_session_id, flush, init, shutdown, span, stats, trace, with_session
21
+
22
+ __all__ = [
23
+ "__version__",
24
+ "init",
25
+ "observe",
26
+ "trace",
27
+ "span",
28
+ "with_session",
29
+ "current_session_id",
30
+ "flush",
31
+ "shutdown",
32
+ "stats",
33
+ ]
coding_sh/_client.py ADDED
@@ -0,0 +1,418 @@
1
+ """Config and transport: a bounded queue drained by one daemon sender thread.
2
+
3
+ Transport contract (docs/PRODUCTION.md section 4, shared with the TS SDK):
4
+
5
+ - batches of <= max_batch events and <= 4 MiB go to POST <url>/v1/ingest, gzip-compressed;
6
+ - one request in flight at a time, from a daemon thread;
7
+ - retries on 429, 5xx and network errors, honoring Retry-After, otherwise backing off
8
+ 1, 2, 4 ... s up to 30 s, with at most 5 attempts;
9
+ - 400/401/403 (and any other 4xx) drop the batch and warn once per status;
10
+ - a full queue drops new events, counts them, and warns at most once a minute;
11
+ - an atexit flush is bounded to 2 s;
12
+ - event ids are fixed when the event is created, so a retried batch never duplicates.
13
+
14
+ Nothing here ever raises into the app, and nothing logs the key or a request body.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import atexit
20
+ import collections
21
+ import json
22
+ import logging
23
+ import os
24
+ import threading
25
+ import time
26
+ import urllib.error
27
+ import urllib.request
28
+ from typing import Any, Deque, Dict, List, Optional
29
+
30
+ try: # gzip needs zlib, which a few minimal builds lack.
31
+ import gzip as _gzip
32
+ except Exception: # pragma: no cover
33
+ _gzip = None # type: ignore[assignment]
34
+
35
+ __version__ = "0.1.3"
36
+
37
+ log = logging.getLogger("coding_sh")
38
+
39
+ MAX_BATCH_BYTES = 4 * 1024 * 1024
40
+ MAX_ATTEMPTS = 5
41
+ MAX_BACKOFF_S = 30.0
42
+ ATEXIT_TIMEOUT_S = 2.0
43
+ REQUEST_TIMEOUT_S = 10.0
44
+ QUEUE_WARN_INTERVAL_S = 60.0
45
+
46
+ _TRUE = {"1", "true", "yes", "on"}
47
+
48
+
49
+ def _env(name: str) -> Optional[str]:
50
+ v = os.environ.get(name)
51
+ return v if v not in (None, "") else None
52
+
53
+
54
+ def _env_bool(name: str) -> Optional[bool]:
55
+ v = _env(name)
56
+ return None if v is None else v.strip().lower() in _TRUE
57
+
58
+
59
+ def ingest_url(url: str) -> str:
60
+ """Base URL (https://obs.example.com) -> https://obs.example.com/v1/ingest."""
61
+ u = url.strip().rstrip("/")
62
+ return u if u.endswith("/v1/ingest") else u + "/v1/ingest"
63
+
64
+
65
+ class Config:
66
+ """Resolved settings. Arguments win over env vars, env vars over defaults."""
67
+
68
+ def __init__(
69
+ self,
70
+ url: Optional[str] = None,
71
+ key: Optional[str] = None,
72
+ service: Optional[str] = None,
73
+ capture: Optional[str] = None,
74
+ flush_interval_ms: Optional[float] = None,
75
+ max_batch: Optional[int] = None,
76
+ max_queue: Optional[int] = None,
77
+ disabled: Optional[bool] = None,
78
+ debug: Optional[bool] = None,
79
+ ) -> None:
80
+ self.url = url or _env("CODING_SH_URL")
81
+ self.key = key or _env("CODING_SH_INGEST_KEY")
82
+ self.service = service or _env("CODING_SH_SERVICE") or _env("OTEL_SERVICE_NAME")
83
+ mode = (capture or _env("CODING_SH_CAPTURE_MODE") or "full").strip().lower()
84
+ self.capture = "metadata" if mode == "metadata" else "full"
85
+ self.flush_interval_s = max(0.0, float(1000 if flush_interval_ms is None else flush_interval_ms)) / 1000.0
86
+ self.max_batch = max(1, min(500, int(500 if max_batch is None else max_batch)))
87
+ self.max_queue = max(1, int(10000 if max_queue is None else max_queue))
88
+ env_disabled = _env_bool("CODING_SH_DISABLED")
89
+ self.disabled = bool(disabled if disabled is not None else env_disabled) or not self.url
90
+ self.debug = bool(debug if debug is not None else _env_bool("CODING_SH_DEBUG"))
91
+
92
+ @property
93
+ def enabled(self) -> bool:
94
+ return not self.disabled
95
+
96
+ @property
97
+ def full_capture(self) -> bool:
98
+ return self.capture == "full"
99
+
100
+
101
+ def _json_default(o: Any) -> Any:
102
+ for attr in ("model_dump", "to_dict", "dict"):
103
+ f = getattr(o, attr, None)
104
+ if callable(f):
105
+ try:
106
+ return f()
107
+ except Exception:
108
+ pass
109
+ if isinstance(o, (set, frozenset, tuple)):
110
+ return list(o)
111
+ if isinstance(o, bytes):
112
+ return "<%d bytes>" % len(o)
113
+ return repr(o)
114
+
115
+
116
+ def dumps(o: Any) -> str:
117
+ return json.dumps(o, default=_json_default, ensure_ascii=False, separators=(",", ":"))
118
+
119
+
120
+ class _Result:
121
+ __slots__ = ("status", "retry_after", "body")
122
+
123
+ def __init__(self, status: int, retry_after: Optional[float] = None, body: bytes = b"") -> None:
124
+ self.status = status # 0 = network error
125
+ self.retry_after = retry_after
126
+ self.body = body
127
+
128
+
129
+ def _parse_retry_after(v: Optional[str]) -> Optional[float]:
130
+ if not v:
131
+ return None
132
+ try:
133
+ return max(0.0, min(MAX_BACKOFF_S, float(v.strip())))
134
+ except ValueError:
135
+ return None # HTTP-date form: fall back to backoff
136
+
137
+
138
+ class Transport:
139
+ """Thread-safe event queue plus a lazily started daemon sender."""
140
+
141
+ # Patched by tests to keep retries fast.
142
+ backoff_base_s = 1.0
143
+
144
+ def __init__(self, cfg: Config) -> None:
145
+ self.cfg = cfg
146
+ self._endpoint = ingest_url(cfg.url) if cfg.url else ""
147
+ self._envelope_head = (
148
+ '{"v":0,' + ('"service":' + dumps(cfg.service) + "," if cfg.service else "") + '"events":['
149
+ ).encode("utf-8")
150
+ self._reset_state()
151
+ self._warned: Dict[str, bool] = {}
152
+ self._last_queue_warn = 0.0
153
+ if cfg.enabled:
154
+ atexit.register(self._atexit)
155
+ if hasattr(os, "register_at_fork"):
156
+ os.register_at_fork(after_in_child=self._after_fork)
157
+
158
+ # ------------------------------------------------------------------ state
159
+
160
+ def _reset_state(self) -> None:
161
+ # Reentrant: a stream proxy finalized by the GC may enqueue while this thread holds it.
162
+ self._lock = threading.RLock()
163
+ self._cv = threading.Condition(self._lock)
164
+ self._q: Deque[bytes] = collections.deque() # serialized events
165
+ self._inflight = 0
166
+ self._flush_waiters = 0
167
+ self._thread: Optional[threading.Thread] = None
168
+ self._closed = False
169
+ self._halt = threading.Event() # set once the process is going away for good
170
+ self._sent = 0
171
+ self._dropped = 0
172
+ self._failed = 0
173
+
174
+ def _after_fork(self) -> None:
175
+ # The child gets a copy of the parent's queue, which the parent still sends.
176
+ # Start clean so nothing is sent twice and no dead thread or held lock is inherited.
177
+ self._reset_state()
178
+
179
+ # ------------------------------------------------------------------ public
180
+
181
+ def enqueue(self, event: Dict[str, Any]) -> None:
182
+ if not self.cfg.enabled:
183
+ return
184
+ try:
185
+ data = dumps(event).encode("utf-8")
186
+ except Exception as e: # pragma: no cover - dumps has a repr fallback
187
+ self._debug("event not serializable: %s", type(e).__name__)
188
+ return
189
+ warn = False
190
+ with self._cv:
191
+ if self._closed:
192
+ return
193
+ if len(data) + len(self._envelope_head) + 2 > MAX_BATCH_BYTES or len(self._q) >= self.cfg.max_queue:
194
+ self._dropped += 1
195
+ now = time.monotonic()
196
+ if now - self._last_queue_warn >= QUEUE_WARN_INTERVAL_S:
197
+ self._last_queue_warn = now
198
+ warn = True
199
+ else:
200
+ self._q.append(data)
201
+ if len(self._q) >= self.cfg.max_batch:
202
+ self._cv.notify_all()
203
+ self._ensure_thread()
204
+ if warn:
205
+ log.warning("coding.sh: event queue full or event too large; dropping events (dropped=%d)", self._dropped)
206
+
207
+ def flush(self, timeout: Optional[float] = None) -> bool:
208
+ """Block until everything queued so far is sent (or given up). True when drained."""
209
+ if not self.cfg.enabled:
210
+ return True
211
+ deadline = None if timeout is None else time.monotonic() + max(0.0, timeout)
212
+ with self._cv:
213
+ if not self._q and not self._inflight:
214
+ return True
215
+ alive = self._thread is not None and self._thread.is_alive()
216
+ if not alive:
217
+ alive = self._ensure_thread()
218
+ if not alive:
219
+ # No thread can be started (interpreter shutdown): send in this thread.
220
+ self._drain_sync(deadline)
221
+ with self._cv:
222
+ return not self._q and not self._inflight
223
+ with self._cv:
224
+ self._flush_waiters += 1
225
+ self._cv.notify_all()
226
+ try:
227
+ while self._q or self._inflight:
228
+ remaining = None if deadline is None else deadline - time.monotonic()
229
+ if remaining is not None and remaining <= 0:
230
+ return False
231
+ self._cv.wait(remaining if remaining is not None else 0.5)
232
+ return True
233
+ finally:
234
+ self._flush_waiters -= 1
235
+
236
+ def shutdown(self, timeout: Optional[float] = ATEXIT_TIMEOUT_S) -> bool:
237
+ ok = self.flush(timeout)
238
+ with self._cv:
239
+ self._closed = True
240
+ self._cv.notify_all()
241
+ return ok
242
+
243
+ def stats(self) -> Dict[str, int]:
244
+ with self._lock:
245
+ return {
246
+ "queued": len(self._q) + self._inflight,
247
+ "sent": self._sent,
248
+ "dropped": self._dropped,
249
+ "failed": self._failed,
250
+ }
251
+
252
+ # ------------------------------------------------------------------ sender
253
+
254
+ def _ensure_thread(self) -> bool:
255
+ """Start the sender if needed. Caller holds the lock."""
256
+ if self._thread is not None and self._thread.is_alive():
257
+ return True
258
+ try:
259
+ t = threading.Thread(target=self._run, name="coding-sh-sender", daemon=True)
260
+ t.start()
261
+ except RuntimeError: # "can't create new thread at interpreter shutdown"
262
+ self._thread = None
263
+ return False
264
+ self._thread = t
265
+ return True
266
+
267
+ def _take_batch(self) -> List[bytes]:
268
+ """Pop up to max_batch events / 4 MiB. Caller holds the lock."""
269
+ batch: List[bytes] = []
270
+ size = len(self._envelope_head) + 2
271
+ while self._q and len(batch) < self.cfg.max_batch:
272
+ data = self._q[0]
273
+ if batch and size + len(data) + 1 > MAX_BATCH_BYTES:
274
+ break
275
+ self._q.popleft()
276
+ batch.append(data)
277
+ size += len(data) + 1
278
+ self._inflight = len(batch)
279
+ return batch
280
+
281
+ def _run(self) -> None:
282
+ while True:
283
+ with self._cv:
284
+ while not self._q and not self._closed:
285
+ self._cv.wait()
286
+ if not self._q:
287
+ return
288
+ # Give the batch time to fill unless someone is waiting on a flush.
289
+ deadline = time.monotonic() + self.cfg.flush_interval_s
290
+ while len(self._q) < self.cfg.max_batch and not self._flush_waiters and not self._closed:
291
+ remaining = deadline - time.monotonic()
292
+ if remaining <= 0:
293
+ break
294
+ self._cv.wait(remaining)
295
+ batch = self._take_batch()
296
+ try:
297
+ self._deliver(batch)
298
+ except Exception as e: # pragma: no cover - _deliver handles its own errors
299
+ self._debug("sender error: %s", type(e).__name__)
300
+ with self._cv:
301
+ self._failed += len(batch)
302
+ finally:
303
+ with self._cv:
304
+ self._inflight = 0
305
+ self._cv.notify_all()
306
+
307
+ def _drain_sync(self, deadline: Optional[float]) -> None:
308
+ while True:
309
+ with self._cv:
310
+ if not self._q:
311
+ return
312
+ batch = self._take_batch()
313
+ try:
314
+ self._deliver(batch, deadline)
315
+ finally:
316
+ with self._cv:
317
+ self._inflight = 0
318
+ self._cv.notify_all()
319
+ if deadline is not None and time.monotonic() >= deadline:
320
+ return
321
+
322
+ def _deliver(self, batch: List[bytes], deadline: Optional[float] = None) -> None:
323
+ if not batch:
324
+ return
325
+ body = self._envelope_head + b",".join(batch) + b"]}"
326
+ n = len(batch)
327
+ for attempt in range(1, MAX_ATTEMPTS + 1):
328
+ res = self._post(body)
329
+ if 200 <= res.status < 300:
330
+ self._on_accepted(n, res.body)
331
+ return
332
+ retryable = res.status == 0 or res.status == 429 or res.status >= 500
333
+ if not retryable:
334
+ self._warn_once(
335
+ "status:%d" % res.status,
336
+ "coding.sh: ingest returned HTTP %d; dropping %d events%s"
337
+ % (res.status, n, " (check CODING_SH_INGEST_KEY)" if res.status in (401, 403) else ""),
338
+ )
339
+ break
340
+ if attempt == MAX_ATTEMPTS:
341
+ self._warn_once("retries", "coding.sh: ingest unavailable after %d attempts; dropping events" % MAX_ATTEMPTS)
342
+ break
343
+ delay = res.retry_after
344
+ if delay is None:
345
+ delay = min(MAX_BACKOFF_S, self.backoff_base_s * (2 ** (attempt - 1)))
346
+ if deadline is not None and time.monotonic() + delay > deadline:
347
+ break
348
+ self._debug("ingest %s, retrying in %.1fs", res.status or "network error", delay)
349
+ if self._halt.wait(delay):
350
+ break
351
+ with self._cv:
352
+ self._failed += n
353
+
354
+ def _on_accepted(self, n: int, body: bytes) -> None:
355
+ rejected = 0
356
+ try:
357
+ parsed = json.loads(body.decode("utf-8")) if body else {}
358
+ rej = parsed.get("rejected") if isinstance(parsed, dict) else None
359
+ if isinstance(rej, list) and rej:
360
+ rejected = len(rej)
361
+ first = rej[0] if isinstance(rej[0], dict) else {}
362
+ self._warn_once("rejected", "coding.sh: server rejected %d event(s): %s" % (rejected, str(first.get("error", ""))[:200]))
363
+ except Exception:
364
+ pass
365
+ with self._cv:
366
+ self._sent += n - rejected
367
+ self._failed += rejected
368
+
369
+ def _post(self, body: bytes) -> _Result:
370
+ headers = {"Content-Type": "application/json", "User-Agent": "coding-sh-python/" + __version__}
371
+ data = body
372
+ if _gzip is not None:
373
+ try:
374
+ data = _gzip.compress(body, compresslevel=6)
375
+ headers["Content-Encoding"] = "gzip"
376
+ except Exception:
377
+ data = body
378
+ if self.cfg.key:
379
+ headers["Authorization"] = "Bearer " + self.cfg.key
380
+ req = urllib.request.Request(self._endpoint, data=data, headers=headers, method="POST")
381
+ try:
382
+ with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT_S) as resp:
383
+ return _Result(resp.status, None, resp.read(1 << 20))
384
+ except urllib.error.HTTPError as e:
385
+ try:
386
+ e.read()
387
+ except Exception:
388
+ pass
389
+ ra = _parse_retry_after(e.headers.get("Retry-After") if e.headers else None)
390
+ try:
391
+ e.close()
392
+ except Exception:
393
+ pass
394
+ return _Result(e.code, ra)
395
+ except Exception as e: # URLError, timeouts, connection resets
396
+ self._debug("ingest network error: %s", type(e).__name__)
397
+ return _Result(0)
398
+
399
+ # ------------------------------------------------------------------ misc
400
+
401
+ def _atexit(self) -> None:
402
+ try:
403
+ self.flush(ATEXIT_TIMEOUT_S)
404
+ except Exception:
405
+ pass
406
+ finally:
407
+ self._halt.set()
408
+
409
+ def _warn_once(self, key: str, msg: str) -> None:
410
+ if self._warned.get(key):
411
+ self._debug("%s", msg)
412
+ return
413
+ self._warned[key] = True
414
+ log.warning("%s", msg)
415
+
416
+ def _debug(self, fmt: str, *args: Any) -> None:
417
+ if self.cfg.debug:
418
+ log.warning("coding.sh debug: " + fmt, *args)
coding_sh/_gateway.py ADDED
@@ -0,0 +1,156 @@
1
+ """Which LLM gateway a model call went through, and the request id its lookup API takes.
2
+
3
+ The table (_gateway_table.py) is generated from the server's gateway registry, so the SDK spots
4
+ the same gateways the server does. Signals, strongest first:
5
+
6
+ 1. the base URL (or request URL): a gateway host, or a self-hosted gateway whose URL env var
7
+ (LITELLM_PROXY_API_BASE, KONG_PROXY_URL, ...) names the same host;
8
+ 2. a response header only one gateway sets: its request id header (cf-aig-log-id, helicone-id,
9
+ x-litellm-call-id, x-portkey-trace-id, ...), or a marker header (x-router-provider, ...);
10
+ 3. a response id with a gateway's shape (Vercel `gen_...`, OpenRouter `gen-...`, Requesty `rqsty-...`).
11
+
12
+ Response headers come from an httpx response hook added to the observed client's HTTP client.
13
+ The hook only reads headers, and only for a call this SDK is recording (a context variable).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import contextlib
19
+ import contextvars
20
+ import inspect
21
+ import os
22
+ import urllib.parse
23
+ from typing import Any, Dict, Iterator, Mapping, Optional, Tuple
24
+
25
+ from ._gateway_table import GATEWAY_TABLE
26
+
27
+ _HOOKED = "__coding_sh_gateway_hook__"
28
+ _current: contextvars.ContextVar[Any] = contextvars.ContextVar("coding_sh_llm_call", default=None)
29
+
30
+
31
+ def _host(url: Any) -> Optional[str]:
32
+ if not url:
33
+ return None
34
+ try:
35
+ host = urllib.parse.urlsplit(str(url)).hostname
36
+ except Exception:
37
+ return None
38
+ return host.lower() if host else None
39
+
40
+
41
+ def _host_matches(host: str, pattern: str) -> bool:
42
+ return host.endswith(pattern) if pattern.startswith(".") else host == pattern or host.endswith("." + pattern)
43
+
44
+
45
+ def gateway_for_url(url: Any, env: Optional[Mapping[str, str]] = None) -> Optional[str]:
46
+ """The gateway a base URL points at: by hostname, or a self-hosted gateway's URL env var on the same host."""
47
+ host = _host(url)
48
+ if not host:
49
+ return None
50
+ env = os.environ if env is None else env
51
+ for g in GATEWAY_TABLE:
52
+ if any(_host_matches(host, h) for h in g["hosts"]):
53
+ return g["id"]
54
+ if any(_host(env.get(k)) == host for k in g["url_env"]):
55
+ return g["id"]
56
+ return None
57
+
58
+
59
+ def _header(headers: Any, name: str) -> Optional[str]:
60
+ if headers is None:
61
+ return None
62
+ try:
63
+ v = headers.get(name)
64
+ except Exception:
65
+ return None
66
+ return v.strip() if isinstance(v, str) and v.strip() else None
67
+
68
+
69
+ def _shaped(g: Dict[str, Any], v: Optional[str]) -> bool:
70
+ return bool(v) and (g["id_pattern"] is None or g["id_pattern"].search(v) is not None)
71
+
72
+
73
+ def _request_id(g: Dict[str, Any], headers: Any, response_id: Optional[str]) -> Optional[str]:
74
+ for name in g["id_headers"]:
75
+ v = _header(headers, name)
76
+ if _shaped(g, v):
77
+ return v
78
+ if response_id and g["id_pattern"] is not None and g["id_pattern"].search(response_id):
79
+ return response_id
80
+ return None
81
+
82
+
83
+ def detect_gateway(
84
+ url: Any = None, headers: Any = None, response_id: Optional[str] = None, env: Optional[Mapping[str, str]] = None
85
+ ) -> Optional[Tuple[str, Optional[str]]]:
86
+ """(gateway, request id) for a model call, from its URL, response headers and response id."""
87
+ by_url = gateway_for_url(url, env)
88
+ if by_url:
89
+ g = next(r for r in GATEWAY_TABLE if r["id"] == by_url)
90
+ return by_url, _request_id(g, headers, response_id)
91
+ for g in GATEWAY_TABLE:
92
+ for name in g["id_headers"]:
93
+ v = _header(headers, name)
94
+ if _shaped(g, v):
95
+ return g["id"], v
96
+ for g in GATEWAY_TABLE:
97
+ if any(_header(headers, name) is not None for name in g["mark_headers"]):
98
+ return g["id"], _request_id(g, headers, response_id)
99
+ for g in GATEWAY_TABLE:
100
+ if response_id and g["id_pattern"] is not None and g["id_pattern"].search(response_id):
101
+ return g["id"], response_id
102
+ return None
103
+
104
+
105
+ # ---------------------------------------------------------------------------- response headers
106
+
107
+
108
+ @contextlib.contextmanager
109
+ def capturing(call: Any) -> Iterator[None]:
110
+ """Response headers seen inside this block belong to `call`."""
111
+ token = _current.set(call)
112
+ try:
113
+ yield
114
+ finally:
115
+ try:
116
+ _current.reset(token)
117
+ except Exception:
118
+ pass
119
+
120
+
121
+ def _take(response: Any) -> None:
122
+ call = _current.get()
123
+ if call is None:
124
+ return
125
+ try:
126
+ call.response_headers = response.headers
127
+ call.request_url = str(response.request.url)
128
+ except Exception:
129
+ pass
130
+
131
+
132
+ def _sync_hook(response: Any) -> None:
133
+ _take(response)
134
+
135
+
136
+ async def _async_hook(response: Any) -> None:
137
+ _take(response)
138
+
139
+
140
+ setattr(_sync_hook, _HOOKED, True)
141
+ setattr(_async_hook, _HOOKED, True)
142
+
143
+
144
+ def hook_responses(client: Any) -> None:
145
+ """Add the header hook to an openai / anthropic client's httpx client (once)."""
146
+ try:
147
+ http = getattr(client, "_client", None)
148
+ hooks = getattr(http, "event_hooks", None)
149
+ if not isinstance(hooks, dict) or not isinstance(hooks.get("response"), list):
150
+ return
151
+ if any(getattr(h, _HOOKED, False) for h in hooks["response"]):
152
+ return
153
+ hook = _async_hook if inspect.iscoroutinefunction(getattr(http, "send", None)) else _sync_hook
154
+ http.event_hooks = {**hooks, "response": [*hooks["response"], hook]}
155
+ except Exception:
156
+ pass