openvibers/openvibe.sdk Commits

0.67.0: bot.machines.chat asks a machine's own model (kind ai) and aiModels is the owner's switch; Machine types with computer.ai and ai_speed (#91)

OP OpenVibers committed 17 days ago · ff91f0320f · 10 files, +91 −11

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 3e5fcc7..7eca5f2 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -3,6 +3,17 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
release may change an API and says so here.
++## 0.67.0 (2026-10-27)
++
++- **`openvibe-sdk/bot`: a machine's own models** (AI on your own machines; OpenVibe.Node v0.27.0's ai plugin).
++ - `bot.machines.chat(id, { model, messages, max_tokens?, temperature? })` asks one of them for one reply (bot.command@1
++ kind `ai`) and answers `{ model, message, usage?, ms? }`. A machine that does not answer throws an `OpenVibeError`
++ with code `bot.ai_nack`.
++ - `bot.machines.aiModels(id, on)` is the owner's "Let my AI use this computer's models".
++- Types: `Machine` for `machines.list()`, with `computer.ai` (the models and whether a server answers) and
++ `computer.ai_speed` (how fast the last model answered there, Node v0.29.0); `MachineChatInput` and `MachineChatResult`;
++ `MachineCommandInput.kind` takes `ai`.
++
## 0.66.0 (2026-10-27)
- **`openvibe-sdk/bot`: machines and desks** (the Desks plan; the roadmap's SDK section). `bot.machines.list()`,
ddiff --git a/README.md b/README.md
iindex 45d3c55..41ce2ef 100644
---- a/README.md
++++ b/README.md
@@@ -2,14 +2,14 @@
> Supported browser and server clients for the OpenVibe platform.
--**Status:** alpha, v0.66.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat and AI clients from WS-F task 4; per-actor limits from WS-R task 4). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
++**Status:** alpha, v0.67.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat and AI clients from WS-F task 4; per-actor limits from WS-R task 4). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.66.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0
```
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from Contracts v0.86.0, a devDependency the tests check them against).
ddiff --git a/STATUS.json b/STATUS.json
iindex 8bc64c8..8c42d47 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.66.0",
++ "version": "0.67.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex 88e30b0..693c970 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,4 +1,4 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.66.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.67.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, registry, modules, realtime, media, community, bot, search, themes, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
@@@ -43,7 +43,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.66.0';
++const SDK_VERSION = '0.67.0';
module.exports = {
createClient,
@@@ -1780,6 +1780,11 @@ function (module, exports) {
* input and exec on it. A desk command that the machine does not do throws an OpenVibeError with code `bot.desk_nack`
* (the machine's own fault code is `fault`), so a desk reads as plain awaits.
*
++ * A machine's own models (AI on your own machines, contracts 0.219.0, OpenVibe.Node v0.27.0's ai plugin):
++ * `machines.chat(id, { model, messages, max_tokens?, temperature? })` asks one of them for one reply (kind ai) and answers
++ * `{ model, message: { role, content }, usage?, ms? }`; a machine that does not answer throws `bot.ai_nack`.
++ * `machines.aiModels(id, on)` is the owner's "Let my AI use this computer's models".
++ *
* Not wrapped: the operator WebSocket (/control — Realtime frames bot.command@1), device
* bootstrap (POST /devices/bind, a Network node token) and the internal Run -> Bot job dispatch
* (bot.job.dispatch is service-to-service and is never delegated to people or apps). The signed
@@@ -1883,6 +1888,21 @@ function createBotClient(client, defaults = {}) {
command: (id, input, o = {}) => call({ method: 'POST', path: `/api/v1/machines/${enc(id)}/commands`, json: input }, o),
/** { desk, person_driving, last_person_at } — whether a person is using that desk now (the person always wins). */
deskTurn: (id, desk, o = {}) => call({ path: `/api/v1/machines/${enc(id)}/desk-turn`, query: { desk } }, o),
++ /**
++ * One reply from a model on the machine (kind ai): { model, messages, max_tokens?, temperature? } ->
++ * { model, message: { role, content }, usage?, ms? }. Its models are machines.list()'s computer.ai.models, and
++ * how fast they answer there computer.ai_speed. A machine that does not answer throws (code bot.ai_nack).
++ */
++ async chat(id, { model, messages, max_tokens, temperature } = {}, o = {}) {
++ if (typeof model !== 'string' || !model) throw new TypeError('model is one of the machine\'s models (computer.ai.models)');
++ if (!Array.isArray(messages) || !messages.length) throw new TypeError('messages are [{ role, content }, …]');
++ const value = { model, messages, ...(Number.isInteger(max_tokens) ? { max_tokens } : {}), ...(typeof temperature === 'number' ? { temperature } : {}) };
++ const r = await machines.command(id, { kind: 'ai', value, ms: o.ms || 120000 }, o);
++ if (r && r.result === 'ack') return r.data === undefined ? {} : r.data;
++ throw new OpenVibeError({ code: 'bot.ai_nack', status: 409, message: `the machine's model did not answer: ${(r && (r.reason || r.result)) || 'no answer'}`, detail: r && r.reason });
++ },
++ /** { machine } — the owner's "Let my AI use this computer's models" on or off (OpenVibe.AI, for them only). */
++ aiModels: (id, on, o = {}) => call({ method: 'POST', path: `/api/v1/machines/${enc(id)}/ai-models`, json: { on: on === true } }, o),
/**
* A desk of your own on a machine (the Desks plan): a screen or a browser alone, its own files and sign-ins.
* const desk = bot.machines.desk(machineId, { kind: 'browser', profile: 'agent' });
ddiff --git a/package-lock.json b/package-lock.json
iindex 05d35ae..3c8eba3 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.66.0",
++ "version": "0.67.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.66.0",
++ "version": "0.67.0",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex 7fd5db1..2adef70 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.66.0",
++ "version": "0.67.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"types": "types/index.d.ts",
ddiff --git a/src/bot.js b/src/bot.js
iindex c0c4c7e..092866a 100644
---- a/src/bot.js
++++ b/src/bot.js
@@@ -21,6 +21,11 @@
* input and exec on it. A desk command that the machine does not do throws an OpenVibeError with code `bot.desk_nack`
* (the machine's own fault code is `fault`), so a desk reads as plain awaits.
*
++ * A machine's own models (AI on your own machines, contracts 0.219.0, OpenVibe.Node v0.27.0's ai plugin):
++ * `machines.chat(id, { model, messages, max_tokens?, temperature? })` asks one of them for one reply (kind ai) and answers
++ * `{ model, message: { role, content }, usage?, ms? }`; a machine that does not answer throws `bot.ai_nack`.
++ * `machines.aiModels(id, on)` is the owner's "Let my AI use this computer's models".
++ *
* Not wrapped: the operator WebSocket (/control — Realtime frames bot.command@1), device
* bootstrap (POST /devices/bind, a Network node token) and the internal Run -> Bot job dispatch
* (bot.job.dispatch is service-to-service and is never delegated to people or apps). The signed
@@@ -124,6 +129,21 @@ function createBotClient(client, defaults = {}) {
command: (id, input, o = {}) => call({ method: 'POST', path: `/api/v1/machines/${enc(id)}/commands`, json: input }, o),
/** { desk, person_driving, last_person_at } — whether a person is using that desk now (the person always wins). */
deskTurn: (id, desk, o = {}) => call({ path: `/api/v1/machines/${enc(id)}/desk-turn`, query: { desk } }, o),
++ /**
++ * One reply from a model on the machine (kind ai): { model, messages, max_tokens?, temperature? } ->
++ * { model, message: { role, content }, usage?, ms? }. Its models are machines.list()'s computer.ai.models, and
++ * how fast they answer there computer.ai_speed. A machine that does not answer throws (code bot.ai_nack).
++ */
++ async chat(id, { model, messages, max_tokens, temperature } = {}, o = {}) {
++ if (typeof model !== 'string' || !model) throw new TypeError('model is one of the machine\'s models (computer.ai.models)');
++ if (!Array.isArray(messages) || !messages.length) throw new TypeError('messages are [{ role, content }, …]');
++ const value = { model, messages, ...(Number.isInteger(max_tokens) ? { max_tokens } : {}), ...(typeof temperature === 'number' ? { temperature } : {}) };
++ const r = await machines.command(id, { kind: 'ai', value, ms: o.ms || 120000 }, o);
++ if (r && r.result === 'ack') return r.data === undefined ? {} : r.data;
++ throw new OpenVibeError({ code: 'bot.ai_nack', status: 409, message: `the machine's model did not answer: ${(r && (r.reason || r.result)) || 'no answer'}`, detail: r && r.reason });
++ },
++ /** { machine } — the owner's "Let my AI use this computer's models" on or off (OpenVibe.AI, for them only). */
++ aiModels: (id, on, o = {}) => call({ method: 'POST', path: `/api/v1/machines/${enc(id)}/ai-models`, json: { on: on === true } }, o),
/**
* A desk of your own on a machine (the Desks plan): a screen or a browser alone, its own files and sign-ins.
* const desk = bot.machines.desk(machineId, { kind: 'browser', profile: 'agent' });
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 155e247..bf24a4e 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.66.0';
++const SDK_VERSION = '0.67.0';
module.exports = {
createClient,
ddiff --git a/test/bot.test.js b/test/bot.test.js
iindex 6cfc99a..598fe59 100644
---- a/test/bot.test.js
++++ b/test/bot.test.js
@@@ -67,12 +67,15 @@ function server() {
if (m === 'GET' && p === '/api/v1/kits/starter') return send(res, 200, { kit: { id: 'starter' } });
if (m === 'GET' && p === '/api/v1/machines') return send(res, 200, { machines: [{ id: DEV, name: 'desk-host', online: true, computer: { desk: { named: true, kinds: ['browser'] } } }] });
if (m === 'GET' && p === `/api/v1/machines/${DEV}/computer`) return send(res, 200, { machine: { id: DEV } });
++ if (m === 'POST' && p === `/api/v1/machines/${DEV}/ai-models`) return send(res, 200, { machine: { id: DEV, ai_models: JSON.parse(body.toString() || '{}').on === true } });
if (m === 'GET' && p === `/api/v1/machines/${DEV}/desk-turn`) return send(res, 200, { desk: u.searchParams.get('desk'), person_driving: false, last_person_at: null });
if (m === 'POST' && p === `/api/v1/machines/${DEV}/commands`) {
const input = JSON.parse(body.toString() || '{}');
seenCommands.push(input);
if (input.kind === 'browser' && input.value.action === 'click') return send(res, 200, { machine_id: DEV, result: 'nack', reason: 'nothing on the page matches #missing' });
if (input.kind === 'desk') return send(res, 200, { machine_id: DEV, result: 'ack', data: { running: input.value.action === 'start', desk: input.value.desk, kind: input.value.kind || 'desktop' } });
++ if (input.kind === 'ai' && input.value.model === 'busy') return send(res, 200, { machine_id: DEV, result: 'nack', reason: 'the model is answering another request on this machine' });
++ if (input.kind === 'ai') return send(res, 200, { machine_id: DEV, result: 'ack', data: { model: input.value.model, message: { role: 'assistant', content: 'A lock one thread holds.' }, usage: { completion_tokens: 6 }, ms: 400 } });
return send(res, 200, { machine_id: DEV, result: 'ack', data: { url: 'https://example.org/', title: 'Example' } });
}
if (p.startsWith('/api/v1/machines/')) return problem(res, 404, 'bot.device_not_found', 'no such machine of yours');
@@@ -107,6 +110,19 @@ run([
assert.throws(() => bot.machines.desk(DEV, { name: 'private' }), TypeError, 'only a named desk of your own');
} finally { await srv.close(); }
}],
++ ['a machine\'s own models: one reply (kind ai), a nack thrown, the owner\'s switch', async () => {
++ const srv = await server();
++ try {
++ const bot = createBotClient(createClient({ baseUrls: { bot: srv.url }, token: 'svc' }), { actingSubject: USR });
++ const out = await bot.machines.chat(DEV, { model: 'llama3.1:8b', messages: [{ role: 'user', content: 'What is a mutex?' }], max_tokens: 80 });
++ assert.deepStrictEqual(out, { model: 'llama3.1:8b', message: { role: 'assistant', content: 'A lock one thread holds.' }, usage: { completion_tokens: 6 }, ms: 400 });
++ assert.deepStrictEqual(seenCommands.at(-1), { kind: 'ai', value: { model: 'llama3.1:8b', messages: [{ role: 'user', content: 'What is a mutex?' }], max_tokens: 80 }, ms: 120000 });
++ await assert.rejects(() => bot.machines.chat(DEV, { model: 'busy', messages: [{ role: 'user', content: 'x' }] }), (e) => e.code === 'bot.ai_nack' && /another request/.test(e.message));
++ await assert.rejects(() => bot.machines.chat(DEV, { model: '', messages: [] }), TypeError);
++ assert.strictEqual((await bot.machines.aiModels(DEV, true)).machine.ai_models, true);
++ assert.strictEqual((await bot.machines.aiModels(DEV, false)).machine.ai_models, false);
++ } finally { await srv.close(); }
++ }],
['a service acting for a person sends X-OV-Subject, its token and an Idempotency-Key', async () => {
const srv = await server();
const bot = createBotClient(createClient({ baseUrls: { bot: srv.url }, token: 'svc-token' }), { actingSubject: USR });
ddiff --git a/types/bot.d.ts b/types/bot.d.ts
iindex 713d9ab..c0e47f8 100644
---- a/types/bot.d.ts
++++ b/types/bot.d.ts
@@@ -81,7 +81,18 @@ export interface PairInput {
capabilities?: Record<string, unknown>;
name?: string;
}
--export interface MachineCommandInput { id?: string; kind: 'exec' | 'fs' | 'screen' | 'input' | 'browser' | 'probe' | 'desk'; value: Record<string, unknown>; ms?: number; }
++export interface MachineCommandInput { id?: string; kind: 'exec' | 'fs' | 'screen' | 'input' | 'browser' | 'probe' | 'desk' | 'ai'; value: Record<string, unknown>; ms?: number; }
++/** A machine's own models (OpenVibe.Node's ai plugin): what it offers and how fast its last model answered there. */
++export interface MachineAi { models: string[]; ready: boolean; max_tokens?: number; }
++export interface MachineAiSpeed { model: string; tokens_per_s: number; }
++export interface Machine {
++ id: string; name: string | null; online: boolean;
++ agent_computer?: boolean; ai_models?: boolean; watch_probe?: boolean;
++ computer: ({ ai?: MachineAi | null; ai_speed?: MachineAiSpeed | null; desks_running?: number | null; [field: string]: unknown }) | null;
++ [field: string]: unknown;
++}
++export interface MachineChatInput { model: string; messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>; max_tokens?: number; temperature?: number; }
++export interface MachineChatResult { model: string; message: { role: 'assistant'; content: string }; usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number }; finish_reason?: string; ms?: number; }
export interface MachineCommandResult { machine_id?: string; id?: string; result: 'ack' | 'nack' | 'expired' | 'refused' | string; data?: Record<string, unknown>; code?: string; reason?: string | null; [field: string]: unknown; }
export interface DeskOptions { name?: string; kind?: 'browser' | 'desktop'; profile?: string; width?: number; height?: number; }
export interface Desk {
@@@ -97,11 +108,13 @@ export interface Desk {
turn(): Promise<{ desk: string; person_driving: boolean; last_person_at: string | null }>;
}
export interface MachinesApi {
-- list(opts?: CallOptions): Promise<{ machines: Array<Record<string, unknown>> }>;
++ list(opts?: CallOptions): Promise<{ machines: Machine[] }>;
computer(id: string, opts?: CallOptions): Promise<{ machine: Record<string, unknown> } | null>;
command(id: string, input: MachineCommandInput, opts?: CallOptions): Promise<MachineCommandResult>;
deskTurn(id: string, desk: string, opts?: CallOptions): Promise<{ desk: string; person_driving: boolean; last_person_at: string | null }>;
desk(id: string, options?: DeskOptions, opts?: CallOptions): Desk;
++ chat(id: string, input: MachineChatInput, opts?: CallOptions & { ms?: number }): Promise<MachineChatResult>;
++ aiModels(id: string, on: boolean, opts?: CallOptions): Promise<{ machine: Machine }>;
}
export interface BotClient {
robots: RobotsApi;