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.
- belfort_ml_client-2026.10.8.dev0/.gitignore +51 -0
- belfort_ml_client-2026.10.8.dev0/PKG-INFO +7 -0
- belfort_ml_client-2026.10.8.dev0/README.md +125 -0
- belfort_ml_client-2026.10.8.dev0/belfort_ml_client/__init__.py +7 -0
- belfort_ml_client-2026.10.8.dev0/belfort_ml_client/belfort_ml_client.cc +244 -0
- belfort_ml_client-2026.10.8.dev0/belfort_ml_client/build.py +13 -0
- belfort_ml_client-2026.10.8.dev0/belfort_ml_client/cli.py +98 -0
- belfort_ml_client-2026.10.8.dev0/belfort_ml_client/client.py +802 -0
- belfort_ml_client-2026.10.8.dev0/belfort_ml_client/py.typed +0 -0
- belfort_ml_client-2026.10.8.dev0/belfort_native/__init__.py +35 -0
- belfort_ml_client-2026.10.8.dev0/belfort_native/build.py +189 -0
- belfort_ml_client-2026.10.8.dev0/belfort_native/common_roles.h +20 -0
- belfort_ml_client-2026.10.8.dev0/belfort_native/cyclops_support.h +242 -0
- belfort_ml_client-2026.10.8.dev0/belfort_native/harness.py +236 -0
- belfort_ml_client-2026.10.8.dev0/belfort_native/json_handler.py +44 -0
- belfort_ml_client-2026.10.8.dev0/belfort_native/probe.py +77 -0
- belfort_ml_client-2026.10.8.dev0/belfort_native/py.typed +0 -0
- belfort_ml_client-2026.10.8.dev0/belfort_native/tools.py +121 -0
- belfort_ml_client-2026.10.8.dev0/hatch_build.py +53 -0
- belfort_ml_client-2026.10.8.dev0/pyproject.toml +96 -0
- belfort_ml_client-2026.10.8.dev0/tests/test_client.py +315 -0
|
@@ -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,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,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())
|