belfort-ml-client 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,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 and scripts/sync_examples.py write 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: 0.2.0
4
+ Summary: The Belfort ML client
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: belfort-ml-bucket==0.2.0
7
+ Requires-Dist: httpx==0.28.1
@@ -0,0 +1,128 @@
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
+ | Secret seed (64 hex characters) | | `BELFORT_CLIENT_SECRET_SEED` | none: release at exit |
36
+
37
+ ## What one run does
38
+
39
+ ```mermaid
40
+ sequenceDiagram
41
+ participant C as belfort-ml-client
42
+ participant G as Gateway
43
+ participant S as Storage
44
+ C->>G: GET /client/code
45
+ G-->>C: presigned GETs, one per artifact, and their digest
46
+ C->>S: download the artifacts
47
+ Note over C: link the client binary, run it: keygen
48
+ C->>G: POST /client/keys
49
+ G-->>C: presigned POSTs, one per evaluation key, bound to its size
50
+ C->>S: upload the evaluation keys
51
+ C->>G: POST /client/keys/verify
52
+ loop until a deploy holds the keys
53
+ C->>G: GET /client/status
54
+ end
55
+ C->>G: POST /client/infer (ciphertext)
56
+ G-->>C: encrypted answer
57
+ Note over C: decrypt
58
+ opt without BELFORT_CLIENT_SECRET_SEED
59
+ C->>G: DELETE /client
60
+ end
61
+ ```
62
+
63
+ 1. `GET /client/code` names the compile the token was opened for, one presigned
64
+ GET per artifact, and the digest the gateway verified. The client downloads
65
+ each into a fresh directory, refuses a name that would land outside it,
66
+ checks the tree against the digest and links `<model>_client`. The linked
67
+ code is cached per model and compile under the cache directory.
68
+ 2. The binary generates the key pair before its first command and keeps the
69
+ secret key in its own memory. `POST /client/keys` names the evaluation keys
70
+ with their sizes and returns one presigned POST each, which S3 accepts only
71
+ at that size; `POST /client/keys/verify` checks they all arrived.
72
+ 3. `GET /client/status` reports `awaiting_keys`, `awaiting_upload`,
73
+ `no_deploy`, `loading`, `ready` or `failed`. Once the keys are
74
+ verified, the gateway loads them onto the deploy the provider started for
75
+ this key set. `no_deploy` keeps the client waiting, since the provider may
76
+ start the deploy after handing over the token.
77
+ 4. `POST /client/infer` carries one ciphertext each way.
78
+
79
+ A token opens one key set, and its keys can be named once. The secret key is
80
+ discarded when the process exits, so each run needs a new token from the
81
+ provider, unless every run sets the same `BELFORT_CLIENT_SECRET_SEED`.
82
+
83
+ From Python, `belfort_ml_client.Client` does the same in steps:
84
+
85
+ ```python
86
+ from belfort_ml_client import Client
87
+
88
+ with Client(token) as client:
89
+ client.prepare() # code, keys, a deploy holding them
90
+ answer = client.infer([values]) # as many as needed in this process
91
+ ```
92
+
93
+ ## The surface
94
+
95
+ | File | What it is |
96
+ |---|---|
97
+ | `client.py` | `Client`: code, keys, status and inference against the gateway |
98
+ | `cli.py` | `belfort-ml-client infer` |
99
+ | `build.py` | Linking the downloaded C++ into `<model>_client`, through [`belfort_native`](../native) |
100
+ | `belfort_ml_client.cc` | The client's role over one model's generated facade: keygen, encrypt, decrypt |
101
+
102
+ ## The link
103
+
104
+ The client links `<model>_client` through this package's `build.py`, which names
105
+ `belfort_ml_client.cc` and hands it to
106
+ [`belfort_native`](../native). Linking needs clang++ and a Cyclops client
107
+ build.
108
+
109
+ The binary links `libcyclops_client.so` (`.dylib` on macOS) and runs without
110
+ CUDA. The platform wheels for Linux x86-64 and aarch64 (manylinux_2_28) and
111
+ macOS arm64 carry the library, its headers and a `REVISION` file under
112
+ `belfort_native/cyclops_client`, with libtommath linked in statically; the link
113
+ uses that prefix when `PERSEUS_CYCLOPS_DIR` is unset. `PERSEUS_CYCLOPS_DIR`
114
+ overrides it, and is required with the sdist: the link looks for the library
115
+ under `PERSEUS_CYCLOPS_DIR/lib`, `client/lib` or `client/build`.
116
+ `uv run --script scripts/fetch_cyclops.py client .toolchain/cyclops-client`
117
+ fetches such a prefix from the Cyclops release at the pinned commit.
118
+ [`scripts/build_client_wheel.sh`](../scripts/build_client_wheel.sh) builds the
119
+ platform wheel on the machine it runs on, and the
120
+ [Build Cyclops client wheels](../.github/workflows/build-cyclops-client.yml)
121
+ workflow builds and tests all three; the release workflow publishes them.
122
+
123
+ The generated client creates the evaluation keys requested by HEIR.
124
+
125
+ ## Tests
126
+
127
+ The [end-to-end suite](../tests/e2e/README.md) tests the Belfort ML client with the
128
+ real gateway, compiler, and GPU runtime. See its setup and test commands.
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("belfort-ml-client")
6
+
7
+ from belfort_ml_client.client import Client, ClientError
8
+
9
+ __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,118 @@
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
+ With BELFORT_CLIENT_SECRET_SEED set to a 32-byte secret seed (64 hex
10
+ characters), the keys derive from it, a later run with the same seed resumes the
11
+ key set, and the run keeps the key set at exit. Without it, the run releases the
12
+ key set at exit, which ends the token.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import logging
20
+ import os
21
+ import sys
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ import httpx
26
+
27
+ from belfort_ml_client.client import Client, ClientError
28
+
29
+ SEED_ENV = "BELFORT_CLIENT_SECRET_SEED"
30
+ log = logging.getLogger("belfort_ml_client")
31
+
32
+
33
+ def read_arguments(payload: Any) -> list[list[float]]:
34
+ if isinstance(payload, dict) and "inputs" in payload:
35
+ arguments = payload["inputs"]
36
+ elif isinstance(payload, dict) and "values" in payload:
37
+ arguments = [payload["values"]]
38
+ else:
39
+ raise ClientError('expected {"values": [...]} or {"inputs": [[...], ...]}')
40
+ if not isinstance(arguments, list) or not arguments:
41
+ raise ClientError("no input values")
42
+ if not all(isinstance(argument, list) and argument for argument in arguments):
43
+ raise ClientError("each argument must be a non-empty list of numbers")
44
+ numbers = [value for argument in arguments for value in argument]
45
+ # bool is an int; neither it nor a string is an input value.
46
+ if not all(
47
+ isinstance(value, int | float) and not isinstance(value, bool)
48
+ for value in numbers
49
+ ):
50
+ raise ClientError("each argument must be a list of numbers")
51
+ return [[float(value) for value in argument] for argument in arguments]
52
+
53
+
54
+ def main(argv: list[str] | None = None) -> int:
55
+ parser = argparse.ArgumentParser(prog="belfort-ml-client")
56
+ commands = parser.add_subparsers(dest="command", required=True)
57
+ infer = commands.add_parser("infer", help="Run one encrypted inference.")
58
+ infer.add_argument("--token", help="The client token (or BELFORT_CLIENT_TOKEN).")
59
+ infer.add_argument(
60
+ "--cache-dir", type=Path, help="Where linked client code is kept."
61
+ )
62
+ infer.add_argument(
63
+ "--input", required=True, help="A JSON file with the input, or - for stdin."
64
+ )
65
+ infer.add_argument(
66
+ "--timeout",
67
+ type=float,
68
+ default=3600,
69
+ help="Seconds to wait for a deploy to load this client's keys.",
70
+ )
71
+ args = parser.parse_args(argv)
72
+
73
+ logging.basicConfig(
74
+ level=logging.INFO, format="%(asctime)s %(message)s", stream=sys.stderr
75
+ )
76
+ # httpx logs every request URL at INFO, and a presigned URL is a credential.
77
+ logging.getLogger("httpx").setLevel(logging.WARNING)
78
+ # Keep stdout for the JSON answer: the link step and its compilers write to
79
+ # fd 1, so point fd 1 at stderr until then.
80
+ answer_out = os.fdopen(os.dup(sys.stdout.fileno()), "w")
81
+ sys.stdout.flush()
82
+ os.dup2(sys.stderr.fileno(), sys.stdout.fileno())
83
+ try:
84
+ text = sys.stdin.read() if args.input == "-" else Path(args.input).read_text()
85
+ arguments = read_arguments(json.loads(text))
86
+ seed = os.environ.get(SEED_ENV)
87
+ secret_seed = bytes.fromhex(seed) if seed else None
88
+ with Client(
89
+ args.token, secret_seed=secret_seed, cache_dir=args.cache_dir
90
+ ) as client:
91
+ try:
92
+ client.prepare(timeout=args.timeout)
93
+ answer = client.infer(arguments)
94
+ finally:
95
+ # Nobody can resume a key set whose seed is gone.
96
+ if secret_seed is None and client.harness is not None:
97
+ try:
98
+ client.release()
99
+ except (ClientError, httpx.HTTPError) as exc:
100
+ log.warning("could not release this client: %s", exc)
101
+ # The link step raises RuntimeError; a gateway unreachable after the
102
+ # retries raises httpx.TransportError.
103
+ except (ClientError, OSError, ValueError, RuntimeError, httpx.HTTPError) as exc:
104
+ print(f"belfort-ml-client: {exc}", file=sys.stderr)
105
+ return 1
106
+ values = answer["values"]
107
+ value = values[0] if len(values) == 1 else values
108
+ print(
109
+ f"The encrypted computation was a success. The result is {value}",
110
+ file=sys.stderr,
111
+ )
112
+ with answer_out:
113
+ print(json.dumps(answer), file=answer_out)
114
+ return 0
115
+
116
+
117
+ if __name__ == "__main__":
118
+ raise SystemExit(main())