hermes-agent-api-client 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.
@@ -0,0 +1,40 @@
1
+ """Typed async client for the Hermes Agent API Server."""
2
+
3
+ from .client import HermesAgentApiClient
4
+ from .models import (
5
+ AssistantDeltaEvent,
6
+ HermesCapabilities,
7
+ HermesEvent,
8
+ KeepaliveEvent,
9
+ TerminalEvent,
10
+ TerminalOutcome,
11
+ ToolProgressEvent,
12
+ UsageEvent,
13
+ )
14
+ from .protocol import (
15
+ HermesAuthenticationError,
16
+ HermesContractError,
17
+ HermesHttpStatusError,
18
+ HermesProtocolError,
19
+ HermesTransportError,
20
+ )
21
+
22
+ __version__ = "0.1.0"
23
+
24
+ __all__ = [
25
+ "AssistantDeltaEvent",
26
+ "HermesAgentApiClient",
27
+ "HermesAuthenticationError",
28
+ "HermesCapabilities",
29
+ "HermesContractError",
30
+ "HermesEvent",
31
+ "HermesHttpStatusError",
32
+ "HermesProtocolError",
33
+ "HermesTransportError",
34
+ "KeepaliveEvent",
35
+ "TerminalEvent",
36
+ "TerminalOutcome",
37
+ "ToolProgressEvent",
38
+ "UsageEvent",
39
+ "__version__",
40
+ ]
@@ -0,0 +1,645 @@
1
+ """Bounded HTTP operations for the Hermes API protocol contracts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from typing import TYPE_CHECKING, Never, Protocol, cast, runtime_checkable
8
+
9
+ import httpx
10
+
11
+ from .models import HermesCapabilities, HermesEvent, TerminalEvent
12
+ from .protocol import (
13
+ HermesAuthenticationError,
14
+ HermesContractError,
15
+ HermesHttpStatusError,
16
+ HermesProtocolError,
17
+ HermesTransportError,
18
+ validate_capabilities,
19
+ )
20
+ from .sse import async_decode_hermes_sse
21
+
22
+ if TYPE_CHECKING:
23
+ import ssl
24
+ from collections.abc import AsyncIterator, Mapping
25
+ from types import TracebackType
26
+ from typing import Self
27
+
28
+
29
+ _CAPABILITY_REQUEST_TIMEOUT = httpx.Timeout(10.0)
30
+ _CAPABILITIES_DEADLINE_SECONDS = 10.0
31
+ _MAX_CAPABILITIES_BYTES = 65_536
32
+
33
+ # Hermes v2026.7.7.2 emits SSE keepalives after 30 seconds of inactivity.
34
+ _HERMES_SSE_KEEPALIVE_SECONDS = 30.0
35
+ # A 15-second margin tolerates normal keepalive scheduling and network jitter.
36
+ _CHAT_STREAM_READ_TIMEOUT_SECONDS = 45.0
37
+ _CHAT_STREAM_TIMEOUT = httpx.Timeout(
38
+ 10.0,
39
+ read=_CHAT_STREAM_READ_TIMEOUT_SECONDS,
40
+ )
41
+
42
+ _SUPPORTED_SCHEMES = frozenset({"http", "https"})
43
+ _VISIBLE_ASCII_MIN = 0x21
44
+ _VISIBLE_ASCII_MAX = 0x7E
45
+ _MAX_CONTENT_LENGTH_DIGITS = 20
46
+
47
+ _INACTIVE_CLIENT_MESSAGE = "HermesAgentApiClient is not active"
48
+ _SINGLE_USE_CLIENT_MESSAGE = "HermesAgentApiClient instances are single-use"
49
+ _VERIFY_INJECTION_MESSAGE = "verify cannot be supplied with an injected HTTP client"
50
+
51
+
52
+ @runtime_checkable
53
+ class _SupportsAclose(Protocol):
54
+ """An owned asynchronous iterator that supports explicit closure."""
55
+
56
+ async def aclose(self) -> None:
57
+ """Release the iterator's upstream resources."""
58
+
59
+
60
+ def _raise_inactive_client() -> Never:
61
+ """Raise the constant inactive error from a secret-free frame."""
62
+ raise RuntimeError(_INACTIVE_CLIENT_MESSAGE)
63
+
64
+
65
+ def _raise_single_use_client() -> Never:
66
+ """Raise the constant single-use error from a secret-free frame."""
67
+ raise RuntimeError(_SINGLE_USE_CLIENT_MESSAGE)
68
+
69
+
70
+ def _raise_verify_injection_conflict() -> Never:
71
+ """Raise the constant injection conflict from a secret-free frame."""
72
+ raise ValueError(_VERIFY_INJECTION_MESSAGE)
73
+
74
+
75
+ def _reraise_scrubbed_failure(failure: BaseException) -> Never:
76
+ """Re-raise a failure after its package traceback state was scrubbed."""
77
+ if isinstance(failure, asyncio.CancelledError):
78
+ failure = failure.with_traceback(None)
79
+ BaseException.__setattr__(failure, "__cause__", None)
80
+ BaseException.__setattr__(failure, "__context__", None)
81
+ raise failure
82
+
83
+
84
+ def _raise_transport_failure(*, transient: bool) -> Never:
85
+ """Raise one metadata-only transport failure from a safe frame."""
86
+ raise HermesTransportError(transient=transient)
87
+
88
+
89
+ def _raise_protocol_failure() -> Never:
90
+ """Raise one fresh metadata-only protocol failure from a safe frame."""
91
+ raise HermesProtocolError
92
+
93
+
94
+ def _normalize_base_url( # pyright: ignore[reportUnusedFunction]
95
+ base_url: str,
96
+ ) -> httpx.URL:
97
+ """Normalize one absolute HTTP base URL without retaining invalid input."""
98
+ parsed: httpx.URL | None = None
99
+ try:
100
+ if isinstance(base_url, str) and base_url.strip(): # pyright: ignore[reportUnnecessaryIsInstance]
101
+ parsed = httpx.URL(base_url)
102
+ except (TypeError, ValueError, httpx.InvalidURL):
103
+ pass
104
+ if (
105
+ parsed is None
106
+ or parsed.scheme not in _SUPPORTED_SCHEMES
107
+ or not parsed.is_absolute_url
108
+ or not parsed.host
109
+ or bool(parsed.userinfo)
110
+ ):
111
+ base_url = ""
112
+ parsed = None
113
+ _raise_transport_failure(transient=False)
114
+ return parsed
115
+
116
+
117
+ def _operation_url(base_url: httpx.URL, path: str) -> httpx.URL:
118
+ """Append an ASCII operation path while preserving encoded base semantics."""
119
+ encoded_base_path = base_url.raw_path.split(b"?", 1)[0].rstrip(b"/")
120
+ return base_url.copy_with(
121
+ raw_path=encoded_base_path + path.encode("ascii"),
122
+ query=None,
123
+ fragment=None,
124
+ )
125
+
126
+
127
+ def _request_headers( # pyright: ignore[reportUnusedFunction]
128
+ bearer_key: str,
129
+ *,
130
+ json_body: bool = False,
131
+ ) -> dict[str, str]:
132
+ """Build bearer headers from visible ASCII without retaining bad input."""
133
+ if (
134
+ not isinstance(bearer_key, str) # pyright: ignore[reportUnnecessaryIsInstance]
135
+ or not bearer_key
136
+ or not bearer_key.isascii()
137
+ or any(
138
+ not _VISIBLE_ASCII_MIN <= ord(character) <= _VISIBLE_ASCII_MAX
139
+ for character in bearer_key
140
+ )
141
+ ):
142
+ bearer_key = ""
143
+ _raise_transport_failure(transient=False)
144
+ headers = {"Authorization": f"Bearer {bearer_key}"}
145
+ if json_body:
146
+ headers["Content-Type"] = "application/json"
147
+ return headers
148
+
149
+
150
+ def _serialize_request(request: Mapping[str, object]) -> bytes:
151
+ """Serialize compact strict JSON without retaining serialization failures."""
152
+ invalid_body = False
153
+ normalized_request: dict[str, object] = {}
154
+ payload = b""
155
+ try:
156
+ normalized_request = dict(request)
157
+ payload = json.dumps(
158
+ normalized_request,
159
+ ensure_ascii=False,
160
+ allow_nan=False,
161
+ separators=(",", ":"),
162
+ ).encode()
163
+ except Exception: # noqa: BLE001 - hostile mappings may raise any Exception
164
+ invalid_body = True
165
+ if invalid_body:
166
+ request = {}
167
+ normalized_request = {}
168
+ payload = b""
169
+ _raise_transport_failure(transient=False)
170
+ return payload
171
+
172
+
173
+ def _serialize_request_safely(request: Mapping[str, object]) -> bytes | None:
174
+ """Return serialized bytes or a sentinel after a sanitized failure."""
175
+ try:
176
+ return _serialize_request(request)
177
+ except HermesTransportError:
178
+ return None
179
+
180
+
181
+ def _status_failure(status_code: int) -> HermesContractError:
182
+ """Classify an HTTP status without retaining its response exception."""
183
+ if status_code in {401, 403}:
184
+ return HermesAuthenticationError(status_code=status_code)
185
+ return HermesHttpStatusError(status_code=status_code)
186
+
187
+
188
+ def _declared_content_length(response: httpx.Response) -> tuple[bool, int | None]:
189
+ """Validate a bounded decimal Content-Length value."""
190
+ value = response.headers.get("content-length")
191
+ if value is None:
192
+ return (True, None)
193
+ if (
194
+ len(value) > _MAX_CONTENT_LENGTH_DIGITS
195
+ or not value.isascii()
196
+ or not value.isdigit()
197
+ ):
198
+ return (False, None)
199
+ length = int(value)
200
+ if length > _MAX_CAPABILITIES_BYTES:
201
+ return (False, None)
202
+ return (True, length)
203
+
204
+
205
+ async def _read_capabilities_body( # noqa: C901, PLR0912, PLR0915
206
+ http_client: httpx.AsyncClient,
207
+ endpoint: httpx.URL,
208
+ headers: Mapping[str, str],
209
+ ) -> HermesCapabilities:
210
+ """Read and validate one bounded capability response within its byte budget."""
211
+ payload = bytearray()
212
+ response: httpx.Response | None = None
213
+ cancellation: asyncio.CancelledError | None = None
214
+ status_code: int | None = None
215
+ protocol_failed = False
216
+ read_transport_failed = False
217
+ cleanup_failed = False
218
+ primary_outcome = False
219
+ chunk = b""
220
+ capabilities: HermesCapabilities | None = None
221
+ try:
222
+ async with http_client.stream(
223
+ "GET",
224
+ endpoint,
225
+ headers=headers,
226
+ timeout=_CAPABILITY_REQUEST_TIMEOUT,
227
+ follow_redirects=False,
228
+ ) as response:
229
+ try:
230
+ try:
231
+ response.raise_for_status()
232
+ except httpx.HTTPStatusError as error:
233
+ status_code = error.response.status_code
234
+ primary_outcome = True
235
+ else:
236
+ valid_length, _ = _declared_content_length(response)
237
+ if valid_length:
238
+ try:
239
+ async for chunk in response.aiter_bytes():
240
+ if len(chunk) > _MAX_CAPABILITIES_BYTES - len(payload):
241
+ valid_length = False
242
+ break
243
+ payload.extend(chunk)
244
+ except Exception: # noqa: BLE001 - classify read or close failures safely
245
+ if response.is_closed:
246
+ cleanup_failed = True
247
+ else:
248
+ read_transport_failed = True
249
+ primary_outcome = True
250
+ if not valid_length:
251
+ protocol_failed = True
252
+ primary_outcome = True
253
+ elif not read_transport_failed:
254
+ capabilities = _parse_capabilities_payload(bytes(payload))
255
+ protocol_failed = capabilities is None
256
+ primary_outcome = primary_outcome or protocol_failed
257
+ except asyncio.CancelledError as caught:
258
+ if not primary_outcome and response.is_closed:
259
+ capabilities = _parse_capabilities_payload(bytes(payload))
260
+ protocol_failed = capabilities is None
261
+ primary_outcome = protocol_failed
262
+ if not primary_outcome:
263
+ cancellation = caught.with_traceback(None)
264
+ except (asyncio.CancelledError, Exception):
265
+ if (
266
+ cancellation is None
267
+ and not primary_outcome
268
+ and response is not None
269
+ and response.is_closed
270
+ ):
271
+ capabilities = _parse_capabilities_payload(bytes(payload))
272
+ protocol_failed = capabilities is None
273
+ primary_outcome = protocol_failed
274
+ if cancellation is None and not primary_outcome:
275
+ raise
276
+ if cancellation is not None:
277
+ response = None
278
+ payload.clear()
279
+ payload = bytearray()
280
+ del http_client, endpoint, headers
281
+ _reraise_scrubbed_failure(cancellation)
282
+ if status_code is not None:
283
+ response = None
284
+ payload.clear()
285
+ payload = bytearray()
286
+ del http_client, endpoint, headers
287
+ raise _status_failure(status_code)
288
+ if protocol_failed:
289
+ response = None
290
+ chunk = b""
291
+ payload.clear()
292
+ payload = bytearray()
293
+ capabilities = None
294
+ del http_client, endpoint, headers
295
+ _raise_protocol_failure()
296
+ if read_transport_failed:
297
+ response = None
298
+ chunk = b""
299
+ payload.clear()
300
+ payload = bytearray()
301
+ capabilities = None
302
+ del http_client, endpoint, headers
303
+ _raise_transport_failure(transient=True)
304
+ if cleanup_failed:
305
+ response = None
306
+ chunk = b""
307
+ payload.clear()
308
+ payload = bytearray()
309
+ capabilities = None
310
+ del http_client, endpoint, headers
311
+ _raise_transport_failure(transient=True)
312
+ return cast("HermesCapabilities", capabilities)
313
+
314
+
315
+ def _load_json(payload: bytes) -> tuple[bool, object]:
316
+ """Decode JSON without retaining parser errors or rejected response bytes."""
317
+ try:
318
+ return (True, json.loads(payload))
319
+ except (json.JSONDecodeError, UnicodeDecodeError):
320
+ return (False, None)
321
+
322
+
323
+ def _parse_capabilities_payload(payload: bytes) -> HermesCapabilities | None:
324
+ """Parse one bounded capability payload or return no result when invalid."""
325
+ valid_json, document = _load_json(payload)
326
+ if not valid_json:
327
+ return None
328
+ try:
329
+ return validate_capabilities(document)
330
+ except HermesProtocolError:
331
+ return None
332
+
333
+
334
+ async def _probe_capabilities( # pyright: ignore[reportUnusedFunction]
335
+ http_client: httpx.AsyncClient,
336
+ base_url: httpx.URL,
337
+ headers: Mapping[str, str],
338
+ ) -> HermesCapabilities:
339
+ """Fetch and validate bounded capabilities using a caller-owned client."""
340
+ endpoint = _operation_url(base_url, "/v1/capabilities")
341
+ capabilities: HermesCapabilities | None = None
342
+ status_failure: HermesContractError | None = None
343
+ transport_failed = False
344
+ try:
345
+ async with asyncio.timeout(_CAPABILITIES_DEADLINE_SECONDS):
346
+ capabilities = await _read_capabilities_body(http_client, endpoint, headers)
347
+ except TimeoutError:
348
+ transport_failed = True
349
+ except HermesContractError as error:
350
+ status_failure = error
351
+ except httpx.RequestError:
352
+ transport_failed = True
353
+ except Exception: # noqa: BLE001 - translate opaque cleanup failures safely
354
+ transport_failed = True
355
+ if transport_failed:
356
+ del http_client, base_url, headers, endpoint
357
+ capabilities = None
358
+ _raise_transport_failure(transient=True)
359
+ if status_failure is not None:
360
+ del http_client, base_url, headers, endpoint
361
+ capabilities = None
362
+ raise status_failure
363
+ return cast("HermesCapabilities", capabilities)
364
+
365
+
366
+ async def _stream_chat_events( # pyright: ignore[reportUnusedFunction] # noqa: C901, PLR0912, PLR0915
367
+ http_client: httpx.AsyncClient,
368
+ base_url: httpx.URL,
369
+ headers: Mapping[str, str],
370
+ request: Mapping[str, object],
371
+ ) -> AsyncIterator[HermesEvent]:
372
+ """Stream events while owning only the individual response lifetime."""
373
+ endpoint = _operation_url(base_url, "/v1/chat/completions")
374
+ request_headers = dict(headers)
375
+ request_headers["Content-Type"] = "application/json"
376
+ request_body = _serialize_request_safely(request)
377
+ if request_body is None:
378
+ del http_client, base_url, headers, request, endpoint
379
+ request_headers.clear()
380
+ _raise_transport_failure(transient=False)
381
+
382
+ protocol_failed = False
383
+ event: HermesEvent | None = None
384
+ terminal_events: list[TerminalEvent] = []
385
+ transport_failure: HermesContractError | None = None
386
+ response: httpx.Response | None = None
387
+ cancellation: asyncio.CancelledError | None = None
388
+ try:
389
+ async with http_client.stream(
390
+ "POST",
391
+ endpoint,
392
+ headers=request_headers,
393
+ content=request_body,
394
+ timeout=_CHAT_STREAM_TIMEOUT,
395
+ follow_redirects=False,
396
+ ) as response:
397
+ try:
398
+ try:
399
+ response.raise_for_status()
400
+ except httpx.HTTPStatusError as error:
401
+ transport_failure = _status_failure(error.response.status_code)
402
+ else:
403
+ try:
404
+ async for event in async_decode_hermes_sse(
405
+ response.aiter_bytes()
406
+ ):
407
+ if isinstance(event, TerminalEvent):
408
+ terminal_events.append(event)
409
+ else:
410
+ yield event
411
+ except HermesProtocolError:
412
+ protocol_failed = True
413
+ except HermesTransportError:
414
+ transport_failure = HermesTransportError(transient=True)
415
+ except asyncio.CancelledError as caught:
416
+ cancellation = caught.with_traceback(None)
417
+ except asyncio.CancelledError as caught:
418
+ if cancellation is None:
419
+ cancellation = caught.with_traceback(None)
420
+ except httpx.HTTPStatusError as error:
421
+ if cancellation is None and transport_failure is None and not protocol_failed:
422
+ transport_failure = _status_failure(error.response.status_code)
423
+ except httpx.RequestError:
424
+ if cancellation is None and transport_failure is None and not protocol_failed:
425
+ transport_failure = HermesTransportError(transient=True)
426
+ except Exception: # noqa: BLE001 - translate opaque cleanup failures safely
427
+ if cancellation is None and transport_failure is None and not protocol_failed:
428
+ transport_failure = HermesTransportError(transient=True)
429
+
430
+ response = None
431
+ event = None
432
+ if protocol_failed:
433
+ cancellation = None
434
+ del http_client, base_url, headers, request, endpoint
435
+ request_headers.clear()
436
+ request_body = b""
437
+ terminal_events.clear()
438
+ _raise_protocol_failure()
439
+ if transport_failure is not None:
440
+ cancellation = None
441
+ del http_client, base_url, headers, request, endpoint
442
+ request_headers.clear()
443
+ request_body = b""
444
+ terminal_events.clear()
445
+ raise transport_failure
446
+ if cancellation is not None:
447
+ del http_client, base_url, headers, request, endpoint
448
+ request_headers.clear()
449
+ request_body = b""
450
+ terminal_events.clear()
451
+ _reraise_scrubbed_failure(cancellation)
452
+ terminal_event = terminal_events[0]
453
+ terminal_events.clear()
454
+ del http_client, base_url, headers, request, endpoint
455
+ request_headers.clear()
456
+ request_body = b""
457
+ yield terminal_event
458
+
459
+
460
+ class HermesAgentApiClient:
461
+ """Single-use async context manager for bound Hermes operations."""
462
+
463
+ def __init__(
464
+ self,
465
+ base_url: str,
466
+ bearer_key: str,
467
+ *,
468
+ http_client: httpx.AsyncClient | None = None,
469
+ verify: ssl.SSLContext | bool | None = None,
470
+ ) -> None:
471
+ """Bind endpoint, authentication, ownership, and TLS verification."""
472
+ if http_client is not None and verify is not None:
473
+ base_url = ""
474
+ bearer_key = ""
475
+ http_client = None
476
+ verify = None
477
+ del self
478
+ _raise_verify_injection_conflict()
479
+ bound_base_url: httpx.URL | None = None
480
+ bound_headers: dict[str, str] | None = None
481
+ binding_failure: BaseException | None = None
482
+ try:
483
+ bound_base_url = _normalize_base_url(base_url)
484
+ bound_headers = _request_headers(bearer_key)
485
+ except BaseException as caught: # noqa: BLE001 - scrub on interruption
486
+ caught = caught.with_traceback(None)
487
+ binding_failure = caught
488
+ base_url = ""
489
+ bearer_key = ""
490
+ if binding_failure is not None:
491
+ bound_base_url = None
492
+ bound_headers = None
493
+ http_client = None
494
+ verify = None
495
+ del self
496
+ _reraise_scrubbed_failure(binding_failure)
497
+ self._base_url = cast("httpx.URL", bound_base_url)
498
+ self._headers = cast("dict[str, str]", bound_headers)
499
+ self._injected_http_client = http_client
500
+ self._verify = True if verify is None else verify
501
+ self._active_http_client: httpx.AsyncClient | None = None
502
+ self._entered = False
503
+ self._exited = False
504
+
505
+ async def __aenter__(self) -> Self:
506
+ """Activate this instance exactly once and create owned transport."""
507
+ if self._entered or self._exited:
508
+ del self
509
+ _raise_single_use_client()
510
+ active = self._injected_http_client
511
+ if active is None:
512
+ creation_failure: BaseException | None = None
513
+ try:
514
+ active = httpx.AsyncClient(
515
+ verify=self._verify,
516
+ follow_redirects=False,
517
+ )
518
+ except BaseException as caught: # noqa: BLE001 - scrub on interruption
519
+ caught = caught.with_traceback(None)
520
+ creation_failure = caught
521
+ if creation_failure is not None:
522
+ del self
523
+ _reraise_scrubbed_failure(creation_failure)
524
+ self._active_http_client = active
525
+ self._entered = True
526
+ return self
527
+
528
+ async def __aexit__(
529
+ self,
530
+ exc_type: type[BaseException] | None,
531
+ exc_value: BaseException | None,
532
+ traceback: TracebackType | None,
533
+ ) -> None:
534
+ """Deactivate this instance and close only an owned HTTP client."""
535
+ active = self._active_http_client
536
+ self._active_http_client = None
537
+ self._exited = True
538
+ owns_active = active is not None and self._injected_http_client is None
539
+ body_failed = exc_value is not None
540
+ cleanup_failed = False
541
+ cleanup_cancellation: asyncio.CancelledError | None = None
542
+ del self, exc_type, exc_value, traceback
543
+ if owns_active:
544
+ try:
545
+ await cast("httpx.AsyncClient", active).aclose()
546
+ except asyncio.CancelledError as cancellation:
547
+ cleanup_cancellation = cancellation.with_traceback(None)
548
+ except Exception: # noqa: BLE001 - translate opaque cleanup failures safely
549
+ cleanup_failed = True
550
+ active = None
551
+ if body_failed:
552
+ return
553
+ if cleanup_cancellation is not None:
554
+ _reraise_scrubbed_failure(cleanup_cancellation)
555
+ if cleanup_failed:
556
+ _raise_transport_failure(transient=True)
557
+
558
+ def _require_active_client(self) -> httpx.AsyncClient:
559
+ """Return the active HTTP client or reject out-of-lifecycle use."""
560
+ active = self._active_http_client
561
+ if active is None:
562
+ del self
563
+ _raise_inactive_client()
564
+ return active
565
+
566
+ async def probe_capabilities(self) -> HermesCapabilities:
567
+ """Fetch capabilities using this client's bound endpoint and auth."""
568
+ active: httpx.AsyncClient | None = None
569
+ inactive = False
570
+ try:
571
+ active = self._require_active_client()
572
+ except RuntimeError as failure:
573
+ failure.__traceback__ = None
574
+ inactive = True
575
+ if inactive or active is None:
576
+ del self
577
+ _raise_inactive_client()
578
+ base_url: httpx.URL | None = self._base_url
579
+ headers: Mapping[str, str] = self._headers
580
+ del self
581
+ result: HermesCapabilities | None = None
582
+ failure: BaseException | None = None
583
+ try:
584
+ result = await _probe_capabilities(active, base_url, headers)
585
+ except BaseException as caught: # noqa: BLE001 - scrub on cancellation too
586
+ caught = caught.with_traceback(None)
587
+ failure = caught
588
+ active = None
589
+ base_url = None
590
+ headers = {}
591
+ if failure is not None:
592
+ _reraise_scrubbed_failure(failure)
593
+ return cast("HermesCapabilities", result)
594
+
595
+ async def stream_chat_events(
596
+ self,
597
+ request: Mapping[str, object],
598
+ ) -> AsyncIterator[HermesEvent]:
599
+ """Stream chat events using this client's bound endpoint and auth."""
600
+ active: httpx.AsyncClient | None = None
601
+ inactive = False
602
+ try:
603
+ active = self._require_active_client()
604
+ except RuntimeError as failure:
605
+ failure.__traceback__ = None
606
+ inactive = True
607
+ if inactive or active is None:
608
+ del self
609
+ _raise_inactive_client()
610
+ base_url: httpx.URL | None = self._base_url
611
+ headers: Mapping[str, str] = self._headers
612
+ del self
613
+ event: HermesEvent | None = None
614
+ operation_failure: BaseException | None = None
615
+ delegated_stream: AsyncIterator[HermesEvent] | None = aiter(
616
+ _stream_chat_events(
617
+ active,
618
+ base_url,
619
+ headers,
620
+ request,
621
+ )
622
+ )
623
+ try:
624
+ async for event in delegated_stream:
625
+ yield event
626
+ except BaseException as caught: # noqa: BLE001 - scrub on cancellation too
627
+ if not isinstance(caught, GeneratorExit):
628
+ caught = caught.with_traceback(None)
629
+ operation_failure = caught
630
+ finally:
631
+ if isinstance(delegated_stream, _SupportsAclose):
632
+ try:
633
+ await delegated_stream.aclose()
634
+ except BaseException as caught: # noqa: BLE001 - preserve close error
635
+ caught = caught.with_traceback(None)
636
+ if operation_failure is None:
637
+ operation_failure = caught
638
+ active = None
639
+ base_url = None
640
+ headers = {}
641
+ request = {}
642
+ event = None
643
+ delegated_stream = None
644
+ if operation_failure is not None:
645
+ _reraise_scrubbed_failure(operation_failure)