causilo-client 0.8.13__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,39 @@
1
+ """Radix inference API client.
2
+
3
+ from radix import Radix
4
+
5
+ r = Radix(key_file="radix-key.json")
6
+ pred = r.predict(context_df, query_df, target="label", model="radix-a-clf")
7
+
8
+ Handles authentication, Parquet encoding, multipart assembly, token refresh
9
+ and retries. Callers pass two DataFrames and nothing else.
10
+ """
11
+
12
+ from .client import (
13
+ MODELS,
14
+ Causilo,
15
+ RadixAuthError,
16
+ RadixError,
17
+ RadixOverloaded,
18
+ RadixQuotaExceeded,
19
+ RadixTimeout,
20
+ RadixTooLarge,
21
+ RadixUnavailable,
22
+ RadixValidationError,
23
+ to_batch_parquet,
24
+ )
25
+
26
+ __all__ = [
27
+ "Causilo",
28
+ "RadixError",
29
+ "RadixValidationError",
30
+ "RadixTooLarge",
31
+ "RadixUnavailable",
32
+ "RadixQuotaExceeded",
33
+ "RadixOverloaded",
34
+ "RadixTimeout",
35
+ "RadixAuthError",
36
+ "MODELS",
37
+ "to_batch_parquet",
38
+ ]
39
+ __version__ = "0.8.13"
@@ -0,0 +1,774 @@
1
+ """Causilo inference API client.
2
+
3
+ Import name is causilo_client, not causilo: that one is the model package on
4
+ PyPI, and a customer may well have both installed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import io
10
+ import json
11
+ import os
12
+ import threading
13
+ import time
14
+ import uuid
15
+ from datetime import timezone
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ import pandas as pd
20
+ import requests
21
+
22
+ # Checked before a request is sent, so a stale copy of this list refuses keys
23
+ # the server would have served. radix-b-clf and radix-b-reg were served until
24
+ # 2026-09-11 and are gone; causilo-clf and causilo-reg replace them. A customer
25
+ # running an older build of this client will be refused locally on a causilo
26
+ # key -- the fix is a client release, not a server change.
27
+ MODELS = ("radix-a-clf", "radix-a-reg", "causilo-clf", "causilo-reg")
28
+
29
+ # Where an operator-issued token rides on top of Authorization. SageMaker's
30
+ # front end consumes the SigV4 Authorization header and does not pass it to the
31
+ # container, so a token sent only there arrives nowhere and the call is billed
32
+ # to nobody. This header is the one AWS documents as passed through, capped at
33
+ # 1024 ASCII characters. The pair form leaves room for anything else that wants
34
+ # the field -- a trace identifier, say -- which a bare token would not.
35
+ #
36
+ # Sent on every call once a token= is configured, not only against SageMaker: a
37
+ # client cannot tell the two platforms apart from the endpoint alone, and the
38
+ # server treats the same token in both headers as one identity rather than two.
39
+ # A caller on the key_file= path sends nothing here. A Google ID token is not
40
+ # ours to check, it usually exceeds 1024 characters, and Cloud Run IAM has
41
+ # already established who they are.
42
+ _CUSTOM_ATTRIBUTES_HEADER = "X-Amzn-SageMaker-Custom-Attributes"
43
+ _TOKEN_ATTRIBUTE = "radix-token"
44
+
45
+ # Sent with every prediction and again with a cancel. Ours to choose, because a
46
+ # call that times out never sees the server's request id and still has to name
47
+ # what it is giving up on. Neither Cloud Run nor SageMaker tells the server when
48
+ # a caller hangs up, so without this the server finishes the work, bills it and
49
+ # logs it as served.
50
+ _CANCEL_KEY_HEADER = "X-Radix-Cancel-Key"
51
+
52
+ # Retry when the server marks the error retryable=true, or when it answers with
53
+ # one of these status codes.
54
+ # 504 and 502 are deliberately absent. Cloud Run answers 504 when the request
55
+ # passed its own timeout and 502 when the container died mid-request; either
56
+ # way the server took the work and charged for it, and in the 502 case the
57
+ # process that would have refunded it is the one that died. Retrying pays for
58
+ # it again -- four times over, which is the shape the server-side refunds were
59
+ # written to close.
60
+ _RETRY_STATUS = frozenset({429, 500, 503})
61
+ _MAX_RETRIES = 3
62
+ _BACKOFF_S = (1.0, 3.0, 8.0)
63
+ # Cap on a server-sent Retry-After, so one bad header cannot hang the caller.
64
+ _MAX_RETRY_AFTER_S = 30.0
65
+
66
+ # Refresh the ID token this many seconds before it expires. A large table can
67
+ # spend minutes in a single call, and a token that lapses in flight comes back
68
+ # as a 401 after the work has already been paid for.
69
+ _TOKEN_MARGIN_S = 300
70
+
71
+
72
+ class RadixError(Exception):
73
+ """An error returned by the server.
74
+
75
+ `request_id` identifies the failing call in the operator's logs. It is
76
+ appended to the message rather than left on the attribute alone, because
77
+ what reaches a support request is whatever the traceback printed, and
78
+ nobody reads attributes off an exception they did not expect. Empty when
79
+ the refusal came from the platform in front of the service rather than
80
+ from the service itself.
81
+ """
82
+
83
+ def __init__(
84
+ self,
85
+ message: str,
86
+ *,
87
+ status: int = 0,
88
+ error_code: str = "",
89
+ request_id: str = "",
90
+ ) -> None:
91
+ if request_id:
92
+ message = f"{message} (request {request_id})"
93
+ super().__init__(message)
94
+ self.status = status
95
+ self.error_code = error_code
96
+ self.request_id = request_id
97
+
98
+
99
+ class RadixValidationError(RadixError):
100
+ """The request was invalid (422). Fix it and send again."""
101
+
102
+
103
+ class RadixTooLarge(RadixError):
104
+ """The table is too large (413). Reduce rows or columns."""
105
+
106
+
107
+ class RadixUnavailable(RadixError):
108
+ """Temporarily unavailable (503). Retrying may succeed."""
109
+
110
+
111
+ class RadixTimeout(RadixError):
112
+ """No response within the client's timeout.
113
+
114
+ `refunded` is the number of cells the server gave back when this client
115
+ told it the call was abandoned, 0 if there was nothing to give back, and
116
+ None if the cancel itself could not be delivered -- in which case the
117
+ charge may stand and the message says so.
118
+
119
+ Deliberately not a subclass of RadixUnavailable: the request reached the
120
+ server, which is very likely still computing it, so this is the one
121
+ failure that must not be retried automatically. Every retry is charged
122
+ against the monthly quota for work that will complete regardless, and the
123
+ server has nothing to refund because nothing failed there.
124
+ """
125
+
126
+ refunded: int | None = None
127
+
128
+
129
+ class RadixAuthError(RadixError):
130
+ """The credential was rejected (401) or is not permitted (403).
131
+
132
+ 403 is what an unfinished setup looks like: the service account exists and
133
+ the token is valid, but it has not been granted the invoker role on this
134
+ service, or the key belongs to another project.
135
+ """
136
+
137
+
138
+ class RadixQuotaExceeded(RadixError):
139
+ """The monthly usage quota is spent (429). Resets on the 1st, UTC.
140
+
141
+ Retrying will not help before the reset. Ask for a higher cap, or send
142
+ less.
143
+
144
+ `scope` says which of the two caps refused: "caller" is this one API key,
145
+ "tenant" is everything the account holds together. The fixes differ, so it
146
+ is worth branching on. A key that is spent while the account has room
147
+ means another key still works; an account that is spent means none of them
148
+ do.
149
+
150
+ Empty against a server older than 0.8.2, which refused without saying
151
+ which cap. Treat "" as "unknown", not as "caller" -- guessing the cheaper
152
+ of the two sends the customer to buy a second key that will not work.
153
+ """
154
+
155
+ def __init__(self, message: str, *, status: int = 0, error_code: str = "",
156
+ request_id: str = "", scope: str = "") -> None:
157
+ super().__init__(
158
+ message, status=status, error_code=error_code, request_id=request_id
159
+ )
160
+ self.scope = scope
161
+
162
+
163
+ class RadixOverloaded(RadixError):
164
+ """The service had no free capacity in time (429).
165
+
166
+ Not the same 429 as RadixQuotaExceeded, and the difference matters: this
167
+ one says the request waited for an instance longer than Cloud Run is
168
+ willing to hold it, so it never reached the model at all. Nothing was
169
+ charged against the monthly quota, and calling again once the queue drains
170
+ succeeds. A caller that reads this as "we are out of quota" goes looking
171
+ for a limit increase that would change nothing.
172
+
173
+ The two are told apart by the body: our own 429 carries an error_code,
174
+ the one Cloud Run generates does not.
175
+ """
176
+
177
+
178
+ class Causilo:
179
+ """A client bound to one inference endpoint.
180
+
181
+ Authentication, pick one:
182
+
183
+ key_file Path to a service account key JSON. The standard route when
184
+ calling from outside GCP. Requires ``google-auth``.
185
+ token A bearer token issued by the operator. Use this when the
186
+ caller cannot reach ``oauth2.googleapis.com``, and on
187
+ SageMaker, where it is also what the usage records are
188
+ attributed to.
189
+ (neither) No authentication. Local testing only.
190
+
191
+ A ``token`` travels in ``Authorization`` and in
192
+ ``X-Amzn-SageMaker-Custom-Attributes`` both, because the SageMaker front
193
+ end passes on only the second. Callers who invoke the endpoint through
194
+ boto3 rather than this client send the same string themselves:
195
+ ``invoke_endpoint(..., CustomAttributes="radix-token=<token>")``.
196
+
197
+ ``endpoint`` falls back to the ``RADIX_ENDPOINT`` environment variable.
198
+ """
199
+
200
+ def __init__(
201
+ self,
202
+ endpoint: str | None = None,
203
+ *,
204
+ key_file: str | Path | None = None,
205
+ token: str | None = None,
206
+ timeout: float = 900.0,
207
+ session: requests.Session | None = None,
208
+ ) -> None:
209
+ endpoint = endpoint or os.environ.get("RADIX_ENDPOINT", "")
210
+ if not endpoint:
211
+ raise ValueError(
212
+ "endpoint is required. Pass it directly or set RADIX_ENDPOINT."
213
+ )
214
+ # Require https. A bearer token and the whole table go in the request;
215
+ # over http they travel in clear. A typo'd http:// endpoint would still
216
+ # "work" (Cloud Run redirects to https), so nothing would flag the leak
217
+ # -- reject it here instead. localhost is exempt for local testing.
218
+ from urllib.parse import urlparse
219
+
220
+ parsed = urlparse(endpoint)
221
+ if parsed.scheme != "https" and parsed.hostname not in (
222
+ "localhost",
223
+ "127.0.0.1",
224
+ ):
225
+ raise ValueError(
226
+ f"endpoint must use https (got {parsed.scheme or 'no scheme'}). "
227
+ "The token and data are sent in the request body."
228
+ )
229
+ # A comma would split the token in half inside the custom attributes
230
+ # header, and a non-ASCII one cannot travel in an HTTP field value at
231
+ # all. Neither can be a token we issued -- the server reads its list
232
+ # from a comma-separated variable -- so this catches a typo or a
233
+ # pasted-in credential from somewhere else, here rather than as a 401
234
+ # against a live endpoint.
235
+ if token is not None and ("," in token or not token.isascii()):
236
+ raise ValueError(
237
+ "token must be ASCII and cannot contain a comma. Check that "
238
+ "the whole token was copied, and only the token."
239
+ )
240
+ self.endpoint = endpoint.rstrip("/")
241
+ self.timeout = timeout
242
+ self._session = session or requests.Session()
243
+ self._static_token = token
244
+ self._key_file = Path(key_file) if key_file else None
245
+ self._credentials = None
246
+ self._token_cache: tuple[str, float] | None = None
247
+ self._token_lock = threading.Lock()
248
+
249
+ def __getstate__(self) -> dict:
250
+ """Let a Radix cross a process boundary.
251
+
252
+ multiprocessing and joblib pickle whatever the worker function closes
253
+ over, and a lock, a requests.Session and a credentials object are none
254
+ of them picklable -- so building the client once and mapping over
255
+ chunks, which is the obvious way to use this, used to fail with
256
+ "cannot pickle '_thread.lock' object" before a single call went out.
257
+ Those are dropped and rebuilt on the other side, along with any minted
258
+ ID token: each process mints its own.
259
+
260
+ The session's transport settings are carried across by value, because
261
+ losing them is silent and only in the workers. A caller who configures
262
+ a corporate CA bundle or an explicit proxy, watches one call succeed,
263
+ then fans out would otherwise have every worker fail TLS with a
264
+ message about the endpoint being unreachable.
265
+
266
+ A ``token=`` credential IS in the pickle, in clear text -- it is the
267
+ only way the worker can authenticate at all. joblib writes pickles to
268
+ temporary files, so treat a pickled client as the credential itself.
269
+ """
270
+ state = self.__dict__.copy()
271
+ for field in ("_token_lock", "_session", "_credentials", "_token_cache"):
272
+ state.pop(field, None)
273
+ session = self._session
274
+ state["_session_settings"] = {
275
+ "verify": session.verify,
276
+ "proxies": dict(session.proxies),
277
+ "cert": session.cert,
278
+ "trust_env": session.trust_env,
279
+ "headers": dict(session.headers),
280
+ }
281
+ return state
282
+
283
+ def __setstate__(self, state: dict) -> None:
284
+ settings = state.pop("_session_settings", {})
285
+ self.__dict__.update(state)
286
+ self._token_lock = threading.Lock()
287
+ self._session = requests.Session()
288
+ for name, value in settings.items():
289
+ if name == "headers":
290
+ self._session.headers.update(value)
291
+ else:
292
+ setattr(self._session, name, value)
293
+ self._credentials = None
294
+ self._token_cache = None
295
+
296
+ # --- Public API -------------------------------------------------------
297
+
298
+ def predict(
299
+ self,
300
+ context: pd.DataFrame,
301
+ query: pd.DataFrame,
302
+ *,
303
+ target: str,
304
+ model: str,
305
+ output_type: str | None = None,
306
+ quantiles: list[float] | None = None,
307
+ ):
308
+ """Return the prediction only.
309
+
310
+ ``context`` includes the target column; ``query`` does not. Feature
311
+ columns must match in name and order across both frames.
312
+ """
313
+ prediction, _ = self.predict_with_metadata(
314
+ context,
315
+ query,
316
+ target=target,
317
+ model=model,
318
+ output_type=output_type,
319
+ quantiles=quantiles,
320
+ )
321
+ return prediction
322
+
323
+ def predict_with_metadata(
324
+ self,
325
+ context: pd.DataFrame,
326
+ query: pd.DataFrame,
327
+ *,
328
+ target: str,
329
+ model: str,
330
+ output_type: str | None = None,
331
+ quantiles: list[float] | None = None,
332
+ ) -> tuple[Any, dict]:
333
+ """Return ``(prediction, metadata)``.
334
+
335
+ ``metadata`` carries the settings the server applied and any warning.
336
+ For classification, ``metadata["classes"]`` gives the column order of
337
+ the probability matrix.
338
+ """
339
+ if model not in MODELS:
340
+ raise ValueError(f"model must be one of: {', '.join(MODELS)}.")
341
+
342
+ params: dict[str, Any] = {"model": model, "target": target}
343
+ if output_type:
344
+ params["output_type"] = output_type
345
+ if quantiles:
346
+ params["quantiles"] = list(quantiles)
347
+
348
+ body, content_type = _multipart(context, query, params)
349
+ response = self._post(body, content_type, accept="application/json")
350
+ doc = response.json()
351
+ return doc["prediction"], doc.get("metadata", {})
352
+
353
+ def health(self) -> bool:
354
+ """Whether the model is loaded and ready to serve."""
355
+ try:
356
+ r = self._session.get(
357
+ f"{self.endpoint}/ping", headers=self._headers(), timeout=30
358
+ )
359
+ except requests.RequestException:
360
+ return False
361
+ return r.status_code == 200
362
+
363
+ def wake(self, timeout: float = 120.0) -> bool:
364
+ """Warm the instance up ahead of time.
365
+
366
+ With no traffic the service scales to zero, and coming back takes
367
+ on the order of ten seconds. Call this while preparing a large table
368
+ so the two overlap.
369
+ """
370
+ deadline = time.monotonic() + timeout
371
+ while time.monotonic() < deadline:
372
+ if self.health():
373
+ return True
374
+ time.sleep(2.0)
375
+ return False
376
+
377
+ def usage(self) -> dict:
378
+ """This month's consumption for the credential this client holds.
379
+
380
+ Returns the body of ``GET /usage``. Always has ``caller`` and
381
+ ``metered``; the figures are present only when ``metered`` is true.
382
+
383
+ u = rx.usage()
384
+ if u["metered"] and u["remaining"] < 1_000_000:
385
+ ...
386
+
387
+ ``metered`` is false both when no cap applies and when the ledger could
388
+ not be read, and those are not distinguished on purpose. Treat it as
389
+ "no figure available", never as zero -- a client that defaults to zero
390
+ reports full remaining quota during an outage of the thing that counts
391
+ it.
392
+
393
+ Scoped to this credential. There is no argument for whose usage to
394
+ fetch, because the credential is the answer.
395
+ """
396
+ r = self._session.get(
397
+ f"{self.endpoint}/usage", headers=self._headers(), timeout=30
398
+ )
399
+ if r.status_code != 200:
400
+ raise _to_error(r)
401
+ return r.json()
402
+
403
+ # --- Internals --------------------------------------------------------
404
+
405
+ def _post(self, body: bytes, content_type: str, accept: str) -> requests.Response:
406
+ last: RadixError | None = None
407
+ cancel_key = uuid.uuid4().hex
408
+ for attempt in range(_MAX_RETRIES + 1):
409
+ # Reset per attempt. A connection error on attempt 0 leaves response
410
+ # unbound, and a stale response from an earlier attempt must not feed
411
+ # _sleep_for a stale Retry-After.
412
+ response: requests.Response | None = None
413
+ headers = {**self._headers(), _CANCEL_KEY_HEADER: cancel_key}
414
+ headers["Content-Type"] = content_type
415
+ headers["Accept"] = accept
416
+ try:
417
+ response = self._session.post(
418
+ f"{self.endpoint}/invocations",
419
+ data=body,
420
+ headers=headers,
421
+ timeout=self.timeout,
422
+ )
423
+ except requests.exceptions.ReadTimeout as exc:
424
+ refunded = self._cancel(cancel_key)
425
+ # The request was delivered and the server is still working on
426
+ # it. Retrying charges the month again for a call that will
427
+ # finish anyway, and the server cannot refund it -- nothing
428
+ # failed on its side. This is the same 4x shape the server-side
429
+ # refunds were written to close, on the half of the wire they
430
+ # cannot reach.
431
+ # Tell the server we gave up. It cannot see the hang-up itself
432
+ # -- neither platform passes one to the container -- so this is
433
+ # the only way the charge for work nobody will receive comes
434
+ # back. Best effort: a cancel that fails leaves things as they
435
+ # were before it existed, and the message says which happened.
436
+ if refunded:
437
+ released = f" The server released the charge ({refunded:,} cells)."
438
+ elif refunded == 0:
439
+ released = " Nothing was pending to release."
440
+ else:
441
+ released = (
442
+ " The cancel could not be delivered, so the charge may "
443
+ "stand; quote the time of the call if you dispute it."
444
+ )
445
+ err = RadixTimeout(
446
+ f"No response within {self.timeout:.0f}s, and the server is "
447
+ "probably still computing. The call was not retried, "
448
+ "because a retry is charged again. Raise timeout= or send "
449
+ f"a smaller table. ({exc}){released}"
450
+ )
451
+ err.refunded = refunded
452
+ raise err from None
453
+ except requests.exceptions.SSLError as exc:
454
+ # A CA bundle problem, which is what a corporate TLS proxy
455
+ # looks like. No number of retries fixes it.
456
+ raise RadixError(
457
+ f"TLS verification failed: {exc}. If the network inspects "
458
+ "TLS, point requests at the corporate CA bundle "
459
+ "(REQUESTS_CA_BUNDLE, or session=)."
460
+ ) from None
461
+ except requests.RequestException as exc:
462
+ last = RadixUnavailable(f"Could not reach the endpoint: {exc}")
463
+ else:
464
+ if response.status_code == 200:
465
+ return response
466
+ last = _to_error(response)
467
+ # A 401 mid-run is usually a token that lapsed in flight, so
468
+ # drop the cache and let the next attempt mint a fresh one
469
+ # rather than failing a call the GPU has already been paid for.
470
+ # Only worth doing when there is something to refresh from: a
471
+ # static token cannot change, so retrying it just makes a wrong
472
+ # credential take three backoffs to report.
473
+ if last.status == 401 and self._key_file and attempt < _MAX_RETRIES:
474
+ self._token_cache = None
475
+ elif not _retryable(response, last):
476
+ raise last
477
+
478
+ if attempt < _MAX_RETRIES:
479
+ time.sleep(_sleep_for(response, attempt))
480
+
481
+ raise last if last else RadixError("The request failed.")
482
+
483
+ def _cancel(self, cancel_key: str) -> int | None:
484
+ """Tell the server a call was abandoned. Best effort, never raises.
485
+
486
+ Returns cells refunded, 0 if nothing was pending, None if the cancel
487
+ could not be delivered. Ten seconds is generous for a request that
488
+ does no inference; a cancel that hangs must not extend a timeout the
489
+ caller has already waited through.
490
+ """
491
+ try:
492
+ r = self._session.post(
493
+ f"{self.endpoint}/cancel",
494
+ headers={**self._headers(), _CANCEL_KEY_HEADER: cancel_key},
495
+ timeout=10,
496
+ )
497
+ if r.status_code != 200:
498
+ return None
499
+ return int(r.json().get("refunded", 0))
500
+ except (requests.RequestException, ValueError):
501
+ return None
502
+
503
+ def _headers(self) -> dict[str, str]:
504
+ headers: dict[str, str] = {}
505
+ token = self._bearer()
506
+ if token:
507
+ headers["Authorization"] = f"Bearer {token}"
508
+ if self._static_token:
509
+ headers[_CUSTOM_ATTRIBUTES_HEADER] = (
510
+ f"{_TOKEN_ATTRIBUTE}={self._static_token}"
511
+ )
512
+ return headers
513
+
514
+ def _bearer(self) -> str | None:
515
+ if self._static_token:
516
+ return self._static_token
517
+ if not self._key_file:
518
+ return None
519
+
520
+ now = time.time()
521
+ if self._token_cache and self._token_cache[1] - _TOKEN_MARGIN_S > now:
522
+ return self._token_cache[0]
523
+
524
+ # Serialise the mint. Threads that all start on a cold cache otherwise
525
+ # build IDTokenCredentials from the same file at once and refresh the
526
+ # shared object concurrently; some of them read self._credentials.token
527
+ # while another refresh is mid-flight and send an empty Authorization
528
+ # header, which Cloud Run answers with 403. Measured on 2026-08-27:
529
+ # six threads sharing one Radix, four came back 403. Under the lock the
530
+ # first thread fills the cache and the rest take the fast path above.
531
+ with self._token_lock:
532
+ now = time.time()
533
+ if self._token_cache and self._token_cache[1] - _TOKEN_MARGIN_S > now:
534
+ return self._token_cache[0]
535
+ return self._mint_token(now)
536
+
537
+ def _mint_token(self, now: float) -> str:
538
+ try:
539
+ import google.auth.transport.requests
540
+ from google.oauth2 import service_account
541
+ except ImportError:
542
+ raise RadixError(
543
+ "Using a service account key requires google-auth: "
544
+ "pip install google-auth"
545
+ ) from None
546
+
547
+ # Everything google-auth raises here -- a key file that is not a service
548
+ # account key, a clock too far off, a network that cannot reach
549
+ # oauth2.googleapis.com -- would otherwise surface as a library
550
+ # exception the caller has no reason to have imported, and the last of
551
+ # those is the likeliest failure on a corporate network. Name the two
552
+ # things worth checking.
553
+ try:
554
+ if self._credentials is None:
555
+ self._credentials = (
556
+ service_account.IDTokenCredentials.from_service_account_file(
557
+ str(self._key_file), target_audience=self.endpoint
558
+ )
559
+ )
560
+ self._credentials.refresh(google.auth.transport.requests.Request())
561
+ except Exception as exc:
562
+ self._credentials = None
563
+ raise RadixError(
564
+ f"Could not get a token for {self._key_file}: "
565
+ f"{type(exc).__name__}: {exc}. Check that the file is a service "
566
+ "account key in JSON, and that this machine can reach "
567
+ "oauth2.googleapis.com."
568
+ ) from None
569
+ expiry = self._credentials.expiry
570
+ # google-auth returns expiry as a naive UTC datetime. Calling
571
+ # .timestamp() on a naive datetime interprets it in local time, which in
572
+ # a UTC-negative zone reads the token as valid hours past its real
573
+ # expiry -- so the client keeps sending a lapsed token and every call
574
+ # 401s. Attach UTC before converting.
575
+ if expiry is not None:
576
+ expiry_ts = expiry.replace(tzinfo=timezone.utc).timestamp()
577
+ else:
578
+ expiry_ts = now + 3600
579
+ self._token_cache = (self._credentials.token, expiry_ts)
580
+ return self._token_cache[0]
581
+
582
+
583
+ def _sleep_for(response, attempt: int) -> float:
584
+ """How long to wait before the next attempt.
585
+
586
+ A server-sent Retry-After wins over the local backoff schedule: it is the
587
+ only party that knows when capacity frees up. Capped so a bad value cannot
588
+ stall the caller for minutes.
589
+ """
590
+ if response is not None:
591
+ raw = response.headers.get("Retry-After", "")
592
+ if raw.isdigit():
593
+ return min(float(raw), _MAX_RETRY_AFTER_S)
594
+ return _BACKOFF_S[min(attempt, len(_BACKOFF_S) - 1)]
595
+
596
+
597
+ # --- Request assembly --------------------------------------------------------
598
+
599
+
600
+ def _to_parquet(frame: pd.DataFrame) -> bytes:
601
+ buf = io.BytesIO()
602
+ frame.to_parquet(buf, index=False, compression="zstd")
603
+ return buf.getvalue()
604
+
605
+
606
+ def _multipart(
607
+ context: pd.DataFrame, query: pd.DataFrame, params: dict
608
+ ) -> tuple[bytes, str]:
609
+ boundary = uuid.uuid4().hex
610
+ chunks: list[bytes] = []
611
+ for name, frame in (("context", context), ("query", query)):
612
+ chunks.append(
613
+ f"--{boundary}\r\n"
614
+ f'Content-Disposition: form-data; name="{name}"; '
615
+ f'filename="{name}.parquet"\r\n'
616
+ f"Content-Type: application/vnd.apache.parquet\r\n\r\n".encode()
617
+ + _to_parquet(frame)
618
+ + b"\r\n"
619
+ )
620
+ chunks.append(
621
+ f"--{boundary}\r\n"
622
+ f'Content-Disposition: form-data; name="params"\r\n'
623
+ f"Content-Type: application/json\r\n\r\n"
624
+ f"{json.dumps(params)}\r\n".encode()
625
+ )
626
+ chunks.append(f"--{boundary}--\r\n".encode())
627
+ return b"".join(chunks), f"multipart/form-data; boundary={boundary}"
628
+
629
+
630
+ def to_batch_parquet(
631
+ frame: pd.DataFrame,
632
+ *,
633
+ target: str,
634
+ model: str,
635
+ path: str | Path,
636
+ output_type: str | None = None,
637
+ ) -> Path:
638
+ """Write a single Parquet file for batch transform.
639
+
640
+ **Rows with a blank target become the query**; filled rows are the
641
+ context. Parameters go into the Parquet schema metadata because batch
642
+ transform cannot carry headers.
643
+ """
644
+ import pyarrow as pa
645
+ import pyarrow.parquet as pq
646
+
647
+ if target not in frame.columns:
648
+ raise ValueError(f"Table has no target column {target!r}.")
649
+ if not frame[target].isna().any():
650
+ raise ValueError(
651
+ f"Target column {target!r} has no blank values. "
652
+ "Leave the target empty on the rows you want predicted."
653
+ )
654
+
655
+ params: dict[str, Any] = {"model": model, "target": target}
656
+ if output_type:
657
+ params["output_type"] = output_type
658
+
659
+ table = pa.Table.from_pandas(frame, preserve_index=False)
660
+ table = table.replace_schema_metadata(
661
+ {b"radix-params": json.dumps(params).encode("utf-8")}
662
+ )
663
+ path = Path(path)
664
+ pq.write_table(table, path, compression="zstd")
665
+ return path
666
+
667
+
668
+ # --- Response handling -------------------------------------------------------
669
+
670
+
671
+ def _to_error(response: requests.Response) -> RadixError:
672
+ """Turn a failed response into the right exception class.
673
+
674
+ The status inside the RFC 9457 body wins over the HTTP status. SageMaker
675
+ rewrites every 4xx and 5xx a container returns into a 424 ModelError, so
676
+ on that path the HTTP status says nothing about what went wrong. RFC 9457
677
+ keeps the original in the `status` member for exactly this case.
678
+ """
679
+ try:
680
+ doc = response.json()
681
+ except ValueError:
682
+ doc = {}
683
+
684
+ status = doc.get("status")
685
+ if not isinstance(status, int):
686
+ status = response.status_code
687
+
688
+ code = doc.get("code") or doc.get("error_code", "")
689
+ message = (
690
+ doc.get("detail")
691
+ or doc.get("message")
692
+ or response.reason
693
+ or "The request failed."
694
+ )
695
+ if status == 502:
696
+ return RadixUnavailable(
697
+ "The service dropped this request (502). It was charged and was "
698
+ "not retried automatically, because a retry is charged again. Try "
699
+ "once more, and send a smaller table if it repeats.",
700
+ status=status,
701
+ error_code=code,
702
+ )
703
+ if status == 504:
704
+ return RadixTimeout(
705
+ "The service did not finish this request within its own timeout "
706
+ "(504). It was charged and is probably still running, so the call "
707
+ "was not retried. Send a smaller table.",
708
+ status=status,
709
+ error_code=code,
710
+ )
711
+ cls = {
712
+ 400: RadixValidationError,
713
+ 401: RadixAuthError,
714
+ 403: RadixAuthError,
715
+ 406: RadixValidationError,
716
+ 413: RadixTooLarge,
717
+ 422: RadixValidationError,
718
+ 429: RadixQuotaExceeded,
719
+ 503: RadixUnavailable,
720
+ }.get(status, RadixError)
721
+ # Cloud Run rejects an unauthorized call before the container sees it, so
722
+ # the body is HTML and `message` above falls back to the reason phrase --
723
+ # "Forbidden", by itself, for the single likeliest failure on install day.
724
+ # Say what to check instead.
725
+ if cls is RadixAuthError and not code:
726
+ message = (
727
+ "The service rejected this credential (403 Forbidden). Check that "
728
+ "the service account in the key file has been granted the Cloud "
729
+ "Run invoker role on this service, and that the key belongs to the "
730
+ "right project."
731
+ if status == 403
732
+ else "The service did not accept this credential (401 "
733
+ "Unauthorized). The token may have expired, or the key file may "
734
+ "not match this endpoint."
735
+ )
736
+ # Two different things answer 429. Ours carries `code` in an RFC 9457 body;
737
+ # the one Cloud Run returns when no instance came free in time is a plain
738
+ # HTML page with no code at all. Reporting both as "quota exhausted" sends
739
+ # the caller after a limit increase for what is really a queue that was
740
+ # busy for a moment.
741
+ if cls is RadixQuotaExceeded and code != "quota_exceeded":
742
+ cls = RadixOverloaded
743
+ # Header first: it arrives even when the body is not ours to read. Both
744
+ # copies exist because SageMaker strips the header, so neither alone is
745
+ # enough.
746
+ request_id = str(
747
+ response.headers.get("X-Request-Id", "") or doc.get("request_id", "")
748
+ )
749
+ if cls is RadixQuotaExceeded:
750
+ return cls(message, status=status, error_code=code,
751
+ request_id=request_id, scope=str(doc.get("scope", "")))
752
+ return cls(message or "No instance was free in time.", status=status,
753
+ error_code=code, request_id=request_id)
754
+
755
+
756
+ def _retryable(response: requests.Response, error: RadixError) -> bool:
757
+ """Trust the server's own retryable flag first; fall back to the status code.
758
+
759
+ The server knows whether the failure was transient -- a 500 from a busy
760
+ engine is worth another attempt, one from a malformed table never is. The
761
+ status code is only a guess, so it decides only when the flag is absent.
762
+ """
763
+ try:
764
+ doc = response.json()
765
+ except ValueError:
766
+ doc = {}
767
+ flag = doc.get("retryable")
768
+ if isinstance(flag, bool):
769
+ return flag
770
+ status = doc.get("status")
771
+ if not isinstance(status, int):
772
+ status = response.status_code
773
+ return status in _RETRY_STATUS
774
+
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.4
2
+ Name: causilo-client
3
+ Version: 0.8.13
4
+ Summary: Python client for the Causilo inference API
5
+ License: Proprietary
6
+ Project-URL: Homepage, https://nums.world
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: pandas>=2.0
10
+ Requires-Dist: pyarrow>=15
11
+ Requires-Dist: requests>=2.32
12
+ Requires-Dist: google-auth>=2.28
13
+
14
+ # causilo-client
15
+
16
+ Python client for the Causilo inference API. Send a table with the answers
17
+ filled in and a table with them blank; get a prediction for every blank row.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ pip install -U causilo-client
23
+ ```
24
+
25
+ Python 3.11 or newer. `pandas`, `pyarrow`, `requests` and `google-auth` are
26
+ pulled in automatically.
27
+
28
+ The import name is `causilo_client`. `causilo` on PyPI is the model package,
29
+ and the two can be installed side by side. Code written against the earlier
30
+ `radix` name changes one import line: `from causilo_client import Causilo`.
31
+
32
+ ## Use
33
+
34
+ ```python
35
+ import os
36
+
37
+ from causilo_client import Causilo
38
+
39
+ cx = Causilo(
40
+ os.environ["CAUSILO_ENDPOINT"],
41
+ key_file=os.environ["CAUSILO_KEY_FILE"],
42
+ )
43
+
44
+ pred = cx.predict(context_df, query_df, target="churned", model="causilo-clf")
45
+ ```
46
+
47
+ `context_df` holds the target column; `query_df` does not. The feature columns
48
+ must match in name and order across the two. The endpoint URL and the
49
+ credential are issued to you at handover; neither is published here.
50
+
51
+ Authentication takes either a service account key file (`key_file=`) or a
52
+ bearer token issued by the operator (`token=`). Which one applies is agreed
53
+ before handover.
54
+
55
+ The integration guide covers the call arguments, the input rules, the response
56
+ metadata, what each error means, and what the client does on your behalf when a
57
+ call times out.
@@ -0,0 +1,6 @@
1
+ causilo_client/__init__.py,sha256=HyUmmAIzNwnRfbcaB6xidkd2qqmAodD02cjM0PmkITE,833
2
+ causilo_client/client.py,sha256=2Q8KEmF2btVEEz654ISWq2bSp8g1D7_tH4wSg7Gh04E,32124
3
+ causilo_client-0.8.13.dist-info/METADATA,sha256=3pkwV8niLpB5i4r-0apOAy-F3BHli2w9J8HSi9hLXko,1724
4
+ causilo_client-0.8.13.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
5
+ causilo_client-0.8.13.dist-info/top_level.txt,sha256=jXX4_JiFj_wxrC0ksiWZiBTEpmBtE-UJqY58gOrA0eY,15
6
+ causilo_client-0.8.13.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ causilo_client