belfort-ml 0.2.0__tar.gz

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,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .mypy_cache/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ .coverage
8
+ htmlcov/
9
+ .venv/
10
+ build/
11
+ dist/
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: belfort-ml
3
+ Version: 0.2.0
4
+ Summary: Model-provider SDK for Belfort's encrypted Machine Learning platform
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: belfort-ml-bucket==0.2.0
7
+ Requires-Dist: belfort-torch-mlir==2026.10.9
8
+ Requires-Dist: httpx==0.28.1
9
+ Requires-Dist: torch==2.13.0
@@ -0,0 +1,235 @@
1
+ # Belfort ML Python Client
2
+
3
+ `belfort_ml`: an httpx client for the Belfort CaaS gateway. A model provider
4
+ uses it to compile a PyTorch model, register it, and run inference on it while
5
+ it stays encrypted.
6
+
7
+ ## Install
8
+
9
+ Not published. It is a `uv` workspace member, so run `uv sync` from the repo
10
+ root.
11
+
12
+ ## Configuration
13
+
14
+ ```python
15
+ import belfort_ml
16
+
17
+ provider = belfort_ml.Provider() # reads the environment
18
+ provider = belfort_ml.Provider(api_key="blf_live_...") # or pass it in
19
+ ```
20
+
21
+ | Argument | Environment | Default |
22
+ |---|---|---|
23
+ | `api_key` | `BELFORT_API_KEY` | required |
24
+ | `base_url` | `BELFORT_BASE_URL` | `https://eml.belfortlabs.com/api/v1` |
25
+ | `timeouts` | — | `Timeouts()` |
26
+ | `max_retries` | — | `3` |
27
+
28
+ Explicit arguments beat the environment. A missing key raises
29
+ `ConfigurationError` at construction. `Timeouts` is per operation class:
30
+ `standard` 30s, `upload_part` 300s, `inference` 120s, `poll` 10s.
31
+
32
+ ## Quick start
33
+
34
+ ```python
35
+ with belfort_ml.Provider() as provider:
36
+ account = provider.account.get()
37
+ print(account["mp_id"], account["credits"]["available"])
38
+ ```
39
+
40
+ The provider holds a connection pool for its lifetime, so use it as a context
41
+ manager or call `close()`.
42
+
43
+ `provider.account` and `provider.credits` return plain dicts. `provider.models` and
44
+ `provider.deploys` return typed objects — `Model`, `ClientKeySet`, `Deploy`,
45
+ `LoadedKeys`, `InferenceResult` — that carry their own methods, so
46
+ `model.deploy()` works without passing ids around.
47
+
48
+ ## The gateway surface
49
+
50
+ | Method | Endpoint |
51
+ |---|---|
52
+ | `provider.account.get()` | `GET /account` |
53
+ | `provider.credits.get()` | `GET /credits` |
54
+ | `provider.credits.transactions(cursor=, limit=, kind=)` | `GET /credits/transactions` |
55
+ | `provider.models.create(artifacts, name=)` | `POST /models`, S3 PUTs, `POST /models/{m_id}/compile`, then polls |
56
+ | `provider.models.get(id)` / `.get(name=)` / `.list()` | `GET /models/{m_id}` / `GET /models` |
57
+ | `model.refresh()` / `.delete()` | `GET .../status` / `DELETE /models/{m_id}` |
58
+ | `model.fetch_artifacts(dir, bucket_url=)` | Reads the compiled artifacts from the bucket |
59
+ | `model.create_client(label, ttl_seconds=)` | `POST /models/{m_id}/client-tokens`: the token one end client runs on |
60
+ | `model.artifact_downloads()` | One presigned GET per artifact of the model's compile |
61
+ | `model.clients(status=)` / `model.delete_client(c_id)` | `GET /models/{m_id}/clients`, `DELETE /models/{m_id}/clients/{c_id}` |
62
+ | `model.deploy(key_set=, max_lifetime=, progress=)` | `POST /deploys` for the key set it serves, then polls and writes each launch step to stderr |
63
+ | `provider.deploys.list()` / `.get(id)` / `.stop_all()` | `GET /deploys`, `GET /deploys/{d_id}`, `DELETE` each running one |
64
+ | `deploy.refresh()` / `.extend(n)` / `.stop()` | `GET .../status` / `POST .../extend` / `DELETE /deploys/{d_id}` |
65
+ | `deploy.load_keys()` / `.unload_keys(k)` | `POST .../keys` for the deploy's key set / `DELETE .../keys/{k_id}` |
66
+ | `deploy.infer(loaded, bytes)` / `.infer_batch(...)` | `POST /deploys/{d_id}/infer` |
67
+
68
+ Nothing calls `GET /jobs/{job_id}`. Every `wait=True` path polls the resource's
69
+ own `/status` route: 2s backing off to 10s, then `belfort_ml.TimeoutError`. A ^C
70
+ while a deploy starts prints the deploy id and that the instance is still
71
+ billing, then re-raises.
72
+
73
+ `deploy(...)` is a context manager. Exit stops the instance whether the block
74
+ completed or raised, and a deploy someone else already stopped is not an error.
75
+
76
+ `provider.credits.transactions` returns one page, newest first, keyset-paginated
77
+ on `tx_id`; follow `next_cursor` yourself. The models and deploys lists follow
78
+ cursors lazily.
79
+
80
+ Uploads go straight to S3 through presigned multipart URLs, without the Belfort
81
+ credential. The file is split into the presigned part count, with 4 parts in
82
+ flight, each retried on its own and streamed from disk.
83
+
84
+ ## Compiling locally
85
+
86
+ ```python
87
+ from belfort_ml._compile._export import load_calibration_data, load_pytorch_model
88
+
89
+ model = load_pytorch_model("smallnet.pt") # eval mode, CPU
90
+ calibration = load_calibration_data( # 512 examples by default
91
+ "calibration.npz", key="inputs", input_shape=(1, 3, 32, 32)
92
+ )
93
+ artifacts = belfort_ml.compile(
94
+ model, calibration[0], "smallnet", "./build", calibration_inputs=calibration
95
+ )
96
+ ```
97
+
98
+ `compile` writes `build/smallnet_torch.mlir` and returns the `Artifacts` that
99
+ `provider.models.create` takes: the file and the parameters it declares. No
100
+ network is involved.
101
+
102
+ `name` becomes the generated C++ code's name, so it must be a C++ identifier:
103
+ ASCII letters, digits and underscores, not starting with a digit, at most 200
104
+ characters, with no `__` and no leading `_` before an uppercase letter. It may
105
+ not be a C++ keyword, `std`, `heir`, `detail` or one of the native build's
106
+ macros (`PERSEUS_MODEL`, `PERSEUS_HEADER`, `PERSEUS_SERVER`, `DEFAULT_STREAM`)
107
+ or a standard-library macro (`NULL`, `EOF`, `errno`, `stdin`, `stdout`, `stderr`). Any other name raises `ValueError`
108
+ before export.
109
+
110
+ `example_input` fixes the shapes. The graph is specialized to it, so a deployed
111
+ model serves that input shape only. A model the frontend cannot lower raises
112
+ `CompileError` carrying the frontend's message.
113
+
114
+ `compile` warns with `MultiCiphertextWarning` when a layer needs more slots
115
+ than one ciphertext holds (32768), so the compiler must split its tensors
116
+ across ciphertexts. The estimate is
117
+ [`belfort_bucket.slots`](../bucket/belfort_bucket/slots.py), the one Perseus
118
+ sizes the compile with. It is a lower bound: no warning does not prove that
119
+ each tensor fits one ciphertext.
120
+
121
+ Two annotations travel with the IR. Every input carries `{secret.secret}` unless
122
+ the model marks a subset with `belfort_ml.Secret`, as
123
+ [`examples/public-examples/mlp/mlp.py`](../examples/public-examples/mlp/mlp.py) does; and each op the compiler
124
+ must approximate carries the value range the calibration examples put through
125
+ it. Only those bounds reach the IR, never the examples.
126
+
127
+ The end client is its own component, [`belfort-ml-client/`](../belfort-ml-client).
128
+
129
+ ## Reading the compiled artifacts
130
+
131
+ Perseus writes under `{mp_id}/{m_id}/compiles/{compile_id}/`. The gateway
132
+ reports a status and the chosen parameters; the files themselves are read from
133
+ the bucket:
134
+
135
+ ```python
136
+ compiled = model.fetch_artifacts(
137
+ "./artifacts", bucket_url="http://localhost:9000/belfort-caas-models"
138
+ )
139
+ compiled.compile_id # the model's compile
140
+ compiled.params # what the compiler actually chose
141
+ compiled.entry_function # the exported function the C++ is named after
142
+ compiled.files # every downloaded file, tree preserved
143
+ ```
144
+
145
+ | Argument | Environment | Default |
146
+ |---|---|---|
147
+ | `bucket_url` | `BELFORT_BUCKET_URL` | required |
148
+ | `access_key_id` | `BELFORT_BUCKET_ACCESS_KEY_ID` | boto3's own chain |
149
+ | `secret_access_key` | `BELFORT_BUCKET_SECRET_ACCESS_KEY` | boto3's own chain |
150
+ | `region` | `BELFORT_BUCKET_REGION` | `us-east-1` |
151
+
152
+ `bucket_url` names the endpoint and the bucket together, as
153
+ [`bucket/`](../bucket) describes; a URL carrying a key prefix raises
154
+ `ConfigurationError`.
155
+
156
+ It reads the model's compile, `model.active_compile_id`. Pass `store=` a
157
+ `belfort_bucket.Bucket` to reuse one reader across several models.
158
+
159
+ ## The end client
160
+
161
+ `model.create_client(label)` opens a key set for one end client and returns a
162
+ `ClientToken`. Hand its `token` to the client on your own channel; the client
163
+ then talks to the gateway itself, with no bucket credential and no API key:
164
+
165
+ ```bash
166
+ belfort-ml-client infer --token "$BELFORT_CLIENT_TOKEN" --input input.json
167
+ ```
168
+
169
+ The client downloads and links its code, generates its key pair, uploads the
170
+ evaluation keys, and waits for the gateway to load them onto the deploy started
171
+ for its key set with `model.deploy(key_set=minted, max_lifetime=...)`. [`belfort-ml-client`](../belfort-ml-client) describes that side, and
172
+ [`e2e/`](../tests/e2e) runs both.
173
+
174
+ A key set travels as a directory, one object per evaluation key: `<n>.evk` and
175
+ one `<n>.s2d`.
176
+
177
+ ## Errors
178
+
179
+ Everything descends from `BelfortMLError`, and local failures separate from
180
+ gateway failures.
181
+
182
+ | Raised locally | When |
183
+ |---|---|
184
+ | `ConfigurationError` | No API key, or a `base_url` that is not http(s) |
185
+ | `CompileError` | Local or gateway-side compile failure |
186
+ | `UploadError` | An S3 part failed after retries |
187
+ | `DownloadError` | Reading the bucket failed, or it holds no such compile |
188
+ | `KeyMaterialError` | A key directory holds something that may not be published |
189
+ | `TimeoutError` | Poll deadline exceeded |
190
+ | `ConnectionError` | Gateway unreachable after retries; httpx's error rides along as `__cause__` |
191
+
192
+ `APIError` covers anything the gateway returned, carrying `message`, `code`,
193
+ `detail`, `status_code` and `request_id`. Quote `request_id` when you report a
194
+ failure; it locates the request in the gateway logs.
195
+
196
+ | Status | Class | Extra |
197
+ |---|---|---|
198
+ | 400, 413 | `ValidationError` | |
199
+ | 401 | `AuthenticationError` | |
200
+ | 402 | `InsufficientCredits` | `.required`, `.available` |
201
+ | 403 | `PermissionError` | |
202
+ | 404 | `NotFoundError` | |
203
+ | 409 | `ConflictError` | `DeployLimitReached` for `deploy_limit_reached`, with `.limit`, `.running` |
204
+ | 429 | `RateLimitError` | `.retry_after` |
205
+ | 503 | `ServiceUnavailable` | |
206
+
207
+ A response that is not the gateway's `{"error": {...}}` envelope — a proxy page,
208
+ a crash — degrades to the raw text.
209
+
210
+ ## Retries, idempotency and logging
211
+
212
+ Connection errors, timeouts, 429 and 500/502/504 are retried `max_retries`
213
+ times, with exponential backoff and full jitter capped at 30s. A 429's
214
+ `Retry-After` wins over the computed backoff. 4xx is never retried, and neither
215
+ is inference. A read or write timeout can come after the gateway acted on the
216
+ request, so after one only GET and HEAD are sent again.
217
+
218
+ Idempotent requests carry one `Idempotency-Key` per logical operation, reused
219
+ across that operation's retries. The gateway does not honour it yet.
220
+
221
+ Logging goes to the standard `belfort_ml` logger and is silent without a
222
+ handler. DEBUG lines carry method, path, status and request id only, never
223
+ headers or bodies.
224
+
225
+ ## Examples and tests
226
+
227
+ [`examples/`](./examples/) covers the account, credits and error handling.
228
+
229
+ ```bash
230
+ cd gateway && docker compose up -d minio # the artifact tests need it
231
+ uv run pytest # from belfort-ml/
232
+ ```
233
+
234
+ CI runs this package from
235
+ [`.github/workflows/test-packages.yml`](../.github/workflows/test-packages.yml).
@@ -0,0 +1,76 @@
1
+ from importlib.metadata import version
2
+
3
+ __version__ = version("belfort-ml")
4
+
5
+ from belfort_bucket import CompiledArtifacts
6
+
7
+ from belfort_ml._compile import compile # noqa: A004
8
+ from belfort_ml._compile._annotations import Secret
9
+ from belfort_ml.errors import (
10
+ APIError,
11
+ AuthenticationError,
12
+ BelfortMLError,
13
+ CompileError,
14
+ ConfigurationError,
15
+ ConflictError,
16
+ ConnectionError,
17
+ DeployLimitReached,
18
+ DownloadError,
19
+ InsufficientCredits,
20
+ KeyMaterialError,
21
+ MultiCiphertextWarning,
22
+ NotFoundError,
23
+ PermissionError,
24
+ RateLimitError,
25
+ ServiceUnavailable,
26
+ TimeoutError,
27
+ UploadError,
28
+ ValidationError,
29
+ )
30
+ from belfort_ml.gateway_connector.deploys import (
31
+ Deploy,
32
+ InferenceResult,
33
+ LoadedKeys,
34
+ )
35
+ from belfort_ml.gateway_connector.models import (
36
+ ClientKeySet,
37
+ ClientToken,
38
+ Model,
39
+ )
40
+ from belfort_ml.gateway_connector.provider import Provider
41
+ from belfort_ml.types import Artifacts, CompileParams
42
+
43
+ __all__ = [
44
+ "APIError",
45
+ "Artifacts",
46
+ "AuthenticationError",
47
+ "ClientKeySet",
48
+ "CompileError",
49
+ "CompileParams",
50
+ "CompiledArtifacts",
51
+ "Deploy",
52
+ "DeployLimitReached",
53
+ "ClientToken",
54
+ "InferenceResult",
55
+ "KeyMaterialError",
56
+ "LoadedKeys",
57
+ "Model",
58
+ "MultiCiphertextWarning",
59
+ "ConfigurationError",
60
+ "ConflictError",
61
+ "ConnectionError",
62
+ "DownloadError",
63
+ "InsufficientCredits",
64
+ "BelfortMLError",
65
+ "NotFoundError",
66
+ "PermissionError",
67
+ "Provider",
68
+ "RateLimitError",
69
+ "Secret",
70
+ "ServiceUnavailable",
71
+ "TimeoutError",
72
+ "UploadError",
73
+ "ValidationError",
74
+ "__version__",
75
+ "compile",
76
+ ]
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import re
5
+ import shutil
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from belfort_ml._compile._export import SampleInput, export_torch_mlir
10
+ from belfort_ml.types import Artifacts
11
+
12
+ # `name` names the C++ Perseus generates (`heir::generated::<name>`) and its
13
+ # artifacts, so it must be a C++ identifier that is neither a keyword, a
14
+ # namespace that code refers to, nor a macro the native build or the standard
15
+ # headers define, and short enough that `<name>_cheddar.mlir` fits a 255-byte
16
+ # file name.
17
+ _NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]{0,199}")
18
+ _RESERVED_NAMES = frozenset(
19
+ """
20
+ alignas alignof and and_eq asm auto bitand bitor bool break case catch char
21
+ char8_t char16_t char32_t class compl concept const consteval constexpr
22
+ constinit const_cast continue co_await co_return co_yield decltype default
23
+ delete do double dynamic_cast else enum explicit export extern false float
24
+ for friend goto if inline int long mutable namespace new noexcept not not_eq
25
+ nullptr operator or or_eq private protected public register reinterpret_cast
26
+ requires return short signed sizeof static static_assert static_cast struct
27
+ switch template this thread_local throw true try typedef typeid typename
28
+ union unsigned using virtual void volatile wchar_t while xor xor_eq
29
+ detail heir std
30
+ DEFAULT_STREAM PERSEUS_HEADER PERSEUS_MODEL PERSEUS_SERVER
31
+ EOF NULL errno stderr stdin stdout
32
+ """.split()
33
+ )
34
+
35
+
36
+ def _validate_name(name: str) -> None:
37
+ if not _NAME.fullmatch(name):
38
+ raise ValueError(
39
+ f"model name {name!r} may use only ASCII letters, digits and "
40
+ "underscores, may not start with a digit, and may be at most 200 "
41
+ "characters long"
42
+ )
43
+ # C++ reserves these for the implementation (`__FILE__`, `_Bool`).
44
+ if name in _RESERVED_NAMES or "__" in name or re.match(r"_[A-Z]", name):
45
+ raise ValueError(
46
+ f"model name {name!r} is reserved in the C++ the compiler generates"
47
+ )
48
+
49
+
50
+ def compile( # noqa: A001
51
+ model: Any, # torch.nn.Module
52
+ example_input: SampleInput,
53
+ name: str,
54
+ output_dir: str | Path,
55
+ *,
56
+ calibration_inputs: list[SampleInput] | None = None,
57
+ allow_unsupported_ops: bool = False,
58
+ ) -> Artifacts:
59
+ _validate_name(name)
60
+ mlir_path = export_torch_mlir(
61
+ model,
62
+ example_input,
63
+ name,
64
+ output_dir,
65
+ calibration_inputs=calibration_inputs,
66
+ allow_unsupported_ops=allow_unsupported_ops,
67
+ )
68
+ return Artifacts(
69
+ mlir_path=mlir_path, source_path=_copy_source(model, name, output_dir)
70
+ )
71
+
72
+
73
+ def _copy_source(model: Any, name: str, output_dir: str | Path) -> Path | None:
74
+ """Copy the file that defines the model's class to `<name>_model.py` next to
75
+ the MLIR, or return None when the class has no source file."""
76
+ try:
77
+ source = inspect.getsourcefile(type(model))
78
+ except TypeError: # a built-in class
79
+ return None
80
+ if source is None or not Path(source).is_file():
81
+ return None
82
+ target = Path(output_dir) / f"{name}_model.py"
83
+ if Path(source).resolve() != target.resolve():
84
+ shutil.copyfile(source, target)
85
+ return target