belfort-ml-client 2026.10.8.dev0__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,51 @@
1
+ .vscode/*
2
+ !.vscode/extensions.json
3
+ node_modules/
4
+
5
+ # The Cyclops client bundle build_client_wheel.sh adds to a wheel; never
6
+ # committed, and kept out of the sdist a local build makes.
7
+ native/belfort_native/cyclops_client/
8
+
9
+ # Python, for every workspace member
10
+ __pycache__/
11
+ *.py[cod]
12
+ *.egg-info/
13
+ .mypy_cache/
14
+ .ruff_cache/
15
+ .pytest_cache/
16
+ .coverage
17
+ htmlcov/
18
+ .venv/
19
+
20
+ gateway/backend/app/frontend/
21
+ /test-results/
22
+ /playwright-report/
23
+ /blob-report/
24
+ /playwright/.cache/
25
+ .DS_Store
26
+ /.gocache
27
+ /perseus-compiler/output
28
+ /cyclops-runtime/.gocache
29
+
30
+ # test.py writes here
31
+ /build/
32
+ /.toolchain/
33
+
34
+ # uv build and scripts/build_client_wheel.sh write here
35
+ /dist/
36
+
37
+ # OpenTofu: keep provider locks, input examples, and reviewed environment inputs.
38
+ # State remains in S3; local plans and input snapshots stay out of Git.
39
+ **/.terraform/
40
+ *.tfstate
41
+ *.tfstate.*
42
+ *.tfplan
43
+ *.tfvars
44
+ !example.tfvars
45
+ !/infra/perseus-environment/staging.tfvars
46
+ !/infra/perseus-environment/main.tfvars
47
+ !/infra/gateway/staging.tfvars
48
+ !/infra/gateway/main.tfvars
49
+ *.tfvars.json
50
+ crash.log
51
+ crash.*.log
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: belfort-ml-client
3
+ Version: 2026.10.8.dev0
4
+ Summary: The Belfort ML client
5
+ Requires-Python: <4.0,>=3.12
6
+ Requires-Dist: belfort-ml-bucket==2026.10.8.dev0
7
+ Requires-Dist: httpx==0.28.1
@@ -0,0 +1,125 @@
1
+ # Belfort ML client
2
+
3
+ The end client: the **only** party that holds the secret key.
4
+
5
+ Its provider mints a client token (`model.create_client(label)` in
6
+ `belfort-ml`) and hands it over on its own channel. With that token the client
7
+ talks to the gateway itself. Only the `/client/*` routes accept the token, and
8
+ it names one key set of one model.
9
+
10
+ ```bash
11
+ belfort-ml-client infer --token "$BELFORT_CLIENT_TOKEN" --input input.json
12
+ ```
13
+
14
+ `input.json` is `{"values": [...]}`, or `{"inputs": [[...], ...]}` for an entry
15
+ that takes several arguments, each flattened row-major. Only one of them may be
16
+ encrypted: the gateway carries one ciphertext each way. The decrypted answer
17
+ goes to stdout as JSON:
18
+
19
+ ```json
20
+ {"values": [...], "request_id": "req_...", "latency_ms": 847, "encrypt_ms": 12, "decrypt_ms": 3}
21
+ ```
22
+
23
+ `latency_ms` is the time the gateway's call to the GPU runtime took: the
24
+ ciphertext transfers between the gateway and the runtime, the wait in the
25
+ runtime, and the GPU computation. It does not include the transfers between
26
+ this client and the gateway, or the gateway's own work. A replayed request
27
+ reports the time of its first run. `encrypt_ms` and `decrypt_ms` are the time
28
+ this client took to encrypt the input and to decrypt the answer.
29
+
30
+ | Setting | Flag | Environment | Default |
31
+ |---|---|---|---|
32
+ | Token | `--token` | `BELFORT_CLIENT_TOKEN` | required |
33
+ | Linked code | `--cache-dir` | `BELFORT_CLIENT_CACHE` | `~/.cache/belfort-ml-client` |
34
+ | Wait for a deploy | `--timeout` | | 3600 s |
35
+
36
+ ## What one run does
37
+
38
+ ```mermaid
39
+ sequenceDiagram
40
+ participant C as belfort-ml-client
41
+ participant G as Gateway
42
+ participant S as Storage
43
+ C->>G: GET /client/code
44
+ G-->>C: presigned GETs, one per artifact, and their digest
45
+ C->>S: download the artifacts
46
+ Note over C: link the client binary, run it: keygen
47
+ C->>G: POST /client/keys
48
+ G-->>C: presigned POSTs, one per evaluation key, bound to its size
49
+ C->>S: upload the evaluation keys
50
+ C->>G: POST /client/keys/verify
51
+ loop until a deploy holds the keys
52
+ C->>G: GET /client/status
53
+ end
54
+ C->>G: POST /client/infer (ciphertext)
55
+ G-->>C: encrypted answer
56
+ Note over C: decrypt
57
+ C->>G: DELETE /client
58
+ ```
59
+
60
+ 1. `GET /client/code` names the compile the token was opened for, one presigned
61
+ GET per artifact, and the digest the gateway verified. The client downloads
62
+ each into a fresh directory, refuses a name that would land outside it,
63
+ checks the tree against the digest and links `<model>_client`. The linked
64
+ code is cached per model and compile under the cache directory.
65
+ 2. The binary generates the key pair before its first command and keeps the
66
+ secret key in its own memory. `POST /client/keys` names the evaluation keys
67
+ with their sizes and returns one presigned POST each, which S3 accepts only
68
+ at that size; `POST /client/keys/verify` checks they all arrived.
69
+ 3. `GET /client/status` reports `awaiting_keys`, `awaiting_upload`,
70
+ `no_deploy`, `loading`, `ready` or `failed`. Once the keys are
71
+ verified, the gateway loads them onto the deploy the provider started for
72
+ this key set. `no_deploy` keeps the client waiting, since the provider may
73
+ start the deploy after handing over the token.
74
+ 4. `POST /client/infer` carries one ciphertext each way.
75
+
76
+ A token opens one key set, and its keys can be named once. The secret key is
77
+ discarded when the process exits, so each run needs a new token from the
78
+ provider.
79
+
80
+ From Python, `belfort_ml_client.Client` does the same in steps:
81
+
82
+ ```python
83
+ from belfort_ml_client import Client
84
+
85
+ with Client(token) as client:
86
+ client.prepare() # code, keys, a deploy holding them
87
+ answer = client.infer([values]) # as many as needed in this process
88
+ ```
89
+
90
+ ## The surface
91
+
92
+ | File | What it is |
93
+ |---|---|
94
+ | `client.py` | `Client`: code, keys, status and inference against the gateway |
95
+ | `cli.py` | `belfort-ml-client infer` |
96
+ | `build.py` | Linking the downloaded C++ into `<model>_client`, through [`belfort_native`](../native) |
97
+ | `belfort_ml_client.cc` | The client's role over one model's generated facade: keygen, encrypt, decrypt |
98
+
99
+ ## The link
100
+
101
+ The client links `<model>_client` through this package's `build.py`, which names
102
+ `belfort_ml_client.cc` and hands it to
103
+ [`belfort_native`](../native). Linking needs clang++ and a Cyclops client
104
+ build.
105
+
106
+ The binary links `libcyclops_client.so` (`.dylib` on macOS) and runs without
107
+ CUDA. The platform wheels for Linux x86-64 and aarch64 (manylinux_2_28) and
108
+ macOS arm64 carry the library, its headers and a `REVISION` file under
109
+ `belfort_native/cyclops_client`, with libtommath linked in statically; the link
110
+ uses that prefix when `PERSEUS_CYCLOPS_DIR` is unset. `PERSEUS_CYCLOPS_DIR`
111
+ overrides it, and is required with the sdist: the link looks for the library
112
+ under `PERSEUS_CYCLOPS_DIR/lib`, `client/lib` or `client/build`.
113
+ `uv run --script scripts/fetch_cyclops.py client .toolchain/cyclops-client`
114
+ fetches such a prefix from the Cyclops release at the pinned commit.
115
+ [`scripts/build_client_wheel.sh`](../scripts/build_client_wheel.sh) builds the
116
+ platform wheel on the machine it runs on, and the
117
+ [Build Cyclops client wheels](../.github/workflows/build-cyclops-client.yml)
118
+ workflow builds and tests all three; the release workflow publishes them.
119
+
120
+ The generated client creates the evaluation keys requested by HEIR.
121
+
122
+ ## Tests
123
+
124
+ The [end-to-end suite](../tests/e2e/README.md) tests the Belfort ML client with the
125
+ real gateway, compiler, and GPU runtime. See its setup and test commands.
@@ -0,0 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ __version__ = "2026.10.8.dev0"
4
+
5
+ from belfort_ml_client.client import Client, ClientError
6
+
7
+ __all__ = ["Client", "ClientError", "__version__"]
@@ -0,0 +1,244 @@
1
+ #include <algorithm>
2
+ #include <cmath>
3
+ #include <cstddef>
4
+ #include <cstdint>
5
+ #include <cstdio>
6
+ #include <cstdlib>
7
+ #include <cstring>
8
+ #include <filesystem>
9
+ #include <fstream>
10
+ #include <iostream>
11
+ #include <memory>
12
+ #include <nlohmann/json.hpp>
13
+ #include <optional>
14
+ #include <string>
15
+ #include <tuple>
16
+ #include <utility>
17
+ #include <vector>
18
+
19
+ #include "common_roles.h"
20
+
21
+ namespace perseus {
22
+ namespace {
23
+
24
+ inline constexpr std::size_t kInputCount =
25
+ std::tuple_size_v<model::CleartextInputs>;
26
+
27
+ // Read blob `Argument` of `example` into its slot of the cleartext tuple.
28
+ template <std::size_t Argument>
29
+ void read_input(model::CleartextInputs& inputs, const std::string& resource_dir,
30
+ int example) {
31
+ using Slot = std::tuple_element_t<Argument, model::CleartextInputs>;
32
+ const std::vector<double> values =
33
+ read_f64(input_path(resource_dir, example, Argument, kInputCount),
34
+ Nest<Slot>::count);
35
+ Nest<Slot>::fill(std::get<Argument>(inputs), values.data());
36
+ }
37
+
38
+ template <std::size_t... Argument>
39
+ void read_inputs(model::CleartextInputs& inputs, const std::string& resource_dir,
40
+ int example, std::index_sequence<Argument...>) {
41
+ (read_input<Argument>(inputs, resource_dir, example), ...);
42
+ }
43
+
44
+ using SecretSeed = ::cyclops::prng::Seed;
45
+
46
+ // The seed is the secret key, so it arrives on stdin, never on argv or disk:
47
+ // one line holding a JSON array of its bytes.
48
+ SecretSeed read_seed() {
49
+ std::string line;
50
+ std::getline(std::cin, line);
51
+ SecretSeed seed;
52
+ const auto bytes = nlohmann::json::parse(line, nullptr, false);
53
+ const bool valid =
54
+ bytes.is_array() && bytes.size() == seed.bytes.size() &&
55
+ std::all_of(bytes.begin(), bytes.end(), [](const nlohmann::json& byte) {
56
+ return byte.is_number_unsigned() &&
57
+ byte.get<nlohmann::json::number_unsigned_t>() <= 0xff;
58
+ });
59
+ if (!valid) {
60
+ std::fprintf(stderr, "expected a JSON array of %zu secret seed bytes\n",
61
+ seed.bytes.size());
62
+ std::exit(1);
63
+ }
64
+ bytes.get_to(seed.bytes);
65
+ return seed;
66
+ }
67
+
68
+ } // namespace
69
+
70
+ void export_keys(model::Context& context,
71
+ const ::cyclops::EvkMap<model::word>& keys,
72
+ const std::string& dir) {
73
+ using Keys = ::cyclops::EvkMap<model::word>;
74
+ std::filesystem::create_directories(dir);
75
+ std::size_t n = 0;
76
+ for (const auto& [index, evk] : keys) {
77
+ // The sparse-to-dense key has its own, smaller wire form.
78
+ if (index.idx == Keys::kSparseToDenseKeyIndex) {
79
+ write_file(blob_path(dir, n++, "s2d"),
80
+ model::SendSparseToDenseKey(kWireSerializer, context.param_,
81
+ evk));
82
+ } else {
83
+ write_file(blob_path(dir, n++, "evk"),
84
+ model::SendEvaluationKey(kWireSerializer, context.param_, evk,
85
+ index.idx));
86
+ }
87
+ }
88
+ }
89
+
90
+ void encrypt_example_to(model::Context& context, model::SecretKey key,
91
+ const std::string& resource_dir, int example,
92
+ const std::string& dir) {
93
+ model::CleartextInputs inputs{};
94
+ read_inputs(inputs, resource_dir, example,
95
+ std::make_index_sequence<kInputCount>{});
96
+ auto encrypted = model::Encrypt(context, key, inputs);
97
+ std::size_t n = 0;
98
+ auto write = [&](const auto& ciphertext) {
99
+ write_file(blob_path(dir, n++, "ct"),
100
+ model::SendCiphertext(kWireSerializer, context.param_,
101
+ ciphertext));
102
+ };
103
+ heir::cyclops::forEachLeaf(encrypted, write);
104
+ }
105
+
106
+ std::vector<double> decrypt_result_from(model::Context& context,
107
+ model::SecretKey key,
108
+ const std::string& dir) {
109
+ model::EncryptedOutputs outputs{};
110
+ std::size_t n = 0;
111
+ auto read = [&](auto& ciphertext) {
112
+ // The file name fixes the order, so only the ciphertext is kept.
113
+ ciphertext =
114
+ model::ReceiveCiphertext(context.param_,
115
+ read_file(blob_path(dir, n++, "ct")))
116
+ .ct;
117
+ };
118
+ heir::cyclops::forEachLeaf(outputs, read);
119
+ // `Decrypt` returns one output's nest, or a tuple of them; `Nest` handles both.
120
+ auto decrypted = model::Decrypt(context, key, outputs);
121
+ std::vector<double> values;
122
+ values.reserve(Nest<decltype(decrypted)>::count);
123
+ Nest<decltype(decrypted)>::collect(decrypted, values);
124
+ return values;
125
+ }
126
+
127
+ // Debug ciphertexts leave the server encrypted; only this process decrypts
128
+ // them.
129
+ nlohmann::json decrypt_checkpoints(model::Context& context,
130
+ model::SecretKey key,
131
+ const std::string& path) {
132
+ std::ifstream in(path, std::ios::binary);
133
+ cereal::PortableBinaryInputArchive archive(in);
134
+ auto records = nlohmann::json::array();
135
+ while (in.peek() != std::char_traits<char>::eof()) {
136
+ std::string name, metadata;
137
+ std::uint32_t count;
138
+ archive(name, metadata, count);
139
+ std::vector<double> values;
140
+ double scale = 0;
141
+ int level = 0;
142
+ for (std::uint32_t i = 0; i < count; ++i) {
143
+ std::string bytes;
144
+ archive(bytes);
145
+ auto received = model::ReceiveCiphertext(context.param_, bytes);
146
+ if (i == 0) {
147
+ scale = std::log2(received.ct.GetScale());
148
+ level = received.ct.GetNP().num_main_;
149
+ }
150
+ if (values.size() >= 256) continue;
151
+ heir::cyclops::Plaintext<model::word> plaintext;
152
+ key->Decrypt(plaintext, received.ct);
153
+ std::vector<double> decoded, imaginary;
154
+ context.encoder_.DecodeSlots(decoded, imaginary, plaintext);
155
+ values.insert(
156
+ values.end(), decoded.begin(),
157
+ decoded.begin() + std::min(256 - values.size(), decoded.size()));
158
+ }
159
+ records.push_back({{"name", name},
160
+ {"is_input", name.find("/input/") != std::string::npos},
161
+ {"scale", scale},
162
+ {"level", level},
163
+ {"values", values}});
164
+ }
165
+ return records;
166
+ }
167
+
168
+ int client_main(const std::shared_ptr<model::Context>& context,
169
+ const std::string& resource_dir,
170
+ const std::optional<SecretSeed>& secret_seed, Timings timings,
171
+ Clock::time_point started) {
172
+ model::KeyPair keys;
173
+ timings.key_setup_ms =
174
+ timed("keygen", [&] { keys = model::KeyGen(context, secret_seed); });
175
+ export_keys(*context, keys.storage->GetEvkMap(), resource_dir + "/keys");
176
+ timings.harness_total_ms = ms_since(started);
177
+ write_report({}, timings);
178
+
179
+ std::string command;
180
+ while (std::cin >> command) {
181
+ const Clock::time_point request = Clock::now();
182
+ Timings t;
183
+ std::vector<std::vector<double>> results;
184
+ if (command == "debug") {
185
+ std::string path;
186
+ std::cin >> path;
187
+ nlohmann::json report = {
188
+ {"debug_records",
189
+ decrypt_checkpoints(*context, keys.secret_key, path)}};
190
+ std::cout << report.dump() << std::endl;
191
+ continue;
192
+ } else if (command == "secret_seed") {
193
+ nlohmann::json report = {
194
+ {"secret_seed", keys.storage->GetSecretSeed().bytes}};
195
+ std::cout << report.dump() << std::endl;
196
+ continue;
197
+ } else if (command == "encrypt") {
198
+ int example = 0;
199
+ std::string dir;
200
+ std::cin >> example >> dir;
201
+ t.encrypt_ms = timed("encrypt", [&] {
202
+ encrypt_example_to(*context, keys.secret_key, resource_dir, example, dir);
203
+ });
204
+ } else if (command == "decrypt") {
205
+ std::string dir;
206
+ std::cin >> dir;
207
+ t.decrypt_ms = timed("decrypt", [&] {
208
+ results.push_back(decrypt_result_from(*context, keys.secret_key, dir));
209
+ });
210
+ } else {
211
+ unknown_command(command);
212
+ }
213
+ t.harness_total_ms = ms_since(request);
214
+ write_report(results, t);
215
+ }
216
+ return 0;
217
+ }
218
+
219
+ } // namespace perseus
220
+
221
+ int main(int argc, char** argv) {
222
+ const std::string resource_dir = argc > 1 ? argv[1] : ".";
223
+ // --seed-from-stdin: the first stdin line is the hex secret seed to restore.
224
+ const bool seeded = argc > 3 && std::strcmp(argv[3], "--seed-from-stdin") == 0;
225
+ if ((argc > 2 && std::strcmp(argv[2], "--client") != 0) ||
226
+ (argc > 3 && !seeded) || argc > 4) {
227
+ std::fprintf(stderr, "usage: %s <dir> [--client [--seed-from-stdin]]\n",
228
+ argv[0]);
229
+ return 1;
230
+ }
231
+
232
+ namespace model = perseus::model;
233
+ const perseus::Clock::time_point started = perseus::Clock::now();
234
+ perseus::Timings timings;
235
+ timings.cuda_init_ms = perseus::device_init();
236
+
237
+ std::shared_ptr<model::Context> context;
238
+ timings.setup_ms = perseus::timed("setup", [&] { context = model::Setup(); });
239
+
240
+ std::optional<perseus::SecretSeed> secret_seed;
241
+ if (seeded) secret_seed = perseus::read_seed();
242
+ return perseus::client_main(context, resource_dir, secret_seed, timings,
243
+ started);
244
+ }
@@ -0,0 +1,13 @@
1
+ """Link a downloaded compile and the client's drivers into `<model>_client`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from belfort_native import link
8
+
9
+ DRIVER = Path(__file__).with_name("belfort_ml_client.cc")
10
+
11
+
12
+ def build_client(model_dir: Path, runtime: str) -> Path:
13
+ return link(model_dir, runtime, DRIVER, "_client", side="client")
@@ -0,0 +1,98 @@
1
+ """`belfort-ml-client infer`: one inference on the token a provider handed over.
2
+
3
+ belfort-ml-client infer --token "$BELFORT_CLIENT_TOKEN" --input input.json
4
+
5
+ `input.json` holds `{"values": [...]}` for a single-argument model, or
6
+ `{"inputs": [[...], ...]}` for several arguments, each flattened row-major. The
7
+ decrypted answer goes to stdout as JSON; progress goes to stderr.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import logging
15
+ import os
16
+ import sys
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ import httpx
21
+
22
+ from belfort_ml_client.client import Client, ClientError
23
+
24
+
25
+ def read_arguments(payload: Any) -> list[list[float]]:
26
+ if isinstance(payload, dict) and "inputs" in payload:
27
+ arguments = payload["inputs"]
28
+ elif isinstance(payload, dict) and "values" in payload:
29
+ arguments = [payload["values"]]
30
+ else:
31
+ raise ClientError('expected {"values": [...]} or {"inputs": [[...], ...]}')
32
+ if not isinstance(arguments, list) or not arguments:
33
+ raise ClientError("no input values")
34
+ if not all(isinstance(argument, list) and argument for argument in arguments):
35
+ raise ClientError("each argument must be a non-empty list of numbers")
36
+ numbers = [value for argument in arguments for value in argument]
37
+ # bool is an int; neither it nor a string is an input value.
38
+ if not all(
39
+ isinstance(value, int | float) and not isinstance(value, bool)
40
+ for value in numbers
41
+ ):
42
+ raise ClientError("each argument must be a list of numbers")
43
+ return [[float(value) for value in argument] for argument in arguments]
44
+
45
+
46
+ def main(argv: list[str] | None = None) -> int:
47
+ parser = argparse.ArgumentParser(prog="belfort-ml-client")
48
+ commands = parser.add_subparsers(dest="command", required=True)
49
+ infer = commands.add_parser("infer", help="Run one encrypted inference.")
50
+ infer.add_argument("--token", help="The client token (or BELFORT_CLIENT_TOKEN).")
51
+ infer.add_argument(
52
+ "--cache-dir", type=Path, help="Where linked client code is kept."
53
+ )
54
+ infer.add_argument(
55
+ "--input", required=True, help="A JSON file with the input, or - for stdin."
56
+ )
57
+ infer.add_argument(
58
+ "--timeout",
59
+ type=float,
60
+ default=3600,
61
+ help="Seconds to wait for a deploy to load this client's keys.",
62
+ )
63
+ args = parser.parse_args(argv)
64
+
65
+ logging.basicConfig(
66
+ level=logging.INFO, format="%(asctime)s %(message)s", stream=sys.stderr
67
+ )
68
+ # httpx logs every request URL at INFO, and a presigned URL is a credential.
69
+ logging.getLogger("httpx").setLevel(logging.WARNING)
70
+ # Keep stdout for the JSON answer: the link step and its compilers write to
71
+ # fd 1, so point fd 1 at stderr until then.
72
+ answer_out = os.fdopen(os.dup(sys.stdout.fileno()), "w")
73
+ sys.stdout.flush()
74
+ os.dup2(sys.stderr.fileno(), sys.stdout.fileno())
75
+ try:
76
+ text = sys.stdin.read() if args.input == "-" else Path(args.input).read_text()
77
+ arguments = read_arguments(json.loads(text))
78
+ with Client(args.token, cache_dir=args.cache_dir) as client:
79
+ client.prepare(timeout=args.timeout)
80
+ answer = client.infer(arguments)
81
+ # The link step raises RuntimeError; a gateway unreachable after the
82
+ # retries raises httpx.TransportError.
83
+ except (ClientError, OSError, ValueError, RuntimeError, httpx.HTTPError) as exc:
84
+ print(f"belfort-ml-client: {exc}", file=sys.stderr)
85
+ return 1
86
+ values = answer["values"]
87
+ value = values[0] if len(values) == 1 else values
88
+ print(
89
+ f"The encrypted computation was a success. The result is {value}",
90
+ file=sys.stderr,
91
+ )
92
+ with answer_out:
93
+ print(json.dumps(answer), file=answer_out)
94
+ return 0
95
+
96
+
97
+ if __name__ == "__main__":
98
+ raise SystemExit(main())