openvibers/openvibe.link Commits
v0.29.0: the ai plugin says how fast each model answers here (ai_tokens_per_s for the last model, the latest answers counting most) and ai_busy while one answers (#76)
Changes
@@@ -5,8 +5,8 @@"wave": "T15","plan": "Plan track T15 (Bot complete) and ADR-043 (Bot: devices, pairing, control and safety); the computer plugin and the job worker serve OpenVibe.Actor and the Codes job runner (tracks T14/T17)","kind": "client",-- "version": "0.28.0",-- "release": "v0.28.0, published 2026-10-27 as a GitHub release (install.sh --ai sets up your own models: it adds the ai plugin, which a driver chosen later keeps, says which model server answers (Ollama, LM Studio, llama.cpp) or how to get one, and --ai-endpoint names another port; v0.27.0: your own models for your OpenVibe work: the ai plugin relays kind ai (contracts 0.219.0), one chat completion, to a local OpenAI-compatible model server (Ollama, LM Studio, llama.cpp), lists its models, runs one at a time, and reaches nothing else; the core routes ai to it like a computer command that never acts; v0.26.0: programs on a desk: exec takes desk, running in that desk's session so a program's windows open there, and detach, starting a program that keeps running on a whole desk (an office suite, a terminal) and ends with its desk; desk templates follow: research, office and dev desks; v0.25.0: the egress guard: every connection every agent browser makes, a page's own scripts included, goes through a proxy the plugin runs on 127.0.0.1 that resolves, checks and connects to the address it checked, so a page never reaches the machine's own network below level full; browser_egress: open turns it off; v0.24.1: a kept profile carries session cookies too: Chrome drops cookies that end with the browser as it quits, and a sign-in is often one, so a desk keeps them beside the profile and the next desk's browser gets them back as it starts, found by the production proof; v0.24.0: kept desk profiles: desk start takes profile, so a desk's browser starts from the person's kept profile (sign in once, on any desk) and saves it back when it stops, closing the browser gracefully so its cookies are written, the last desk to stop wins, caches never kept, and each desk's own copy removed; the browser follows a page its page opens (a sign-in popup, a new tab) and comes back when it closes; the agent's clicks count as a person's, so sites open their sign-in popups; v0.23.0: browser desks, the Desks plan's step D2: desk start takes kind browser, a headless browser alone with no display at about half the cost of a whole desk, on Linux or macOS; its page is its screen, mouse and keyboard and live view over the browser's pipe, exactly the desk's size; a desk host, which has no screen or input of its own, gets screen, screen_stream and input on its desks; a click on a named desk scales to that desk's size; v0.22.0: desks on a desk host, found by the first production proof: a desk's temp folder is short (Chrome's SingletonSocket must fit a Unix socket path, and the desks folder on a desk host was too deep, so Chrome closed as it started), and a desk no command used for desk_idle_s (15 minutes) stops by itself, so a desk an agent left behind when it crashed or restarted ends; v0.21.0: element refs and receipts, the Desks plan's step D3: the agent's browser lists a page's interactive elements as compact refs (e7 button \"Sign in\"), kept on the element while it stays, and click, press and back answer with what changed (url, lines that appeared, lines gone), so agents act on the page's structure before screenshots; v0.20.0: desk hosts, the Desks plan's step D5: install.sh --desk-host turns an old laptop, a mini PC or a spare workstation into a host of desks for its owner's agents, run as its own openvibe-desk account with Xvfb, openbox, D-Bus, xdotool, ImageMagick and Chromium installed and max_desks sized to its memory; v0.19.0: several desks per machine, the Desks plan's step D1: the computer plugin runs named desks (desk-<name>, private kept), each with its own X display, D-Bus, browser profile, downloads and temp folder, up to max_desks (one per 2 GiB of memory by default), so two agents work at once without seeing or driving each other's screen; proven with two real Chrome desks that never share a cookie; v0.18.0: a motion command's validity also counts the time it waits in the queue to its plugin and in the pipe: the core fixes the instant it expires at receipt, writes what is left at the moment it writes the line with expires_at_mono_ns on the clock it shares with the plugin, and never writes one that has run out, and the Python runtime refuses one that ran out in the pipe before handle(); the clock bound is kept in nanoseconds and counts Bot's whole-millisecond stamps, rounded up from nanoseconds; a Node booting latched sets the e-stop on its plugins before starting them; the Python SDK package reports the Node release; v0.17.0: deadlines are checked against the latest the server's clock can be: the estimate plus its bound, the connection's fastest round trip, and motion waits for that bound on every new connection; every stop and estop names its cause, hello carries the newest halt epoch, and the Python SDK gives drivers on_stop(StopInfo) and on_resume to tell an ordinary stop from an emergency without re-arming anything; v0.16.1: the Python runtime refuses a motion command whose deadline passed while it waited behind a slow handler, so a driver never moves on a stale command; plugins.md answers where deadlines come from, re-stamping, order across a change of driver, stop versus estop, and that nothing is held past its validity; v0.16.0: openvibe-node quit ends a running node cleanly with nothing latched, for launchers; each driver command carries its provenance (link session, seq, Bot send time and deadline) and a halt's stop its turn_epoch, and the Python runtime refuses a replay on a session and a command from an ended turn; the no-redelivery contract written down in plugins.md): binaries for linux amd64/arm64/armv7, darwin amd64/arm64 and windows amd64, the plugins tarball, install.sh and SHA256SUMS",++ "version": "0.29.0",++ "release": "v0.29.0, published 2026-10-27 as a GitHub release (the ai plugin says how fast each model answers here: ai_tokens_per_s for the last model, averaged with the earlier answers, and ai_busy while one answers, shown beside the machine's models on openvibe.bot; v0.28.0: install.sh --ai sets up your own models: it adds the ai plugin, which a driver chosen later keeps, says which model server answers (Ollama, LM Studio, llama.cpp) or how to get one, and --ai-endpoint names another port; v0.27.0: your own models for your OpenVibe work: the ai plugin relays kind ai (contracts 0.219.0), one chat completion, to a local OpenAI-compatible model server (Ollama, LM Studio, llama.cpp), lists its models, runs one at a time, and reaches nothing else; the core routes ai to it like a computer command that never acts; v0.26.0: programs on a desk: exec takes desk, running in that desk's session so a program's windows open there, and detach, starting a program that keeps running on a whole desk (an office suite, a terminal) and ends with its desk; desk templates follow: research, office and dev desks; v0.25.0: the egress guard: every connection every agent browser makes, a page's own scripts included, goes through a proxy the plugin runs on 127.0.0.1 that resolves, checks and connects to the address it checked, so a page never reaches the machine's own network below level full; browser_egress: open turns it off; v0.24.1: a kept profile carries session cookies too: Chrome drops cookies that end with the browser as it quits, and a sign-in is often one, so a desk keeps them beside the profile and the next desk's browser gets them back as it starts, found by the production proof; v0.24.0: kept desk profiles: desk start takes profile, so a desk's browser starts from the person's kept profile (sign in once, on any desk) and saves it back when it stops, closing the browser gracefully so its cookies are written, the last desk to stop wins, caches never kept, and each desk's own copy removed; the browser follows a page its page opens (a sign-in popup, a new tab) and comes back when it closes; the agent's clicks count as a person's, so sites open their sign-in popups; v0.23.0: browser desks, the Desks plan's step D2: desk start takes kind browser, a headless browser alone with no display at about half the cost of a whole desk, on Linux or macOS; its page is its screen, mouse and keyboard and live view over the browser's pipe, exactly the desk's size; a desk host, which has no screen or input of its own, gets screen, screen_stream and input on its desks; a click on a named desk scales to that desk's size; v0.22.0: desks on a desk host, found by the first production proof: a desk's temp folder is short (Chrome's SingletonSocket must fit a Unix socket path, and the desks folder on a desk host was too deep, so Chrome closed as it started), and a desk no command used for desk_idle_s (15 minutes) stops by itself, so a desk an agent left behind when it crashed or restarted ends; v0.21.0: element refs and receipts, the Desks plan's step D3: the agent's browser lists a page's interactive elements as compact refs (e7 button \"Sign in\"), kept on the element while it stays, and click, press and back answer with what changed (url, lines that appeared, lines gone), so agents act on the page's structure before screenshots; v0.20.0: desk hosts, the Desks plan's step D5: install.sh --desk-host turns an old laptop, a mini PC or a spare workstation into a host of desks for its owner's agents, run as its own openvibe-desk account with Xvfb, openbox, D-Bus, xdotool, ImageMagick and Chromium installed and max_desks sized to its memory; v0.19.0: several desks per machine, the Desks plan's step D1: the computer plugin runs named desks (desk-<name>, private kept), each with its own X display, D-Bus, browser profile, downloads and temp folder, up to max_desks (one per 2 GiB of memory by default), so two agents work at once without seeing or driving each other's screen; proven with two real Chrome desks that never share a cookie; v0.18.0: a motion command's validity also counts the time it waits in the queue to its plugin and in the pipe: the core fixes the instant it expires at receipt, writes what is left at the moment it writes the line with expires_at_mono_ns on the clock it shares with the plugin, and never writes one that has run out, and the Python runtime refuses one that ran out in the pipe before handle(); the clock bound is kept in nanoseconds and counts Bot's whole-millisecond stamps, rounded up from nanoseconds; a Node booting latched sets the e-stop on its plugins before starting them; the Python SDK package reports the Node release; v0.17.0: deadlines are checked against the latest the server's clock can be: the estimate plus its bound, the connection's fastest round trip, and motion waits for that bound on every new connection; every stop and estop names its cause, hello carries the newest halt epoch, and the Python SDK gives drivers on_stop(StopInfo) and on_resume to tell an ordinary stop from an emergency without re-arming anything; v0.16.1: the Python runtime refuses a motion command whose deadline passed while it waited behind a slow handler, so a driver never moves on a stale command; plugins.md answers where deadlines come from, re-stamping, order across a change of driver, stop versus estop, and that nothing is held past its validity; v0.16.0: openvibe-node quit ends a running node cleanly with nothing latched, for launchers; each driver command carries its provenance (link session, seq, Bot send time and deadline) and a halt's stop its turn_epoch, and the Python runtime refuses a replay on a session and a command from an ended turn; the no-redelivery contract written down in plugins.md): binaries for linux amd64/arm64/armv7, darwin amd64/arm64 and windows amd64, the plugins tarball, install.sh and SHA256SUMS","runtime": "go 1.24 (one static binary per platform); plugins in Python 3; an ESP32 Arduino library","database": "none","deployed": false,@@@ -61,4 +61,7 @@ computer's models** for the machine on openvibe.bot/machines.- At most 200 messages of 64 KB each. A model the machine does not offer is refused (`bad_value`), and so is a requestwhile another runs (`not_ready`).- When no model server answers, the command is `not_ready` and `describe` says `ai.ready: false`.++- Its telemetry (Node v0.29.0) says whether a model is answering now (`ai_busy`). Once one has answered, it also gives++ how fast the last model answered here (`ai_model`, `ai_tokens_per_s`): the reply's tokens over the whole answer's++ time, averaged with the earlier answers, the latest counting most. openvibe.bot/machines shows it beside the models.- It never acts on the machine, so it also runs while the machine's stop is latched.@@@ -61,6 +61,10 @@ class Ai(Plugin):self.busy = threading.Lock()self.cfg = {}self.reachable = False++ # How fast each model answers here, in tokens a second of its reply (an average that favours the latest++ # answers), and the model that answered last: the telemetry Bot shows beside the machine's models.++ self.speed = {}++ self.last_model = None# ---- the model server -----------------------------------------------------------------------------------------def _get(self, url, timeout):@@@ -149,6 +153,25 @@ class Ai(Plugin):threading.Thread(target=self._reply, args=(cmd.id, body), name="ai-reply", daemon=True).start()raise Deferred()++ def _measure(self, model, tokens, ms):++ """Keeps how fast this model answered: the reply's tokens over the whole answer's time (reading the prompt++ included, so it is what a person waits), averaged with the earlier answers, the latest counting most."""++ if not isinstance(tokens, int) or tokens < 1 or not ms or ms < 1:++ return++ rate = tokens / (ms / 1000.0)++ before = self.speed.get(model)++ self.speed[model] = rate if before is None else before * 0.7 + rate * 0.3++ self.last_model = model++++ def poll(self):++ """Sensors: whether a model is answering now (ai_busy), and the last model's speed here (ai_model,++ ai_tokens_per_s) once one has answered."""++ sensors = {"ai_busy": 1 if self.busy.locked() else 0}++ if self.last_model is not None and self.last_model in self.speed:++ sensors["ai_model"] = self.last_model[:120]++ sensors["ai_tokens_per_s"] = round(self.speed[self.last_model], 1)++ return {"sensors": sensors}++def _configure_again(self):try:self._configure(self.cfg)@@@ -176,6 +199,7 @@ class Ai(Plugin):if isinstance(usage, dict):out["usage"] = {k: int(usage[k]) for k in ("prompt_tokens", "completion_tokens", "total_tokens")if isinstance(usage.get(k), (int, float))}++ self._measure(body["model"], (out.get("usage") or {}).get("completion_tokens"), out["ms"])self.complete(cid, out)except urllib.error.HTTPError as e:self.fail(cid, "hardware", "the model server answered %d" % e.code)@@@ -110,6 +110,17 @@ class AiPluginTest(unittest.TestCase):self.assertEqual(path, "/v1/chat/completions")self.assertEqual((body["max_tokens"], body["temperature"], body["stream"]), (50, 0.2, False))++ def test_it_says_how_fast_each_model_answers_here(self):++ self.boot()++ self.assertEqual(self.p.poll(), {"sensors": {"ai_busy": 0}}, "nothing measured before the first answer")++ ticks = iter([10.0, 10.5, 20.0, 21.0]) # the first answer takes 0.5 s, the second 1 s++ self.p.clock = lambda: next(ticks)++ self.ask({"model": "llama3.1:8b", "messages": [{"role": "user", "content": "hi"}]})++ self.assertEqual(self.p.poll()["sensors"], {"ai_busy": 0, "ai_model": "llama3.1:8b", "ai_tokens_per_s": 8.0},++ "4 reply tokens in half a second")++ self.ask({"model": "llama3.1:8b", "messages": [{"role": "user", "content": "again"}]})++ self.assertEqual(self.p.poll()["sensors"]["ai_tokens_per_s"], round(8.0 * 0.7 + 4.0 * 0.3, 1), "the latest counts most")++def test_a_config_can_offer_fewer_models_and_cap_the_reply(self):caps = self.boot(models=["qwen2.5:7b"], max_tokens=100)[0]["capabilities"]["ai"]self.assertEqual(caps["models"], ["qwen2.5:7b"])@@@ -24,7 +24,7 @@ __all__ = ["Plugin", "Fault", "Deferred", "Command", "StopInfo", "Runtime", "run"RUNTIME_CAUSES", "SHARED_CLOCK", "__version__"]# The Node release this runtime ships with (pyproject.toml carries the same).--__version__ = "0.28.0"++__version__ = "0.29.0"PROTOCOL_VERSION = 1HEARTBEAT_TIMEOUT_S = 1.0DEFAULT_DEADLINE_MS = 300@@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"[project]name = "openvibe-plugin"--version = "0.28.0"++version = "0.29.0"description = "Runtime for OpenVibe Node plugins (JSON lines over stdin/stdout, deadlines, heartbeat, e-stop)"requires-python = ">=3.9"license = { text = "AGPL-3.0-or-later" }@@@ -439,7 +439,7 @@ class RuntimeTest(unittest.TestCase):self.assertEqual(rt.stop_infos[-1].halt_epoch, 1792900000500)def test_the_package_reports_the_node_release(self):-- self.assertEqual(openvibe_plugin.__version__, "0.28.0")++ self.assertEqual(openvibe_plugin.__version__, "0.29.0")with open(os.path.join(os.path.dirname(HERE), "pyproject.toml")) as f:self.assertIn('version = "%s"' % openvibe_plugin.__version__, f.read())
@@ -5,8 +5,8 @@
@@ -5,8 +5,8 @@
"wave": "T15",
"wave": "T15",
"plan": "Plan track T15 (Bot complete) and ADR-043 (Bot: devices, pairing, control and safety); the computer plugin and the job worker serve OpenVibe.Actor and the Codes job runner (tracks T14/T17)",
"plan": "Plan track T15 (Bot complete) and ADR-043 (Bot: devices, pairing, control and safety); the computer plugin and the job worker serve OpenVibe.Actor and the Codes job runner (tracks T14/T17)",
"kind": "client",
"kind": "client",
- "version": "0.28.0",
- "release": "v0.28.0, published 2026-10-27 as a GitHub release (install.sh --ai sets up your own models: it adds the ai plugin, which a driver chosen later keeps, says which model server answers (Ollama, LM Studio, llama.cpp) or how to get one, and --ai-endpoint names another port; v0.27.0: your own models for your OpenVibe work: the ai plugin relays kind ai (contracts 0.219.0), one chat completion, to a local OpenAI-compatible model server (Ollama, LM Studio, llama.cpp), lists its models, runs one at a time, and reaches nothing else; the core routes ai to it like a computer command that never acts; v0.26.0: programs on a desk: exec takes desk, running in that desk's session so a program's windows open there, and detach, starting a program that keeps running on a whole desk (an office suite, a terminal) and ends with its desk; desk templates follow: research, office and dev desks; v0.25.0: the egress guard: every connection every agent browser makes, a page's own scripts included, goes through a proxy the plugin runs on 127.0.0.1 that resolves, checks and connects to the address it checked, so a page never reaches the machine's own network below level full; browser_egress: open turns it off; v0.24.1: a kept profile carries session cookies too: Chrome drops cookies that end with the browser as it quits, and a sign-in is often one, so a desk keeps them beside the profile and the next desk's browser gets them back as it starts, found by the production proof; v0.24.0: kept desk profiles: desk start takes profile, so a desk's browser starts from the person's kept profile (sign in once, on any desk) and saves it back when it stops, closing the browser gracefully so its cookies are written, the last desk to stop wins, caches never kept, and each desk's own copy removed; the browser follows a page its page opens (a sign-in popup, a new tab) and comes back when it closes; the agent's clicks count as a person's, so sites open their sign-in popups; v0.23.0: browser desks, the Desks plan's step D2: desk start takes kind browser, a headless browser alone with no display at about half the cost of a whole desk, on Linux or macOS; its page is its screen, mouse and keyboard and live view over the browser's pipe, exactly the desk's size; a desk host, which has no screen or input of its own, gets screen, screen_stream and input on its desks; a click on a named desk scales to that desk's size; v0.22.0: desks on a desk host, found by the first production proof: a desk's temp folder is short (Chrome's SingletonSocket must fit a Unix socket path, and the desks folder on a desk host was too deep, so Chrome closed as it started), and a desk no command used for desk_idle_s (15 minutes) stops by itself, so a desk an agent left behind when it crashed or restarted ends; v0.21.0: element refs and receipts, the Desks plan's step D3: the agent's browser lists a page's interactive elements as compact refs (e7 button \"Sign in\"), kept on the element while it stays, and click, press and back answer with what changed (url, lines that appeared, lines gone), so agents act on the page's structure before screenshots; v0.20.0: desk hosts, the Desks plan's step D5: install.sh --desk-host turns an old laptop, a mini PC or a spare workstation into a host of desks for its owner's agents, run as its own openvibe-desk account with Xvfb, openbox, D-Bus, xdotool, ImageMagick and Chromium installed and max_desks sized to its memory; v0.19.0: several desks per machine, the Desks plan's step D1: the computer plugin runs named desks (desk-<name>, private kept), each with its own X display, D-Bus, browser profile, downloads and temp folder, up to max_desks (one per 2 GiB of memory by default), so two agents work at once without seeing or driving each other's screen; proven with two real Chrome desks that never share a cookie; v0.18.0: a motion command's validity also counts the time it waits in the queue to its plugin and in the pipe: the core fixes the instant it expires at receipt, writes what is left at the moment it writes the line with expires_at_mono_ns on the clock it shares with the plugin, and never writes one that has run out, and the Python runtime refuses one that ran out in the pipe before handle(); the clock bound is kept in nanoseconds and counts Bot's whole-millisecond stamps, rounded up from nanoseconds; a Node booting latched sets the e-stop on its plugins before starting them; the Python SDK package reports the Node release; v0.17.0: deadlines are checked against the latest the server's clock can be: the estimate plus its bound, the connection's fastest round trip, and motion waits for that bound on every new connection; every stop and estop names its cause, hello carries the newest halt epoch, and the Python SDK gives drivers on_stop(StopInfo) and on_resume to tell an ordinary stop from an emergency without re-arming anything; v0.16.1: the Python runtime refuses a motion command whose deadline passed while it waited behind a slow handler, so a driver never moves on a stale command; plugins.md answers where deadlines come from, re-stamping, order across a change of driver, stop versus estop, and that nothing is held past its validity; v0.16.0: openvibe-node quit ends a running node cleanly with nothing latched, for launchers; each driver command carries its provenance (link session, seq, Bot send time and deadline) and a halt's stop its turn_epoch, and the Python runtime refuses a replay on a session and a command from an ended turn; the no-redelivery contract written down in plugins.md): binaries for linux amd64/arm64/armv7, darwin amd64/arm64 and windows amd64, the plugins tarball, install.sh and SHA256SUMS",
+ "version": "0.29.0",
+ "release": "v0.29.0, published 2026-10-27 as a GitHub release (the ai plugin says how fast each model answers here: ai_tokens_per_s for the last model, averaged with the earlier answers, and ai_busy while one answers, shown beside the machine's models on openvibe.bot; v0.28.0: install.sh --ai sets up your own models: it adds the ai plugin, which a driver chosen later keeps, says which model server answers (Ollama, LM Studio, llama.cpp) or how to get one, and --ai-endpoint names another port; v0.27.0: your own models for your OpenVibe work: the ai plugin relays kind ai (contracts 0.219.0), one chat completion, to a local OpenAI-compatible model server (Ollama, LM Studio, llama.cpp), lists its models, runs one at a time, and reaches nothing else; the core routes ai to it like a computer command that never acts; v0.26.0: programs on a desk: exec takes desk, running in that desk's session so a program's windows open there, and detach, starting a program that keeps running on a whole desk (an office suite, a terminal) and ends with its desk; desk templates follow: research, office and dev desks; v0.25.0: the egress guard: every connection every agent browser makes, a page's own scripts included, goes through a proxy the plugin runs on 127.0.0.1 that resolves, checks and connects to the address it checked, so a page never reaches the machine's own network below level full; browser_egress: open turns it off; v0.24.1: a kept profile carries session cookies too: Chrome drops cookies that end with the browser as it quits, and a sign-in is often one, so a desk keeps them beside the profile and the next desk's browser gets them back as it starts, found by the production proof; v0.24.0: kept desk profiles: desk start takes profile, so a desk's browser starts from the person's kept profile (sign in once, on any desk) and saves it back when it stops, closing the browser gracefully so its cookies are written, the last desk to stop wins, caches never kept, and each desk's own copy removed; the browser follows a page its page opens (a sign-in popup, a new tab) and comes back when it closes; the agent's clicks count as a person's, so sites open their sign-in popups; v0.23.0: browser desks, the Desks plan's step D2: desk start takes kind browser, a headless browser alone with no display at about half the cost of a whole desk, on Linux or macOS; its page is its screen, mouse and keyboard and live view over the browser's pipe, exactly the desk's size; a desk host, which has no screen or input of its own, gets screen, screen_stream and input on its desks; a click on a named desk scales to that desk's size; v0.22.0: desks on a desk host, found by the first production proof: a desk's temp folder is short (Chrome's SingletonSocket must fit a Unix socket path, and the desks folder on a desk host was too deep, so Chrome closed as it started), and a desk no command used for desk_idle_s (15 minutes) stops by itself, so a desk an agent left behind when it crashed or restarted ends; v0.21.0: element refs and receipts, the Desks plan's step D3: the agent's browser lists a page's interactive elements as compact refs (e7 button \"Sign in\"), kept on the element while it stays, and click, press and back answer with what changed (url, lines that appeared, lines gone), so agents act on the page's structure before screenshots; v0.20.0: desk hosts, the Desks plan's step D5: install.sh --desk-host turns an old laptop, a mini PC or a spare workstation into a host of desks for its owner's agents, run as its own openvibe-desk account with Xvfb, openbox, D-Bus, xdotool, ImageMagick and Chromium installed and max_desks sized to its memory; v0.19.0: several desks per machine, the Desks plan's step D1: the computer plugin runs named desks (desk-<name>, private kept), each with its own X display, D-Bus, browser profile, downloads and temp folder, up to max_desks (one per 2 GiB of memory by default), so two agents work at once without seeing or driving each other's screen; proven with two real Chrome desks that never share a cookie; v0.18.0: a motion command's validity also counts the time it waits in the queue to its plugin and in the pipe: the core fixes the instant it expires at receipt, writes what is left at the moment it writes the line with expires_at_mono_ns on the clock it shares with the plugin, and never writes one that has run out, and the Python runtime refuses one that ran out in the pipe before handle(); the clock bound is kept in nanoseconds and counts Bot's whole-millisecond stamps, rounded up from nanoseconds; a Node booting latched sets the e-stop on its plugins before starting them; the Python SDK package reports the Node release; v0.17.0: deadlines are checked against the latest the server's clock can be: the estimate plus its bound, the connection's fastest round trip, and motion waits for that bound on every new connection; every stop and estop names its cause, hello carries the newest halt epoch, and the Python SDK gives drivers on_stop(StopInfo) and on_resume to tell an ordinary stop from an emergency without re-arming anything; v0.16.1: the Python runtime refuses a motion command whose deadline passed while it waited behind a slow handler, so a driver never moves on a stale command; plugins.md answers where deadlines come from, re-stamping, order across a change of driver, stop versus estop, and that nothing is held past its validity; v0.16.0: openvibe-node quit ends a running node cleanly with nothing latched, for launchers; each driver command carries its provenance (link session, seq, Bot send time and deadline) and a halt's stop its turn_epoch, and the Python runtime refuses a replay on a session and a command from an ended turn; the no-redelivery contract written down in plugins.md): binaries for linux amd64/arm64/armv7, darwin amd64/arm64 and windows amd64, the plugins tarball, install.sh and SHA256SUMS",
"runtime": "go 1.24 (one static binary per platform); plugins in Python 3; an ESP32 Arduino library",
"runtime": "go 1.24 (one static binary per platform); plugins in Python 3; an ESP32 Arduino library",
"database": "none",
"database": "none",
"deployed": false,
"deployed": false,
@@ -61,4 +61,7 @@ computer's models** for the machine on openvibe.bot/machines.
@@ -61,4 +61,7 @@ computer's models** for the machine on openvibe.bot/machines.
- At most 200 messages of 64 KB each. A model the machine does not offer is refused (`bad_value`), and so is a request
- At most 200 messages of 64 KB each. A model the machine does not offer is refused (`bad_value`), and so is a request
while another runs (`not_ready`).
while another runs (`not_ready`).
- When no model server answers, the command is `not_ready` and `describe` says `ai.ready: false`.
- When no model server answers, the command is `not_ready` and `describe` says `ai.ready: false`.
+- Its telemetry (Node v0.29.0) says whether a model is answering now (`ai_busy`). Once one has answered, it also gives
+ how fast the last model answered here (`ai_model`, `ai_tokens_per_s`): the reply's tokens over the whole answer's
+ time, averaged with the earlier answers, the latest counting most. openvibe.bot/machines shows it beside the models.
- It never acts on the machine, so it also runs while the machine's stop is latched.
- It never acts on the machine, so it also runs while the machine's stop is latched.
@@ -61,6 +61,10 @@ class Ai(Plugin):
@@ -61,6 +61,10 @@ class Ai(Plugin):
self.busy = threading.Lock()
self.busy = threading.Lock()
self.cfg = {}
self.cfg = {}
self.reachable = False
self.reachable = False
+ # How fast each model answers here, in tokens a second of its reply (an average that favours the latest
+ # answers), and the model that answered last: the telemetry Bot shows beside the machine's models.
+ self.speed = {}
+ self.last_model = None
# ---- the model server -----------------------------------------------------------------------------------------
# ---- the model server -----------------------------------------------------------------------------------------
def _get(self, url, timeout):
def _get(self, url, timeout):
@@ -149,6 +153,25 @@ class Ai(Plugin):
@@ -149,6 +153,25 @@ class Ai(Plugin):
threading.Thread(target=self._reply, args=(cmd.id, body), name="ai-reply", daemon=True).start()
threading.Thread(target=self._reply, args=(cmd.id, body), name="ai-reply", daemon=True).start()
raise Deferred()
raise Deferred()
+ def _measure(self, model, tokens, ms):
+ """Keeps how fast this model answered: the reply's tokens over the whole answer's time (reading the prompt
+ included, so it is what a person waits), averaged with the earlier answers, the latest counting most."""
+ if not isinstance(tokens, int) or tokens < 1 or not ms or ms < 1:
+ return
+ rate = tokens / (ms / 1000.0)
+ before = self.speed.get(model)
+ self.speed[model] = rate if before is None else before * 0.7 + rate * 0.3
+ self.last_model = model
+
+ def poll(self):
+ """Sensors: whether a model is answering now (ai_busy), and the last model's speed here (ai_model,
+ ai_tokens_per_s) once one has answered."""
+ sensors = {"ai_busy": 1 if self.busy.locked() else 0}
+ if self.last_model is not None and self.last_model in self.speed:
+ sensors["ai_model"] = self.last_model[:120]
+ sensors["ai_tokens_per_s"] = round(self.speed[self.last_model], 1)
+ return {"sensors": sensors}
+
def _configure_again(self):
def _configure_again(self):
try:
try:
self._configure(self.cfg)
self._configure(self.cfg)
@@ -176,6 +199,7 @@ class Ai(Plugin):
@@ -176,6 +199,7 @@ class Ai(Plugin):
if isinstance(usage, dict):
if isinstance(usage, dict):
out["usage"] = {k: int(usage[k]) for k in ("prompt_tokens", "completion_tokens", "total_tokens")
out["usage"] = {k: int(usage[k]) for k in ("prompt_tokens", "completion_tokens", "total_tokens")
if isinstance(usage.get(k), (int, float))}
if isinstance(usage.get(k), (int, float))}
+ self._measure(body["model"], (out.get("usage") or {}).get("completion_tokens"), out["ms"])
self.complete(cid, out)
self.complete(cid, out)
except urllib.error.HTTPError as e:
except urllib.error.HTTPError as e:
self.fail(cid, "hardware", "the model server answered %d" % e.code)
self.fail(cid, "hardware", "the model server answered %d" % e.code)
@@ -110,6 +110,17 @@ class AiPluginTest(unittest.TestCase):
@@ -110,6 +110,17 @@ class AiPluginTest(unittest.TestCase):
self.assertEqual(path, "/v1/chat/completions")
self.assertEqual(path, "/v1/chat/completions")
self.assertEqual((body["max_tokens"], body["temperature"], body["stream"]), (50, 0.2, False))
self.assertEqual((body["max_tokens"], body["temperature"], body["stream"]), (50, 0.2, False))
+ def test_it_says_how_fast_each_model_answers_here(self):
+ self.boot()
+ self.assertEqual(self.p.poll(), {"sensors": {"ai_busy": 0}}, "nothing measured before the first answer")
+ ticks = iter([10.0, 10.5, 20.0, 21.0]) # the first answer takes 0.5 s, the second 1 s
+ self.p.clock = lambda: next(ticks)
+ self.ask({"model": "llama3.1:8b", "messages": [{"role": "user", "content": "hi"}]})
+ self.assertEqual(self.p.poll()["sensors"], {"ai_busy": 0, "ai_model": "llama3.1:8b", "ai_tokens_per_s": 8.0},
+ "4 reply tokens in half a second")
+ self.ask({"model": "llama3.1:8b", "messages": [{"role": "user", "content": "again"}]})
+ self.assertEqual(self.p.poll()["sensors"]["ai_tokens_per_s"], round(8.0 * 0.7 + 4.0 * 0.3, 1), "the latest counts most")
+
def test_a_config_can_offer_fewer_models_and_cap_the_reply(self):
def test_a_config_can_offer_fewer_models_and_cap_the_reply(self):
caps = self.boot(models=["qwen2.5:7b"], max_tokens=100)[0]["capabilities"]["ai"]
caps = self.boot(models=["qwen2.5:7b"], max_tokens=100)[0]["capabilities"]["ai"]
self.assertEqual(caps["models"], ["qwen2.5:7b"])
self.assertEqual(caps["models"], ["qwen2.5:7b"])
@@ -24,7 +24,7 @@ __all__ = ["Plugin", "Fault", "Deferred", "Command", "StopInfo", "Runtime", "run
@@ -24,7 +24,7 @@ __all__ = ["Plugin", "Fault", "Deferred", "Command", "StopInfo", "Runtime", "run
"RUNTIME_CAUSES", "SHARED_CLOCK", "__version__"]
"RUNTIME_CAUSES", "SHARED_CLOCK", "__version__"]
# The Node release this runtime ships with (pyproject.toml carries the same).
# The Node release this runtime ships with (pyproject.toml carries the same).
-__version__ = "0.28.0"
+__version__ = "0.29.0"
PROTOCOL_VERSION = 1
PROTOCOL_VERSION = 1
HEARTBEAT_TIMEOUT_S = 1.0
HEARTBEAT_TIMEOUT_S = 1.0
DEFAULT_DEADLINE_MS = 300
DEFAULT_DEADLINE_MS = 300
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
[project]
name = "openvibe-plugin"
name = "openvibe-plugin"
-version = "0.28.0"
+version = "0.29.0"
description = "Runtime for OpenVibe Node plugins (JSON lines over stdin/stdout, deadlines, heartbeat, e-stop)"
description = "Runtime for OpenVibe Node plugins (JSON lines over stdin/stdout, deadlines, heartbeat, e-stop)"
requires-python = ">=3.9"
requires-python = ">=3.9"
license = { text = "AGPL-3.0-or-later" }
license = { text = "AGPL-3.0-or-later" }
@@ -439,7 +439,7 @@ class RuntimeTest(unittest.TestCase):
@@ -439,7 +439,7 @@ class RuntimeTest(unittest.TestCase):
self.assertEqual(rt.stop_infos[-1].halt_epoch, 1792900000500)
self.assertEqual(rt.stop_infos[-1].halt_epoch, 1792900000500)
def test_the_package_reports_the_node_release(self):
def test_the_package_reports_the_node_release(self):
- self.assertEqual(openvibe_plugin.__version__, "0.28.0")
+ self.assertEqual(openvibe_plugin.__version__, "0.29.0")
with open(os.path.join(os.path.dirname(HERE), "pyproject.toml")) as f:
with open(os.path.join(os.path.dirname(HERE), "pyproject.toml")) as f:
self.assertIn('version = "%s"' % openvibe_plugin.__version__, f.read())
self.assertIn('version = "%s"' % openvibe_plugin.__version__, f.read())