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"
|
causilo_client/client.py
ADDED
|
@@ -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 @@
|
|
|
1
|
+
causilo_client
|