@trillboards/edge-sdk 0.2.138 → 0.2.150
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.
- package/README.md +2 -2
- package/deploy/detector/pyproject.toml +20 -2
- package/deploy/detector/tests/test_execution.py +124 -0
- package/deploy/detector/tests/test_model_identity.py +80 -0
- package/deploy/detector/tests/test_registry.py +18 -0
- package/deploy/detector/tests/test_server.py +3 -1
- package/deploy/detector/tests/test_version_stamp.py +4 -1
- package/deploy/detector/trillboards_detector/_version.py +2 -2
- package/deploy/detector/trillboards_detector/cli.py +3 -0
- package/deploy/detector/trillboards_detector/detections.py +7 -0
- package/deploy/detector/trillboards_detector/errors.py +4 -0
- package/deploy/detector/trillboards_detector/execution.py +109 -0
- package/deploy/detector/trillboards_detector/loader.py +6 -3
- package/deploy/detector/trillboards_detector/registry.py +27 -5
- package/deploy/detector/trillboards_detector/server.py +29 -2
- package/deploy/detector/trillboards_detector/session.py +15 -3
- package/deploy/litertlm/test_trillboards_litertlm_shim.py +81 -0
- package/deploy/litertlm/test_verify_model_proof.py +30 -0
- package/deploy/litertlm/trillboards_litertlm_shim.py +150 -1
- package/deploy/litertlm/verify_model_proof.py +30 -0
- package/deploy/macos/TrillboardsSetupApp.m +131 -3
- package/deploy/macos/build-macos-pkg.sh +13 -10
- package/deploy/macos/scripts/setup-detector.sh +2 -2
- package/deploy/macos/scripts/setup-litertlm.sh +66 -30
- package/deploy/macos/test-setup-litertlm-reinstall.sh +57 -4
- package/deploy/setup-guidance.json +112 -0
- package/deploy/systemd/setup-detector.sh +2 -2
- package/deploy/systemd/setup-litertlm.sh +51 -27
- package/deploy/windows/build-windows.ps1 +11 -6
- package/deploy/windows/bundle-windows-installer.ps1 +3 -3
- package/deploy/windows/e2e/test-local-sense.ps1 +6 -5
- package/deploy/windows/install-update.ps1 +52 -9
- package/deploy/windows/set-executable-icon.ps1 +24 -5
- package/deploy/windows/setup-detector.ps1 +142 -4
- package/deploy/windows/setup-litertlm.ps1 +53 -10
- package/deploy/windows/trillboards-setup.ps1 +101 -2
- package/dist/AudienceAnalyzeClient.d.ts +10 -0
- package/dist/AudienceAnalyzeClient.d.ts.map +1 -1
- package/dist/AudienceAnalyzeClient.js +5 -4
- package/dist/AudienceAnalyzeClient.js.map +1 -1
- package/dist/CloudSensingManager.d.ts +38 -1
- package/dist/CloudSensingManager.d.ts.map +1 -1
- package/dist/CloudSensingManager.js +403 -25
- package/dist/CloudSensingManager.js.map +1 -1
- package/dist/DetectorFieldProducer.d.ts +5 -1
- package/dist/DetectorFieldProducer.d.ts.map +1 -1
- package/dist/DetectorFieldProducer.js +8 -1
- package/dist/DetectorFieldProducer.js.map +1 -1
- package/dist/DurableVlmCandidateSpool.d.ts +83 -2
- package/dist/DurableVlmCandidateSpool.d.ts.map +1 -1
- package/dist/DurableVlmCandidateSpool.js +596 -30
- package/dist/DurableVlmCandidateSpool.js.map +1 -1
- package/dist/EdgeAgent.js +1 -1
- package/dist/FrameGroupingShadow.d.ts +51 -0
- package/dist/FrameGroupingShadow.d.ts.map +1 -0
- package/dist/FrameGroupingShadow.js +158 -0
- package/dist/FrameGroupingShadow.js.map +1 -0
- package/dist/ImageClassifierFieldProducer.d.ts +36 -0
- package/dist/ImageClassifierFieldProducer.d.ts.map +1 -0
- package/dist/ImageClassifierFieldProducer.js +150 -0
- package/dist/ImageClassifierFieldProducer.js.map +1 -0
- package/dist/LiteRtVlmProvider.d.ts +5 -0
- package/dist/LiteRtVlmProvider.d.ts.map +1 -1
- package/dist/LiteRtVlmProvider.js +72 -7
- package/dist/LiteRtVlmProvider.js.map +1 -1
- package/dist/LocalDetectorClient.d.ts +6 -0
- package/dist/LocalDetectorClient.d.ts.map +1 -1
- package/dist/LocalDetectorClient.js +8 -0
- package/dist/LocalDetectorClient.js.map +1 -1
- package/dist/OrderedFrameReview.d.ts +57 -0
- package/dist/OrderedFrameReview.d.ts.map +1 -0
- package/dist/OrderedFrameReview.js +121 -0
- package/dist/OrderedFrameReview.js.map +1 -0
- package/dist/OrderedReviewClient.d.ts +38 -0
- package/dist/OrderedReviewClient.d.ts.map +1 -0
- package/dist/OrderedReviewClient.js +121 -0
- package/dist/OrderedReviewClient.js.map +1 -0
- package/dist/OrderedSequenceStore.d.ts +69 -0
- package/dist/OrderedSequenceStore.d.ts.map +1 -0
- package/dist/OrderedSequenceStore.js +257 -0
- package/dist/OrderedSequenceStore.js.map +1 -0
- package/dist/ProfileReviewWork.d.ts +68 -0
- package/dist/ProfileReviewWork.d.ts.map +1 -0
- package/dist/ProfileReviewWork.js +150 -0
- package/dist/ProfileReviewWork.js.map +1 -0
- package/dist/RetainedFieldContract.d.ts +21 -0
- package/dist/RetainedFieldContract.d.ts.map +1 -0
- package/dist/RetainedFieldContract.js +92 -0
- package/dist/RetainedFieldContract.js.map +1 -0
- package/dist/ReviewModelUse.d.ts +7 -0
- package/dist/ReviewModelUse.d.ts.map +1 -0
- package/dist/ReviewModelUse.js +30 -0
- package/dist/ReviewModelUse.js.map +1 -0
- package/dist/ReviewPriority.d.ts +12 -0
- package/dist/ReviewPriority.d.ts.map +1 -0
- package/dist/ReviewPriority.js +45 -0
- package/dist/ReviewPriority.js.map +1 -0
- package/dist/RuntimeModelSelection.d.ts +10 -0
- package/dist/RuntimeModelSelection.d.ts.map +1 -0
- package/dist/RuntimeModelSelection.js +58 -0
- package/dist/RuntimeModelSelection.js.map +1 -0
- package/dist/SceneChangeDetector.d.ts +1 -1
- package/dist/SceneChangeDetector.d.ts.map +1 -1
- package/dist/SceneChangeDetector.js +18 -6
- package/dist/SceneChangeDetector.js.map +1 -1
- package/dist/ScopedVlmReview.d.ts +76 -0
- package/dist/ScopedVlmReview.d.ts.map +1 -0
- package/dist/ScopedVlmReview.js +267 -0
- package/dist/ScopedVlmReview.js.map +1 -0
- package/dist/SensingProfileClient.d.ts +50 -0
- package/dist/SensingProfileClient.d.ts.map +1 -1
- package/dist/SensingProfileClient.js +58 -2
- package/dist/SensingProfileClient.js.map +1 -1
- package/dist/TemporalReviewClient.d.ts +43 -0
- package/dist/TemporalReviewClient.d.ts.map +1 -0
- package/dist/TemporalReviewClient.js +117 -0
- package/dist/TemporalReviewClient.js.map +1 -0
- package/dist/TemporalReviewJournal.d.ts +91 -0
- package/dist/TemporalReviewJournal.d.ts.map +1 -0
- package/dist/TemporalReviewJournal.js +337 -0
- package/dist/TemporalReviewJournal.js.map +1 -0
- package/dist/UrgentReviewTriage.d.ts +35 -0
- package/dist/UrgentReviewTriage.d.ts.map +1 -0
- package/dist/UrgentReviewTriage.js +52 -0
- package/dist/UrgentReviewTriage.js.map +1 -0
- package/dist/VideoSegmentUploader.d.ts +293 -0
- package/dist/VideoSegmentUploader.d.ts.map +1 -0
- package/dist/VideoSegmentUploader.js +1002 -0
- package/dist/VideoSegmentUploader.js.map +1 -0
- package/dist/VlmCandidateBuffer.d.ts +19 -0
- package/dist/VlmCandidateBuffer.d.ts.map +1 -1
- package/dist/VlmCandidateBuffer.js.map +1 -1
- package/dist/autoSense.d.ts +2 -2
- package/dist/autoSense.d.ts.map +1 -1
- package/dist/autoSense.js +16 -11
- package/dist/autoSense.js.map +1 -1
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/continuousReview.d.ts +71 -0
- package/dist/continuousReview.d.ts.map +1 -0
- package/dist/continuousReview.js +53 -0
- package/dist/continuousReview.js.map +1 -0
- package/dist/detectorCountReview.d.ts +9 -0
- package/dist/detectorCountReview.d.ts.map +1 -0
- package/dist/detectorCountReview.js +79 -0
- package/dist/detectorCountReview.js.map +1 -0
- package/dist/diagnose.d.ts +1 -1
- package/dist/diagnose.js +1 -1
- package/dist/installerOnboardingMode.d.ts.map +1 -1
- package/dist/installerOnboardingMode.js +5 -3
- package/dist/installerOnboardingMode.js.map +1 -1
- package/dist/localInferenceSchema.d.ts +8 -0
- package/dist/localInferenceSchema.d.ts.map +1 -1
- package/dist/localInferenceSchema.js +3 -3
- package/dist/localInferenceSchema.js.map +1 -1
- package/dist/localSenseE2eHarness.d.ts +12 -0
- package/dist/localSenseE2eHarness.d.ts.map +1 -1
- package/dist/localSenseE2eHarness.js +77 -4
- package/dist/localSenseE2eHarness.js.map +1 -1
- package/dist/localSensing.d.ts +91 -18
- package/dist/localSensing.d.ts.map +1 -1
- package/dist/localSensing.js +678 -153
- package/dist/localSensing.js.map +1 -1
- package/dist/mixedScopedCounts.d.ts +17 -0
- package/dist/mixedScopedCounts.d.ts.map +1 -0
- package/dist/mixedScopedCounts.js +90 -0
- package/dist/mixedScopedCounts.js.map +1 -0
- package/dist/sensingCatchRate.d.ts +9 -5
- package/dist/sensingCatchRate.d.ts.map +1 -1
- package/dist/sensingCatchRate.js +10 -6
- package/dist/sensingCatchRate.js.map +1 -1
- package/dist/sensingRunMode.d.ts +5 -0
- package/dist/sensingRunMode.d.ts.map +1 -1
- package/dist/sensingRunMode.js +56 -0
- package/dist/sensingRunMode.js.map +1 -1
- package/package.json +7 -4
- package/retained/ArchiveLifecycle.cjs +79 -0
- package/retained/ModelStageScheduler.cjs +66 -0
- package/retained/ProfileAuthorityClient.cjs +88 -0
- package/retained/RetainedCaptureClient.cjs +164 -0
- package/retained/WaitRecheckCaptureClient.cjs +109 -0
- package/retained/__tests__/archive-lifecycle.test.cjs +117 -0
- package/retained/__tests__/runtime.test.cjs +384 -0
- package/retained/alert-rule-policy.cjs +37 -0
- package/retained/manager.cjs +133 -0
- package/retained/pilot-profile-client.cjs +82 -0
- package/retained/temporal-grouping.cjs +78 -0
- package/retained/website-api-capability.cjs +122 -0
|
@@ -33,7 +33,7 @@ import threading
|
|
|
33
33
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
34
34
|
|
|
35
35
|
from ._version import VERSION
|
|
36
|
-
from .errors import DetectorNotReady, UnknownClasses
|
|
36
|
+
from .errors import DetectorNotReady, ModelVerificationError, UnknownClasses
|
|
37
37
|
|
|
38
38
|
#: Loopback only. litert-lm owns 9379; this is a SECOND service, not a route on
|
|
39
39
|
#: that one — see `LocalDetectorClient.ts` for why (its own uv tool, its own
|
|
@@ -126,6 +126,19 @@ def parse_detect_request(raw: bytes) -> tuple[bytes, float, "list[str] | None"]:
|
|
|
126
126
|
return jpeg, min_confidence, raw_classes
|
|
127
127
|
|
|
128
128
|
|
|
129
|
+
def parse_expected_model(raw: bytes):
|
|
130
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
131
|
+
if "expected_model" not in payload:
|
|
132
|
+
return None
|
|
133
|
+
value = payload["expected_model"]
|
|
134
|
+
if (not isinstance(value, dict) or set(value) != {"modelId", "sha256"}
|
|
135
|
+
or not isinstance(value["modelId"], str) or not value["modelId"]
|
|
136
|
+
or not isinstance(value["sha256"], str) or len(value["sha256"]) != 64
|
|
137
|
+
or any(c not in "0123456789abcdef" for c in value["sha256"])):
|
|
138
|
+
raise WireError(400, "expected_model requires one modelId and a lowercase SHA-256")
|
|
139
|
+
return value
|
|
140
|
+
|
|
141
|
+
|
|
129
142
|
def _assert_wire_safe(detections: list) -> None:
|
|
130
143
|
"""The client's fifteen refusals, checked on THIS side before answering 200.
|
|
131
144
|
|
|
@@ -204,6 +217,12 @@ def build_detect_response(batch) -> dict:
|
|
|
204
217
|
if not isinstance(batch.inference_ms, (int, float)) or not math.isfinite(batch.inference_ms):
|
|
205
218
|
raise ValueError(f"inference_ms is not a finite number: {batch.inference_ms!r}")
|
|
206
219
|
_assert_wire_safe(body["detections"])
|
|
220
|
+
hashes = getattr(batch, "model_hashes", {})
|
|
221
|
+
if hashes:
|
|
222
|
+
if (not isinstance(hashes, dict) or set(hashes) != set(models)
|
|
223
|
+
or any(not isinstance(v, str) or len(v) != 64 or any(c not in "0123456789abcdef" for c in v) for v in hashes.values())):
|
|
224
|
+
raise ValueError("loaded model hash provenance is incomplete")
|
|
225
|
+
body["model_hashes"] = hashes
|
|
207
226
|
return body
|
|
208
227
|
|
|
209
228
|
|
|
@@ -283,6 +302,7 @@ class DetectorHandler(BaseHTTPRequestHandler):
|
|
|
283
302
|
try:
|
|
284
303
|
raw = self._read_body()
|
|
285
304
|
jpeg, min_confidence, requested = parse_detect_request(raw)
|
|
305
|
+
expected_model = parse_expected_model(raw)
|
|
286
306
|
except WireError as err:
|
|
287
307
|
self._json(err.status, {"error": err.message})
|
|
288
308
|
return
|
|
@@ -307,7 +327,11 @@ class DetectorHandler(BaseHTTPRequestHandler):
|
|
|
307
327
|
return
|
|
308
328
|
|
|
309
329
|
try:
|
|
310
|
-
batch = loader.detect(jpeg, min_confidence, wanted)
|
|
330
|
+
batch = (loader.detect(jpeg, min_confidence, wanted) if expected_model is None
|
|
331
|
+
else loader.detect(jpeg, min_confidence, wanted, expected_model=expected_model))
|
|
332
|
+
except ModelVerificationError as err:
|
|
333
|
+
self._json(409, {"error": str(err)})
|
|
334
|
+
return
|
|
311
335
|
except DetectorNotReady as err:
|
|
312
336
|
# 503, never a 200 with an empty array. The client raises
|
|
313
337
|
# DetectorUnavailableError on this and falls through to the VLM.
|
|
@@ -320,6 +344,9 @@ class DetectorHandler(BaseHTTPRequestHandler):
|
|
|
320
344
|
|
|
321
345
|
try:
|
|
322
346
|
body = build_detect_response(batch)
|
|
347
|
+
if expected_model is not None and (body.get("model_hashes") != {expected_model["modelId"]: expected_model["sha256"]}
|
|
348
|
+
or body["model"] != expected_model["modelId"]):
|
|
349
|
+
raise ValueError("selected detector did not return its loaded artifact proof")
|
|
323
350
|
except ValueError as err:
|
|
324
351
|
self._log(f"[detector] REFUSING to emit a malformed payload: {err}")
|
|
325
352
|
self._json(500, {"error": f"refused to emit a malformed payload: {err}"})
|
|
@@ -29,6 +29,7 @@ and fails on any disagreement.
|
|
|
29
29
|
from __future__ import annotations
|
|
30
30
|
|
|
31
31
|
import io
|
|
32
|
+
import hashlib
|
|
32
33
|
import math
|
|
33
34
|
import threading
|
|
34
35
|
import time
|
|
@@ -49,6 +50,7 @@ from .decode import (
|
|
|
49
50
|
)
|
|
50
51
|
from .detections import Detection, DetectionBatch
|
|
51
52
|
from .errors import ModelContractError
|
|
53
|
+
from .execution import LocalOrtSession
|
|
52
54
|
from .labels import ModelVocabulary
|
|
53
55
|
|
|
54
56
|
#: `rfdetr/detr.py` — ImageNet statistics, applied AFTER scaling to [0,1].
|
|
@@ -349,6 +351,7 @@ class OnnxDetector:
|
|
|
349
351
|
model_path: Path,
|
|
350
352
|
vocabulary: ModelVocabulary,
|
|
351
353
|
intra_op_threads: "int | None" = None,
|
|
354
|
+
accelerator: str = "auto",
|
|
352
355
|
):
|
|
353
356
|
self.model_path = model_path
|
|
354
357
|
self.vocabulary = vocabulary
|
|
@@ -364,9 +367,12 @@ class OnnxDetector:
|
|
|
364
367
|
# multiply an already non-monotonic pool by the number of models.
|
|
365
368
|
opts.log_severity_level = 3
|
|
366
369
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
)
|
|
370
|
+
# Loading and hashing the same immutable bytes avoids attributing an
|
|
371
|
+
# old resident session to an OTA file which replaced its original path.
|
|
372
|
+
model_bytes = Path(model_path).read_bytes()
|
|
373
|
+
self.model_sha256 = hashlib.sha256(model_bytes).hexdigest()
|
|
374
|
+
self._session = LocalOrtSession(ort, model_bytes, opts, accelerator)
|
|
375
|
+
del model_bytes
|
|
370
376
|
self.spec = _read_graph_spec(self._session, vocabulary)
|
|
371
377
|
|
|
372
378
|
def detect(
|
|
@@ -391,6 +397,7 @@ class OnnxDetector:
|
|
|
391
397
|
with self._lock:
|
|
392
398
|
infer_started = time.perf_counter()
|
|
393
399
|
outputs = self._session.run(list(self.spec.output_names), {self.spec.input_name: blob})
|
|
400
|
+
self.spec.providers = list(self._session.get_providers())
|
|
394
401
|
infer_ms = (time.perf_counter() - infer_started) * 1000.0
|
|
395
402
|
|
|
396
403
|
if layout == RFDETR_DETS_LABELS:
|
|
@@ -421,8 +428,13 @@ class OnnxDetector:
|
|
|
421
428
|
|
|
422
429
|
batch.inference_ms = infer_ms
|
|
423
430
|
batch.total_ms = (time.perf_counter() - started) * 1000.0
|
|
431
|
+
batch.model_hashes = {self.model_name: self.model_sha256}
|
|
424
432
|
return batch
|
|
425
433
|
|
|
434
|
+
@property
|
|
435
|
+
def acceleration(self):
|
|
436
|
+
return self._session.acceleration
|
|
437
|
+
|
|
426
438
|
|
|
427
439
|
def _read_graph_spec(
|
|
428
440
|
session: "ort.InferenceSession",
|
|
@@ -6,11 +6,14 @@ the recorded kwargs prove WHICH configs are dropped and which survive.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import importlib
|
|
9
|
+
import hashlib
|
|
9
10
|
import os
|
|
10
11
|
import sys
|
|
11
12
|
import tempfile
|
|
12
13
|
import textwrap
|
|
13
14
|
import unittest
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from types import SimpleNamespace
|
|
14
17
|
|
|
15
18
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
16
19
|
sys.path.insert(0, HERE)
|
|
@@ -127,6 +130,84 @@ class ShimTest(unittest.TestCase):
|
|
|
127
130
|
sys.meta_path.insert(0, object())
|
|
128
131
|
self.assertIn(shim.install(), (True, False))
|
|
129
132
|
|
|
133
|
+
def test_loaded_snapshot_survives_replacement_of_the_original_model(self):
|
|
134
|
+
shim = self._load(TRILLBOARDS_LITERTLM_SHIM="1")
|
|
135
|
+
source = Path(self.tmp.name) / "model.litertlm"
|
|
136
|
+
source.write_bytes(b"evaluated local model")
|
|
137
|
+
snapshot, sha = shim._verified_snapshot(source)
|
|
138
|
+
source.write_bytes(b"later OTA model")
|
|
139
|
+
self.assertEqual(Path(snapshot).read_bytes(), b"evaluated local model")
|
|
140
|
+
self.assertEqual(sha, hashlib.sha256(b"evaluated local model").hexdigest())
|
|
141
|
+
|
|
142
|
+
def test_modified_snapshot_is_refused(self):
|
|
143
|
+
shim = self._load(TRILLBOARDS_LITERTLM_SHIM="1")
|
|
144
|
+
source = Path(self.tmp.name) / "model.litertlm"
|
|
145
|
+
source.write_bytes(b"evaluated local model")
|
|
146
|
+
snapshot, _ = shim._verified_snapshot(source)
|
|
147
|
+
Path(snapshot).write_bytes(b"tampered")
|
|
148
|
+
with self.assertRaisesRegex(ValueError, "snapshot was modified"):
|
|
149
|
+
shim._verified_snapshot(source)
|
|
150
|
+
|
|
151
|
+
def _runtime_handler(self, expected, artifact=None, actual_backend="gpu"):
|
|
152
|
+
shim = self._load(TRILLBOARDS_LITERTLM_SHIM="1")
|
|
153
|
+
engine = SimpleNamespace(backend=SimpleNamespace(get_name=lambda: actual_backend))
|
|
154
|
+
if artifact is not None:
|
|
155
|
+
engine._trillboards_artifact_sha256 = artifact
|
|
156
|
+
captured = {}
|
|
157
|
+
|
|
158
|
+
class Handler:
|
|
159
|
+
headers = {"X-Trillboards-Model-Sha256": expected} if expected is not None else {}
|
|
160
|
+
|
|
161
|
+
def send_error(self, status, message):
|
|
162
|
+
captured["error"] = (status, message)
|
|
163
|
+
|
|
164
|
+
def _get_engine(self, model_id, *, backend):
|
|
165
|
+
return engine
|
|
166
|
+
|
|
167
|
+
def do_POST(self):
|
|
168
|
+
selected = self._get_engine("gemma", backend="gpu")
|
|
169
|
+
if selected is not None:
|
|
170
|
+
module._dump_json({"object": "chat.completion", "choices": [{"text": "unchanged"}]})
|
|
171
|
+
|
|
172
|
+
def dump(data, **kwargs):
|
|
173
|
+
captured["response"] = data
|
|
174
|
+
|
|
175
|
+
module = SimpleNamespace(OpenAIHandler=Handler, _dump_json=dump)
|
|
176
|
+
shim.apply_runtime_handler(module)
|
|
177
|
+
module.OpenAIHandler().do_POST()
|
|
178
|
+
return captured
|
|
179
|
+
|
|
180
|
+
def test_http_proof_is_from_the_loaded_engine_not_the_requested_name(self):
|
|
181
|
+
sha = "a" * 64
|
|
182
|
+
captured = self._runtime_handler(sha, sha)
|
|
183
|
+
self.assertEqual(captured["response"]["runtimeModel"], {
|
|
184
|
+
"version": 1, "sha256": sha, "format": "litert-lm", "runtimeModelId": "gemma,gpu",
|
|
185
|
+
})
|
|
186
|
+
self.assertEqual(captured["response"]["choices"], [{"text": "unchanged"}])
|
|
187
|
+
|
|
188
|
+
def test_missing_artifact_proof_is_not_fabricated(self):
|
|
189
|
+
captured = self._runtime_handler("a" * 64)
|
|
190
|
+
self.assertEqual(captured["error"][0], 409)
|
|
191
|
+
self.assertNotIn("response", captured)
|
|
192
|
+
|
|
193
|
+
def test_wrong_artifact_is_refused_before_answering(self):
|
|
194
|
+
captured = self._runtime_handler("a" * 64, "b" * 64)
|
|
195
|
+
self.assertEqual(captured["error"][0], 409)
|
|
196
|
+
self.assertNotIn("response", captured)
|
|
197
|
+
|
|
198
|
+
def test_wrong_backend_is_refused_before_answering(self):
|
|
199
|
+
captured = self._runtime_handler("a" * 64, "a" * 64, actual_backend="cpu")
|
|
200
|
+
self.assertEqual(captured["error"][0], 409)
|
|
201
|
+
|
|
202
|
+
def test_malformed_requested_hash_is_refused(self):
|
|
203
|
+
captured = self._runtime_handler("not-a-hash", "a" * 64)
|
|
204
|
+
self.assertEqual(captured["error"][0], 400)
|
|
205
|
+
|
|
206
|
+
def test_a_request_without_pinning_still_works_without_fabricated_proof(self):
|
|
207
|
+
captured = self._runtime_handler(None)
|
|
208
|
+
self.assertEqual(captured["response"]["choices"], [{"text": "unchanged"}])
|
|
209
|
+
self.assertNotIn("runtimeModel", captured["response"])
|
|
210
|
+
|
|
130
211
|
|
|
131
212
|
if __name__ == "__main__":
|
|
132
213
|
unittest.main()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from verify_model_proof import verify_completion
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ModelProofTest(unittest.TestCase):
|
|
7
|
+
def proof(self, model_id="gemma,gpu", sha="a" * 64):
|
|
8
|
+
return {"runtimeModel": {"version": 1, "format": "litert-lm",
|
|
9
|
+
"runtimeModelId": model_id, "sha256": sha}}
|
|
10
|
+
|
|
11
|
+
def test_exact_gpu(self):
|
|
12
|
+
self.assertTrue(verify_completion(self.proof(), "gemma,gpu", "a" * 64))
|
|
13
|
+
|
|
14
|
+
def test_cpu_normalization(self):
|
|
15
|
+
self.assertTrue(verify_completion(self.proof("gemma,cpu"), "gemma", "a" * 64))
|
|
16
|
+
|
|
17
|
+
def test_gpu_cannot_demote_to_cpu(self):
|
|
18
|
+
self.assertFalse(verify_completion(self.proof("gemma,cpu"), "gemma,gpu", "a" * 64))
|
|
19
|
+
|
|
20
|
+
def test_wrong_file_backend_and_alias(self):
|
|
21
|
+
for payload in [self.proof(sha="b" * 64), self.proof("gemma,cpu"), self.proof("other,gpu")]:
|
|
22
|
+
self.assertFalse(verify_completion(payload, "gemma,gpu", "a" * 64))
|
|
23
|
+
|
|
24
|
+
def test_no_proof_cannot_be_replaced_by_model_text(self):
|
|
25
|
+
for payload in [None, {}, {"choices": [{"message": {"content": "correct model"}}]}]:
|
|
26
|
+
self.assertFalse(verify_completion(payload, "gemma,gpu", "a" * 64))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
if __name__ == "__main__":
|
|
30
|
+
unittest.main()
|
|
@@ -67,7 +67,9 @@ imports nothing, so it piggybacks on the import the CLI was going to do anyway
|
|
|
67
67
|
rather than adding one. `litert-lm list` and `litert-lm --version` — which the
|
|
68
68
|
provisioners run as health checks — keep exactly the startup cost they had.
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
The conversation-speed optimization fails OPEN. A pinned identity request does
|
|
71
|
+
not: it fails CLOSED when the loaded artifact or requested backend is unproven.
|
|
72
|
+
An optimization failure leaves stock behaviour intact and prints
|
|
71
73
|
one line to stderr, which the serve's own log captures: a serve that is merely
|
|
72
74
|
slow keeps sensing, and a serve that refuses to start senses nothing. The line
|
|
73
75
|
is the only way to tell the two apart from the outside, so it is not optional —
|
|
@@ -127,6 +129,8 @@ def apply_to(litert_lm_module: object) -> bool:
|
|
|
127
129
|
Returns True when the wrap is in place (including when it already was).
|
|
128
130
|
"""
|
|
129
131
|
engine_cls = getattr(litert_lm_module, "Engine", None)
|
|
132
|
+
if engine_cls is not None:
|
|
133
|
+
_apply_model_snapshot(engine_cls)
|
|
130
134
|
original = getattr(engine_cls, "create_conversation", None)
|
|
131
135
|
if original is None:
|
|
132
136
|
_say("NOT ACTIVE: litert_lm.Engine.create_conversation is absent — "
|
|
@@ -170,6 +174,8 @@ class _LitertLmImportHook:
|
|
|
170
174
|
sys.meta_path.remove(self)
|
|
171
175
|
except ValueError:
|
|
172
176
|
return None
|
|
177
|
+
if not _enabled():
|
|
178
|
+
return None
|
|
173
179
|
try:
|
|
174
180
|
import importlib.util
|
|
175
181
|
|
|
@@ -198,6 +204,7 @@ def install() -> bool:
|
|
|
198
204
|
if not _enabled():
|
|
199
205
|
_say("disabled by TRILLBOARDS_LITERTLM_SHIM=0")
|
|
200
206
|
return False
|
|
207
|
+
_install_runtime_handler_hook()
|
|
201
208
|
already = sys.modules.get(TARGET_MODULE)
|
|
202
209
|
if already is not None:
|
|
203
210
|
_install_now(already)
|
|
@@ -211,4 +218,146 @@ def install() -> bool:
|
|
|
211
218
|
return False
|
|
212
219
|
|
|
213
220
|
|
|
221
|
+
def _verified_snapshot(model_path):
|
|
222
|
+
"""Copy/hash once at engine load. Inference reads these immutable bytes.
|
|
223
|
+
|
|
224
|
+
Stored by content hash beside the model; OTA replaces the original path,
|
|
225
|
+
never this snapshot. No per-frame hashing or extra model inference.
|
|
226
|
+
"""
|
|
227
|
+
import hashlib
|
|
228
|
+
import os
|
|
229
|
+
import tempfile
|
|
230
|
+
from pathlib import Path
|
|
231
|
+
source = Path(model_path)
|
|
232
|
+
directory = source.parent / 'verified-runtime'
|
|
233
|
+
directory.mkdir(exist_ok=True)
|
|
234
|
+
digest = hashlib.sha256()
|
|
235
|
+
fd, temporary = tempfile.mkstemp(prefix='.loading-', dir=directory)
|
|
236
|
+
try:
|
|
237
|
+
with os.fdopen(fd, 'wb') as target, source.open('rb') as original:
|
|
238
|
+
for chunk in iter(lambda: original.read(1024 * 1024), b''):
|
|
239
|
+
digest.update(chunk)
|
|
240
|
+
target.write(chunk)
|
|
241
|
+
sha = digest.hexdigest()
|
|
242
|
+
snapshot = directory / (sha + source.suffix)
|
|
243
|
+
if snapshot.exists():
|
|
244
|
+
check = hashlib.sha256()
|
|
245
|
+
with snapshot.open('rb') as existing:
|
|
246
|
+
for chunk in iter(lambda: existing.read(1024 * 1024), b''):
|
|
247
|
+
check.update(chunk)
|
|
248
|
+
if check.hexdigest() != sha:
|
|
249
|
+
raise ValueError('verified model snapshot was modified')
|
|
250
|
+
else:
|
|
251
|
+
os.replace(temporary, snapshot)
|
|
252
|
+
return str(snapshot), sha
|
|
253
|
+
finally:
|
|
254
|
+
if os.path.exists(temporary):
|
|
255
|
+
os.unlink(temporary)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _apply_model_snapshot(engine_cls):
|
|
259
|
+
original = engine_cls.__init__
|
|
260
|
+
if getattr(original, '_trillboards_model_snapshot', False):
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
def initialize(self, *args, **kwargs):
|
|
264
|
+
path = kwargs.get('model_path', args[0] if args else None)
|
|
265
|
+
if path is None: # lightweight contract-test engines have no artifact
|
|
266
|
+
return original(self, *args, **kwargs)
|
|
267
|
+
snapshot, sha = _verified_snapshot(path)
|
|
268
|
+
if 'model_path' in kwargs:
|
|
269
|
+
kwargs['model_path'] = snapshot
|
|
270
|
+
else:
|
|
271
|
+
args = (snapshot, *args[1:])
|
|
272
|
+
original(self, *args, **kwargs)
|
|
273
|
+
self._trillboards_artifact_sha256 = sha
|
|
274
|
+
|
|
275
|
+
initialize._trillboards_model_snapshot = True
|
|
276
|
+
engine_cls.__init__ = initialize
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def apply_runtime_handler(module):
|
|
280
|
+
"""Bind proof to the engine used by this HTTP request, outside model text."""
|
|
281
|
+
import contextvars
|
|
282
|
+
import re
|
|
283
|
+
handler = getattr(module, 'OpenAIHandler', None)
|
|
284
|
+
if handler is None or getattr(handler, '_trillboards_runtime_identity', False):
|
|
285
|
+
return
|
|
286
|
+
active = contextvars.ContextVar('trillboards_runtime_identity', default=None)
|
|
287
|
+
original_post, original_engine, original_dump = handler.do_POST, handler._get_engine, module._dump_json
|
|
288
|
+
|
|
289
|
+
def do_post(self):
|
|
290
|
+
expected = self.headers.get('X-Trillboards-Model-Sha256')
|
|
291
|
+
if expected is not None and not re.fullmatch(r'[a-f0-9]{64}', expected):
|
|
292
|
+
self.send_error(400, 'invalid selected artifact hash')
|
|
293
|
+
return
|
|
294
|
+
token = active.set({'expected': expected, 'proof': None})
|
|
295
|
+
try:
|
|
296
|
+
return original_post(self)
|
|
297
|
+
finally:
|
|
298
|
+
active.reset(token)
|
|
299
|
+
|
|
300
|
+
def get_engine(self, *args, **kwargs):
|
|
301
|
+
engine = original_engine(self, *args, **kwargs)
|
|
302
|
+
state = active.get()
|
|
303
|
+
if engine is None or state is None:
|
|
304
|
+
return engine
|
|
305
|
+
sha = getattr(engine, '_trillboards_artifact_sha256', None)
|
|
306
|
+
if state['expected'] is not None and sha != state['expected']:
|
|
307
|
+
self.send_error(409, 'selected model artifact does not match loaded engine')
|
|
308
|
+
return None
|
|
309
|
+
# The CLI separates "model,gpu" into model_id and backend before this
|
|
310
|
+
# call. Reconstruct identity from the loaded engine, not just the name.
|
|
311
|
+
backend = kwargs.get('backend', args[3] if len(args) > 3 else None)
|
|
312
|
+
get_name = getattr(getattr(engine, 'backend', None), 'get_name', None)
|
|
313
|
+
actual_backend = get_name() if callable(get_name) else None
|
|
314
|
+
if state['expected'] is not None and backend is not None and actual_backend != backend:
|
|
315
|
+
self.send_error(409, 'selected backend does not match loaded engine')
|
|
316
|
+
return None
|
|
317
|
+
runtime_id = kwargs.get('model_id', args[0] if args else None)
|
|
318
|
+
if backend is not None:
|
|
319
|
+
runtime_id = f'{runtime_id},{actual_backend}'
|
|
320
|
+
if sha is not None:
|
|
321
|
+
state['proof'] = {'version': 1, 'sha256': sha, 'format': 'litert-lm',
|
|
322
|
+
'runtimeModelId': runtime_id}
|
|
323
|
+
return engine
|
|
324
|
+
|
|
325
|
+
def dump_json(data, **kwargs):
|
|
326
|
+
state = active.get()
|
|
327
|
+
if isinstance(data, dict) and data.get('object') == 'chat.completion' and state and state['proof']:
|
|
328
|
+
data = {**data, 'runtimeModel': dict(state['proof'])}
|
|
329
|
+
return original_dump(data, **kwargs)
|
|
330
|
+
|
|
331
|
+
handler.do_POST, handler._get_engine, module._dump_json = do_post, get_engine, dump_json
|
|
332
|
+
handler._trillboards_runtime_identity = True
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class _RuntimeHandlerImportHook:
|
|
336
|
+
def find_spec(self, fullname, path=None, target=None):
|
|
337
|
+
if fullname != 'litert_lm_cli.commands.openai_handler':
|
|
338
|
+
return None
|
|
339
|
+
sys.meta_path.remove(self)
|
|
340
|
+
if not _enabled():
|
|
341
|
+
return None
|
|
342
|
+
import importlib.util
|
|
343
|
+
spec = importlib.util.find_spec(fullname)
|
|
344
|
+
if spec is None or spec.loader is None:
|
|
345
|
+
return None
|
|
346
|
+
original = spec.loader.exec_module
|
|
347
|
+
|
|
348
|
+
def execute(module):
|
|
349
|
+
original(module)
|
|
350
|
+
apply_runtime_handler(module)
|
|
351
|
+
spec.loader.exec_module = execute
|
|
352
|
+
return spec
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _install_runtime_handler_hook():
|
|
356
|
+
module = sys.modules.get('litert_lm_cli.commands.openai_handler')
|
|
357
|
+
if module is not None:
|
|
358
|
+
apply_runtime_handler(module)
|
|
359
|
+
elif not any(isinstance(hook, _RuntimeHandlerImportHook) for hook in sys.meta_path):
|
|
360
|
+
sys.meta_path.insert(0, _RuntimeHandlerImportHook())
|
|
361
|
+
|
|
362
|
+
|
|
214
363
|
install()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Validate the server's engine identity, independently of model-generated text."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def verify_completion(payload, model_id, artifact_sha256):
|
|
8
|
+
proof = payload.get("runtimeModel") if isinstance(payload, dict) else None
|
|
9
|
+
if not isinstance(proof, dict):
|
|
10
|
+
return False
|
|
11
|
+
backend = "gpu" if model_id.endswith(",gpu") else "cpu"
|
|
12
|
+
names = {model_id}
|
|
13
|
+
if backend == "cpu" and not model_id.endswith(",cpu"):
|
|
14
|
+
names.add(model_id + ",cpu")
|
|
15
|
+
return (
|
|
16
|
+
proof.get("version") == 1
|
|
17
|
+
and set(proof) == {"version", "format", "sha256", "runtimeModelId"}
|
|
18
|
+
and proof.get("format") == "litert-lm"
|
|
19
|
+
and proof.get("sha256") == artifact_sha256
|
|
20
|
+
and proof.get("runtimeModelId") in names
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
if __name__ == "__main__":
|
|
25
|
+
try:
|
|
26
|
+
with open(sys.argv[1], encoding="utf-8") as response:
|
|
27
|
+
valid = verify_completion(json.load(response), sys.argv[2], sys.argv[3])
|
|
28
|
+
except (OSError, ValueError, IndexError):
|
|
29
|
+
valid = False
|
|
30
|
+
sys.exit(0 if valid else 1)
|