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.
- hermes_agent_api_client/__init__.py +40 -0
- hermes_agent_api_client/client.py +645 -0
- hermes_agent_api_client/models.py +80 -0
- hermes_agent_api_client/protocol.py +267 -0
- hermes_agent_api_client/py.typed +0 -0
- hermes_agent_api_client/sse.py +426 -0
- hermes_agent_api_client-0.1.0.dist-info/METADATA +129 -0
- hermes_agent_api_client-0.1.0.dist-info/RECORD +10 -0
- hermes_agent_api_client-0.1.0.dist-info/WHEEL +4 -0
- hermes_agent_api_client-0.1.0.dist-info/licenses/LICENSE +35 -0
|
@@ -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)
|