structured-data-models-client 1.0.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,898 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES.
2
+ # All rights reserved.
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import math
9
+ import re
10
+ from collections.abc import Mapping
11
+ from typing import Any, Protocol
12
+ from urllib.parse import quote, urlparse
13
+
14
+ import requests
15
+ import urllib3.exceptions
16
+ from requests.adapters import HTTPAdapter
17
+ from urllib3.util.retry import Retry
18
+
19
+ from sdm_client.errors import (
20
+ NimRequestError,
21
+ StructuredDataError,
22
+ format_invalid_params,
23
+ )
24
+
25
+ _PREDICTIONS_PATH = '/v1/predictions'
26
+ _SESSIONS_PATH = '/v1/sessions'
27
+ _HEALTH_READY_PATH = '/v1/health/ready'
28
+ # Supplied by the NIM runtime rather than the Universal TFM contract, so an
29
+ # endpoint is not obliged to serve it.
30
+ _MODELS_PATH = '/v1/models'
31
+ _DEFAULT_TIMEOUT_SECONDS = 60.0
32
+ _DEFAULT_MAX_RETRIES = 3
33
+ _DEFAULT_BACKOFF_FACTOR = 0.5
34
+ #: The transient ones. A 500 is not among them: the payload and the model are
35
+ #: the same on every attempt, so a server fault reproduces, and the caller
36
+ #: mistakes this endpoint reports as a 500 are reclassified from the body as
37
+ #: the caller's after three more uploads of the whole gathered context.
38
+ _RETRY_STATUS = (429, 502, 503, 504)
39
+ _RETRY_AFTER_MAX_SECONDS = 60
40
+ _MAX_BODY_SNIPPET = 512
41
+ _LOCAL_HOSTS = frozenset({'localhost', '127.0.0.1', '::1'})
42
+ # Well above any real prediction response (a full-precision float64 column
43
+ # for a million rows is ~25 MB of JSON), but far below what a compressed
44
+ # hostile body can inflate to.
45
+ _MAX_RESPONSE_BYTES = 64 * 1024 * 1024
46
+ _RESPONSE_CHUNK_BYTES = 1024 * 1024
47
+ # Deleting a session is a bookkeeping call the server answers immediately, and
48
+ # it runs on cleanup paths where the caller is no longer waiting on a result.
49
+ # The full request timeout would let an unreachable endpoint stall a teardown.
50
+ _DELETE_TIMEOUT_SECONDS = 10.0
51
+ _USERINFO_RE = re.compile(r'(?<=//)[^/@\s]+@')
52
+
53
+
54
+ def redact_url(url: str | None) -> str | None:
55
+ r"""Strip any userinfo from ``url`` so it is safe to log or display.
56
+
57
+ A URL carrying a password in its userinfo is a legal way to point this
58
+ client at a deployment, and the credential is then in the URL itself
59
+ rather than in ``api_key``. Anywhere a URL reaches an error message or
60
+ a repr it goes through here first, so the credential does not travel
61
+ with the diagnosis into a log aggregator or a bug report.
62
+
63
+ Mirrors ``sdm_relational_engine.client.client.redact_url``, which guards
64
+ the same credential on the Kumo Relational path. The two cannot share one
65
+ implementation:
66
+ this package must not import the optional driver, and the package both
67
+ depend on is a SQL-connector package with no URL handling. Keep them in
68
+ step.
69
+ """
70
+ if not url:
71
+ return url
72
+ parsed = urlparse(url)
73
+ if not parsed.username and not parsed.password:
74
+ return url
75
+ host = parsed.hostname or ''
76
+ if ':' in host:
77
+ host = f'[{host}]'
78
+ if parsed.port:
79
+ host = f'{host}:{parsed.port}'
80
+ return parsed._replace(netloc=host).geturl()
81
+
82
+
83
+ def scrub_userinfo(text: str) -> str:
84
+ r"""Remove any ``user:secret@`` credential from free text.
85
+
86
+ The counterpart to :func:`redact_url` for strings that merely *contain* a
87
+ URL rather than being one, chiefly the ``requests`` exception messages
88
+ quoted into transport errors, which echo back whatever URL was requested.
89
+ """
90
+ return _USERINFO_RE.sub('', text)
91
+
92
+
93
+ def _validate_tls_verification(url: str, verify_ssl: bool) -> None:
94
+ """Allow disabled TLS verification only for local development endpoints."""
95
+ if verify_ssl:
96
+ return
97
+ host = (urlparse(url).hostname or '').lower()
98
+ if host in _LOCAL_HOSTS:
99
+ return
100
+ raise StructuredDataError(
101
+ f'TLS certificate verification may be disabled only for a loopback '
102
+ f'endpoint, got {redact_url(url)!r}. Set verify_ssl=True and trust the '
103
+ 'endpoint certificate instead.',
104
+ code='INVALID_CONFIGURATION',
105
+ )
106
+
107
+
108
+ def _validate_url(url: str, api_key: str | None) -> None:
109
+ if not isinstance(url, str):
110
+ raise StructuredDataError(
111
+ f'url must be a string, got {type(url).__name__}',
112
+ code='INVALID_CONFIGURATION',
113
+ )
114
+ parsed = urlparse(url)
115
+ shown = redact_url(url)
116
+ if parsed.scheme not in ('http', 'https'):
117
+ raise StructuredDataError(
118
+ f'url must start with http:// or https://, got {shown!r}',
119
+ code='INVALID_CONFIGURATION',
120
+ )
121
+ if not parsed.hostname:
122
+ raise StructuredDataError(
123
+ f'url is missing a host, got {shown!r}',
124
+ code='INVALID_CONFIGURATION',
125
+ )
126
+ # A base URL is a scheme, a host and at most a path prefix. A query or
127
+ # fragment is not a meaningful part of one, and every route is appended to
128
+ # this string. Thus, `http://host?api_key=x` becomes
129
+ # `http://host/?api_key=x/v1/predictions`, which is both a broken URL and a
130
+ # secret in a plaintext request line that redaction never sees.
131
+ if parsed.query or parsed.fragment:
132
+ raise StructuredDataError(
133
+ 'url must be a base endpoint without a query string or fragment; '
134
+ 'pass a credential as api_key rather than in the URL',
135
+ code='INVALID_CONFIGURATION',
136
+ )
137
+
138
+ host = parsed.hostname.lower()
139
+ # Userinfo in the URL is a credential too, and ``requests`` turns it into
140
+ # an ``Authorization: Basic`` header. Checking only ``api_key`` let
141
+ # such a URL send that in the clear.
142
+ carries_credential = bool(api_key or parsed.username or parsed.password)
143
+ if (
144
+ carries_credential
145
+ and parsed.scheme == 'http'
146
+ and host not in _LOCAL_HOSTS
147
+ ):
148
+ what = 'an API key' if api_key else 'the credential in this URL'
149
+ raise StructuredDataError(
150
+ f'refusing to send {what} over plaintext HTTP; use an https:// '
151
+ f'URL (or a localhost endpoint), got {shown!r}',
152
+ code='INVALID_CONFIGURATION',
153
+ )
154
+
155
+
156
+ class _Session(requests.Session):
157
+ r"""A ``Session`` that enforces redirect security policy.
158
+
159
+ ``requests`` strips only the standard ``Authorization`` header when a
160
+ redirect changes origin; a custom header set on the session is re-sent
161
+ verbatim, so without this the configured endpoint could hand the API key
162
+ to any host it names, defeating ``_validate_url``.
163
+
164
+ Redirects are still followed, and this covers the credential only. A
165
+ ``307``/``308`` therefore re-sends the request *body* to the redirect
166
+ target,
167
+ including the caller's whole labelled context table for Kumo Tabular and
168
+ across an ``https`` to ``http`` downgrade, which ``_validate_url`` never
169
+ sees because it runs at construction against the configured URL. That is
170
+ an accepted trade when certificate verification is enabled. When it is
171
+ disabled for a loopback development endpoint, redirect targets must remain
172
+ loopback too, so the request-level ``verify=False`` cannot reach a remote
173
+ host.
174
+ """
175
+
176
+ def __init__(self, *, verify_ssl: bool) -> None:
177
+ super().__init__()
178
+ self._verify_ssl = verify_ssl
179
+
180
+ @staticmethod
181
+ def _release_unread(resp: object) -> None:
182
+ r"""Give up a redirect hop's socket without reading its body.
183
+
184
+ Close rather than drain: the connection is surrendered instead of being
185
+ read to the end so it can be reused. Marking the body consumed is what
186
+ stops ``requests`` reading it afterwards. ``resp.content`` returns the
187
+ empty bytes set here instead of pulling from the socket.
188
+ """
189
+ if not isinstance(resp, requests.Response):
190
+ return
191
+ raw = getattr(resp, 'raw', None)
192
+ if raw is not None:
193
+ raw.close()
194
+ resp._content = b''
195
+ resp._content_consumed = True # type: ignore[attr-defined]
196
+
197
+ def resolve_redirects( # type: ignore[override]
198
+ self,
199
+ resp: requests.Response,
200
+ req: requests.PreparedRequest,
201
+ **kwargs: Any,
202
+ ) -> Any:
203
+ r"""Follow redirects without reading the bodies they carry.
204
+
205
+ ``requests`` consumes each hop's body (``resp.content``) to release the
206
+ socket, with no cap. This layer never needs those bytes because its
207
+ only
208
+ request reports a status. They are attacker-controlled on
209
+ a misconfigured endpoint.
210
+
211
+ Every hop is released, not just the first. ``requests`` resolves the
212
+ rest inside the generator below, reading each one as it loops, so each
213
+ is released as it is yielded, before control returns to that loop.
214
+ """
215
+ # ``Session.send`` calls this for every response, redirect or not, so
216
+ # the check matters: releasing unconditionally would close the body of
217
+ # a successful response before the caller ever saw it.
218
+ if self.get_redirect_target(resp) is not None:
219
+ self._release_unread(resp)
220
+ for hop in super().resolve_redirects(resp, req, **kwargs):
221
+ if isinstance(hop, requests.Response) and (
222
+ self.get_redirect_target(hop) is not None
223
+ ):
224
+ self._release_unread(hop)
225
+ yield hop
226
+
227
+ def rebuild_auth(
228
+ self,
229
+ prepared_request: requests.PreparedRequest,
230
+ response: requests.Response,
231
+ ) -> None:
232
+ super().rebuild_auth(prepared_request, response)
233
+ previous_url = response.request.url
234
+ if (
235
+ previous_url
236
+ and prepared_request.url
237
+ and self.should_strip_auth(previous_url, prepared_request.url)
238
+ ):
239
+ prepared_request.headers.pop('X-API-Key', None)
240
+ if prepared_request.url:
241
+ _validate_tls_verification(prepared_request.url, self._verify_ssl)
242
+
243
+
244
+ def _validate_limits(timeout: Any, max_retries: Any) -> None:
245
+ r"""Reject unusable transport limits at construction.
246
+
247
+ Without this the offending value first surfaces as a raw ``ValueError``
248
+ from urllib3, on the first request rather than at the call that set it.
249
+
250
+ ``inf`` and ``nan`` are rejected explicitly: both compare ``False`` against
251
+ ``<= 0`` and so pass an ordinary positivity test, then fail on the first
252
+ request with ``OverflowError``/``ValueError`` from the socket layer.
253
+ ``inf``
254
+ is the natural way to reach for "no timeout" here, since this constructor
255
+ does not accept ``None``.
256
+ """
257
+ if (
258
+ isinstance(timeout, bool)
259
+ or not isinstance(timeout, (int, float))
260
+ or not math.isfinite(timeout)
261
+ or timeout <= 0
262
+ ):
263
+ raise StructuredDataError(
264
+ f'timeout must be a positive, finite number of seconds, got '
265
+ f'{timeout!r}',
266
+ code='INVALID_CONFIGURATION',
267
+ )
268
+ if (
269
+ isinstance(max_retries, bool)
270
+ or not isinstance(max_retries, int)
271
+ or max_retries < 0
272
+ ):
273
+ raise StructuredDataError(
274
+ f'max_retries must be a non-negative integer, got {max_retries!r}',
275
+ code='INVALID_CONFIGURATION',
276
+ )
277
+
278
+
279
+ def _build_retry(
280
+ max_retries: int,
281
+ backoff_factor: float,
282
+ *,
283
+ retry_post: bool = True,
284
+ ) -> Retry:
285
+ r"""The retry policy this transport's requests run under.
286
+
287
+ ``retry_after_max`` caps how long a server-chosen ``Retry-After`` header
288
+ may park the caller; it needs urllib3 >= 2.3, and older versions fall back
289
+ to urllib3's own (6 hour) cap.
290
+
291
+ ``retry_post=False`` drops ``POST`` from the retryable methods, which
292
+ switches off the read and status retries for a route whose side effect the
293
+ client cannot reconcile. Connection failures stay retryable either way:
294
+ urllib3 gates read and status retries on the method but not connect ones,
295
+ and a request that never reached the server cannot have had an effect.
296
+
297
+ ``DELETE`` is always retryable. Releasing a session is idempotent by
298
+ contract. The NIM answers 204 whether or not the session is still there,
299
+ and the status it returns when it cannot release one right now is a
300
+ `429` whose message asks the caller to retry. Leaving it out meant the
301
+ client raised the one error it documents as worth retrying and then did
302
+ not retry it.
303
+ """
304
+ methods = {'GET', 'DELETE'}
305
+ if retry_post:
306
+ methods.add('POST')
307
+ options: dict[str, Any] = {
308
+ 'total': max_retries,
309
+ 'connect': max_retries,
310
+ 'read': max_retries,
311
+ 'status': max_retries,
312
+ 'backoff_factor': backoff_factor,
313
+ 'status_forcelist': _RETRY_STATUS,
314
+ 'allowed_methods': frozenset(methods),
315
+ 'respect_retry_after_header': True,
316
+ 'raise_on_status': False,
317
+ }
318
+ try:
319
+ return Retry(retry_after_max=_RETRY_AFTER_MAX_SECONDS, **options)
320
+ except TypeError:
321
+ return Retry(**options)
322
+
323
+
324
+ def _timed_out(error: BaseException | None) -> bool:
325
+ r"""Whether a request failure is a timeout, however it reached us.
326
+
327
+ urllib3 wraps a read timeout in ``MaxRetryError``, which ``requests``
328
+ surfaces as ``ConnectionError`` rather than ``Timeout``.
329
+ """
330
+ seen: set[int] = set()
331
+ while error is not None and id(error) not in seen:
332
+ seen.add(id(error))
333
+ if isinstance(
334
+ error, (requests.Timeout, urllib3.exceptions.TimeoutError)
335
+ ):
336
+ return True
337
+ error = error.__cause__ or error.__context__
338
+ return False
339
+
340
+
341
+ def _failed_to_reach(
342
+ error: Exception, url: str, timeout: float | None
343
+ ) -> StructuredDataError:
344
+ r"""One account of a failed request, for every route that can fail.
345
+
346
+ A timeout names the knob and the value rather than urllib3's "Max retries
347
+ exceeded", which says the opposite of the truth when none were asked for.
348
+ """
349
+ if _timed_out(error) and timeout is not None:
350
+ return StructuredDataError(
351
+ f'Request to {redact_url(url)} timed out after {timeout:g} '
352
+ f'seconds, which is the timeout this client was given',
353
+ code='TRANSPORT_ERROR',
354
+ details={'timeout': timeout, 'url': redact_url(url)},
355
+ )
356
+ return StructuredDataError(
357
+ f'Request to {redact_url(url)} failed: {scrub_userinfo(str(error))}',
358
+ code='TRANSPORT_ERROR',
359
+ details={'url': redact_url(url)},
360
+ )
361
+
362
+
363
+ def _decoded_object(content: bytes, url: str) -> dict[str, Any]:
364
+ r"""``content`` as the JSON object it is supposed to be.
365
+
366
+ In one place because it was in two, and one checked only that it was JSON:
367
+ a 200 carrying an array escaped `models()` as a bare ``AttributeError``.
368
+ A deeply nested body raises `RecursionError`, a `RuntimeError`, which would
369
+ otherwise escape the contract these errors state.
370
+ """
371
+ try:
372
+ body = json.loads(content or b'{}')
373
+ except (ValueError, RecursionError) as error:
374
+ raise StructuredDataError(
375
+ f'{redact_url(url)} did not return JSON',
376
+ code='TRANSPORT_ERROR',
377
+ details={'url': redact_url(url)},
378
+ ) from error
379
+ if not isinstance(body, dict):
380
+ raise StructuredDataError(
381
+ f'Expected a JSON object from {redact_url(url)}, got '
382
+ f'{type(body).__name__}',
383
+ code='TRANSPORT_ERROR',
384
+ details={'url': redact_url(url)},
385
+ )
386
+ return body
387
+
388
+
389
+ def _snippet(text: str) -> str:
390
+ r"""A one-line, length-capped view of a response body, so a multi-megabyte
391
+ HTML error page cannot become a multi-megabyte exception message.
392
+ """
393
+ text = ' '.join(text.split())
394
+ if len(text) <= _MAX_BODY_SNIPPET:
395
+ return text
396
+ return f'{text[:_MAX_BODY_SNIPPET]}... [truncated, {len(text)} chars total]'
397
+
398
+
399
+ def _read_capped(response: requests.Response, url: str) -> bytes:
400
+ r"""Reads a streamed response body, refusing anything past the cap.
401
+
402
+ ``requests`` inflates ``Content-Encoding: gzip`` with no ratio limit, so
403
+ an unbounded read lets a small compressed body expand into hundreds of
404
+ megabytes of client memory before anything is parsed.
405
+ """
406
+ body = bytearray()
407
+ for chunk in response.iter_content(_RESPONSE_CHUNK_BYTES):
408
+ if len(body) + len(chunk) > _MAX_RESPONSE_BYTES:
409
+ raise StructuredDataError(
410
+ f'Response body from {redact_url(url)} exceeds the '
411
+ f'{_MAX_RESPONSE_BYTES} byte limit',
412
+ code='TRANSPORT_ERROR',
413
+ )
414
+ body.extend(chunk)
415
+ return bytes(body)
416
+
417
+
418
+ def _path_segment(value: str) -> str:
419
+ r"""Escapes a server-chosen id before it is spliced into a URL path, so a
420
+ hostile or malformed ``session_id`` cannot redirect the call to another
421
+ route.
422
+
423
+ ``quote`` leaves a dot alone, because it is unreserved. A resolver then
424
+ removes the only two segments that mean something to it, ``.`` and ``..``.
425
+ Thus, ``..`` turned a scoring call against a pinned
426
+ context into a one-shot prediction elsewhere. Those name no session and
427
+ are refused; ``...`` is an ordinary segment and is escaped like any
428
+ other. An empty id is refused for its own reason: it contributes no
429
+ segment at all, so the path closes up around it.
430
+ """
431
+ if value in {'', '.', '..'}:
432
+ raise StructuredDataError(
433
+ f'{value!r} is not a session id: it does not survive as a path '
434
+ f'segment when the URL is resolved, so the call would be made '
435
+ f'against a different route than the one it names',
436
+ code='INVALID_RESPONSE',
437
+ )
438
+ return quote(value, safe='')
439
+
440
+
441
+ def _build_session(
442
+ url: str,
443
+ api_key: str | None,
444
+ verify_ssl: bool,
445
+ max_retries: int,
446
+ backoff_factor: float,
447
+ ) -> requests.Session:
448
+ r"""The pooled session, with ``POST /v1/sessions`` held out of the retries.
449
+
450
+ Creating a session fits and pins the context in the worker's memory before
451
+ the response is written, so a re-sent ``POST`` leaves one orphaned pinned
452
+ context per attempt: the client keeps only the last ``session_id`` and can
453
+ never ``DELETE`` the earlier ones. That is a side effect the client cannot
454
+ reconcile, unlike ``/v1/predictions``, which the same policy may safely
455
+ replay.
456
+
457
+ ``requests`` resolves an adapter by longest matching URL prefix, so the
458
+ stricter policy is mounted on the exact create-session URL and the ordinary
459
+ one re-mounted on the routes below it: scoring against a pinned context and
460
+ releasing one are both replayable and keep the full policy.
461
+ """
462
+ session = _Session(verify_ssl=verify_ssl)
463
+ if api_key:
464
+ session.headers['X-API-Key'] = api_key
465
+ adapter = HTTPAdapter(max_retries=_build_retry(max_retries, backoff_factor))
466
+ session.mount('http://', adapter)
467
+ session.mount('https://', adapter)
468
+ session.mount(
469
+ url + _SESSIONS_PATH,
470
+ HTTPAdapter(
471
+ max_retries=_build_retry(
472
+ max_retries, backoff_factor, retry_post=False
473
+ )
474
+ ),
475
+ )
476
+ session.mount(url + _SESSIONS_PATH + '/', adapter)
477
+ return session
478
+
479
+
480
+ class WireTransport(Protocol):
481
+ r"""What a client needs from whatever carries its requests.
482
+
483
+ A NIM reached over HTTP is one; a Databricks Model Serving endpoint is
484
+ another. The client asks only this of either.
485
+ """
486
+
487
+ #: Whether the endpoint can pin a context. A client refuses ``fit()`` up
488
+ #: front when it cannot, rather than preparing a session to be told so.
489
+ serves_sessions: bool
490
+
491
+ @property
492
+ def url(self) -> str: ...
493
+
494
+ def close(self) -> None: ...
495
+
496
+ def health_ready(self) -> bool: ...
497
+
498
+ def list_models(self) -> list[str]: ...
499
+
500
+ def predict(self, payload: dict[str, Any]) -> dict[str, Any]: ...
501
+
502
+ def create_session(self, payload: dict[str, Any]) -> dict[str, Any]: ...
503
+
504
+ def session_predict(
505
+ self, session_id: str, payload: dict[str, Any]
506
+ ) -> dict[str, Any]: ...
507
+
508
+ def delete_session(self, session_id: str) -> None: ...
509
+
510
+
511
+ class Transport:
512
+ r"""The HTTP wire layer to one Universal TFM NIM.
513
+
514
+ Owns a pooled ``requests.Session`` with retry/backoff on the transient
515
+ failures :data:`_RETRY_STATUS` names. Not part of the public API surface;
516
+ a ``SDMClient`` holds one of these.
517
+
518
+ ``timeout`` bounds each individual attempt, not the call as a whole: a
519
+ request that exhausts ``max_retries`` can take up to
520
+ ``(max_retries + 1) * timeout`` plus backoff.
521
+ """
522
+
523
+ serves_sessions = True
524
+
525
+ def __init__(
526
+ self,
527
+ url: str,
528
+ api_key: str | None = None,
529
+ *,
530
+ verify_ssl: bool = True,
531
+ timeout: float = _DEFAULT_TIMEOUT_SECONDS,
532
+ max_retries: int = _DEFAULT_MAX_RETRIES,
533
+ backoff_factor: float = _DEFAULT_BACKOFF_FACTOR,
534
+ ) -> None:
535
+ _validate_url(url, api_key)
536
+ _validate_limits(timeout, max_retries)
537
+ _validate_tls_verification(url, verify_ssl)
538
+ self._url = url.rstrip('/')
539
+ self._api_key = api_key
540
+ self._verify_ssl = verify_ssl
541
+ self._timeout = timeout
542
+ self._closed = False
543
+ self._max_retries = max_retries
544
+ self._session = _build_session(
545
+ self._url, api_key, verify_ssl, max_retries, backoff_factor
546
+ )
547
+
548
+ @property
549
+ def url(self) -> str:
550
+ return self._url
551
+
552
+ @property
553
+ def api_key(self) -> str | None:
554
+ return self._api_key
555
+
556
+ @property
557
+ def verify_ssl(self) -> bool:
558
+ return self._verify_ssl
559
+
560
+ @property
561
+ def timeout(self) -> float:
562
+ return self._timeout
563
+
564
+ @property
565
+ def max_retries(self) -> int:
566
+ return self._max_retries
567
+
568
+ def close(self) -> None:
569
+ self._closed = True
570
+ self._session.close()
571
+
572
+ def _require_open(self) -> None:
573
+ if self._closed:
574
+ raise StructuredDataError(
575
+ 'this client is closed; construct a new SDMClient',
576
+ code='INVALID_CONFIGURATION',
577
+ )
578
+
579
+ def health_ready(
580
+ self,
581
+ *,
582
+ timeout: float | None = None,
583
+ retry: bool = True,
584
+ raise_on_not_ready: bool = False,
585
+ ) -> bool:
586
+ self._require_open()
587
+ request_timeout = self._timeout if timeout is None else timeout
588
+ _validate_limits(request_timeout, 0)
589
+ url = self._url + _HEALTH_READY_PATH
590
+ session = (
591
+ self._session
592
+ if retry
593
+ else _build_session(
594
+ self._url,
595
+ self._api_key,
596
+ self._verify_ssl,
597
+ max_retries=0,
598
+ backoff_factor=_DEFAULT_BACKOFF_FACTOR,
599
+ )
600
+ )
601
+ try:
602
+ # Streamed and closed unread: only the status matters, and the
603
+ # body is attacker-controlled on a misconfigured endpoint.
604
+ with session.get(
605
+ url,
606
+ timeout=request_timeout,
607
+ verify=self._verify_ssl,
608
+ stream=True,
609
+ ) as response:
610
+ if response.status_code != 200 and raise_on_not_ready:
611
+ raise StructuredDataError(
612
+ f'Readiness check at {redact_url(url)} returned '
613
+ f'HTTP {response.status_code}',
614
+ code='TRANSPORT_ERROR',
615
+ details={
616
+ 'status_code': response.status_code,
617
+ 'url': redact_url(url),
618
+ },
619
+ )
620
+ return response.status_code == 200
621
+ except requests.RequestException as error:
622
+ if _timed_out(error):
623
+ raise StructuredDataError(
624
+ f'Readiness check at {redact_url(url)} timed out after '
625
+ f'{request_timeout:g} seconds',
626
+ code='TRANSPORT_ERROR',
627
+ details={
628
+ 'timeout': request_timeout,
629
+ 'url': redact_url(url),
630
+ },
631
+ ) from error
632
+ raise _failed_to_reach(error, url, None) from error
633
+ finally:
634
+ if session is not self._session:
635
+ session.close()
636
+
637
+ def list_models(self, *, timeout: float | None = None) -> list[str]:
638
+ r"""The model ids the endpoint advertises.
639
+
640
+ ``/v1/models`` is supplied by the NIM runtime and is **not** one of the
641
+ contract's four paths, so an endpoint may not serve it at all. That is
642
+ reported as a transport error naming the path rather than being
643
+ answered from this client's own list of models, which is what the
644
+ previous generation did. That meant it claimed a model the
645
+ endpoint did not serve.
646
+ """
647
+ self._require_open()
648
+ request_timeout = self._timeout if timeout is None else timeout
649
+ _validate_limits(request_timeout, 0)
650
+ url = self._url + _MODELS_PATH
651
+ try:
652
+ with self._session.get(
653
+ url,
654
+ timeout=request_timeout,
655
+ verify=self._verify_ssl,
656
+ stream=True,
657
+ ) as response:
658
+ content = _read_capped(response, url)
659
+ if response.status_code != 200:
660
+ raise _to_nim_error(response.status_code, content)
661
+ except requests.RequestException as error:
662
+ raise _failed_to_reach(error, url, request_timeout) from error
663
+
664
+ entries = _decoded_object(content, url).get('data')
665
+ if entries is None:
666
+ entries = []
667
+ if not isinstance(entries, list):
668
+ raise StructuredDataError(
669
+ f'Expected a list of models under "data" from '
670
+ f'{redact_url(url)}, got {type(entries).__name__}',
671
+ code='TRANSPORT_ERROR',
672
+ details={'url': redact_url(url)},
673
+ )
674
+ return [
675
+ str(entry['id'])
676
+ for entry in entries
677
+ if isinstance(entry, dict) and entry.get('id')
678
+ ]
679
+
680
+ def predict(self, payload: dict[str, Any]) -> dict[str, Any]:
681
+ return self._post(_PREDICTIONS_PATH, payload)
682
+
683
+ def create_session(self, payload: dict[str, Any]) -> dict[str, Any]:
684
+ r"""Pins a context server-side; ``payload`` carries the context-only
685
+ sections of a prediction request.
686
+ """
687
+ return self._post(_SESSIONS_PATH, payload)
688
+
689
+ def session_predict(
690
+ self,
691
+ session_id: str,
692
+ payload: dict[str, Any],
693
+ ) -> dict[str, Any]:
694
+ r"""Scores rows against a pinned context; ``payload`` carries only the
695
+ per-call sections.
696
+ """
697
+ return self._post(
698
+ f'{_SESSIONS_PATH}/{_path_segment(session_id)}/predictions', payload
699
+ )
700
+
701
+ def delete_session(self, session_id: str) -> None:
702
+ r"""Releases a pinned context. Idempotent server-side: the NIM answers
703
+ 204 whether or not the session is still there.
704
+ """
705
+ self._require_open()
706
+ url = f'{self._url}{_SESSIONS_PATH}/{_path_segment(session_id)}'
707
+ request_timeout = min(self._timeout, _DELETE_TIMEOUT_SECONDS)
708
+ try:
709
+ with self._session.delete(
710
+ url,
711
+ timeout=request_timeout,
712
+ verify=self._verify_ssl,
713
+ stream=True,
714
+ ) as response:
715
+ status_code = response.status_code
716
+ content = _read_capped(response, url)
717
+ except requests.RequestException as error:
718
+ raise _failed_to_reach(error, url, request_timeout) from error
719
+ if status_code >= 400:
720
+ raise _to_nim_error(status_code, content)
721
+
722
+ def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
723
+ self._require_open()
724
+ url = self._url + path
725
+ # Encode before sending, so a value with no JSON form is named here
726
+ # rather than raising a TypeError from inside `requests` that mentions
727
+ # neither this client nor the offending key.
728
+ _check_encodable(payload)
729
+ try:
730
+ body = json.dumps(payload, allow_nan=False)
731
+ except RecursionError as error: # pragma: no cover - see the check
732
+ raise StructuredDataError(
733
+ 'this request is nested too deeply to encode as JSON',
734
+ code='INVALID_REQUEST',
735
+ ) from error
736
+ except (TypeError, ValueError) as error:
737
+ raise StructuredDataError(
738
+ f'this request carries a value that cannot be sent as JSON: '
739
+ f'{error}',
740
+ code='INVALID_REQUEST',
741
+ ) from error
742
+ try:
743
+ with self._session.post(
744
+ url,
745
+ # Encoded here rather than handed over as `str`: requests
746
+ # gives a str body to httplib, which encodes latin-1, so this
747
+ # is only safe today because `json.dumps` defaults to
748
+ # `ensure_ascii=True`. Not a default worth depending on.
749
+ data=body.encode('utf-8'),
750
+ headers={'Content-Type': 'application/json'},
751
+ timeout=self._timeout,
752
+ verify=self._verify_ssl,
753
+ stream=True,
754
+ ) as response:
755
+ status_code = response.status_code
756
+ content = _read_capped(response, url)
757
+ except requests.RequestException as error:
758
+ raise _failed_to_reach(error, url, self._timeout) from error
759
+ if status_code >= 400:
760
+ raise _to_nim_error(status_code, content)
761
+ return _decoded_object(content, url)
762
+
763
+
764
+ #: Deeper than any real request and far below the interpreter's own limit,
765
+ #: so the walk below reports rather than exhausting the stack.
766
+ _MAX_PAYLOAD_DEPTH = 200
767
+
768
+
769
+ def _check_encodable(payload: Any) -> None:
770
+ r"""Refuse a payload that nests too deeply or refers back to itself.
771
+
772
+ Not left to ``json.dumps`` raising ``RecursionError``. Whether it does is
773
+ an implementation detail of the encoder: CPython 3.12 raises on a 30,000
774
+ level mapping and 3.14 encodes the same value without complaint, so a
775
+ guard written around the exception protects one interpreter and not the
776
+ next. This package supports both.
777
+
778
+ Iterative, because a recursive check would hit the same limit it exists to
779
+ report.
780
+ """
781
+ stack: list[tuple[Any, int, tuple[int, ...]]] = [(payload, 0, ())]
782
+ while stack:
783
+ value, depth, parents = stack.pop()
784
+ if not isinstance(value, (Mapping, list, tuple)):
785
+ continue
786
+ if id(value) in parents:
787
+ raise StructuredDataError(
788
+ 'this request contains a value that refers back to itself, '
789
+ 'so it cannot be sent as JSON',
790
+ code='INVALID_REQUEST',
791
+ )
792
+ if depth > _MAX_PAYLOAD_DEPTH:
793
+ raise StructuredDataError(
794
+ f'this request nests more than {_MAX_PAYLOAD_DEPTH} levels '
795
+ f'deep, so it cannot be sent as JSON',
796
+ code='INVALID_REQUEST',
797
+ )
798
+ nested = (*parents, id(value))
799
+ items = value.values() if isinstance(value, Mapping) else value
800
+ for item in items:
801
+ stack.append((item, depth + 1, nested))
802
+
803
+
804
+ _THE_CALLERS_TO_FIX = (
805
+ ('failed to collect any context examples', 'EMPTY_CONTEXT'),
806
+ )
807
+
808
+ _LEAKED_EXCEPTION = re.compile(
809
+ r'\b(?:failed unexpectedly with\s+)?'
810
+ r'[A-Za-z_][A-Za-z0-9_]*(?:Error|Exception)\s*:\s*'
811
+ )
812
+
813
+
814
+ def _diagnosis_in(reported: Any) -> tuple[str, str] | None:
815
+ r"""The wording and stable code of a failure the caller can act on.
816
+
817
+ ``_THE_CALLERS_TO_FIX`` holds the failures the endpoint reports as its own
818
+ that describe the request instead. Each is the caller's to fix, and an
819
+ orchestrator told a server faulted will retry one that can only fail the
820
+ same way.
821
+
822
+ They are matched on the endpoint's wording, which is the only signal the
823
+ response gives: the status says server, the code says internal, and only
824
+ the sentence says otherwise. A build that rewords one reverts to reporting
825
+ it the way the endpoint did, rather than misreporting anything.
826
+
827
+ Only a string is read. A body whose ``detail`` is an object is not the
828
+ shape the contract describes, and rendering one to text could match this
829
+ on a debug field the endpoint never meant as its diagnosis.
830
+ """
831
+ if not isinstance(reported, str):
832
+ return None
833
+ said = reported.casefold()
834
+ for wording, code in _THE_CALLERS_TO_FIX:
835
+ if wording in said:
836
+ return wording, code
837
+ return None
838
+
839
+
840
+ def _the_diagnosis_alone(message: str, wording: str) -> str:
841
+ r"""The part of ``message`` that says what the caller got wrong.
842
+
843
+ What comes before it is the endpoint describing its own failure: which
844
+ prediction, at which address, and the Python type that reached its
845
+ response, matched by ``_LEAKED_EXCEPTION``. None of that is the caller's
846
+ to act on, and a type from inside the server reads as a crash, which is
847
+ the impression being corrected.
848
+ """
849
+ found = message.casefold().find(wording)
850
+ kept = message[found:].strip() if found >= 0 else message
851
+ return _LEAKED_EXCEPTION.sub('', kept).strip() or message
852
+
853
+
854
+ def _to_nim_error(status_code: int, content: bytes) -> NimRequestError:
855
+ r"""The error an endpoint's failed response means.
856
+
857
+ A failure the caller can act on is reported as theirs whatever status it
858
+ arrived under, with what the endpoint called it kept in ``details``: that
859
+ is worth knowing even where it was wrong.
860
+ """
861
+ try:
862
+ body = json.loads(content)
863
+ except (ValueError, RecursionError):
864
+ body = {}
865
+ if not isinstance(body, dict):
866
+ body = {}
867
+ code = body.get('code')
868
+ reported = (
869
+ body.get('detail')
870
+ or body.get('title')
871
+ or content.decode('utf-8', 'replace')
872
+ )
873
+ message = (
874
+ _snippet(str(reported))
875
+ if reported
876
+ else (f'NIM request failed with status {status_code}')
877
+ )
878
+ # A validation failure's top-level detail is often only "Request validation
879
+ # failed."; the per-field diagnosis is in invalid_params, so render it into
880
+ # the message rather than leaving it for the caller to dig out of details.
881
+ message += format_invalid_params(body.get('invalid_params'))
882
+ details = {
883
+ key: value
884
+ for key, value in body.items()
885
+ if key not in ('code', 'detail', 'title', 'type', 'status', 'instance')
886
+ }
887
+ found = _diagnosis_in(reported) if status_code >= 500 else None
888
+ if found is not None:
889
+ wording, diagnosis = found
890
+ details = {**details, 'endpoint_code': code, 'diagnosis': diagnosis}
891
+ code = 'INVALID_REQUEST'
892
+ message = _the_diagnosis_alone(message, wording)
893
+ return NimRequestError(
894
+ status_code,
895
+ code=code,
896
+ message=message,
897
+ details=details,
898
+ )