@trillboards/edge-sdk 0.2.60 → 0.2.63

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 (52) hide show
  1. package/deploy/litertlm/test_trillboards_litertlm_shim.py +132 -0
  2. package/deploy/litertlm/trillboards-litertlm-shim.pth +1 -0
  3. package/deploy/litertlm/trillboards_litertlm_shim.py +214 -0
  4. package/deploy/macos/bench-local-vlm.sh +8 -4
  5. package/deploy/macos/build-macos-pkg.sh +4 -0
  6. package/deploy/macos/scripts/setup-litertlm.sh +210 -0
  7. package/deploy/macos/test-setup-litertlm-reinstall.sh +59 -1
  8. package/deploy/systemd/setup-litertlm.sh +218 -10
  9. package/deploy/windows/build-windows.ps1 +4 -0
  10. package/deploy/windows/register-litertlm-task.ps1 +22 -0
  11. package/deploy/windows/setup-litertlm.ps1 +228 -0
  12. package/deploy/windows/trillboards-edge.cmd +19 -0
  13. package/dist/CloudSensingManager.d.ts +2 -0
  14. package/dist/CloudSensingManager.d.ts.map +1 -1
  15. package/dist/EdgeAgent.js +1 -1
  16. package/dist/LiteRtVlmProvider.d.ts +74 -7
  17. package/dist/LiteRtVlmProvider.d.ts.map +1 -1
  18. package/dist/LiteRtVlmProvider.js.map +1 -1
  19. package/dist/analyzeCostObserver.d.ts +96 -0
  20. package/dist/analyzeCostObserver.d.ts.map +1 -0
  21. package/dist/analyzeCostObserver.js +130 -0
  22. package/dist/analyzeCostObserver.js.map +1 -0
  23. package/dist/connectorCadence.d.ts +120 -1
  24. package/dist/connectorCadence.d.ts.map +1 -1
  25. package/dist/connectorCadence.js +127 -2
  26. package/dist/connectorCadence.js.map +1 -1
  27. package/dist/deployedCadence.d.ts +13 -1
  28. package/dist/deployedCadence.d.ts.map +1 -1
  29. package/dist/deployedCadence.js +11 -2
  30. package/dist/deployedCadence.js.map +1 -1
  31. package/dist/deployedCadenceSource.d.ts +10 -0
  32. package/dist/deployedCadenceSource.d.ts.map +1 -1
  33. package/dist/deployedCadenceSource.js +11 -0
  34. package/dist/deployedCadenceSource.js.map +1 -1
  35. package/dist/diagnose.d.ts +1 -1
  36. package/dist/diagnose.js +1 -1
  37. package/dist/localInferenceSchema.d.ts +8 -3
  38. package/dist/localInferenceSchema.d.ts.map +1 -1
  39. package/dist/localInferenceSchema.js +8 -3
  40. package/dist/localInferenceSchema.js.map +1 -1
  41. package/dist/localSensing.d.ts +22 -1
  42. package/dist/localSensing.d.ts.map +1 -1
  43. package/dist/localSensing.js +36 -2
  44. package/dist/localSensing.js.map +1 -1
  45. package/dist/localVlmBench.d.ts +82 -0
  46. package/dist/localVlmBench.d.ts.map +1 -1
  47. package/dist/localVlmBench.js +88 -0
  48. package/dist/localVlmBench.js.map +1 -1
  49. package/dist/sensingRunMode.d.ts.map +1 -1
  50. package/dist/sensingRunMode.js +21 -2
  51. package/dist/sensingRunMode.js.map +1 -1
  52. package/package.json +1 -1
@@ -36,8 +36,18 @@ fi
36
36
  exit 0
37
37
  EOF
38
38
 
39
+ # Fake curl models BOTH endpoints the setup script uses:
40
+ # GET /v1/models -> the catalogue body `local_ai_healthy` greps
41
+ # POST /v1/chat/completions -> the vision probe, which asks for `%{http_code}`
42
+ # TRILLBOARDS_TEST_VISION_PROBE_CODE lets a test drive the GPU-vision fallback.
39
43
  cat >"$FAKE_BIN/curl" <<'EOF'
40
44
  #!/bin/bash
45
+ for arg in "$@"; do
46
+ if [ "$arg" = "-w" ]; then
47
+ printf '%s' "${TRILLBOARDS_TEST_VISION_PROBE_CODE:-200}"
48
+ exit 0
49
+ fi
50
+ done
41
51
  printf '{"data":[{"id":"gemma-4-E2B-it.litertlm"}]}\n'
42
52
  EOF
43
53
 
@@ -103,7 +113,55 @@ if ! OUTPUT="$(/bin/bash "$SETUP" 2>&1)"; then
103
113
  fi
104
114
 
105
115
  [ "$(cat "$TRILLBOARDS_TEST_STATE_DIR/bootstrap-attempts")" = "2" ]
116
+ # A host whose GPU vision encoder answers keeps it: config.json must carry
117
+ # vision_backend=gpu and must NOT have been rewritten by the fallback.
118
+ grep -Fq '"vision_backend":"gpu"' "$LITERT_LM_DIR/config.json"
119
+ grep -Fq '"speculative_decoding":true' "$LITERT_LM_DIR/config.json"
120
+ grep -Fq 'local AI healthy with the GPU vision encoder' <<<"$OUTPUT"
106
121
  grep -Fq 'Bootstrap failed: 37: Operation already in progress' \
107
122
  "$HOME/Library/Logs/Trillboards/litertlm-setup.log"
108
123
  grep -Fq 'local AI is installed and healthy' <<<"$OUTPUT"
109
- printf 'macOS LiteRT-LM reinstall race regression passed\n'
124
+
125
+ # -- Scenario 2: a host whose GPU vision encoder does NOT answer --------------
126
+ #
127
+ # This is the case the cascade exists for. Pinning the vision encoder to the GPU
128
+ # is the one setting here that can make a host WORSE than it was, and there is no
129
+ # demotion path for it: `recordGpuDemotion` re-posts the BARE model id, which
130
+ # moves the MAIN backend and leaves vision_backend untouched. So a box whose GPU
131
+ # will not bind must end this script serving on the CPU encoder, not dead.
132
+ #
133
+ # `local_ai_healthy` cannot catch it -- /v1/models answers 200 without ever
134
+ # building an engine. Only a real image inference can, which is why the probe
135
+ # POSTs one and why this test drives its status code.
136
+ printf 'loaded\n' >"$TRILLBOARDS_TEST_STATE_DIR/service-state"
137
+ printf '0\n' >"$TRILLBOARDS_TEST_STATE_DIR/bootstrap-attempts"
138
+ export TRILLBOARDS_TEST_VISION_PROBE_CODE=500
139
+
140
+ if ! OUTPUT2="$(/bin/bash "$SETUP" 2>&1)"; then
141
+ printf '%s\n' "$OUTPUT2" >&2
142
+ printf 'FAIL: setup exited non-zero when the GPU vision probe failed; it must fall back, not fail\n' >&2
143
+ exit 1
144
+ fi
145
+
146
+ grep -Fq 'falling back to the CPU encoder' <<<"$OUTPUT2"
147
+ grep -Fq 'local AI healthy on the CPU vision encoder' <<<"$OUTPUT2"
148
+ grep -Fq 'local AI is installed and healthy' <<<"$OUTPUT2"
149
+
150
+ # The fallback must REMOVE vision_backend and KEEP speculative_decoding: the
151
+ # latter is delegate independent and byte-identical to greedy, so it is still
152
+ # correct on a CPU-vision host and is the only speedup that host keeps.
153
+ if grep -Fq 'vision_backend' "$LITERT_LM_DIR/config.json"; then
154
+ printf 'FAIL: fallback left vision_backend in config.json\n' >&2
155
+ cat "$LITERT_LM_DIR/config.json" >&2
156
+ exit 1
157
+ fi
158
+ grep -Fq '"speculative_decoding":true' "$LITERT_LM_DIR/config.json"
159
+
160
+ # 2 bootstraps for the first start (attempt 1 hits the 37 race), + 1 for the
161
+ # restart the fallback performs.
162
+ [ "$(cat "$TRILLBOARDS_TEST_STATE_DIR/bootstrap-attempts")" = "3" ]
163
+
164
+ unset TRILLBOARDS_TEST_VISION_PROBE_CODE
165
+
166
+ printf 'macOS LiteRT-LM reinstall race + GPU-vision fallback regressions passed\n'
167
+
@@ -69,6 +69,182 @@ log() {
69
69
  printf '[trillboards setup-litertlm] %s\n' "$*"
70
70
  }
71
71
  fail() { log "ERROR: $*"; exit 1; }
72
+ # -- Local-inference tuning (config.json) ------------------------------------
73
+ #
74
+ # `litert-lm serve` takes NO flags for any of this: `serve` accepts only
75
+ # --host/--port/--api/--cors-origin/--verbose. Every engine knob it honours comes
76
+ # from `$LITERT_LM_DIR/config.json`, which `litert_lm_cli.config.get_model_config`
77
+ # merges per model id. So this file IS the tuning surface for everything it can
78
+ # express, and the serve binary stays stock: we pass it no flags and we modify
79
+ # none of its files.
80
+ #
81
+ # ONE knob is NOT expressible there, and it is the largest one left. Every
82
+ # request the serve handles gets `ConstrainedDecodingConfig(enable=True, ...)`
83
+ # built for it unconditionally -- `enable` is True even when the caller sent no
84
+ # `response_format` and the generation is therefore unconstrained -- so the
85
+ # native conversation-create builds constraint machinery for a grammar nothing
86
+ # will use, once per frame. `config_schema.json` on the pinned 0.16.0 has 17
87
+ # keys (backend, cpu_thread_count, audio_backend, vision_backend, cache,
88
+ # max_num_tokens, temperature, top_p, top_k, seed, gpu_decode_steps_per_sync,
89
+ # speculative_decoding, thinking, thinking_budget, activation_data_type, plus
90
+ # the `default`/`models` containers) and NONE of them reaches it; no request
91
+ # field reaches it either, and both 0.15.0 and 0.16.1 construct it identically,
92
+ # so a version move does not reach it. That is why install_litertlm_shim below
93
+ # exists -- see it for the measurement and for why it is not a patch.
94
+ #
95
+ # Two settings, both MEASURED on an M4 Max (12P+4E/48G), litert-lm 0.16.0, Gemma
96
+ # 4 E2B, delegate asserted GPU from the native log, 16 warm-ups before timing,
97
+ # arms interleaved sample-by-sample, operator/SOP field set + the two committed
98
+ # CCTV fixtures:
99
+ #
100
+ # vision_backend=gpu The vision encoder ran on the CPU because NOTHING ASKED
101
+ # IT NOT TO -- `Engine(vision_backend=...)` defaults to
102
+ # None and the 0.13.1 HTTP shim hardcoded Backend.CPU()
103
+ # (upstream b/515805503). It is not a hardware limit and
104
+ # not a model constraint; GPU vision is Google's own
105
+ # reference config and CPU is their FALLBACK tier.
106
+ # Image encode 0.804 s -> 0.175 s p50, i.e. 76% of a SOP
107
+ # frame removed.
108
+ # speculative_decoding 1.433x on the decode-heavy audience schema, and the
109
+ # generations are BYTE-IDENTICAL to greedy (12/12 same
110
+ # text), which is the property that makes it safe: it is
111
+ # a speed change, not an answer change.
112
+ #
113
+ # Together, in-process: 1.054 s -> 0.385 s per SOP frame (2.765x paired, min
114
+ # 2.560 / max 2.919 over 16 rounds); 3,416 -> 9,295 analyses/hour. Through this
115
+ # stock serve over HTTP: 1752.5 ms -> 1005.8 ms p50 (1.742x), 12/12 schema-valid
116
+ # on BOTH arms. Accuracy is not traded for it: people_present 16/16 on both arms,
117
+ # person_count 8/16 on both, and where they differ the GPU encoder is CLOSER to
118
+ # the hand label (4 vs 3 against a truth of 5).
119
+ #
120
+ # WHY THE VERIFY BELOW EXISTS. Pinning the vision encoder to the GPU is the one
121
+ # setting here that can make a host WORSE than it is today: a box whose GPU will
122
+ # not bind fails engine init, and unlike the main backend there is no demotion
123
+ # path for it -- `LiteRtVlmProvider.recordGpuDemotion` re-posts the BARE model
124
+ # id, which changes the main backend and leaves this config untouched. So a
125
+ # blind write would brick sensing on exactly the weak hosts that need it most.
126
+ # We therefore do what Google's own cascade does: try GPU vision, prove it with
127
+ # one real image inference, and fall back to the CPU default if it does not
128
+ # answer.
129
+ CONFIG_JSON="$LITERT_DIR/config.json"
130
+
131
+ # A 223-byte 32x32 grey JPEG. Small enough to inline, real enough that decoding
132
+ # it exercises the vision encoder we are trying to prove.
133
+ LITERTLM_PROBE_JPEG_B64="/9j/4AAQSkZJRgABAgAAAQABAAD//gAPTGF2YzYzLjEuMTAxAP/bAEMACD4+ST5JVVVVVVVVZF1kaGhoZGRkZGhoaHBwcIODg3BwcGhocHB8fIODj5OPh4eDh5OTm5uburqystnZ4P/////EAEoAAQAAAAAAAAAAAAAAAAAAAAABAQAAAAAAAAAAAAAAAAAAAAAQAQAAAAAAAAAAAAAAAAAAAAARAQAAAAAAAAAAAAAAAAAAAAD/wAARCAAgACADASIAAhEAAxEA/9oADAMBAAIRAxEAPwAAAAAAAAD/2Q=="
134
+
135
+ # Write config.json. $1 = "gpu" pins the vision encoder; empty leaves it on the
136
+ # CPU default. speculative_decoding is written EITHER WAY -- it is delegate
137
+ # independent and byte-identical, so it survives the fallback.
138
+ write_litertlm_config() {
139
+ _vision="$1"
140
+ mkdir -p "$LITERT_DIR" 2>/dev/null || true
141
+ if [ -n "$_vision" ]; then
142
+ printf '{"models":{"%s":{"vision_backend":"%s","speculative_decoding":true}}}\n' \
143
+ "$MODEL_ID" "$_vision" > "$CONFIG_JSON.tmp" 2>/dev/null || return 1
144
+ else
145
+ printf '{"models":{"%s":{"speculative_decoding":true}}}\n' \
146
+ "$MODEL_ID" > "$CONFIG_JSON.tmp" 2>/dev/null || return 1
147
+ fi
148
+ mv -f "$CONFIG_JSON.tmp" "$CONFIG_JSON" 2>/dev/null || return 1
149
+ log "wrote $CONFIG_JSON (vision_backend=${_vision:-cpu-default}, speculative_decoding=true)"
150
+ }
151
+
152
+ # -- The one knob config.json cannot express -----------------------------------
153
+ #
154
+ # `litert-lm serve` builds `ConstrainedDecodingConfig(enable=True, provider=...)`
155
+ # for EVERY request, at both openai_handler.py construction sites, with `enable`
156
+ # hardcoded True -- so a request that carried no `response_format`, which is
157
+ # every request the connector sends, still makes the native conversation-create
158
+ # build constraint machinery for a grammar nothing will use. It is rebuilt per
159
+ # request because the serve creates one conversation per request.
160
+ #
161
+ # MEASURED (M4 Max 16c/48G, litert-lm 0.16.0, Gemma 4 E2B, GPU/Metal, vision_backend
162
+ # =gpu + speculative_decoding=true, operator/SOP field set, the two committed CCTV
163
+ # fixtures, arms alternated sample-by-sample with the leading arm rotated each
164
+ # round, load average 22-25):
165
+ #
166
+ # in-process, one engine, n=10 pairs
167
+ # arm create_conversation send_message total
168
+ # stock 0.717 s 0.560 s 1.275 s
169
+ # without it 0.001 s 0.603 s 0.604 s
170
+ # paired 2.194x (min 2.040, max 2.284); generations BYTE-IDENTICAL 10/10;
171
+ # prefill 493 / decode 58 on both arms
172
+ #
173
+ # through two stock serves on two ports, interleaved, n=16 pairs
174
+ # 1.309 s -> 0.592 s p50 (p95 3.664 -> 0.965), paired 2.42x (min 2.174, max
175
+ # 4.657), 16/16 schema-valid and 16/16 byte-identical on both arms
176
+ #
177
+ # bench-local-vlm.mjs --arms gpu-sop, 16 warm-ups, n=8, back to back
178
+ # 1.32 s/frame (2,727 analyses/hour) -> 0.72 s/frame (5,000/hour), 8/8
179
+ # schema-valid and 83.3% field accuracy on BOTH arms
180
+ #
181
+ # A speed change, not an answer change -- the same bar speculative_decoding had
182
+ # to clear. The RATIO is itself load-dependent (3.734x at 0.0% CPU idle vs 2.42x
183
+ # at load 25, because the removed work is CPU-bound and the retained work is
184
+ # GPU-bound), so quote the near-idle figure.
185
+ #
186
+ # We do NOT patch upstream. Two files are ADDED to the litert-lm tool venv's
187
+ # site-packages: our shim, and a one-line .pth that imports it. Every upstream
188
+ # file stays byte-identical, no launch definition changes on any platform (the
189
+ # systemd unit's ExecStart is untouched), and a serve started by hand gets the
190
+ # fix too. The shim is lazy --
191
+ # it only registers an import hook, so `litert-lm list`/`--version`, which this
192
+ # script runs as health checks, do not gain a dylib load. It fails OPEN: any
193
+ # failure leaves stock behaviour and prints one line to the serve's own log, and
194
+ # `TRILLBOARDS_LITERTLM_SHIM=0` disables it without uninstalling anything.
195
+ #
196
+ # Runs UNCONDITIONALLY, never inside the install/repair branch: `uv tool install
197
+ # --force` rebuilds the venv and takes both files with it, and the healthy-CLI
198
+ # path skips that branch entirely -- so gating this behind it is exactly how a
199
+ # repaired-and-healthy machine would keep the slow serve while reporting fine.
200
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd || echo .)"
201
+ find_litertlm_shim() {
202
+ for _c in "$SCRIPT_DIR/trillboards_litertlm_shim.py" \
203
+ "$SCRIPT_DIR/../litertlm/trillboards_litertlm_shim.py" \
204
+ "$SCRIPT_DIR/../../litertlm/trillboards_litertlm_shim.py"; do
205
+ [ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; }
206
+ done
207
+ return 1
208
+ }
209
+ install_litertlm_shim() {
210
+ _shim="$(find_litertlm_shim)" || { log "WARN: litert-lm shim source not found; serve keeps stock (slower) conversation setup"; return 1; }
211
+ _tooldir="$("$UV" tool dir 2>/dev/null | tr -d '\r')"
212
+ [ -n "$_tooldir" ] || { log "WARN: could not resolve \`uv tool dir\`; skipping litert-lm shim"; return 1; }
213
+ _py="$_tooldir/litert-lm/bin/python"
214
+ [ -x "$_py" ] || { log "WARN: litert-lm venv python missing at $_py; skipping shim"; return 1; }
215
+ _site="$("$_py" -c 'import sysconfig;print(sysconfig.get_paths()["purelib"])' 2>/dev/null | tr -d '\r')"
216
+ [ -n "$_site" ] && [ -d "$_site" ] || { log "WARN: could not resolve litert-lm site-packages; skipping shim"; return 1; }
217
+ cp -f "$_shim" "$_site/trillboards_litertlm_shim.py" 2>/dev/null \
218
+ || { log "WARN: could not copy the litert-lm shim into $_site"; return 1; }
219
+ printf 'import trillboards_litertlm_shim\n' > "$_site/trillboards-litertlm-shim.pth" 2>/dev/null \
220
+ || { log "WARN: could not write the litert-lm shim .pth"; return 1; }
221
+ # Capability is established by ATTEMPTING THE OPERATION. Importing litert_lm
222
+ # here is the only thing that proves the wrap actually landed on the class the
223
+ # serve will use; a file that merely exists proves nothing.
224
+ if "$_py" -c 'import litert_lm,sys; sys.exit(0 if getattr(litert_lm.Engine.create_conversation,"_trillboards_litertlm_shim",False) else 1)' >>"$LOG" 2>&1; then
225
+ log "litert-lm fast-conversation shim active in $_site"
226
+ return 0
227
+ fi
228
+ log "WARN: litert-lm shim did not take effect; serve keeps stock (slower) conversation setup"
229
+ return 1
230
+ }
231
+
232
+ # One real image inference against the running serve. A 200 means the vision
233
+ # encoder loaded AND ran on whatever backend config.json just asked for.
234
+ # Deliberately NOT a /v1/models check: that answers 200 without ever building an
235
+ # engine, so it cannot tell a working GPU encoder from one that fails on first
236
+ # use.
237
+ verify_litertlm_vision() {
238
+ _body="$LITERT_DIR/.vision-probe.json"
239
+ printf '{"model":"%s,gpu","messages":[{"role":"user","content":[{"type":"text","text":"Reply with the single word: ok"},{"type":"image_url","image_url":{"url":"data:image/jpeg;base64,%s"}}]}],"max_completion_tokens":8,"temperature":0}' \
240
+ "$MODEL_ID" "$LITERTLM_PROBE_JPEG_B64" > "$_body" 2>/dev/null || return 1
241
+ _code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 180 \
242
+ -X POST "http://127.0.0.1:$SERVE_PORT/v1/chat/completions" \
243
+ -H 'Content-Type: application/json' --data-binary @"$_body" 2>/dev/null)
244
+ rm -f "$_body" 2>/dev/null || true
245
+ [ "$_code" = "200" ]
246
+ }
247
+
72
248
  local_ai_healthy() {
73
249
  curl -fsS --max-time 3 "http://127.0.0.1:$SERVE_PORT/v1/models" 2>/dev/null \
74
250
  | grep -Fq "$MODEL_ID"
@@ -145,6 +321,8 @@ else
145
321
  log "litert-lm CLI repaired at $("$LITERTLM" --version 2>/dev/null | awk '{print $NF}')"
146
322
  fi
147
323
 
324
+ install_litertlm_shim || true
325
+
148
326
  # 3. Model. Idempotent: skip entirely if litert-lm already has it.
149
327
  HAVE_MODEL=0
150
328
  if "$LITERTLM" list 2>/dev/null | grep -q "$MODEL_ID"; then
@@ -172,25 +350,55 @@ fi
172
350
 
173
351
  # 4. Serve unit. Installed by install.sh (which has sudo); here we only start it
174
352
  # when we can, so a re-run from the service user still recovers a stopped serve.
175
- if command -v systemctl >/dev/null 2>&1; then
353
+ restart_litertlm_serve() {
354
+ command -v systemctl >/dev/null 2>&1 || return 0
176
355
  if systemctl list-unit-files "$SERVE_UNIT.service" >/dev/null 2>&1; then
177
356
  systemctl restart "$SERVE_UNIT" >>"$LOG" 2>&1 \
178
357
  || sudo -n systemctl restart "$SERVE_UNIT" >>"$LOG" 2>&1 \
179
- || log "WARN: could not restart $SERVE_UNIT — start it with: sudo systemctl restart $SERVE_UNIT"
358
+ || log "WARN: could not restart $SERVE_UNIT - start it with: sudo systemctl restart $SERVE_UNIT"
180
359
  else
181
360
  log "WARN: $SERVE_UNIT.service is not installed yet (install.sh installs it)"
182
361
  fi
183
- fi
362
+ }
363
+
364
+ wait_for_litertlm_health() {
365
+ log "waiting for local AI health check on :$SERVE_PORT"
366
+ for _attempt in $(seq 1 60); do
367
+ if local_ai_healthy; then return 0; fi
368
+ sleep 2
369
+ done
370
+ return 1
371
+ }
372
+
373
+ # Ask for GPU vision BEFORE the restart so the engine builds with it. The engine
374
+ # is constructed lazily on the first inference, so config.json must be on disk
375
+ # before that request, not merely before the next boot.
376
+ write_litertlm_config "gpu" || log "WARN: could not write $CONFIG_JSON - the serve will use its defaults"
377
+
378
+ restart_litertlm_serve
184
379
 
185
- log "waiting for local AI health check on :$SERVE_PORT"
186
380
  HEALTHY=0
187
- for _attempt in $(seq 1 60); do
188
- if local_ai_healthy; then HEALTHY=1; break; fi
189
- sleep 2
190
- done
381
+ if wait_for_litertlm_health; then HEALTHY=1; fi
382
+
191
383
  if [ "$HEALTHY" -eq 1 ]; then
192
- log "local AI is installed and healthy — the connector will run ON-DEVICE"
193
- else
384
+ # The cascade. `local_ai_healthy` only proves /v1/models answers, which it does
385
+ # without ever building an engine - so it CANNOT see a GPU vision encoder that
386
+ # fails on first use. One real image inference is the only thing that can.
387
+ if verify_litertlm_vision; then
388
+ log "local AI healthy with the GPU vision encoder - the connector will run ON-DEVICE"
389
+ else
390
+ log "WARN: GPU vision encoder did not answer a probe inference - falling back to the CPU encoder"
391
+ write_litertlm_config "" || log "WARN: could not rewrite $CONFIG_JSON"
392
+ restart_litertlm_serve
393
+ if wait_for_litertlm_health; then
394
+ log "local AI healthy on the CPU vision encoder (slower, but serving)"
395
+ else
396
+ HEALTHY=0
397
+ fi
398
+ fi
399
+ fi
400
+
401
+ if [ "$HEALTHY" -ne 1 ]; then
194
402
  # NOT fatal. auto-sense waits and beacons, so an install that gets this far
195
403
  # with the model on disk recovers by itself once the serve comes up.
196
404
  log "WARN: Gemma is installed but the serve did not answer yet; auto-sense will"
@@ -222,6 +222,10 @@ Log "Customer setup window smoke test passed"
222
222
  # model path from the customer SFX and verifies local AI before pairing; the
223
223
  # serve task keeps `litert-lm serve :9379` alive for local-sense.
224
224
  Copy-Item -Force "$Here\setup-litertlm.ps1" "$Payload\bin\setup-litertlm.ps1"
225
+ # Staged NEXT TO setup-litertlm.ps1 because that is where Install-LitertlmShim
226
+ # looks first. Copied into the litert-lm tool venv's site-packages at
227
+ # provisioning time; nothing else reads it, and no upstream file is modified.
228
+ Copy-Item -Force "$Here\..\litertlm\trillboards_litertlm_shim.py" "$Payload\bin\trillboards_litertlm_shim.py"
225
229
  Copy-Item -Force "$Here\register-litertlm-task.ps1" "$Payload\bin\register-litertlm-task.ps1"
226
230
  Copy-Item -Force "$Here\check-feeds.js" "$Payload\bin\check-feeds.js"
227
231
 
@@ -26,6 +26,28 @@ $TaskName = "TrillboardsLiteRtLm"
26
26
  $UserId = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
27
27
  # litert-lm serve --host 127.0.0.1 --port <port> (loopback only -- the connector
28
28
  # is local; never expose the model server off-box).
29
+ #
30
+ # THE SERVE'S OWN LOG IS DISCARDED ON WINDOWS, AND ONLY ON WINDOWS. A Scheduled
31
+ # Task has no stdout, so everything `litert-lm serve` prints goes nowhere. The
32
+ # other two platforms keep it: the macOS LaunchAgent sets StandardOutPath /
33
+ # StandardErrorPath (com.trillboards.litertlm.plist), and the unit sets
34
+ # StandardOutput=journal (trillboards-litertlm.service). `litert-lm serve` has no
35
+ # --log-file of its own -- only --verbose -- so redirection is the only route.
36
+ #
37
+ # This is not cosmetic. On 2026-08-19 windows-latest could not complete a single
38
+ # frame on the shipped config (run 32227929611: both provisioner probes died with
39
+ # connection-level errors, then two 12-minute request timeouts), and the reason is
40
+ # STILL unknown for exactly this reason -- the one process that knows why wrote
41
+ # its explanation to a handle nobody was holding.
42
+ #
43
+ # DO NOT "fix" this by wrapping the action in cmd.exe /c "... >> log 2>&1". The
44
+ # stop path below filters Win32_Process on Name = python.exe|litert-lm.exe|
45
+ # pythonw.exe, so a cmd.exe parent is invisible to it and would be orphaned on
46
+ # every re-register, and Task Scheduler's restart-on-failure would then be
47
+ # watching the wrapper instead of the server. Capturing this needs either an
48
+ # upstream --log-file or a purpose-built launcher that keeps the image name, and
49
+ # it must be proved against the S4U/boot-trigger ladder (the 0.2.17 incident)
50
+ # before it ships.
29
51
  $argument = 'serve --host 127.0.0.1 --port ' + $Port
30
52
  $Action = New-ScheduledTaskAction -Execute $LitertLmPath -Argument $argument
31
53
 
@@ -133,6 +133,105 @@ function Test-ServeHealthy {
133
133
  # that sentence stays gone rather than merely de-prioritised.
134
134
  #
135
135
  # Never throws: a diagnostic must not be able to fail an install.
136
+ # -- Local-inference tuning (config.json) ------------------------------------
137
+ #
138
+ # `litert-lm serve` takes NO flags for any of this: serve accepts only
139
+ # --host/--port/--api/--cors-origin/--verbose. Every engine knob it honours comes
140
+ # from $LITERT_LM_DIR\config.json, merged per model id by
141
+ # litert_lm_cli.config.get_model_config. So this file IS the tuning surface for
142
+ # everything it can express, and the serve binary stays stock: we pass it no
143
+ # flags and we modify none of its files.
144
+ #
145
+ # ONE knob is NOT expressible there, and it is the largest one left. Every
146
+ # request the serve handles gets ConstrainedDecodingConfig(enable=True, ...)
147
+ # built for it unconditionally -- enable is True even when the caller sent no
148
+ # response_format and the generation is therefore unconstrained -- so the native
149
+ # conversation-create builds constraint machinery for a grammar nothing will
150
+ # use, once per frame. config_schema.json on the pinned 0.16.0 has 17 keys and
151
+ # NONE reaches it; no request field reaches it either, and 0.15.0 and 0.16.1
152
+ # construct it identically, so a version move does not reach it. That is why
153
+ # Install-LitertlmShim below exists -- see it for the measurement.
154
+ #
155
+ # MEASURED (M4 Max 12P+4E/48G, litert-lm 0.16.0, Gemma 4 E2B, delegate asserted
156
+ # GPU from the native log, 16 warm-ups, arms interleaved, operator/SOP field set,
157
+ # the two committed CCTV fixtures): image encode 0.804 s -> 0.175 s p50 and the
158
+ # whole SOP frame 1.054 s -> 0.385 s (2.765x paired); through the stock serve
159
+ # over HTTP 1752.5 ms -> 1005.8 ms p50 (1.742x), 12/12 schema-valid on both arms.
160
+ # The vision encoder was on the CPU because NOTHING ASKED IT NOT TO -- GPU vision
161
+ # is Google's reference config and CPU is their documented fallback tier.
162
+ # speculative_decoding is written too: 1.433x on the decode-heavy schema with
163
+ # BYTE-IDENTICAL generations, so it is a speed change and not an answer change.
164
+ $script:LitertlmProbeJpegB64 = "/9j/4AAQSkZJRgABAgAAAQABAAD//gAPTGF2YzYzLjEuMTAxAP/bAEMACD4+ST5JVVVVVVVVZF1kaGhoZGRkZGhoaHBwcIODg3BwcGhocHB8fIODj5OPh4eDh5OTm5uburqystnZ4P/////EAEoAAQAAAAAAAAAAAAAAAAAAAAABAQAAAAAAAAAAAAAAAAAAAAAQAQAAAAAAAAAAAAAAAAAAAAARAQAAAAAAAAAAAAAAAAAAAAD/wAARCAAgACADASIAAhEAAxEA/9oADAMBAAIRAxEAPwAAAAAAAAD/2Q=="
165
+
166
+ function Get-LitertlmConfigPath { Join-Path $LitertDir "config.json" }
167
+
168
+ # $VisionBackend = "gpu" pins the vision encoder; "" leaves it on the CPU
169
+ # default. speculative_decoding is written EITHER WAY -- it is delegate
170
+ # independent, so it survives the fallback.
171
+ function Write-LitertlmConfig {
172
+ param([string]$VisionBackend = "")
173
+ try {
174
+ if (-not (Test-Path -LiteralPath $LitertDir)) {
175
+ New-Item -ItemType Directory -Force -Path $LitertDir | Out-Null
176
+ }
177
+ $model = [ordered]@{}
178
+ if ($VisionBackend) { $model["vision_backend"] = $VisionBackend }
179
+ $model["speculative_decoding"] = $true
180
+ $cfg = [ordered]@{ models = [ordered]@{ $ModelId = $model } }
181
+ $path = Get-LitertlmConfigPath
182
+ $tmp = "$path.tmp"
183
+ # UTF-8 WITHOUT a BOM, written explicitly rather than via -Encoding.
184
+ #
185
+ # `Set-Content -Encoding UTF8` emits a BOM on Windows PowerShell 5.1 (it is
186
+ # BOM-less only on PowerShell 7+), and this script runs under whichever host
187
+ # the installer happens to invoke. litert-lm parses this file with a plain
188
+ # `json.load`, which REFUSES a BOM:
189
+ # Failed to parse config.json: Unexpected UTF-8 BOM (decode using utf-8-sig)
190
+ # That is a 500 on EVERY inference, not a degraded mode -- and because
191
+ # /v1/models answers 200 without building an engine, the install still looked
192
+ # healthy. Measured on windows-latest 2026-08-19, run 32223243603: 0 frames
193
+ # out of a 3-arm matrix, every arm HTTP 500. The macOS and Linux
194
+ # provisioners write this file with `printf` and were never affected.
195
+ [System.IO.File]::WriteAllText(
196
+ $tmp,
197
+ ($cfg | ConvertTo-Json -Depth 6),
198
+ (New-Object System.Text.UTF8Encoding($false)))
199
+ Move-Item -LiteralPath $tmp -Destination $path -Force
200
+ $label = if ($VisionBackend) { $VisionBackend } else { "cpu-default" }
201
+ Log "wrote $path (vision_backend=$label, speculative_decoding=true)"
202
+ return $true
203
+ } catch {
204
+ Log "WARN: could not write litert-lm config.json: $_"
205
+ return $false
206
+ }
207
+ }
208
+
209
+ # One real image inference. A 200 means the vision encoder loaded AND ran on the
210
+ # backend config.json just asked for. Deliberately NOT Test-ServeHealthy:
211
+ # /v1/models answers without ever building an engine, so it cannot tell a working
212
+ # GPU encoder from one that fails on first use.
213
+ function Test-LitertlmVision {
214
+ try {
215
+ $dataUri = "data:image/jpeg;base64," + $script:LitertlmProbeJpegB64
216
+ $body = @{
217
+ model = "$ModelId,gpu"
218
+ messages = @(@{ role = "user"; content = @(
219
+ @{ type = "text"; text = "Reply with the single word: ok" },
220
+ @{ type = "image_url"; image_url = @{ url = $dataUri } }
221
+ )})
222
+ max_completion_tokens = 8
223
+ temperature = 0
224
+ } | ConvertTo-Json -Depth 8
225
+ $resp = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/v1/chat/completions" `
226
+ -Method POST -ContentType "application/json" -Body $body `
227
+ -TimeoutSec 180 -UseBasicParsing
228
+ return ($resp.StatusCode -eq 200)
229
+ } catch {
230
+ Log "vision probe did not answer: $_"
231
+ return $false
232
+ }
233
+ }
234
+
136
235
  function Get-AdvertisedModelIds {
137
236
  try {
138
237
  $response = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/v1/models" -UseBasicParsing -TimeoutSec 5
@@ -376,6 +475,90 @@ if ((Test-LitertLmCli) -and ($installedLitertLmVersion -eq $LitertLmVersion)) {
376
475
  Log "litert-lm CLI repaired successfully at $(Get-LitertLmVersion)"
377
476
  }
378
477
 
478
+ # -- The one knob config.json cannot express -----------------------------------
479
+ #
480
+ # `litert-lm serve` builds ConstrainedDecodingConfig(enable=True, provider=...)
481
+ # for EVERY request, at both openai_handler.py construction sites, with enable
482
+ # hardcoded True -- so a request that carried no response_format, which is every
483
+ # request the connector sends, still makes the native conversation-create build
484
+ # constraint machinery for a grammar nothing will use. It is rebuilt per request
485
+ # because the serve creates one conversation per request.
486
+ #
487
+ # MEASURED (M4 Max 16c/48G, litert-lm 0.16.0, Gemma 4 E2B, GPU/Metal, vision_backend
488
+ # =gpu + speculative_decoding=true, operator/SOP field set, the two committed CCTV
489
+ # fixtures, arms alternated sample-by-sample with the leading arm rotated each
490
+ # round, load average 22-25):
491
+ #
492
+ # in-process, one engine, n=10 pairs
493
+ # arm create_conversation send_message total
494
+ # stock 0.717 s 0.560 s 1.275 s
495
+ # without it 0.001 s 0.603 s 0.604 s
496
+ # paired 2.194x (min 2.040, max 2.284); generations BYTE-IDENTICAL 10/10;
497
+ # prefill 493 / decode 58 on both arms
498
+ #
499
+ # through two stock serves on two ports, interleaved, n=16 pairs
500
+ # 1.309 s -> 0.592 s p50 (p95 3.664 -> 0.965), paired 2.42x (min 2.174, max
501
+ # 4.657), 16/16 schema-valid and 16/16 byte-identical on both arms
502
+ #
503
+ # bench-local-vlm.mjs --arms gpu-sop, 16 warm-ups, n=8, back to back
504
+ # 1.32 s/frame (2,727 analyses/hour) -> 0.72 s/frame (5,000/hour), 8/8
505
+ # schema-valid and 83.3% field accuracy on BOTH arms
506
+ #
507
+ # A speed change, not an answer change -- the same bar speculative_decoding had
508
+ # to clear. The RATIO is itself load-dependent (3.734x at 0.0% CPU idle vs 2.42x
509
+ # at load 25, because the removed work is CPU-bound and the retained work is
510
+ # GPU-bound), so quote the near-idle figure.
511
+ #
512
+ # We do NOT patch upstream. Two files are ADDED to the litert-lm tool venv's
513
+ # site-packages: our shim, and a one-line .pth that imports it. Every upstream
514
+ # file stays byte-identical, the Scheduled Task's action is untouched (so the
515
+ # model OTA's `schtasks /End; /Run /TN TrillboardsLiteRtLm` still finds the same
516
+ # task), and a serve started by hand gets the fix too. It fails OPEN: any
517
+ # failure leaves stock behaviour and prints one line to the serve's own log, and
518
+ # TRILLBOARDS_LITERTLM_SHIM=0 disables it without uninstalling anything.
519
+ #
520
+ # Runs UNCONDITIONALLY, never inside the install/repair branch: `uv tool install
521
+ # --force` rebuilds the venv and takes both files with it, while Test-LitertLmCli
522
+ # short-circuits that branch on a healthy CLI -- gating this behind it is exactly
523
+ # how a repaired-and-healthy machine would keep the slow serve and report fine.
524
+ # Never throws: this is a speed fix, and it must not be able to fail an install.
525
+ function Install-LitertlmShim {
526
+ param([string]$UvPath)
527
+ try {
528
+ $shim = $null
529
+ foreach ($c in @(
530
+ (Join-Path $PSScriptRoot "trillboards_litertlm_shim.py"),
531
+ (Join-Path $PSScriptRoot "..\litertlm\trillboards_litertlm_shim.py")
532
+ )) { if (Test-Path -LiteralPath $c -PathType Leaf) { $shim = $c; break } }
533
+ if (-not $shim) { Log "WARN: litert-lm shim source not found; serve keeps stock (slower) conversation setup"; return $false }
534
+
535
+ $toolDir = (& $UvPath tool dir 2>$null | Select-Object -First 1)
536
+ if ([string]::IsNullOrWhiteSpace($toolDir)) { Log "WARN: could not resolve 'uv tool dir'; skipping litert-lm shim"; return $false }
537
+ $py = Join-Path $toolDir.Trim() "litert-lm\Scripts\python.exe"
538
+ if (-not (Test-Path -LiteralPath $py -PathType Leaf)) { Log "WARN: litert-lm venv python missing at $py; skipping shim"; return $false }
539
+
540
+ $site = (& $py -c "import sysconfig;print(sysconfig.get_paths()['purelib'])" 2>$null | Select-Object -First 1)
541
+ if ([string]::IsNullOrWhiteSpace($site) -or -not (Test-Path -LiteralPath $site.Trim())) {
542
+ Log "WARN: could not resolve litert-lm site-packages; skipping shim"; return $false
543
+ }
544
+ $site = $site.Trim()
545
+ Copy-Item -LiteralPath $shim -Destination (Join-Path $site "trillboards_litertlm_shim.py") -Force
546
+ Set-Content -LiteralPath (Join-Path $site "trillboards-litertlm-shim.pth") -Value "import trillboards_litertlm_shim" -Encoding ASCII
547
+
548
+ # Capability is established by ATTEMPTING THE OPERATION. Importing litert_lm
549
+ # here is the only thing that proves the wrap landed on the class the serve
550
+ # will use; a file that merely exists proves nothing.
551
+ & $py -c "import litert_lm,sys; sys.exit(0 if getattr(litert_lm.Engine.create_conversation,'_trillboards_litertlm_shim',False) else 1)" *>> $LogPath
552
+ if ($LASTEXITCODE -eq 0) { Log "litert-lm fast-conversation shim active in $site"; return $true }
553
+ Log "WARN: litert-lm shim did not take effect; serve keeps stock (slower) conversation setup"
554
+ return $false
555
+ } catch {
556
+ Log "WARN: litert-lm shim install failed: $_"
557
+ return $false
558
+ }
559
+ }
560
+ Install-LitertlmShim -UvPath $uvCmd.Source | Out-Null
561
+
379
562
  $bundledModelVerified = $false
380
563
  if (-not [string]::IsNullOrWhiteSpace($BundledModelPath)) {
381
564
  if (-not (Test-Path -LiteralPath $BundledModelPath -PathType Leaf)) {
@@ -475,6 +658,10 @@ if (-not (Test-Path -LiteralPath $registration)) {
475
658
  exit 1
476
659
  }
477
660
 
661
+ # Ask for GPU vision BEFORE the serve starts: the engine is built lazily on the
662
+ # first inference, so config.json must be on disk before that request.
663
+ Write-LitertlmConfig -VisionBackend "gpu" | Out-Null
664
+
478
665
  Log "registering litert-lm serve Scheduled Task on :$Port"
479
666
  & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $registration -LitertLmPath $litertlmPath -Port $Port *>> $LogPath
480
667
  if ($LASTEXITCODE -ne 0) {
@@ -495,6 +682,47 @@ if (-not $healthy) {
495
682
  exit 1
496
683
  }
497
684
 
685
+ # The GPU-vision cascade. Health only proves /v1/models answers, and that route
686
+ # never builds an engine -- so it cannot see a GPU vision encoder that fails on
687
+ # first USE. If the probe inference does not answer we fall back to the CPU
688
+ # encoder rather than leave this host unable to sense at all.
689
+ if (-not (Test-LitertlmVision)) {
690
+ Log "WARN: GPU vision encoder did not answer a probe inference; falling back to the CPU encoder"
691
+ Write-LitertlmConfig | Out-Null
692
+ & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $registration -LitertLmPath $litertlmPath -Port $Port *>> $LogPath
693
+ $healthy = $false
694
+ for ($attempt = 0; $attempt -lt 60 -and -not $healthy; $attempt++) {
695
+ $healthy = Test-ServeHealthy
696
+ if (-not $healthy) { Start-Sleep -Seconds 2 }
697
+ }
698
+ if (-not $healthy) {
699
+ Fail-LocalAi "Gemma is installed, but the local AI service did not respond. Restart Windows, then retry Local AI."
700
+ Stop-SetupLock
701
+ exit 1
702
+ }
703
+ # RE-PROBE, with the same probe that sent us here.
704
+ #
705
+ # Test-ServeHealthy above only proves /v1/models answers, and this file's own
706
+ # comment six lines up says that route never builds an engine. So before this
707
+ # check existed, a CPU fallback that ALSO could not infer logged "healthy on
708
+ # the CPU vision encoder (slower, but serving)" and exited 0 on a machine where
709
+ # every frame 500s. That is exactly how the UTF-8 BOM above shipped as a GREEN
710
+ # install: the cascade detected the failure, downgraded a tier, and then
711
+ # verified the downgrade with a check that cannot fail for this reason.
712
+ #
713
+ # A CPU fallback that cannot infer is not a slower tier. It is a broken
714
+ # install, and the operator has to hear so at setup time rather than discover
715
+ # it as silence from a camera weeks later.
716
+ if (-not (Test-LitertlmVision)) {
717
+ Fail-LocalAi "Gemma is installed and the local AI service is answering, but it could not analyze a test image on either the GPU or the CPU vision encoder. See $LogPath."
718
+ Stop-SetupLock
719
+ exit 1
720
+ }
721
+ Log "local AI healthy on the CPU vision encoder (slower, but serving)"
722
+ } else {
723
+ Log "local AI healthy with the GPU vision encoder"
724
+ }
725
+
498
726
  Complete-Setup
499
727
  Stop-SetupLock
500
728
  exit 0
@@ -19,6 +19,25 @@ if /I "%~1"=="auto-sense" (
19
19
  if errorlevel 1 set "NEEDS_LOCAL_AI=1"
20
20
  curl.exe --fail --silent --max-time 2 http://127.0.0.1:9379/v1/models >nul 2>&1
21
21
  if errorlevel 1 set "NEEDS_LOCAL_AI=1"
22
+ REM A CONFIG litert-lm CANNOT PARSE IS INVISIBLE TO EVERY PROBE ABOVE. The
23
+ REM model is installed, the task exists, and /v1/models answers 200 -- that
24
+ REM route never builds an engine -- while EVERY inference returns 500. That is
25
+ REM exactly what shipped on 2026-08-19: setup-litertlm.ps1 wrote config.json
26
+ REM with `Set-Content -Encoding UTF8`, which is BOM-ful on Windows PowerShell
27
+ REM 5.1, and litert-lm parses the file with a plain json.load that refuses a
28
+ REM BOM. Without this check the repaired provisioner would land by auto-update
29
+ REM and never re-run, so the fix would reach NEW INSTALLS ONLY and every
30
+ REM already-broken box would stay broken forever while reporting healthy.
31
+ REM
32
+ REM Byte comparison, not a parse, and the BOM test comes FIRST: PowerShell's
33
+ REM ConvertFrom-Json happily accepts a BOM, so a parse alone would call the
34
+ REM broken file good. Self-clearing -- once the provisioner rewrites the file
35
+ REM without a BOM this never fires again -- and it fails toward REPAIR, which
36
+ REM is the safe direction for a box that cannot sense.
37
+ if exist "!LITERT_ROOT!\config.json" (
38
+ powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "try { $p = '!LITERT_ROOT!\config.json'; $b = [System.IO.File]::ReadAllBytes($p); if ($b.Length -ge 3 -and $b[0] -eq 0xEF -and $b[1] -eq 0xBB -and $b[2] -eq 0xBF) { exit 1 }; $t = [System.Text.Encoding]::UTF8.GetString($b).Trim(); if ($t.Length -gt 0) { [void]($t | ConvertFrom-Json) }; exit 0 } catch { exit 1 }" >nul 2>&1
39
+ if errorlevel 1 set "NEEDS_LOCAL_AI=1"
40
+ )
22
41
  REM Launch the repair through the wscript host, NOT `start`. cmd's `start`
23
42
  REM allocates a NEW console for the child before PowerShell can hide itself,
24
43
  REM so the customer saw a black window on exactly the reboot that needs a