@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.
Files changed (191) hide show
  1. package/README.md +2 -2
  2. package/deploy/detector/pyproject.toml +20 -2
  3. package/deploy/detector/tests/test_execution.py +124 -0
  4. package/deploy/detector/tests/test_model_identity.py +80 -0
  5. package/deploy/detector/tests/test_registry.py +18 -0
  6. package/deploy/detector/tests/test_server.py +3 -1
  7. package/deploy/detector/tests/test_version_stamp.py +4 -1
  8. package/deploy/detector/trillboards_detector/_version.py +2 -2
  9. package/deploy/detector/trillboards_detector/cli.py +3 -0
  10. package/deploy/detector/trillboards_detector/detections.py +7 -0
  11. package/deploy/detector/trillboards_detector/errors.py +4 -0
  12. package/deploy/detector/trillboards_detector/execution.py +109 -0
  13. package/deploy/detector/trillboards_detector/loader.py +6 -3
  14. package/deploy/detector/trillboards_detector/registry.py +27 -5
  15. package/deploy/detector/trillboards_detector/server.py +29 -2
  16. package/deploy/detector/trillboards_detector/session.py +15 -3
  17. package/deploy/litertlm/test_trillboards_litertlm_shim.py +81 -0
  18. package/deploy/litertlm/test_verify_model_proof.py +30 -0
  19. package/deploy/litertlm/trillboards_litertlm_shim.py +150 -1
  20. package/deploy/litertlm/verify_model_proof.py +30 -0
  21. package/deploy/macos/TrillboardsSetupApp.m +131 -3
  22. package/deploy/macos/build-macos-pkg.sh +13 -10
  23. package/deploy/macos/scripts/setup-detector.sh +2 -2
  24. package/deploy/macos/scripts/setup-litertlm.sh +66 -30
  25. package/deploy/macos/test-setup-litertlm-reinstall.sh +57 -4
  26. package/deploy/setup-guidance.json +112 -0
  27. package/deploy/systemd/setup-detector.sh +2 -2
  28. package/deploy/systemd/setup-litertlm.sh +51 -27
  29. package/deploy/windows/build-windows.ps1 +11 -6
  30. package/deploy/windows/bundle-windows-installer.ps1 +3 -3
  31. package/deploy/windows/e2e/test-local-sense.ps1 +6 -5
  32. package/deploy/windows/install-update.ps1 +52 -9
  33. package/deploy/windows/set-executable-icon.ps1 +24 -5
  34. package/deploy/windows/setup-detector.ps1 +142 -4
  35. package/deploy/windows/setup-litertlm.ps1 +53 -10
  36. package/deploy/windows/trillboards-setup.ps1 +101 -2
  37. package/dist/AudienceAnalyzeClient.d.ts +10 -0
  38. package/dist/AudienceAnalyzeClient.d.ts.map +1 -1
  39. package/dist/AudienceAnalyzeClient.js +5 -4
  40. package/dist/AudienceAnalyzeClient.js.map +1 -1
  41. package/dist/CloudSensingManager.d.ts +38 -1
  42. package/dist/CloudSensingManager.d.ts.map +1 -1
  43. package/dist/CloudSensingManager.js +403 -25
  44. package/dist/CloudSensingManager.js.map +1 -1
  45. package/dist/DetectorFieldProducer.d.ts +5 -1
  46. package/dist/DetectorFieldProducer.d.ts.map +1 -1
  47. package/dist/DetectorFieldProducer.js +8 -1
  48. package/dist/DetectorFieldProducer.js.map +1 -1
  49. package/dist/DurableVlmCandidateSpool.d.ts +83 -2
  50. package/dist/DurableVlmCandidateSpool.d.ts.map +1 -1
  51. package/dist/DurableVlmCandidateSpool.js +596 -30
  52. package/dist/DurableVlmCandidateSpool.js.map +1 -1
  53. package/dist/EdgeAgent.js +1 -1
  54. package/dist/FrameGroupingShadow.d.ts +51 -0
  55. package/dist/FrameGroupingShadow.d.ts.map +1 -0
  56. package/dist/FrameGroupingShadow.js +158 -0
  57. package/dist/FrameGroupingShadow.js.map +1 -0
  58. package/dist/ImageClassifierFieldProducer.d.ts +36 -0
  59. package/dist/ImageClassifierFieldProducer.d.ts.map +1 -0
  60. package/dist/ImageClassifierFieldProducer.js +150 -0
  61. package/dist/ImageClassifierFieldProducer.js.map +1 -0
  62. package/dist/LiteRtVlmProvider.d.ts +5 -0
  63. package/dist/LiteRtVlmProvider.d.ts.map +1 -1
  64. package/dist/LiteRtVlmProvider.js +72 -7
  65. package/dist/LiteRtVlmProvider.js.map +1 -1
  66. package/dist/LocalDetectorClient.d.ts +6 -0
  67. package/dist/LocalDetectorClient.d.ts.map +1 -1
  68. package/dist/LocalDetectorClient.js +8 -0
  69. package/dist/LocalDetectorClient.js.map +1 -1
  70. package/dist/OrderedFrameReview.d.ts +57 -0
  71. package/dist/OrderedFrameReview.d.ts.map +1 -0
  72. package/dist/OrderedFrameReview.js +121 -0
  73. package/dist/OrderedFrameReview.js.map +1 -0
  74. package/dist/OrderedReviewClient.d.ts +38 -0
  75. package/dist/OrderedReviewClient.d.ts.map +1 -0
  76. package/dist/OrderedReviewClient.js +121 -0
  77. package/dist/OrderedReviewClient.js.map +1 -0
  78. package/dist/OrderedSequenceStore.d.ts +69 -0
  79. package/dist/OrderedSequenceStore.d.ts.map +1 -0
  80. package/dist/OrderedSequenceStore.js +257 -0
  81. package/dist/OrderedSequenceStore.js.map +1 -0
  82. package/dist/ProfileReviewWork.d.ts +68 -0
  83. package/dist/ProfileReviewWork.d.ts.map +1 -0
  84. package/dist/ProfileReviewWork.js +150 -0
  85. package/dist/ProfileReviewWork.js.map +1 -0
  86. package/dist/RetainedFieldContract.d.ts +21 -0
  87. package/dist/RetainedFieldContract.d.ts.map +1 -0
  88. package/dist/RetainedFieldContract.js +92 -0
  89. package/dist/RetainedFieldContract.js.map +1 -0
  90. package/dist/ReviewModelUse.d.ts +7 -0
  91. package/dist/ReviewModelUse.d.ts.map +1 -0
  92. package/dist/ReviewModelUse.js +30 -0
  93. package/dist/ReviewModelUse.js.map +1 -0
  94. package/dist/ReviewPriority.d.ts +12 -0
  95. package/dist/ReviewPriority.d.ts.map +1 -0
  96. package/dist/ReviewPriority.js +45 -0
  97. package/dist/ReviewPriority.js.map +1 -0
  98. package/dist/RuntimeModelSelection.d.ts +10 -0
  99. package/dist/RuntimeModelSelection.d.ts.map +1 -0
  100. package/dist/RuntimeModelSelection.js +58 -0
  101. package/dist/RuntimeModelSelection.js.map +1 -0
  102. package/dist/SceneChangeDetector.d.ts +1 -1
  103. package/dist/SceneChangeDetector.d.ts.map +1 -1
  104. package/dist/SceneChangeDetector.js +18 -6
  105. package/dist/SceneChangeDetector.js.map +1 -1
  106. package/dist/ScopedVlmReview.d.ts +76 -0
  107. package/dist/ScopedVlmReview.d.ts.map +1 -0
  108. package/dist/ScopedVlmReview.js +267 -0
  109. package/dist/ScopedVlmReview.js.map +1 -0
  110. package/dist/SensingProfileClient.d.ts +50 -0
  111. package/dist/SensingProfileClient.d.ts.map +1 -1
  112. package/dist/SensingProfileClient.js +58 -2
  113. package/dist/SensingProfileClient.js.map +1 -1
  114. package/dist/TemporalReviewClient.d.ts +43 -0
  115. package/dist/TemporalReviewClient.d.ts.map +1 -0
  116. package/dist/TemporalReviewClient.js +117 -0
  117. package/dist/TemporalReviewClient.js.map +1 -0
  118. package/dist/TemporalReviewJournal.d.ts +91 -0
  119. package/dist/TemporalReviewJournal.d.ts.map +1 -0
  120. package/dist/TemporalReviewJournal.js +337 -0
  121. package/dist/TemporalReviewJournal.js.map +1 -0
  122. package/dist/UrgentReviewTriage.d.ts +35 -0
  123. package/dist/UrgentReviewTriage.d.ts.map +1 -0
  124. package/dist/UrgentReviewTriage.js +52 -0
  125. package/dist/UrgentReviewTriage.js.map +1 -0
  126. package/dist/VideoSegmentUploader.d.ts +293 -0
  127. package/dist/VideoSegmentUploader.d.ts.map +1 -0
  128. package/dist/VideoSegmentUploader.js +1002 -0
  129. package/dist/VideoSegmentUploader.js.map +1 -0
  130. package/dist/VlmCandidateBuffer.d.ts +19 -0
  131. package/dist/VlmCandidateBuffer.d.ts.map +1 -1
  132. package/dist/VlmCandidateBuffer.js.map +1 -1
  133. package/dist/autoSense.d.ts +2 -2
  134. package/dist/autoSense.d.ts.map +1 -1
  135. package/dist/autoSense.js +16 -11
  136. package/dist/autoSense.js.map +1 -1
  137. package/dist/cli.js +1 -1
  138. package/dist/cli.js.map +1 -1
  139. package/dist/config.d.ts +2 -0
  140. package/dist/config.d.ts.map +1 -1
  141. package/dist/config.js.map +1 -1
  142. package/dist/continuousReview.d.ts +71 -0
  143. package/dist/continuousReview.d.ts.map +1 -0
  144. package/dist/continuousReview.js +53 -0
  145. package/dist/continuousReview.js.map +1 -0
  146. package/dist/detectorCountReview.d.ts +9 -0
  147. package/dist/detectorCountReview.d.ts.map +1 -0
  148. package/dist/detectorCountReview.js +79 -0
  149. package/dist/detectorCountReview.js.map +1 -0
  150. package/dist/diagnose.d.ts +1 -1
  151. package/dist/diagnose.js +1 -1
  152. package/dist/installerOnboardingMode.d.ts.map +1 -1
  153. package/dist/installerOnboardingMode.js +5 -3
  154. package/dist/installerOnboardingMode.js.map +1 -1
  155. package/dist/localInferenceSchema.d.ts +8 -0
  156. package/dist/localInferenceSchema.d.ts.map +1 -1
  157. package/dist/localInferenceSchema.js +3 -3
  158. package/dist/localInferenceSchema.js.map +1 -1
  159. package/dist/localSenseE2eHarness.d.ts +12 -0
  160. package/dist/localSenseE2eHarness.d.ts.map +1 -1
  161. package/dist/localSenseE2eHarness.js +77 -4
  162. package/dist/localSenseE2eHarness.js.map +1 -1
  163. package/dist/localSensing.d.ts +91 -18
  164. package/dist/localSensing.d.ts.map +1 -1
  165. package/dist/localSensing.js +678 -153
  166. package/dist/localSensing.js.map +1 -1
  167. package/dist/mixedScopedCounts.d.ts +17 -0
  168. package/dist/mixedScopedCounts.d.ts.map +1 -0
  169. package/dist/mixedScopedCounts.js +90 -0
  170. package/dist/mixedScopedCounts.js.map +1 -0
  171. package/dist/sensingCatchRate.d.ts +9 -5
  172. package/dist/sensingCatchRate.d.ts.map +1 -1
  173. package/dist/sensingCatchRate.js +10 -6
  174. package/dist/sensingCatchRate.js.map +1 -1
  175. package/dist/sensingRunMode.d.ts +5 -0
  176. package/dist/sensingRunMode.d.ts.map +1 -1
  177. package/dist/sensingRunMode.js +56 -0
  178. package/dist/sensingRunMode.js.map +1 -1
  179. package/package.json +7 -4
  180. package/retained/ArchiveLifecycle.cjs +79 -0
  181. package/retained/ModelStageScheduler.cjs +66 -0
  182. package/retained/ProfileAuthorityClient.cjs +88 -0
  183. package/retained/RetainedCaptureClient.cjs +164 -0
  184. package/retained/WaitRecheckCaptureClient.cjs +109 -0
  185. package/retained/__tests__/archive-lifecycle.test.cjs +117 -0
  186. package/retained/__tests__/runtime.test.cjs +384 -0
  187. package/retained/alert-rule-policy.cjs +37 -0
  188. package/retained/manager.cjs +133 -0
  189. package/retained/pilot-profile-client.cjs +82 -0
  190. package/retained/temporal-grouping.cjs +78 -0
  191. 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
- self._session = ort.InferenceSession(
368
- str(model_path), sess_options=opts, providers=["CPUExecutionProvider"]
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
- It also fails OPEN. Every failure path leaves stock behaviour intact and prints
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)