@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.
- package/deploy/litertlm/test_trillboards_litertlm_shim.py +132 -0
- package/deploy/litertlm/trillboards-litertlm-shim.pth +1 -0
- package/deploy/litertlm/trillboards_litertlm_shim.py +214 -0
- package/deploy/macos/bench-local-vlm.sh +8 -4
- package/deploy/macos/build-macos-pkg.sh +4 -0
- package/deploy/macos/scripts/setup-litertlm.sh +210 -0
- package/deploy/macos/test-setup-litertlm-reinstall.sh +59 -1
- package/deploy/systemd/setup-litertlm.sh +218 -10
- package/deploy/windows/build-windows.ps1 +4 -0
- package/deploy/windows/register-litertlm-task.ps1 +22 -0
- package/deploy/windows/setup-litertlm.ps1 +228 -0
- package/deploy/windows/trillboards-edge.cmd +19 -0
- package/dist/CloudSensingManager.d.ts +2 -0
- package/dist/CloudSensingManager.d.ts.map +1 -1
- package/dist/EdgeAgent.js +1 -1
- package/dist/LiteRtVlmProvider.d.ts +74 -7
- package/dist/LiteRtVlmProvider.d.ts.map +1 -1
- package/dist/LiteRtVlmProvider.js.map +1 -1
- package/dist/analyzeCostObserver.d.ts +96 -0
- package/dist/analyzeCostObserver.d.ts.map +1 -0
- package/dist/analyzeCostObserver.js +130 -0
- package/dist/analyzeCostObserver.js.map +1 -0
- package/dist/connectorCadence.d.ts +120 -1
- package/dist/connectorCadence.d.ts.map +1 -1
- package/dist/connectorCadence.js +127 -2
- package/dist/connectorCadence.js.map +1 -1
- package/dist/deployedCadence.d.ts +13 -1
- package/dist/deployedCadence.d.ts.map +1 -1
- package/dist/deployedCadence.js +11 -2
- package/dist/deployedCadence.js.map +1 -1
- package/dist/deployedCadenceSource.d.ts +10 -0
- package/dist/deployedCadenceSource.d.ts.map +1 -1
- package/dist/deployedCadenceSource.js +11 -0
- package/dist/deployedCadenceSource.js.map +1 -1
- package/dist/diagnose.d.ts +1 -1
- package/dist/diagnose.js +1 -1
- package/dist/localInferenceSchema.d.ts +8 -3
- package/dist/localInferenceSchema.d.ts.map +1 -1
- package/dist/localInferenceSchema.js +8 -3
- package/dist/localInferenceSchema.js.map +1 -1
- package/dist/localSensing.d.ts +22 -1
- package/dist/localSensing.d.ts.map +1 -1
- package/dist/localSensing.js +36 -2
- package/dist/localSensing.js.map +1 -1
- package/dist/localVlmBench.d.ts +82 -0
- package/dist/localVlmBench.d.ts.map +1 -1
- package/dist/localVlmBench.js +88 -0
- package/dist/localVlmBench.js.map +1 -1
- package/dist/sensingRunMode.d.ts.map +1 -1
- package/dist/sensingRunMode.js +21 -2
- package/dist/sensingRunMode.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Fixture tests for the litert-lm constrained-decoding shim.
|
|
2
|
+
|
|
3
|
+
Runs with no litert-lm installed: a stand-in `litert_lm` module is planted on
|
|
4
|
+
sys.path so the import hook is exercised exactly as it is in production, and
|
|
5
|
+
the recorded kwargs prove WHICH configs are dropped and which survive.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import importlib
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
import textwrap
|
|
13
|
+
import unittest
|
|
14
|
+
|
|
15
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
16
|
+
sys.path.insert(0, HERE)
|
|
17
|
+
|
|
18
|
+
FAKE_LITERT_LM = textwrap.dedent(
|
|
19
|
+
'''
|
|
20
|
+
class ConstrainedDecodingConfig:
|
|
21
|
+
def __init__(self, enable=False, provider=None):
|
|
22
|
+
self.enable = enable
|
|
23
|
+
self.provider = provider
|
|
24
|
+
|
|
25
|
+
class Engine:
|
|
26
|
+
last_kwargs = None
|
|
27
|
+
|
|
28
|
+
def create_conversation(self, **kwargs):
|
|
29
|
+
Engine.last_kwargs = dict(kwargs)
|
|
30
|
+
return "conversation"
|
|
31
|
+
'''
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ShimTest(unittest.TestCase):
|
|
36
|
+
def setUp(self):
|
|
37
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
38
|
+
self.addCleanup(self.tmp.cleanup)
|
|
39
|
+
with open(os.path.join(self.tmp.name, "litert_lm.py"), "w") as fh:
|
|
40
|
+
fh.write(FAKE_LITERT_LM)
|
|
41
|
+
sys.path.insert(0, self.tmp.name)
|
|
42
|
+
self.addCleanup(lambda: sys.path.remove(self.tmp.name))
|
|
43
|
+
for name in ("litert_lm", "trillboards_litertlm_shim"):
|
|
44
|
+
sys.modules.pop(name, None)
|
|
45
|
+
self.addCleanup(lambda: [sys.modules.pop(n, None) for n in ("litert_lm", "trillboards_litertlm_shim")])
|
|
46
|
+
self._meta = list(sys.meta_path)
|
|
47
|
+
self.addCleanup(lambda: sys.meta_path.__setitem__(slice(None), self._meta))
|
|
48
|
+
|
|
49
|
+
def _load(self, **env):
|
|
50
|
+
old = {k: os.environ.get(k) for k in env}
|
|
51
|
+
os.environ.update({k: v for k, v in env.items() if v is not None})
|
|
52
|
+
for k, v in env.items():
|
|
53
|
+
if v is None:
|
|
54
|
+
os.environ.pop(k, None)
|
|
55
|
+
|
|
56
|
+
def restore():
|
|
57
|
+
for k, v in old.items():
|
|
58
|
+
if v is None:
|
|
59
|
+
os.environ.pop(k, None)
|
|
60
|
+
else:
|
|
61
|
+
os.environ[k] = v
|
|
62
|
+
|
|
63
|
+
self.addCleanup(restore)
|
|
64
|
+
return importlib.import_module("trillboards_litertlm_shim")
|
|
65
|
+
|
|
66
|
+
# ── the rule itself ────────────────────────────────────────────────────
|
|
67
|
+
def test_drops_only_the_no_response_format_shape(self):
|
|
68
|
+
shim = self._load(TRILLBOARDS_LITERTLM_SHIM="1")
|
|
69
|
+
import litert_lm
|
|
70
|
+
|
|
71
|
+
Cfg = litert_lm.ConstrainedDecodingConfig
|
|
72
|
+
self.assertTrue(shim.should_drop(Cfg(enable=True, provider=None)))
|
|
73
|
+
# a caller who asked for a response_format keeps their grammar
|
|
74
|
+
self.assertFalse(shim.should_drop(Cfg(enable=True, provider="LL_GUIDANCE")))
|
|
75
|
+
self.assertFalse(shim.should_drop(Cfg(enable=False, provider=None)))
|
|
76
|
+
self.assertFalse(shim.should_drop(None))
|
|
77
|
+
# truthy-but-not-True must NOT be treated as the upstream shape
|
|
78
|
+
self.assertFalse(shim.should_drop(Cfg(enable=1, provider=None)))
|
|
79
|
+
|
|
80
|
+
# ── the wrap, through a real import ────────────────────────────────────
|
|
81
|
+
def test_import_hook_wraps_and_drops(self):
|
|
82
|
+
self._load(TRILLBOARDS_LITERTLM_SHIM="1")
|
|
83
|
+
import litert_lm
|
|
84
|
+
|
|
85
|
+
cfg = litert_lm.ConstrainedDecodingConfig(enable=True, provider=None)
|
|
86
|
+
litert_lm.Engine().create_conversation(messages=[], constrained_decoding_config=cfg)
|
|
87
|
+
self.assertIsNone(litert_lm.Engine.last_kwargs["constrained_decoding_config"])
|
|
88
|
+
# every other kwarg is passed through untouched
|
|
89
|
+
self.assertEqual(litert_lm.Engine.last_kwargs["messages"], [])
|
|
90
|
+
|
|
91
|
+
def test_import_hook_preserves_a_real_response_format_request(self):
|
|
92
|
+
self._load(TRILLBOARDS_LITERTLM_SHIM="1")
|
|
93
|
+
import litert_lm
|
|
94
|
+
|
|
95
|
+
cfg = litert_lm.ConstrainedDecodingConfig(enable=True, provider="LL_GUIDANCE")
|
|
96
|
+
litert_lm.Engine().create_conversation(constrained_decoding_config=cfg)
|
|
97
|
+
self.assertIs(litert_lm.Engine.last_kwargs["constrained_decoding_config"], cfg)
|
|
98
|
+
|
|
99
|
+
def test_kill_switch_leaves_stock_behaviour(self):
|
|
100
|
+
self._load(TRILLBOARDS_LITERTLM_SHIM="0")
|
|
101
|
+
import litert_lm
|
|
102
|
+
|
|
103
|
+
cfg = litert_lm.ConstrainedDecodingConfig(enable=True, provider=None)
|
|
104
|
+
litert_lm.Engine().create_conversation(constrained_decoding_config=cfg)
|
|
105
|
+
self.assertIs(litert_lm.Engine.last_kwargs["constrained_decoding_config"], cfg)
|
|
106
|
+
|
|
107
|
+
def test_apply_is_idempotent(self):
|
|
108
|
+
shim = self._load(TRILLBOARDS_LITERTLM_SHIM="1")
|
|
109
|
+
import litert_lm
|
|
110
|
+
|
|
111
|
+
first = litert_lm.Engine.create_conversation
|
|
112
|
+
self.assertTrue(shim.apply_to(litert_lm))
|
|
113
|
+
self.assertIs(litert_lm.Engine.create_conversation, first)
|
|
114
|
+
|
|
115
|
+
# ── fails OPEN, never closed ───────────────────────────────────────────
|
|
116
|
+
def test_missing_engine_attribute_does_not_raise(self):
|
|
117
|
+
shim = self._load(TRILLBOARDS_LITERTLM_SHIM="1")
|
|
118
|
+
|
|
119
|
+
class NoEngine:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
self.assertFalse(shim.apply_to(NoEngine()))
|
|
123
|
+
|
|
124
|
+
def test_install_never_raises(self):
|
|
125
|
+
shim = self._load(TRILLBOARDS_LITERTLM_SHIM="1")
|
|
126
|
+
# a hostile meta_path must not take the serve down
|
|
127
|
+
sys.meta_path.insert(0, object())
|
|
128
|
+
self.assertIn(shim.install(), (True, False))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
if __name__ == "__main__":
|
|
132
|
+
unittest.main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import trillboards_litertlm_shim
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Drop the constrained-decoding setup `litert-lm serve` pays on EVERY request.
|
|
2
|
+
|
|
3
|
+
WHY THIS FILE EXISTS
|
|
4
|
+
--------------------
|
|
5
|
+
`litert-lm serve`'s OpenAI shim builds, once per HTTP request, at both
|
|
6
|
+
`openai_handler.py` construction sites (`/v1/chat/completions` and
|
|
7
|
+
`/v1/responses`, 0.15.0 / 0.16.0 / 0.16.1 alike)::
|
|
8
|
+
|
|
9
|
+
provider = LL_GUIDANCE if response_format is not None else None
|
|
10
|
+
constrained_decoding_config = ConstrainedDecodingConfig(enable=True, provider=provider)
|
|
11
|
+
with engine.create_conversation(..., constrained_decoding_config=cfg) as conv:
|
|
12
|
+
|
|
13
|
+
`enable` is **unconditionally True** — including when the caller sent no
|
|
14
|
+
`response_format` at all and the generation that follows is therefore
|
|
15
|
+
unconstrained. Enabling it makes the native `litert_lm_conversation_create`
|
|
16
|
+
build the constraint machinery for a grammar nothing will use, and it is
|
|
17
|
+
rebuilt for every conversation because the serve creates one per request.
|
|
18
|
+
|
|
19
|
+
MEASURED (Apple M4 Max 16c/48G, litert-lm 0.16.0, Gemma 4 E2B, GPU/Metal
|
|
20
|
+
delegate, vision_backend=gpu + speculative_decoding=true from config.json, the
|
|
21
|
+
two committed CCTV fixtures, operator/SOP field set, one engine, arms alternated
|
|
22
|
+
sample-by-sample with the leading arm rotated each round, n=10 pairs, load
|
|
23
|
+
average 22):
|
|
24
|
+
|
|
25
|
+
arm create_conversation send_message total
|
|
26
|
+
stock (enable=True) 0.717 s 0.560 s 1.275 s
|
|
27
|
+
this shim (dropped) 0.001 s 0.603 s 0.604 s
|
|
28
|
+
|
|
29
|
+
paired total ratio 2.194x (min 2.040, max 2.284)
|
|
30
|
+
generations BYTE-IDENTICAL 10/10; prefill 493 and decode 58 tokens on both
|
|
31
|
+
|
|
32
|
+
`send_message` is untouched — this removes setup, not sampling — and the
|
|
33
|
+
answers are the same bytes, which is the property that makes it safe.
|
|
34
|
+
|
|
35
|
+
THE RATIO IS ITSELF LOAD-DEPENDENT, and that is worth knowing before quoting
|
|
36
|
+
one: the same paired comparison through the shipped HTTP path gives 3.734x on a
|
|
37
|
+
box at 0.0% CPU idle and 2.42x at load 25, because the work this removes is
|
|
38
|
+
CPU-bound while the work it leaves is GPU-bound. The near-idle figure is the
|
|
39
|
+
honest one; a number taken under contention flatters this change.
|
|
40
|
+
|
|
41
|
+
The connector never sends `response_format`: LiteRtVlmProvider gets its
|
|
42
|
+
structured output by asking for a bare JSON object and validating client-side,
|
|
43
|
+
and it re-measured `response_format` at +35.2% p50 and DECLINED it. So on the
|
|
44
|
+
shipped path this setup is pure waste, every frame.
|
|
45
|
+
|
|
46
|
+
WHAT IT DOES NOT TOUCH
|
|
47
|
+
----------------------
|
|
48
|
+
The rule fires on EXACTLY `enable is True and provider is None` — the shape the
|
|
49
|
+
shim builds when, and only when, the caller asked for no `response_format`. A
|
|
50
|
+
request that DOES carry one arrives as `provider=LL_GUIDANCE` and is passed
|
|
51
|
+
through unchanged, so constrained decoding still works for anyone who asks for
|
|
52
|
+
it. `ConstrainedDecodingConfig` is constructed nowhere else in the litert-lm
|
|
53
|
+
CLI (`run.py` and `gemini_handler.py` create conversations without one), so no
|
|
54
|
+
other command can reach this wrap.
|
|
55
|
+
|
|
56
|
+
HOW IT IS LOADED
|
|
57
|
+
----------------
|
|
58
|
+
The three provisioners copy this file plus a one-line `.pth` into the litert-lm
|
|
59
|
+
tool venv's site-packages. That leaves every upstream file byte-identical on
|
|
60
|
+
disk, needs no change to how the serve is launched on any platform (the macOS
|
|
61
|
+
LaunchAgent, the systemd unit and the Windows Scheduled Task all still exec
|
|
62
|
+
`litert-lm serve`), and covers a serve started by hand as well as one started
|
|
63
|
+
by a service manager.
|
|
64
|
+
|
|
65
|
+
It is LAZY on purpose: `install()` only registers a `sys.meta_path` hook and
|
|
66
|
+
imports nothing, so it piggybacks on the import the CLI was going to do anyway
|
|
67
|
+
rather than adding one. `litert-lm list` and `litert-lm --version` — which the
|
|
68
|
+
provisioners run as health checks — keep exactly the startup cost they had.
|
|
69
|
+
|
|
70
|
+
It also fails OPEN. Every failure path leaves stock behaviour intact and prints
|
|
71
|
+
one line to stderr, which the serve's own log captures: a serve that is merely
|
|
72
|
+
slow keeps sensing, and a serve that refuses to start senses nothing. The line
|
|
73
|
+
is the only way to tell the two apart from the outside, so it is not optional —
|
|
74
|
+
grep the serve log for `[trillboards-litertlm-shim]`.
|
|
75
|
+
|
|
76
|
+
Set `TRILLBOARDS_LITERTLM_SHIM=0` to disable it without uninstalling anything.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
from __future__ import annotations
|
|
80
|
+
|
|
81
|
+
import os
|
|
82
|
+
import sys
|
|
83
|
+
|
|
84
|
+
__all__ = ["install", "apply_to", "should_drop", "TAG"]
|
|
85
|
+
|
|
86
|
+
TAG = "[trillboards-litertlm-shim]"
|
|
87
|
+
|
|
88
|
+
#: The module whose import triggers the wrap. `Engine.create_conversation` is
|
|
89
|
+
#: the single funnel every serve request goes through.
|
|
90
|
+
TARGET_MODULE = "litert_lm"
|
|
91
|
+
|
|
92
|
+
#: Marker set on the wrapper so a second install() is a no-op rather than a
|
|
93
|
+
#: second layer of indirection.
|
|
94
|
+
_MARKER = "_trillboards_litertlm_shim"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _say(message: str) -> None:
|
|
98
|
+
"""One line to stderr. Never raises — a logging failure must not be fatal."""
|
|
99
|
+
try:
|
|
100
|
+
sys.stderr.write(f"{TAG} {message}\n")
|
|
101
|
+
sys.stderr.flush()
|
|
102
|
+
except Exception: # pragma: no cover - stderr is gone; nothing to do
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _enabled() -> bool:
|
|
107
|
+
"""False only when explicitly switched off, so a missing var means ON."""
|
|
108
|
+
return os.environ.get("TRILLBOARDS_LITERTLM_SHIM", "1").strip() not in ("0", "false", "no")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def should_drop(config: object) -> bool:
|
|
112
|
+
"""Is this the no-response_format shape litert-lm builds on every request?
|
|
113
|
+
|
|
114
|
+
Deliberately exact. `enable is True` and not merely truthy, `provider is
|
|
115
|
+
None` and not merely falsy: anything else is a caller who asked for
|
|
116
|
+
constrained decoding, and dropping theirs would change an answer rather
|
|
117
|
+
than a setup cost.
|
|
118
|
+
"""
|
|
119
|
+
if config is None:
|
|
120
|
+
return False
|
|
121
|
+
return getattr(config, "enable", None) is True and getattr(config, "provider", "?") is None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def apply_to(litert_lm_module: object) -> bool:
|
|
125
|
+
"""Wrap `Engine.create_conversation` on an imported litert_lm. Idempotent.
|
|
126
|
+
|
|
127
|
+
Returns True when the wrap is in place (including when it already was).
|
|
128
|
+
"""
|
|
129
|
+
engine_cls = getattr(litert_lm_module, "Engine", None)
|
|
130
|
+
original = getattr(engine_cls, "create_conversation", None)
|
|
131
|
+
if original is None:
|
|
132
|
+
_say("NOT ACTIVE: litert_lm.Engine.create_conversation is absent — "
|
|
133
|
+
"upstream shape changed; serving with stock (slower) behaviour")
|
|
134
|
+
return False
|
|
135
|
+
if getattr(original, _MARKER, False):
|
|
136
|
+
return True
|
|
137
|
+
|
|
138
|
+
def create_conversation(self, *args, **kwargs):
|
|
139
|
+
if should_drop(kwargs.get("constrained_decoding_config")):
|
|
140
|
+
kwargs["constrained_decoding_config"] = None
|
|
141
|
+
return original(self, *args, **kwargs)
|
|
142
|
+
|
|
143
|
+
# Not functools.wraps: the marker must survive, and copying __wrapped__
|
|
144
|
+
# would let a second install() unwrap back to the stock method.
|
|
145
|
+
create_conversation.__name__ = getattr(original, "__name__", "create_conversation")
|
|
146
|
+
create_conversation.__doc__ = getattr(original, "__doc__", None)
|
|
147
|
+
setattr(create_conversation, _MARKER, True)
|
|
148
|
+
setattr(engine_cls, "create_conversation", create_conversation)
|
|
149
|
+
return True
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _install_now(module: object) -> None:
|
|
153
|
+
if apply_to(module):
|
|
154
|
+
_say("ACTIVE: unconstrained conversations skip constrained-decoding setup")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class _LitertLmImportHook:
|
|
158
|
+
"""Applies the wrap immediately after `litert_lm` finishes importing.
|
|
159
|
+
|
|
160
|
+
A `find_spec` that answers only for the target module, hands the real
|
|
161
|
+
machinery back the job of finding it, and decorates the resulting loader's
|
|
162
|
+
`exec_module` so the wrap lands after the module body has run. It removes
|
|
163
|
+
itself first, so it can never recurse and never slows a later import.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
def find_spec(self, fullname, path=None, target=None): # noqa: D401
|
|
167
|
+
if fullname != TARGET_MODULE:
|
|
168
|
+
return None
|
|
169
|
+
try:
|
|
170
|
+
sys.meta_path.remove(self)
|
|
171
|
+
except ValueError:
|
|
172
|
+
return None
|
|
173
|
+
try:
|
|
174
|
+
import importlib.util
|
|
175
|
+
|
|
176
|
+
spec = importlib.util.find_spec(fullname)
|
|
177
|
+
if spec is None or spec.loader is None or not hasattr(spec.loader, "exec_module"):
|
|
178
|
+
return None
|
|
179
|
+
real_exec_module = spec.loader.exec_module
|
|
180
|
+
|
|
181
|
+
def exec_module(module):
|
|
182
|
+
real_exec_module(module)
|
|
183
|
+
try:
|
|
184
|
+
_install_now(module)
|
|
185
|
+
except Exception as exc: # noqa: BLE001
|
|
186
|
+
_say(f"NOT ACTIVE: {exc!r} — serving with stock (slower) behaviour")
|
|
187
|
+
|
|
188
|
+
spec.loader.exec_module = exec_module
|
|
189
|
+
return spec
|
|
190
|
+
except Exception as exc: # noqa: BLE001
|
|
191
|
+
_say(f"NOT ACTIVE: {exc!r} — serving with stock (slower) behaviour")
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def install() -> bool:
|
|
196
|
+
"""Register the import hook. Safe to call more than once. Never raises."""
|
|
197
|
+
try:
|
|
198
|
+
if not _enabled():
|
|
199
|
+
_say("disabled by TRILLBOARDS_LITERTLM_SHIM=0")
|
|
200
|
+
return False
|
|
201
|
+
already = sys.modules.get(TARGET_MODULE)
|
|
202
|
+
if already is not None:
|
|
203
|
+
_install_now(already)
|
|
204
|
+
return True
|
|
205
|
+
if any(isinstance(f, _LitertLmImportHook) for f in sys.meta_path):
|
|
206
|
+
return True
|
|
207
|
+
sys.meta_path.insert(0, _LitertLmImportHook())
|
|
208
|
+
return True
|
|
209
|
+
except Exception as exc: # noqa: BLE001
|
|
210
|
+
_say(f"NOT ACTIVE: {exc!r} — serving with stock (slower) behaviour")
|
|
211
|
+
return False
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
install()
|
|
@@ -6,10 +6,14 @@
|
|
|
6
6
|
#
|
|
7
7
|
# WHY IT EXISTS. Local-only inference makes the box spec the product spec, and
|
|
8
8
|
# every latency number in the capacity plan came off one Apple M4 Max
|
|
9
|
-
# workstation — including the 7.30 s/frame in connectorCadence.ts
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
9
|
+
# workstation — including the 7.30 s/frame in connectorCadence.ts, which is now
|
|
10
|
+
# the BOOTSTRAP a connector uses until it has measured itself (see
|
|
11
|
+
# analyzeCostObserver.ts) rather than a fleet-wide fact. A 3-core / 7 GB Apple
|
|
12
|
+
# silicon runner is the closest CI has to the Mac mini we would actually
|
|
13
|
+
# recommend, and its 7 GB is why it is also the cheapest evidence about memory:
|
|
14
|
+
# the "16 GB+ RAM" minimum this used to point at was RETIRED on 2026-08-18 —
|
|
15
|
+
# cctv-rushi-activation.md now publishes 8 GB, against a measured working set of
|
|
16
|
+
# 2.7 GB stock and 3.31 GB on the shipped GPU-vision path.
|
|
13
17
|
#
|
|
14
18
|
# It does NOT use setup-litertlm.sh's LaunchAgent path: that bootstraps a
|
|
15
19
|
# per-login agent, and a CI runner's session is not the session a customer logs
|
|
@@ -398,6 +398,10 @@ cp "$PKG_ROOT/deploy/macos/com.trillboards.edge.plist" "$PAYLOAD/share/com.trill
|
|
|
398
398
|
# verifies the private service before it opens camera pairing.
|
|
399
399
|
cp "$PKG_ROOT/deploy/macos/com.trillboards.litertlm.plist" "$PAYLOAD/share/com.trillboards.litertlm.plist"
|
|
400
400
|
cp "$PKG_ROOT/deploy/macos/scripts/setup-litertlm.sh" "$PAYLOAD/share/setup-litertlm.sh"
|
|
401
|
+
# Staged NEXT TO setup-litertlm.sh because that is where install_litertlm_shim
|
|
402
|
+
# looks first. It is copied into the litert-lm tool venv's site-packages at
|
|
403
|
+
# provisioning time; nothing else reads it, and no upstream file is modified.
|
|
404
|
+
cp "$PKG_ROOT/deploy/litertlm/trillboards_litertlm_shim.py" "$PAYLOAD/share/trillboards_litertlm_shim.py"
|
|
401
405
|
cp "$PKG_ROOT/deploy/macos/scripts/ensure-litertlm.sh" "$PAYLOAD/share/ensure-litertlm.sh"
|
|
402
406
|
cp "$PKG_ROOT/deploy/macos/apply-update.sh" "$PAYLOAD/share/apply-update.sh"
|
|
403
407
|
chmod +x "$PAYLOAD/share/setup-litertlm.sh" "$PAYLOAD/share/ensure-litertlm.sh" \
|
|
@@ -61,6 +61,182 @@ mkdir -p "$LOG_DIR" "$LA_DIR" 2>/dev/null || true
|
|
|
61
61
|
LOG="$LOG_DIR/litertlm-setup.log"
|
|
62
62
|
log() { printf '[trillboards setup-litertlm] %s %s\n' "$(date '+%H:%M:%S')" "$*" >>"$LOG" 2>&1; printf '[trillboards setup-litertlm] %s\n' "$*"; }
|
|
63
63
|
fail() { log "ERROR: $*"; exit 1; }
|
|
64
|
+
# -- Local-inference tuning (config.json) ------------------------------------
|
|
65
|
+
#
|
|
66
|
+
# `litert-lm serve` takes NO flags for any of this: `serve` accepts only
|
|
67
|
+
# --host/--port/--api/--cors-origin/--verbose. Every engine knob it honours comes
|
|
68
|
+
# from `$LITERT_LM_DIR/config.json`, which `litert_lm_cli.config.get_model_config`
|
|
69
|
+
# merges per model id. So this file IS the tuning surface for everything it can
|
|
70
|
+
# express, and the serve binary stays stock: we pass it no flags and we modify
|
|
71
|
+
# none of its files.
|
|
72
|
+
#
|
|
73
|
+
# ONE knob is NOT expressible there, and it is the largest one left. Every
|
|
74
|
+
# request the serve handles gets `ConstrainedDecodingConfig(enable=True, ...)`
|
|
75
|
+
# built for it unconditionally -- `enable` is True even when the caller sent no
|
|
76
|
+
# `response_format` and the generation is therefore unconstrained -- so the
|
|
77
|
+
# native conversation-create builds constraint machinery for a grammar nothing
|
|
78
|
+
# will use, once per frame. `config_schema.json` on the pinned 0.16.0 has 17
|
|
79
|
+
# keys (backend, cpu_thread_count, audio_backend, vision_backend, cache,
|
|
80
|
+
# max_num_tokens, temperature, top_p, top_k, seed, gpu_decode_steps_per_sync,
|
|
81
|
+
# speculative_decoding, thinking, thinking_budget, activation_data_type, plus
|
|
82
|
+
# the `default`/`models` containers) and NONE of them reaches it; no request
|
|
83
|
+
# field reaches it either, and both 0.15.0 and 0.16.1 construct it identically,
|
|
84
|
+
# so a version move does not reach it. That is why install_litertlm_shim below
|
|
85
|
+
# exists -- see it for the measurement and for why it is not a patch.
|
|
86
|
+
#
|
|
87
|
+
# Two settings, both MEASURED on an M4 Max (12P+4E/48G), litert-lm 0.16.0, Gemma
|
|
88
|
+
# 4 E2B, delegate asserted GPU from the native log, 16 warm-ups before timing,
|
|
89
|
+
# arms interleaved sample-by-sample, operator/SOP field set + the two committed
|
|
90
|
+
# CCTV fixtures:
|
|
91
|
+
#
|
|
92
|
+
# vision_backend=gpu The vision encoder ran on the CPU because NOTHING ASKED
|
|
93
|
+
# IT NOT TO -- `Engine(vision_backend=...)` defaults to
|
|
94
|
+
# None and the 0.13.1 HTTP shim hardcoded Backend.CPU()
|
|
95
|
+
# (upstream b/515805503). It is not a hardware limit and
|
|
96
|
+
# not a model constraint; GPU vision is Google's own
|
|
97
|
+
# reference config and CPU is their FALLBACK tier.
|
|
98
|
+
# Image encode 0.804 s -> 0.175 s p50, i.e. 76% of a SOP
|
|
99
|
+
# frame removed.
|
|
100
|
+
# speculative_decoding 1.433x on the decode-heavy audience schema, and the
|
|
101
|
+
# generations are BYTE-IDENTICAL to greedy (12/12 same
|
|
102
|
+
# text), which is the property that makes it safe: it is
|
|
103
|
+
# a speed change, not an answer change.
|
|
104
|
+
#
|
|
105
|
+
# Together, in-process: 1.054 s -> 0.385 s per SOP frame (2.765x paired, min
|
|
106
|
+
# 2.560 / max 2.919 over 16 rounds); 3,416 -> 9,295 analyses/hour. Through this
|
|
107
|
+
# stock serve over HTTP: 1752.5 ms -> 1005.8 ms p50 (1.742x), 12/12 schema-valid
|
|
108
|
+
# on BOTH arms. Accuracy is not traded for it: people_present 16/16 on both arms,
|
|
109
|
+
# person_count 8/16 on both, and where they differ the GPU encoder is CLOSER to
|
|
110
|
+
# the hand label (4 vs 3 against a truth of 5).
|
|
111
|
+
#
|
|
112
|
+
# WHY THE VERIFY BELOW EXISTS. Pinning the vision encoder to the GPU is the one
|
|
113
|
+
# setting here that can make a host WORSE than it is today: a box whose GPU will
|
|
114
|
+
# not bind fails engine init, and unlike the main backend there is no demotion
|
|
115
|
+
# path for it -- `LiteRtVlmProvider.recordGpuDemotion` re-posts the BARE model
|
|
116
|
+
# id, which changes the main backend and leaves this config untouched. So a
|
|
117
|
+
# blind write would brick sensing on exactly the weak hosts that need it most.
|
|
118
|
+
# We therefore do what Google's own cascade does: try GPU vision, prove it with
|
|
119
|
+
# one real image inference, and fall back to the CPU default if it does not
|
|
120
|
+
# answer.
|
|
121
|
+
CONFIG_JSON="$LITERT_DIR/config.json"
|
|
122
|
+
|
|
123
|
+
# A 223-byte 32x32 grey JPEG. Small enough to inline, real enough that decoding
|
|
124
|
+
# it exercises the vision encoder we are trying to prove.
|
|
125
|
+
LITERTLM_PROBE_JPEG_B64="/9j/4AAQSkZJRgABAgAAAQABAAD//gAPTGF2YzYzLjEuMTAxAP/bAEMACD4+ST5JVVVVVVVVZF1kaGhoZGRkZGhoaHBwcIODg3BwcGhocHB8fIODj5OPh4eDh5OTm5uburqystnZ4P/////EAEoAAQAAAAAAAAAAAAAAAAAAAAABAQAAAAAAAAAAAAAAAAAAAAAQAQAAAAAAAAAAAAAAAAAAAAARAQAAAAAAAAAAAAAAAAAAAAD/wAARCAAgACADASIAAhEAAxEA/9oADAMBAAIRAxEAPwAAAAAAAAD/2Q=="
|
|
126
|
+
|
|
127
|
+
# Write config.json. $1 = "gpu" pins the vision encoder; empty leaves it on the
|
|
128
|
+
# CPU default. speculative_decoding is written EITHER WAY -- it is delegate
|
|
129
|
+
# independent and byte-identical, so it survives the fallback.
|
|
130
|
+
write_litertlm_config() {
|
|
131
|
+
_vision="$1"
|
|
132
|
+
mkdir -p "$LITERT_DIR" 2>/dev/null || true
|
|
133
|
+
if [ -n "$_vision" ]; then
|
|
134
|
+
printf '{"models":{"%s":{"vision_backend":"%s","speculative_decoding":true}}}\n' \
|
|
135
|
+
"$MODEL_ID" "$_vision" > "$CONFIG_JSON.tmp" 2>/dev/null || return 1
|
|
136
|
+
else
|
|
137
|
+
printf '{"models":{"%s":{"speculative_decoding":true}}}\n' \
|
|
138
|
+
"$MODEL_ID" > "$CONFIG_JSON.tmp" 2>/dev/null || return 1
|
|
139
|
+
fi
|
|
140
|
+
mv -f "$CONFIG_JSON.tmp" "$CONFIG_JSON" 2>/dev/null || return 1
|
|
141
|
+
log "wrote $CONFIG_JSON (vision_backend=${_vision:-cpu-default}, speculative_decoding=true)"
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
# -- The one knob config.json cannot express -----------------------------------
|
|
145
|
+
#
|
|
146
|
+
# `litert-lm serve` builds `ConstrainedDecodingConfig(enable=True, provider=...)`
|
|
147
|
+
# for EVERY request, at both openai_handler.py construction sites, with `enable`
|
|
148
|
+
# hardcoded True -- so a request that carried no `response_format`, which is
|
|
149
|
+
# every request the connector sends, still makes the native conversation-create
|
|
150
|
+
# build constraint machinery for a grammar nothing will use. It is rebuilt per
|
|
151
|
+
# request because the serve creates one conversation per request.
|
|
152
|
+
#
|
|
153
|
+
# MEASURED (M4 Max 16c/48G, litert-lm 0.16.0, Gemma 4 E2B, GPU/Metal, vision_backend
|
|
154
|
+
# =gpu + speculative_decoding=true, operator/SOP field set, the two committed CCTV
|
|
155
|
+
# fixtures, arms alternated sample-by-sample with the leading arm rotated each
|
|
156
|
+
# round, load average 22-25):
|
|
157
|
+
#
|
|
158
|
+
# in-process, one engine, n=10 pairs
|
|
159
|
+
# arm create_conversation send_message total
|
|
160
|
+
# stock 0.717 s 0.560 s 1.275 s
|
|
161
|
+
# without it 0.001 s 0.603 s 0.604 s
|
|
162
|
+
# paired 2.194x (min 2.040, max 2.284); generations BYTE-IDENTICAL 10/10;
|
|
163
|
+
# prefill 493 / decode 58 on both arms
|
|
164
|
+
#
|
|
165
|
+
# through two stock serves on two ports, interleaved, n=16 pairs
|
|
166
|
+
# 1.309 s -> 0.592 s p50 (p95 3.664 -> 0.965), paired 2.42x (min 2.174, max
|
|
167
|
+
# 4.657), 16/16 schema-valid and 16/16 byte-identical on both arms
|
|
168
|
+
#
|
|
169
|
+
# bench-local-vlm.mjs --arms gpu-sop, 16 warm-ups, n=8, back to back
|
|
170
|
+
# 1.32 s/frame (2,727 analyses/hour) -> 0.72 s/frame (5,000/hour), 8/8
|
|
171
|
+
# schema-valid and 83.3% field accuracy on BOTH arms
|
|
172
|
+
#
|
|
173
|
+
# A speed change, not an answer change -- the same bar speculative_decoding had
|
|
174
|
+
# to clear. The RATIO is itself load-dependent (3.734x at 0.0% CPU idle vs 2.42x
|
|
175
|
+
# at load 25, because the removed work is CPU-bound and the retained work is
|
|
176
|
+
# GPU-bound), so quote the near-idle figure.
|
|
177
|
+
#
|
|
178
|
+
# We do NOT patch upstream. Two files are ADDED to the litert-lm tool venv's
|
|
179
|
+
# site-packages: our shim, and a one-line .pth that imports it. Every upstream
|
|
180
|
+
# file stays byte-identical, no launch definition changes on any platform (so
|
|
181
|
+
# the model OTA's `launchctl kickstart com.trillboards.litertlm` still finds the
|
|
182
|
+
# same label), and a serve started by hand gets the fix too. The shim is lazy --
|
|
183
|
+
# it only registers an import hook, so `litert-lm list`/`--version`, which this
|
|
184
|
+
# script runs as health checks, do not gain a dylib load. It fails OPEN: any
|
|
185
|
+
# failure leaves stock behaviour and prints one line to the serve's own log, and
|
|
186
|
+
# `TRILLBOARDS_LITERTLM_SHIM=0` disables it without uninstalling anything.
|
|
187
|
+
#
|
|
188
|
+
# Runs UNCONDITIONALLY, never inside the install/repair branch: `uv tool install
|
|
189
|
+
# --force` rebuilds the venv and takes both files with it, and the healthy-CLI
|
|
190
|
+
# path skips that branch entirely -- so gating this behind it is exactly how a
|
|
191
|
+
# repaired-and-healthy machine would keep the slow serve while reporting fine.
|
|
192
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd || echo .)"
|
|
193
|
+
find_litertlm_shim() {
|
|
194
|
+
for _c in "$SCRIPT_DIR/trillboards_litertlm_shim.py" \
|
|
195
|
+
"$SCRIPT_DIR/../litertlm/trillboards_litertlm_shim.py" \
|
|
196
|
+
"$SCRIPT_DIR/../../litertlm/trillboards_litertlm_shim.py"; do
|
|
197
|
+
[ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; }
|
|
198
|
+
done
|
|
199
|
+
return 1
|
|
200
|
+
}
|
|
201
|
+
install_litertlm_shim() {
|
|
202
|
+
_shim="$(find_litertlm_shim)" || { log "WARN: litert-lm shim source not found; serve keeps stock (slower) conversation setup"; return 1; }
|
|
203
|
+
_tooldir="$("$UV" tool dir 2>/dev/null | tr -d '\r')"
|
|
204
|
+
[ -n "$_tooldir" ] || { log "WARN: could not resolve \`uv tool dir\`; skipping litert-lm shim"; return 1; }
|
|
205
|
+
_py="$_tooldir/litert-lm/bin/python"
|
|
206
|
+
[ -x "$_py" ] || { log "WARN: litert-lm venv python missing at $_py; skipping shim"; return 1; }
|
|
207
|
+
_site="$("$_py" -c 'import sysconfig;print(sysconfig.get_paths()["purelib"])' 2>/dev/null | tr -d '\r')"
|
|
208
|
+
[ -n "$_site" ] && [ -d "$_site" ] || { log "WARN: could not resolve litert-lm site-packages; skipping shim"; return 1; }
|
|
209
|
+
cp -f "$_shim" "$_site/trillboards_litertlm_shim.py" 2>/dev/null \
|
|
210
|
+
|| { log "WARN: could not copy the litert-lm shim into $_site"; return 1; }
|
|
211
|
+
printf 'import trillboards_litertlm_shim\n' > "$_site/trillboards-litertlm-shim.pth" 2>/dev/null \
|
|
212
|
+
|| { log "WARN: could not write the litert-lm shim .pth"; return 1; }
|
|
213
|
+
# Capability is established by ATTEMPTING THE OPERATION. Importing litert_lm
|
|
214
|
+
# here is the only thing that proves the wrap actually landed on the class the
|
|
215
|
+
# serve will use; a file that merely exists proves nothing.
|
|
216
|
+
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
|
|
217
|
+
log "litert-lm fast-conversation shim active in $_site"
|
|
218
|
+
return 0
|
|
219
|
+
fi
|
|
220
|
+
log "WARN: litert-lm shim did not take effect; serve keeps stock (slower) conversation setup"
|
|
221
|
+
return 1
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
# One real image inference against the running serve. A 200 means the vision
|
|
225
|
+
# encoder loaded AND ran on whatever backend config.json just asked for.
|
|
226
|
+
# Deliberately NOT a /v1/models check: that answers 200 without ever building an
|
|
227
|
+
# engine, so it cannot tell a working GPU encoder from one that fails on first
|
|
228
|
+
# use.
|
|
229
|
+
verify_litertlm_vision() {
|
|
230
|
+
_body="$LITERT_DIR/.vision-probe.json"
|
|
231
|
+
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}' \
|
|
232
|
+
"$MODEL_ID" "$LITERTLM_PROBE_JPEG_B64" > "$_body" 2>/dev/null || return 1
|
|
233
|
+
_code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 180 \
|
|
234
|
+
-X POST "http://127.0.0.1:$SERVE_PORT/v1/chat/completions" \
|
|
235
|
+
-H 'Content-Type: application/json' --data-binary @"$_body" 2>/dev/null)
|
|
236
|
+
rm -f "$_body" 2>/dev/null || true
|
|
237
|
+
[ "$_code" = "200" ]
|
|
238
|
+
}
|
|
239
|
+
|
|
64
240
|
local_ai_healthy() {
|
|
65
241
|
curl -fsS --max-time 3 "http://127.0.0.1:$SERVE_PORT/v1/models" 2>/dev/null \
|
|
66
242
|
| grep -Fq "$MODEL_ID"
|
|
@@ -251,6 +427,8 @@ else
|
|
|
251
427
|
log "litert-lm CLI repaired successfully at $("$LITERTLM" --version 2>/dev/null | awk '{print $NF}')"
|
|
252
428
|
fi
|
|
253
429
|
|
|
430
|
+
install_litertlm_shim || true
|
|
431
|
+
|
|
254
432
|
# 3. Model - import the verified bundled file. Idempotent: skip entirely if
|
|
255
433
|
# litert-lm already has the model. Engineering runs may use HF only with the
|
|
256
434
|
# explicit TRILLBOARDS_ALLOW_MODEL_DOWNLOAD=1 opt-in.
|
|
@@ -305,6 +483,7 @@ if [ -f "$TEMPLATE" ]; then
|
|
|
305
483
|
-e "s#__PORT__#$SERVE_PORT#g" \
|
|
306
484
|
-e "s#__LOG_DIR__#$LOG_DIR#g" \
|
|
307
485
|
"$TEMPLATE" > "$TARGET" 2>/dev/null || log "WARN: could not render serve plist"
|
|
486
|
+
write_litertlm_config "gpu" || log "WARN: could not write $CONFIG_JSON - the serve will use its defaults"
|
|
308
487
|
restart_launch_agent \
|
|
309
488
|
|| { [ "$REQUIRE_BUNDLED_MODEL" = "1" ] && fail "local AI service could not be started"; log "WARN: launchctl bootstrap failed for serve agent"; }
|
|
310
489
|
else
|
|
@@ -312,6 +491,29 @@ else
|
|
|
312
491
|
log "WARN: serve plist template missing at $TEMPLATE"
|
|
313
492
|
fi
|
|
314
493
|
|
|
494
|
+
# The GPU-vision cascade. `local_ai_healthy` only proves /v1/models answers, and
|
|
495
|
+
# that route never builds an engine - so it cannot see a GPU vision encoder that
|
|
496
|
+
# fails on first USE. One real image inference is the only thing that can, and if
|
|
497
|
+
# it does not answer we fall back to the CPU encoder rather than leave the host
|
|
498
|
+
# unable to sense at all.
|
|
499
|
+
litertlm_vision_cascade() {
|
|
500
|
+
if verify_litertlm_vision; then
|
|
501
|
+
log "local AI healthy with the GPU vision encoder"
|
|
502
|
+
return 0
|
|
503
|
+
fi
|
|
504
|
+
log "WARN: GPU vision encoder did not answer a probe inference - falling back to the CPU encoder"
|
|
505
|
+
write_litertlm_config "" || log "WARN: could not rewrite $CONFIG_JSON"
|
|
506
|
+
restart_launch_agent || log "WARN: could not restart the serve agent after the vision fallback"
|
|
507
|
+
for _attempt in $(seq 1 60); do
|
|
508
|
+
if local_ai_healthy; then
|
|
509
|
+
log "local AI healthy on the CPU vision encoder (slower, but serving)"
|
|
510
|
+
return 0
|
|
511
|
+
fi
|
|
512
|
+
sleep 2
|
|
513
|
+
done
|
|
514
|
+
return 1
|
|
515
|
+
}
|
|
516
|
+
|
|
315
517
|
if [ "$REQUIRE_BUNDLED_MODEL" = "1" ]; then
|
|
316
518
|
log "waiting for local AI health check"
|
|
317
519
|
HEALTHY=0
|
|
@@ -320,7 +522,15 @@ if [ "$REQUIRE_BUNDLED_MODEL" = "1" ]; then
|
|
|
320
522
|
sleep 2
|
|
321
523
|
done
|
|
322
524
|
[ "$HEALTHY" -eq 1 ] || fail "Gemma is installed, but the local AI service did not respond"
|
|
525
|
+
litertlm_vision_cascade || fail "Gemma is installed, but the local AI service stopped answering after the vision fallback"
|
|
323
526
|
log "local AI is installed and healthy"
|
|
527
|
+
else
|
|
528
|
+
# Best effort, and deliberately NOT a new wait: if the serve is already up we
|
|
529
|
+
# settle the vision backend now; if it is not, ensure-litertlm.sh re-runs this
|
|
530
|
+
# script and we settle it then.
|
|
531
|
+
if local_ai_healthy; then
|
|
532
|
+
litertlm_vision_cascade || log "WARN: the serve stopped answering after the vision fallback"
|
|
533
|
+
fi
|
|
324
534
|
fi
|
|
325
535
|
|
|
326
536
|
log "done"
|