openvibers/openvibe.bot Commits
Community AI offers: report a machine's offer and place an opted-in run (#308)
Changes
nnew file mode 100644@@@ -0,0 +1,21 @@++# Community AI offers through Bot++++OpenVibe.Node's `ai` plugin can offer a person's local models to the network. Bot is the transport and the reporter:++++- **Report** — `server/jobs/offers.js` `buildAi()` reads every live Network-paired device whose++ `capabilities.ai.ai.offer.enabled` is true and publishes one offer per machine to Network's++ `POST /internal/ai/offers/report` (`source: bot`) with the owner, models, hours, concurrency, credit price, audience,++ ready flag and health. It is a separate report from the worker/desk `platform.resource-offer@1` set, sent by the same++ publisher (`createAiOfferReporter`, `server/network.js`).++- **Dispatch** — `POST /api/v1/machines/:id/commands` with `kind: ai` reaches a machine only from OpenVibe.AI++ (`svc:ai`, `bot.machine.ai`). A machine's own models need `ai_models` (the owner's "Let my AI use this computer's++ models" switch). A **community** run is marked with the `community/<model>` model prefix and is allowed when the++ machine's own offer is enabled: the offer is the opt-in, and the Node plugin enforces hours, concurrency and its++ model list. The prefix never reaches the local model server.++++The command's value is `bot.command@1`, which is closed to extra fields, so the prefix carries the community flag++without changing the contract. The offer itself is not a `platform.resource-offer@1`: that contract closes `detail`++for kind node/provider and cannot carry a model list, hours, a price and an audience. Network has the dedicated++`platform_ai_offers` table.++++Test: `node test/community-ai.test.js`.@@@ -53,6 +53,7 @@ const machineRoles = require('../machine-roles');const kits = require('../kits');const resourceIndex = require('./resource-index');const resourceControl = require('./resource-control');++const { aiOf } = require('../machine-ai');const deskLeases = require('./desk-leases');const CAP = {@@@ -78,9 +79,22 @@ const AGENT_SERVICES = new Set(['svc:actor']);const MACHINE_SERVICES = {'svc:actor': { kinds: null, flag: 'agent_computer', off: 'the owner has not let their agent use this computer' },'svc:watch': { kinds: new Set(['probe']), flag: 'watch_probe', off: 'the owner has not let their watches check things from this computer' },-- // AI on your own machines (contracts 0.219.0): OpenVibe.AI runs a model on the owner's own model server, nothing else.-- 'svc:ai': { kinds: new Set(['ai']), flag: 'ai_models', off: 'the owner has not let their AI use this computer\'s models', cap: 'bot.machine.ai' },++ // AI (contracts 0.219.0): OpenVibe.AI runs a model on the owner's own model server for the owner, or — only for a++ // machine whose ai plugin offers its models to the network — a community request that opted in (below).++ // A command that says `community: true` runs on the machine's offer to the network instead, so the Node ai plugin's++ // own opt-in (capabilities.ai.offer.enabled) stands in for the "Let my AI use this computer's models" switch.++ 'svc:ai': { kinds: new Set(['ai']), flag: 'ai_models', off: 'the owner has not let their AI use this computer\'s models', cap: 'bot.machine.ai', community: true },};++/**++ * A machine offering its models to the network (OpenVibe.Node ai plugin 0.3.0): the owner turned on "Offer to the++ * network" and the offer names its models. Hours, concurrency and price are the plugin's to enforce; Bot only decides++ * whether AI may send a community run here at all.++ */++function communityAi(capabilities) {++ const ai = aiOf(capabilities);++ const offer = ai && ai.offer;++ return Boolean(offer && offer.enabled === true && Array.isArray(offer.models) && offer.models.length);++}// The network's machines hub (plan L34): the one service that acts for a person on their own machines. The// capability alone is not enough: any service holding it could name any person in X-OV-Subject, so the hub is// this service and no other.@@@ -498,7 +512,12 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submconst device = await domain.devices.get(String(req.params.id || '').slice(0, 64));const ownerSubject = device && !device.revoked_at ? await domain.devices.ownerOf(device) : null;if (!ownerSubject || ownerSubject !== subject) fail(404, 'bot.device_not_found', 'no such machine of yours');-- if (agentNeedsOptIn && svc && !device[svc.flag]) fail(403, 'bot.agent_not_allowed', svc.off);++ // A community run is the machine's own offer to the network, which is its own opt-in; anything else AI asks++ // of it (the owner's own models) still needs "Let my AI use this computer's models".++ const community = Boolean(svc && svc.community && req.body && req.body.value && typeof req.body.value.model === 'string' && req.body.value.model.startsWith('community/'));++ if (agentNeedsOptIn && svc && !device[svc.flag] && !(community && communityAi(device.capabilities))) {++ fail(403, 'bot.agent_not_allowed', community ? 'the owner has not offered this computer\'s models to the network' : svc.off);++ }return { device, subject };}const machineOut = (device) => {@@@ -90,7 +90,11 @@ function createApp(opts = {}) {// started by server/index.js with the other jobs. Without Network credentials nothing is reported.const offerReporter = opts.offerReporter !== undefined ? opts.offerReporter: (config.oauth.clientSecret ? createOfferReporter(config, { fetchImpl }) : null);-- const offers = createOfferPublisher({ db, hub, reporter: offerReporter, log, now, firstPartyOwners: (config.deskHosts && config.deskHosts.firstPartyOwners) || [] });++ // A test that injects its own worker reporter gets no real Network client for AI offers unless it injects that too.++ const aiOfferReporter = opts.aiOfferReporter !== undefined ? opts.aiOfferReporter++ : (opts.offerReporter !== undefined ? null : (config.oauth.clientSecret ? require('./network').createAiOfferReporter(config, { fetchImpl }) : null));++ const offers = createOfferPublisher({ db, hub, reporter: offerReporter, aiReporter: aiOfferReporter, log, now,++ firstPartyOwners: (config.deskHosts && config.deskHosts.firstPartyOwners) || [] });domain.devices.onChange(() => offers.poke());const sim = opts.sim || createSimulator({ config, domain, hub, now, log });// Server-side ONVIF cameras (server/onvif): the same in-process attach as the simulator, but with a real@@@ -9,6 +9,12 @@** createOfferPublisher({ db, hub, reporter, log, now, intervalMs, settleMs }) → { build, publish, start, stop, poke }*++ * The same set carries a community AI offer (the design's "Offer to the network"): a machine whose OpenVibe.Node ai++ * plugin advertises `capabilities.ai.offer.enabled` with models, hours, concurrency, a credit price and an audience is++ * one user-owned provider offer, `ai:<device>`, whose detail.kind is `ai` and whose health follows its connection and++ * its own open/ready flags. Network validates the detail; OpenVibe.AI reads the internal list and places only requests++ * that opt in to community capacity on it.++ ** An offer that does not match the contract is left out (Network would refuse the whole report for one bad offer).* Without a reporter (no Network credentials) nothing is reported.*/@@@ -18,7 +24,25 @@ const { json } = require('../util');const CONTRACT = 'platform.resource-offer@1';const CLASSES = new Set(['function', 'code', 'browser', 'linux', 'desktop', 'gpu']);--function createOfferPublisher({ db, hub, reporter = null, log = console, now = () => Date.now(), intervalMs = 60_000, settleMs = 2_000, firstPartyOwners = [] }) {++/** The ai plugin's offer, or null when it is off or incomplete (an incomplete one is left out, never half reported). */++function aiOfferOf(capabilities) {++ // The Node reports each plugin's describe under its name, so the ai plugin's {ai: {models, offer}} arrives as++ // capabilities.ai.ai; a direct capabilities.ai.offer is accepted too (an older/other reporter).++ let ai = json(capabilities, {}).ai;++ if (ai && ai.ai) ai = ai.ai;++ const offer = ai && ai.offer;++ if (!offer || offer.enabled !== true || !Array.isArray(offer.models) || !offer.models.length) return null;++ if (!offer.hours || typeof offer.hours !== 'object' || !offer.price || typeof offer.price !== 'object') return null;++ if (!Number.isInteger(offer.concurrency) || offer.concurrency < 1 || offer.concurrency > 8) return null;++ if (!['everyone', 'followers', 'group'].includes(offer.audience)) return null;++ if (offer.audience === 'group' && (typeof offer.group !== 'string' || !offer.group.trim())) return null;++ for (const key of ['in_per_million', 'out_per_million']) {++ if (typeof offer.price[key] !== 'number' || !Number.isFinite(offer.price[key]) || offer.price[key] < 0) return null;++ }++ return offer;++}++++function createOfferPublisher({ db, hub, reporter = null, aiReporter = null, log = console, now = () => Date.now(), intervalMs = 60_000, settleMs = 2_000, firstPartyOwners = [] }) {const firstParty = new Set(firstPartyOwners);let timer = null;let soon = null;@@@ -80,33 +104,64 @@ function createOfferPublisher({ db, hub, reporter = null, log = console, now = (return offers;}++ /** The community AI offers: one per machine whose Node ai plugin advertises an enabled offer. */++ async function buildAi() {++ const rows = await db.many(++ `SELECT d.id, d.capabilities, d.owner_subject, r.owner_subject AS robot_owner++ FROM devices d LEFT JOIN robots r ON r.id = d.robot_ids ->> 0++ WHERE d.revoked_at IS NULL AND (d.capabilities -> 'ai' -> 'ai' -> 'offer' ->> 'enabled' = 'true'++ OR d.capabilities -> 'ai' -> 'offer' ->> 'enabled' = 'true')++ ORDER BY d.id`);++ const at = new Date(now()).toISOString();++ const offers = [];++ for (const row of rows) {++ const offer = aiOfferOf(row.capabilities);++ const owner = row.owner_subject || row.robot_owner;++ if (!offer || !owner) continue;++ const online = Boolean(hub && hub.isOnline(row.id));++ offers.push({++ offer_id: `ai:${row.id}`, machine_id: row.id, owner,++ models: offer.models.slice(0, 40),++ hours: { start: offer.hours.start, end: offer.hours.end, tz: offer.hours.tz },++ concurrency: offer.concurrency,++ price: { in_per_million: offer.price.in_per_million, out_per_million: offer.price.out_per_million },++ audience: offer.audience, group: offer.audience === 'group' ? offer.group : null,++ ready: online && offer.ready === true,++ health: { status: online && offer.ready === true ? 'up' : 'down', checked_at: at },++ });++ }++ return offers;++ }++/** Report the whole set now (one report at a time; a change during one reports again after it). */async function publish() {-- if (!reporter) return null;++ if (!reporter && !aiReporter) return null;if (running) { again = true; return running; }running = (async () => {++ const out = {};try {-- return await reporter.report(await build());-- } catch (e) {-- log.warn(`[Bot] offers report: ${e.message}`);-- return null;-- } finally {-- running = null;-- if (again) { again = false; poke(); }-- }-- })();++ if (reporter) out.offers = await reporter.report(await build());++ } catch (e) { log.warn(`[Bot] offers report: ${e.message}`); }++ try {++ if (aiReporter) out.ai = await aiReporter.report(await buildAi());++ } catch (e) { log.warn(`[Bot] ai offers report: ${e.message}`); }++ return out;++ })().finally(() => {++ running = null;++ if (again) { again = false; poke(); }++ });return running;}/** A device changed: report a moment later (changes in a burst are reported once). Only once started. */function poke() {-- if (!reporter || !timer || soon) return;++ if ((!reporter && !aiReporter) || !timer || soon) return;soon = setTimeout(() => { soon = null; void publish(); }, settleMs);if (soon.unref) soon.unref();}function start() {-- if (!reporter || timer) return;++ if ((!reporter && !aiReporter) || timer) return;timer = setInterval(() => { void publish(); }, intervalMs);if (timer.unref) timer.unref();poke();@@@ -117,7 +172,7 @@ function createOfferPublisher({ db, hub, reporter = null, log = console, now = (timer = null;soon = null;}-- return { build, publish, start, stop, poke };++ return { build, buildAi, publish, start, stop, poke };}--module.exports = { createOfferPublisher };++module.exports = { createOfferPublisher, aiOfferOf };@@@ -89,7 +89,7 @@ function createNodePrincipals(config, { fetchImpl = globalThis.fetch } = {}) {* report(offers) → Network's answer { source, offers, marked_down }* A failure throws 503 bot.network_unavailable.*/--function createOfferReporter(config, { fetchImpl = globalThis.fetch } = {}) {++function createOfferReporter(config, { fetchImpl = globalThis.fetch, path = '/internal/resources/report' } = {}) {const tokens = serviceAuth.createTokenClient({tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,audience: 'openvibe.network', scope: 'network.resource.report', fetchImpl,@@@ -97,7 +97,7 @@ function createOfferReporter(config, { fetchImpl = globalThis.fetch } = {}) {async function report(offers) {let res;try {-- res = await fetchImpl(`${config.network.internalUrl}/internal/resources/report`, {++ res = await fetchImpl(`${config.network.internalUrl}${path}`, {method: 'POST', headers: { ...(await tokens.authHeaders()), Accept: 'application/json', 'Content-Type': 'application/json' },body: JSON.stringify({ source: 'bot', offers }), signal: AbortSignal.timeout(8000),});@@@ -186,4 +186,9 @@ function createIdentity(config, { fetchImpl = globalThis.fetch, now = () => Datereturn { byUsername, names };}--module.exports = { createKeyProvider, createUserAuth, createNodePrincipals, createIdentity, createOfferReporter };++/** The community AI offers Bot reports (design: "Offer to the network"; Network /internal/ai/offers/report). */++function createAiOfferReporter(config, { fetchImpl = globalThis.fetch } = {}) {++ return createOfferReporter(config, { fetchImpl, path: '/internal/ai/offers/report' });++}++++module.exports = { createKeyProvider, createUserAuth, createNodePrincipals, createIdentity, createOfferReporter, createAiOfferReporter };@@@ -793,7 +793,9 @@ function renderMachinesPage({ machines = [], pairing = null }) {const caps = m.capabilities && typeof m.capabilities === 'object' ? m.capabilities : {};// Its own models (OpenVibe Node's ai plugin, server/machine-ai.js): what it offers, and whether a model server answers.const models = aiOf(caps);-- const ai = models ? { models: Array.isArray(models.models) ? models.models.slice(0, 20).map(String) : [], ready: models.ready === true } : null;++ const ai = models ? { models: Array.isArray(models.models) ? models.models.slice(0, 20).map(String) : [], ready: models.ready === true,++ offer: models.offer && typeof models.offer === 'object' ? models.offer : null,++ speed: models.speed && typeof models.speed === 'object' ? models.speed : null } : null;const c = caps.computer;if (!c || typeof c !== 'object') return ai ? { on: false, ai } : null;const level = m.computer_level || ((c.files || c.terminal || c.screen || {}).level) || null;@@@ -848,6 +850,14 @@ function renderMachinesPage({ machines = [], pairing = null }) {// Turned on: OpenVibe.Chat's "Your AI key" comes filled in with this computer and its first model (saved only there).// With several such machines, any of them (contracts 0.221.0): the fastest that is on answers.+ (m.ai_models && c.ai.models.length ? `<a class="button quiet" href="https://openvibe.chat/settings?own=node&machine=${modelMachines > 1 ? 'any' : encodeURIComponent(m.id)}&model=${encodeURIComponent(c.ai.models[0])}#oc-ai-key">Use these models in OpenVibe.Chat</a>` : '')++ + (() => {++ const o = c.ai.offer;++ if (!o || o.enabled !== true) return '';++ const speed = c.ai.speed && typeof c.ai.speed === 'object' ? Object.entries(c.ai.speed).slice(0, 3).map(([model, rate]) => `${model} ${rate} tok/s`).join(', ') : '';++ return `<div class="machine-share"><span class="pill">${o.paused ? 'Offer paused: this machine is in use' : 'Offering its models to the network'}</span>`++ + `<span class="muted">${esc(o.privacy || 'A community machine sees the prompt. Only requests that allow community capacity are sent here, and every answer is labelled Community.')}</span>`++ + `${speed ? `<span class="muted">${esc(speed)}</span>` : ''}</div>`;++ })()+ '</div>' : '';// A machine that offers only its models (the ai plugin, no computer plugin): just them.if (!c.on) return `<div class="machine-computer">${aiRow}</div>`;@@@ -40,6 +40,7 @@ const appIcon = require('openvibe-shared/app-icon');const { getProfile, listProfiles, loadProfiles } = require('../profiles');const { frameAncestors } = require('../config');const { BotError, json } = require('../util');++const { aiOf } = require('../machine-ai');const kits = require('../kits');const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage } = require('./render');const { createMachineScreen } = require('../machine-screen');@@@ -615,6 +616,8 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limicomputer_desks: Number.isInteger(sensors.computer_desks) ? sensors.computer_desks : null,desk_leases: d.shared_desks ? await domain.deskLeases.count(d.id) : 0,ai_models: !!d.ai_models,++ // The Node ai plugin's community offer, when it has one (models, hours, price, audience, paused, privacy).++ ai_offer: (() => { const ai = aiOf(d.capabilities); return ai && ai.offer && typeof ai.offer === 'object' ? ai.offer : null; })(),// How fast its last model answered there (Node v0.29.0's ai plugin), and whether one is answering now.ai_speed: typeof sensors.ai_tokens_per_s === 'number' && sensors.ai_tokens_per_s > 0 && typeof sensors.ai_model === 'string'? { model: sensors.ai_model.slice(0, 120), tokens_per_s: sensors.ai_tokens_per_s } : null,nnew file mode 100644@@@ -0,0 +1,90 @@++'use strict';++// "Offer to the network" (the design's community machines; plan E4): a Node ai plugin advertises its owner's models,++// hours, concurrency, price and audience in status.capabilities.ai.offer; Bot reports it as a user-owned provider offer++// whose detail.kind is `ai`, and AI may send a community run to that machine even with "Let my AI use this computer's++// models" off, because the offer is the opt-in. Without the offer the same run is refused. This is Bot's half: the++// report Network validates and the command gate AI places through.++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++++(async () => {++ const reports = [];++ const aiReports = [];++ const reporter = { report: async (offers) => { reports.push(offers); return { source: 'bot', offers: offers.length, marked_down: 0 }; } };++ const aiReporter = { report: async (offers) => { aiReports.push(offers); return { source: 'bot', offers: offers.length, marked_down: 0 }; } };++ const t = await boot({ env: { BOT_ROUTINE_TICK_MS: '0', BOT_WEBHOOK_TICK_MS: '0' }, appOpts: { offerReporter: reporter, aiOfferReporter: aiReporter } });++ const alex = t.network.newUser('alex');++ let seq = 0;++ const frame = (type, fields) => ({ v: 1, seq: ++seq, ts: Date.now(), type, ...fields });++ const poll = async (fn, ms = 8000) => { const end = Date.now() + ms; while (Date.now() < end) { const v = await fn(); if (v) return v; await t.wait(30); } return null; };++ const seen = [];++ const OFFER = { enabled: true, models: ['llama3.1:8b'], hours: { start: '00:00', end: '23:59', tz: 'UTC' }, concurrency: 2,++ price: { in_per_million: 10, out_per_million: 30 }, audience: 'followers', group: null, open_now: true, ready: true };++++ /** A Network-paired machine whose fake Node advertises an ai offer and answers one ai command. */++ async function machine() {++ const principal = t.network.addNode({ owner: alex.subject, ref: `worker:${alex.subject}`, name: 'gpu-box' });++ const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });++ const deviceId = (await c.waitFor((m) => m.type === 'hello')).device_id;++ await c.waitFor((m) => m.type === 'config');++ c.send(frame('status', { capabilities: { ai: { ai: { models: ['llama3.1:8b'], ready: true, offer: OFFER } } }, drivers: [], faults: [] }));++ c.ws.on('message', (raw) => {++ let m; try { m = JSON.parse(raw.toString()); } catch { return; }++ if (m.type !== 'command') return;++ seen.push(m);++ if (m.kind === 'ai') c.send(frame('ack', { id: m.id, data: { model: m.value.model, message: { role: 'assistant', content: 'hello from the box' }, usage: { prompt_tokens: 8, completion_tokens: 4, total_tokens: 12 } } }));++ else c.send(frame('nack', { id: m.id, fault_code: 'not_allowed', message: 'no' }));++ });++ await poll(async () => { const d = await t.db.maybe('SELECT capabilities FROM devices WHERE id = $1', [deviceId]); return d && d.capabilities.ai && d.capabilities.ai.ai && d.capabilities.ai.ai.offer; });++ return { c, deviceId };++ }++++ const cmd = (deviceId, body) => t.call('POST', `/api/v1/machines/${deviceId}/commands`, {++ body, sub: 'svc:ai', cap: ['bot.machine.ai'], headers: { 'X-OV-Subject': alex.subject },++ });++++ let mine;++ await check('an ai offer becomes one user-owned provider offer with its settings', async () => {++ mine = await machine();++ const built = await t.app.locals.offers.buildAi();++ const offer = built.find((o) => o.offer_id === `ai:${mine.deviceId}`);++ assert.ok(offer, JSON.stringify(built));++ assert.deepStrictEqual([offer.machine_id, offer.owner, offer.models], [mine.deviceId, alex.subject, ['llama3.1:8b']]);++ assert.deepStrictEqual(offer.hours, { start: '00:00', end: '23:59', tz: 'UTC' });++ assert.deepStrictEqual([offer.concurrency, offer.price, offer.audience], [2, { in_per_million: 10, out_per_million: 30 }, 'followers']);++ assert.strictEqual(offer.ready, true);++ assert.strictEqual(offer.health.status, 'up');++ await t.app.locals.offers.publish();++ assert.ok(aiReports.at(-1).some((o) => o.offer_id === `ai:${mine.deviceId}`), 'the AI offers are reported to Network');++ assert.ok(reports.at(-1).some((o) => o.offer_id === `node:${mine.deviceId}`) === false, 'the worker report is separate');++ const page = await (await fetch(`${t.base}/machines`, { headers: { Cookie: `ov_token=${t.network.signUser(alex)}` } })).text();++ assert.ok(page.includes('A community machine sees the prompt'), 'the machine page says the privacy cost plainly');++ assert.ok(page.includes('Offering its models to the network'));++ });++++ await check('AI sends a community run to the offered machine, even with the own-AI switch off', async () => {++ const r = await cmd(mine.deviceId, { id: 'c1', kind: 'ai', value: { model: 'community/llama3.1:8b', messages: [{ role: 'user', content: 'hello' }] } });++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual([r.json.result, r.json.data.message.content], ['ack', 'hello from the box']);++ assert.strictEqual(seen.at(-1).value.model, 'community/llama3.1:8b', 'the community/ prefix marks it; the local model name is stripped by the plugin');++ const row = await t.db.maybe('SELECT * FROM command_audit WHERE robot_id = $1 AND kind = $2 ORDER BY id DESC LIMIT 1', [mine.deviceId, 'ai']);++ assert.strictEqual(row.result, 'ack', 'and it is audited as any machine command is');++ });++++ await check('a machine without the offer still refuses a community run, and the owner switch has not moved', async () => {++ const other = t.network.addNode({ owner: alex.subject, ref: `worker:${alex.subject}`, name: 'plain-box' });++ const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(other)}` } });++ const deviceId = (await c.waitFor((m) => m.type === 'hello')).device_id;++ await c.waitFor((m) => m.type === 'config');++ c.send(frame('status', { capabilities: { ai: { ai: { models: ['llama3.1:8b'], ready: true, offer: { enabled: false } } } }, drivers: [], faults: [] }));++ await poll(async () => { const d = await t.db.maybe('SELECT capabilities FROM devices WHERE id = $1', [deviceId]); return d && d.capabilities.ai; });++ const run = await cmd(deviceId, { kind: 'ai', value: { model: 'community/llama3.1:8b', messages: [{ role: 'user', content: 'x' }] } });++ assert.deepStrictEqual([run.status, run.json.code], [403, 'bot.agent_not_allowed']);++ assert.match(run.json.detail || '', /not offered/i);++ const own = await cmd(deviceId, { kind: 'ai', value: { model: 'llama3.1:8b', messages: [{ role: 'user', content: 'x' }] } });++ assert.deepStrictEqual([own.status, own.json.code], [403, 'bot.agent_not_allowed'], 'without either opt-in AI runs nothing there');++ assert.ok(!(await t.app.locals.offers.buildAi()).some((o) => o.offer_id === `ai:${deviceId}`), 'and no offer is reported');++ });++++ await done(t);++})().catch((err) => { console.error(err); process.exit(1); });@@@ -31,6 +31,9 @@ const { boot, check, done } = require('./helpers/app');c.send(frame('status', { capabilities: { worker: { capabilities: classes.map((x) => `worker:${x}`), runtime_classes: classes } } }));await poll(async () => { const d = await t.db.maybe('SELECT capabilities FROM devices WHERE id = $1', [deviceId]); return d && d.capabilities.worker; });}++ // Beat like a real Node while the socket is open: the later checks take longer than two missed heartbeats.++ const beat = setInterval(() => { try { c.send(frame('heartbeat', { t: Date.now() })); } catch { /* closed */ } }, 100);++ c.ws.on('close', () => clearInterval(beat));return { principal, c, deviceId };}
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+# Community AI offers through Bot
+
+OpenVibe.Node's `ai` plugin can offer a person's local models to the network. Bot is the transport and the reporter:
+
+- **Report** — `server/jobs/offers.js` `buildAi()` reads every live Network-paired device whose
+ `capabilities.ai.ai.offer.enabled` is true and publishes one offer per machine to Network's
+ `POST /internal/ai/offers/report` (`source: bot`) with the owner, models, hours, concurrency, credit price, audience,
+ ready flag and health. It is a separate report from the worker/desk `platform.resource-offer@1` set, sent by the same
+ publisher (`createAiOfferReporter`, `server/network.js`).
+- **Dispatch** — `POST /api/v1/machines/:id/commands` with `kind: ai` reaches a machine only from OpenVibe.AI
+ (`svc:ai`, `bot.machine.ai`). A machine's own models need `ai_models` (the owner's "Let my AI use this computer's
+ models" switch). A **community** run is marked with the `community/<model>` model prefix and is allowed when the
+ machine's own offer is enabled: the offer is the opt-in, and the Node plugin enforces hours, concurrency and its
+ model list. The prefix never reaches the local model server.
+
+The command's value is `bot.command@1`, which is closed to extra fields, so the prefix carries the community flag
+without changing the contract. The offer itself is not a `platform.resource-offer@1`: that contract closes `detail`
+for kind node/provider and cannot carry a model list, hours, a price and an audience. Network has the dedicated
+`platform_ai_offers` table.
+
+Test: `node test/community-ai.test.js`.
@@ -53,6 +53,7 @@ const machineRoles = require('../machine-roles');
@@ -53,6 +53,7 @@ const machineRoles = require('../machine-roles');
const kits = require('../kits');
const kits = require('../kits');
const resourceIndex = require('./resource-index');
const resourceIndex = require('./resource-index');
const resourceControl = require('./resource-control');
const resourceControl = require('./resource-control');
+const { aiOf } = require('../machine-ai');
const deskLeases = require('./desk-leases');
const deskLeases = require('./desk-leases');
const CAP = {
const CAP = {
@@ -78,9 +79,22 @@ const AGENT_SERVICES = new Set(['svc:actor']);
@@ -78,9 +79,22 @@ const AGENT_SERVICES = new Set(['svc:actor']);
const MACHINE_SERVICES = {
const MACHINE_SERVICES = {
'svc:actor': { kinds: null, flag: 'agent_computer', off: 'the owner has not let their agent use this computer' },
'svc:actor': { kinds: null, flag: 'agent_computer', off: 'the owner has not let their agent use this computer' },
'svc:watch': { kinds: new Set(['probe']), flag: 'watch_probe', off: 'the owner has not let their watches check things from this computer' },
'svc:watch': { kinds: new Set(['probe']), flag: 'watch_probe', off: 'the owner has not let their watches check things from this computer' },
- // AI on your own machines (contracts 0.219.0): OpenVibe.AI runs a model on the owner's own model server, nothing else.
- 'svc:ai': { kinds: new Set(['ai']), flag: 'ai_models', off: 'the owner has not let their AI use this computer\'s models', cap: 'bot.machine.ai' },
+ // AI (contracts 0.219.0): OpenVibe.AI runs a model on the owner's own model server for the owner, or — only for a
+ // machine whose ai plugin offers its models to the network — a community request that opted in (below).
+ // A command that says `community: true` runs on the machine's offer to the network instead, so the Node ai plugin's
+ // own opt-in (capabilities.ai.offer.enabled) stands in for the "Let my AI use this computer's models" switch.
+ 'svc:ai': { kinds: new Set(['ai']), flag: 'ai_models', off: 'the owner has not let their AI use this computer\'s models', cap: 'bot.machine.ai', community: true },
};
};
+/**
+ * A machine offering its models to the network (OpenVibe.Node ai plugin 0.3.0): the owner turned on "Offer to the
+ * network" and the offer names its models. Hours, concurrency and price are the plugin's to enforce; Bot only decides
+ * whether AI may send a community run here at all.
+ */
+function communityAi(capabilities) {
+ const ai = aiOf(capabilities);
+ const offer = ai && ai.offer;
+ return Boolean(offer && offer.enabled === true && Array.isArray(offer.models) && offer.models.length);
+}
// The network's machines hub (plan L34): the one service that acts for a person on their own machines. The
// The network's machines hub (plan L34): the one service that acts for a person on their own machines. The
// capability alone is not enough: any service holding it could name any person in X-OV-Subject, so the hub is
// capability alone is not enough: any service holding it could name any person in X-OV-Subject, so the hub is
// this service and no other.
// this service and no other.
@@ -498,7 +512,12 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -498,7 +512,12 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
const device = await domain.devices.get(String(req.params.id || '').slice(0, 64));
const device = await domain.devices.get(String(req.params.id || '').slice(0, 64));
const ownerSubject = device && !device.revoked_at ? await domain.devices.ownerOf(device) : null;
const ownerSubject = device && !device.revoked_at ? await domain.devices.ownerOf(device) : null;
if (!ownerSubject || ownerSubject !== subject) fail(404, 'bot.device_not_found', 'no such machine of yours');
if (!ownerSubject || ownerSubject !== subject) fail(404, 'bot.device_not_found', 'no such machine of yours');
- if (agentNeedsOptIn && svc && !device[svc.flag]) fail(403, 'bot.agent_not_allowed', svc.off);
+ // A community run is the machine's own offer to the network, which is its own opt-in; anything else AI asks
+ // of it (the owner's own models) still needs "Let my AI use this computer's models".
+ const community = Boolean(svc && svc.community && req.body && req.body.value && typeof req.body.value.model === 'string' && req.body.value.model.startsWith('community/'));
+ if (agentNeedsOptIn && svc && !device[svc.flag] && !(community && communityAi(device.capabilities))) {
+ fail(403, 'bot.agent_not_allowed', community ? 'the owner has not offered this computer\'s models to the network' : svc.off);
+ }
return { device, subject };
return { device, subject };
}
}
const machineOut = (device) => {
const machineOut = (device) => {
@@ -90,7 +90,11 @@ function createApp(opts = {}) {
@@ -90,7 +90,11 @@ function createApp(opts = {}) {
// started by server/index.js with the other jobs. Without Network credentials nothing is reported.
// started by server/index.js with the other jobs. Without Network credentials nothing is reported.
const offerReporter = opts.offerReporter !== undefined ? opts.offerReporter
const offerReporter = opts.offerReporter !== undefined ? opts.offerReporter
: (config.oauth.clientSecret ? createOfferReporter(config, { fetchImpl }) : null);
: (config.oauth.clientSecret ? createOfferReporter(config, { fetchImpl }) : null);
- const offers = createOfferPublisher({ db, hub, reporter: offerReporter, log, now, firstPartyOwners: (config.deskHosts && config.deskHosts.firstPartyOwners) || [] });
+ // A test that injects its own worker reporter gets no real Network client for AI offers unless it injects that too.
+ const aiOfferReporter = opts.aiOfferReporter !== undefined ? opts.aiOfferReporter
+ : (opts.offerReporter !== undefined ? null : (config.oauth.clientSecret ? require('./network').createAiOfferReporter(config, { fetchImpl }) : null));
+ const offers = createOfferPublisher({ db, hub, reporter: offerReporter, aiReporter: aiOfferReporter, log, now,
+ firstPartyOwners: (config.deskHosts && config.deskHosts.firstPartyOwners) || [] });
domain.devices.onChange(() => offers.poke());
domain.devices.onChange(() => offers.poke());
const sim = opts.sim || createSimulator({ config, domain, hub, now, log });
const sim = opts.sim || createSimulator({ config, domain, hub, now, log });
// Server-side ONVIF cameras (server/onvif): the same in-process attach as the simulator, but with a real
// Server-side ONVIF cameras (server/onvif): the same in-process attach as the simulator, but with a real
@@ -9,6 +9,12 @@
@@ -9,6 +9,12 @@
*
*
* createOfferPublisher({ db, hub, reporter, log, now, intervalMs, settleMs }) → { build, publish, start, stop, poke }
* createOfferPublisher({ db, hub, reporter, log, now, intervalMs, settleMs }) → { build, publish, start, stop, poke }
*
*
+ * The same set carries a community AI offer (the design's "Offer to the network"): a machine whose OpenVibe.Node ai
+ * plugin advertises `capabilities.ai.offer.enabled` with models, hours, concurrency, a credit price and an audience is
+ * one user-owned provider offer, `ai:<device>`, whose detail.kind is `ai` and whose health follows its connection and
+ * its own open/ready flags. Network validates the detail; OpenVibe.AI reads the internal list and places only requests
+ * that opt in to community capacity on it.
+ *
* An offer that does not match the contract is left out (Network would refuse the whole report for one bad offer).
* An offer that does not match the contract is left out (Network would refuse the whole report for one bad offer).
* Without a reporter (no Network credentials) nothing is reported.
* Without a reporter (no Network credentials) nothing is reported.
*/
*/
@@ -18,7 +24,25 @@ const { json } = require('../util');
@@ -18,7 +24,25 @@ const { json } = require('../util');
const CONTRACT = 'platform.resource-offer@1';
const CONTRACT = 'platform.resource-offer@1';
const CLASSES = new Set(['function', 'code', 'browser', 'linux', 'desktop', 'gpu']);
const CLASSES = new Set(['function', 'code', 'browser', 'linux', 'desktop', 'gpu']);
-function createOfferPublisher({ db, hub, reporter = null, log = console, now = () => Date.now(), intervalMs = 60_000, settleMs = 2_000, firstPartyOwners = [] }) {
+/** The ai plugin's offer, or null when it is off or incomplete (an incomplete one is left out, never half reported). */
+function aiOfferOf(capabilities) {
+ // The Node reports each plugin's describe under its name, so the ai plugin's {ai: {models, offer}} arrives as
+ // capabilities.ai.ai; a direct capabilities.ai.offer is accepted too (an older/other reporter).
+ let ai = json(capabilities, {}).ai;
+ if (ai && ai.ai) ai = ai.ai;
+ const offer = ai && ai.offer;
+ if (!offer || offer.enabled !== true || !Array.isArray(offer.models) || !offer.models.length) return null;
+ if (!offer.hours || typeof offer.hours !== 'object' || !offer.price || typeof offer.price !== 'object') return null;
+ if (!Number.isInteger(offer.concurrency) || offer.concurrency < 1 || offer.concurrency > 8) return null;
+ if (!['everyone', 'followers', 'group'].includes(offer.audience)) return null;
+ if (offer.audience === 'group' && (typeof offer.group !== 'string' || !offer.group.trim())) return null;
+ for (const key of ['in_per_million', 'out_per_million']) {
+ if (typeof offer.price[key] !== 'number' || !Number.isFinite(offer.price[key]) || offer.price[key] < 0) return null;
+ }
+ return offer;
+}
+
+function createOfferPublisher({ db, hub, reporter = null, aiReporter = null, log = console, now = () => Date.now(), intervalMs = 60_000, settleMs = 2_000, firstPartyOwners = [] }) {
const firstParty = new Set(firstPartyOwners);
const firstParty = new Set(firstPartyOwners);
let timer = null;
let timer = null;
let soon = null;
let soon = null;
@@ -80,33 +104,64 @@ function createOfferPublisher({ db, hub, reporter = null, log = console, now = (
@@ -80,33 +104,64 @@ function createOfferPublisher({ db, hub, reporter = null, log = console, now = (
return offers;
return offers;
}
}
+ /** The community AI offers: one per machine whose Node ai plugin advertises an enabled offer. */
+ async function buildAi() {
+ const rows = await db.many(
+ `SELECT d.id, d.capabilities, d.owner_subject, r.owner_subject AS robot_owner
+ FROM devices d LEFT JOIN robots r ON r.id = d.robot_ids ->> 0
+ WHERE d.revoked_at IS NULL AND (d.capabilities -> 'ai' -> 'ai' -> 'offer' ->> 'enabled' = 'true'
+ OR d.capabilities -> 'ai' -> 'offer' ->> 'enabled' = 'true')
+ ORDER BY d.id`);
+ const at = new Date(now()).toISOString();
+ const offers = [];
+ for (const row of rows) {
+ const offer = aiOfferOf(row.capabilities);
+ const owner = row.owner_subject || row.robot_owner;
+ if (!offer || !owner) continue;
+ const online = Boolean(hub && hub.isOnline(row.id));
+ offers.push({
+ offer_id: `ai:${row.id}`, machine_id: row.id, owner,
+ models: offer.models.slice(0, 40),
+ hours: { start: offer.hours.start, end: offer.hours.end, tz: offer.hours.tz },
+ concurrency: offer.concurrency,
+ price: { in_per_million: offer.price.in_per_million, out_per_million: offer.price.out_per_million },
+ audience: offer.audience, group: offer.audience === 'group' ? offer.group : null,
+ ready: online && offer.ready === true,
+ health: { status: online && offer.ready === true ? 'up' : 'down', checked_at: at },
+ });
+ }
+ return offers;
+ }
+
/** Report the whole set now (one report at a time; a change during one reports again after it). */
/** Report the whole set now (one report at a time; a change during one reports again after it). */
async function publish() {
async function publish() {
- if (!reporter) return null;
+ if (!reporter && !aiReporter) return null;
if (running) { again = true; return running; }
if (running) { again = true; return running; }
running = (async () => {
running = (async () => {
+ const out = {};
try {
try {
- return await reporter.report(await build());
- } catch (e) {
- log.warn(`[Bot] offers report: ${e.message}`);
- return null;
- } finally {
- running = null;
- if (again) { again = false; poke(); }
- }
- })();
+ if (reporter) out.offers = await reporter.report(await build());
+ } catch (e) { log.warn(`[Bot] offers report: ${e.message}`); }
+ try {
+ if (aiReporter) out.ai = await aiReporter.report(await buildAi());
+ } catch (e) { log.warn(`[Bot] ai offers report: ${e.message}`); }
+ return out;
+ })().finally(() => {
+ running = null;
+ if (again) { again = false; poke(); }
+ });
return running;
return running;
}
}
/** A device changed: report a moment later (changes in a burst are reported once). Only once started. */
/** A device changed: report a moment later (changes in a burst are reported once). Only once started. */
function poke() {
function poke() {
- if (!reporter || !timer || soon) return;
+ if ((!reporter && !aiReporter) || !timer || soon) return;
soon = setTimeout(() => { soon = null; void publish(); }, settleMs);
soon = setTimeout(() => { soon = null; void publish(); }, settleMs);
if (soon.unref) soon.unref();
if (soon.unref) soon.unref();
}
}
function start() {
function start() {
- if (!reporter || timer) return;
+ if ((!reporter && !aiReporter) || timer) return;
timer = setInterval(() => { void publish(); }, intervalMs);
timer = setInterval(() => { void publish(); }, intervalMs);
if (timer.unref) timer.unref();
if (timer.unref) timer.unref();
poke();
poke();
@@ -117,7 +172,7 @@ function createOfferPublisher({ db, hub, reporter = null, log = console, now = (
@@ -117,7 +172,7 @@ function createOfferPublisher({ db, hub, reporter = null, log = console, now = (
timer = null;
timer = null;
soon = null;
soon = null;
}
}
- return { build, publish, start, stop, poke };
+ return { build, buildAi, publish, start, stop, poke };
}
}
-module.exports = { createOfferPublisher };
+module.exports = { createOfferPublisher, aiOfferOf };
@@ -89,7 +89,7 @@ function createNodePrincipals(config, { fetchImpl = globalThis.fetch } = {}) {
@@ -89,7 +89,7 @@ function createNodePrincipals(config, { fetchImpl = globalThis.fetch } = {}) {
* report(offers) → Network's answer { source, offers, marked_down }
* report(offers) → Network's answer { source, offers, marked_down }
* A failure throws 503 bot.network_unavailable.
* A failure throws 503 bot.network_unavailable.
*/
*/
-function createOfferReporter(config, { fetchImpl = globalThis.fetch } = {}) {
+function createOfferReporter(config, { fetchImpl = globalThis.fetch, path = '/internal/resources/report' } = {}) {
const tokens = serviceAuth.createTokenClient({
const tokens = serviceAuth.createTokenClient({
tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
audience: 'openvibe.network', scope: 'network.resource.report', fetchImpl,
audience: 'openvibe.network', scope: 'network.resource.report', fetchImpl,
@@ -97,7 +97,7 @@ function createOfferReporter(config, { fetchImpl = globalThis.fetch } = {}) {
@@ -97,7 +97,7 @@ function createOfferReporter(config, { fetchImpl = globalThis.fetch } = {}) {
async function report(offers) {
async function report(offers) {
let res;
let res;
try {
try {
- res = await fetchImpl(`${config.network.internalUrl}/internal/resources/report`, {
+ res = await fetchImpl(`${config.network.internalUrl}${path}`, {
method: 'POST', headers: { ...(await tokens.authHeaders()), Accept: 'application/json', 'Content-Type': 'application/json' },
method: 'POST', headers: { ...(await tokens.authHeaders()), Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ source: 'bot', offers }), signal: AbortSignal.timeout(8000),
body: JSON.stringify({ source: 'bot', offers }), signal: AbortSignal.timeout(8000),
});
});
@@ -186,4 +186,9 @@ function createIdentity(config, { fetchImpl = globalThis.fetch, now = () => Date
@@ -186,4 +186,9 @@ function createIdentity(config, { fetchImpl = globalThis.fetch, now = () => Date
return { byUsername, names };
return { byUsername, names };
}
}
-module.exports = { createKeyProvider, createUserAuth, createNodePrincipals, createIdentity, createOfferReporter };
+/** The community AI offers Bot reports (design: "Offer to the network"; Network /internal/ai/offers/report). */
+function createAiOfferReporter(config, { fetchImpl = globalThis.fetch } = {}) {
+ return createOfferReporter(config, { fetchImpl, path: '/internal/ai/offers/report' });
+}
+
+module.exports = { createKeyProvider, createUserAuth, createNodePrincipals, createIdentity, createOfferReporter, createAiOfferReporter };
@@ -793,7 +793,9 @@ function renderMachinesPage({ machines = [], pairing = null }) {
@@ -793,7 +793,9 @@ function renderMachinesPage({ machines = [], pairing = null }) {
const caps = m.capabilities && typeof m.capabilities === 'object' ? m.capabilities : {};
const caps = m.capabilities && typeof m.capabilities === 'object' ? m.capabilities : {};
// Its own models (OpenVibe Node's ai plugin, server/machine-ai.js): what it offers, and whether a model server answers.
// Its own models (OpenVibe Node's ai plugin, server/machine-ai.js): what it offers, and whether a model server answers.
const models = aiOf(caps);
const models = aiOf(caps);
- const ai = models ? { models: Array.isArray(models.models) ? models.models.slice(0, 20).map(String) : [], ready: models.ready === true } : null;
+ const ai = models ? { models: Array.isArray(models.models) ? models.models.slice(0, 20).map(String) : [], ready: models.ready === true,
+ offer: models.offer && typeof models.offer === 'object' ? models.offer : null,
+ speed: models.speed && typeof models.speed === 'object' ? models.speed : null } : null;
const c = caps.computer;
const c = caps.computer;
if (!c || typeof c !== 'object') return ai ? { on: false, ai } : null;
if (!c || typeof c !== 'object') return ai ? { on: false, ai } : null;
const level = m.computer_level || ((c.files || c.terminal || c.screen || {}).level) || null;
const level = m.computer_level || ((c.files || c.terminal || c.screen || {}).level) || null;
@@ -848,6 +850,14 @@ function renderMachinesPage({ machines = [], pairing = null }) {
@@ -848,6 +850,14 @@ function renderMachinesPage({ machines = [], pairing = null }) {
// Turned on: OpenVibe.Chat's "Your AI key" comes filled in with this computer and its first model (saved only there).
// Turned on: OpenVibe.Chat's "Your AI key" comes filled in with this computer and its first model (saved only there).
// With several such machines, any of them (contracts 0.221.0): the fastest that is on answers.
// With several such machines, any of them (contracts 0.221.0): the fastest that is on answers.
+ (m.ai_models && c.ai.models.length ? `<a class="button quiet" href="https://openvibe.chat/settings?own=node&machine=${modelMachines > 1 ? 'any' : encodeURIComponent(m.id)}&model=${encodeURIComponent(c.ai.models[0])}#oc-ai-key">Use these models in OpenVibe.Chat</a>` : '')
+ (m.ai_models && c.ai.models.length ? `<a class="button quiet" href="https://openvibe.chat/settings?own=node&machine=${modelMachines > 1 ? 'any' : encodeURIComponent(m.id)}&model=${encodeURIComponent(c.ai.models[0])}#oc-ai-key">Use these models in OpenVibe.Chat</a>` : '')
+ + (() => {
+ const o = c.ai.offer;
+ if (!o || o.enabled !== true) return '';
+ const speed = c.ai.speed && typeof c.ai.speed === 'object' ? Object.entries(c.ai.speed).slice(0, 3).map(([model, rate]) => `${model} ${rate} tok/s`).join(', ') : '';
+ return `<div class="machine-share"><span class="pill">${o.paused ? 'Offer paused: this machine is in use' : 'Offering its models to the network'}</span>`
+ + `<span class="muted">${esc(o.privacy || 'A community machine sees the prompt. Only requests that allow community capacity are sent here, and every answer is labelled Community.')}</span>`
+ + `${speed ? `<span class="muted">${esc(speed)}</span>` : ''}</div>`;
+ })()
+ '</div>' : '';
+ '</div>' : '';
// A machine that offers only its models (the ai plugin, no computer plugin): just them.
// A machine that offers only its models (the ai plugin, no computer plugin): just them.
if (!c.on) return `<div class="machine-computer">${aiRow}</div>`;
if (!c.on) return `<div class="machine-computer">${aiRow}</div>`;
@@ -40,6 +40,7 @@ const appIcon = require('openvibe-shared/app-icon');
@@ -40,6 +40,7 @@ const appIcon = require('openvibe-shared/app-icon');
const { getProfile, listProfiles, loadProfiles } = require('../profiles');
const { getProfile, listProfiles, loadProfiles } = require('../profiles');
const { frameAncestors } = require('../config');
const { frameAncestors } = require('../config');
const { BotError, json } = require('../util');
const { BotError, json } = require('../util');
+const { aiOf } = require('../machine-ai');
const kits = require('../kits');
const kits = require('../kits');
const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage } = require('./render');
const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage } = require('./render');
const { createMachineScreen } = require('../machine-screen');
const { createMachineScreen } = require('../machine-screen');
@@ -615,6 +616,8 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -615,6 +616,8 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
computer_desks: Number.isInteger(sensors.computer_desks) ? sensors.computer_desks : null,
computer_desks: Number.isInteger(sensors.computer_desks) ? sensors.computer_desks : null,
desk_leases: d.shared_desks ? await domain.deskLeases.count(d.id) : 0,
desk_leases: d.shared_desks ? await domain.deskLeases.count(d.id) : 0,
ai_models: !!d.ai_models,
ai_models: !!d.ai_models,
+ // The Node ai plugin's community offer, when it has one (models, hours, price, audience, paused, privacy).
+ ai_offer: (() => { const ai = aiOf(d.capabilities); return ai && ai.offer && typeof ai.offer === 'object' ? ai.offer : null; })(),
// How fast its last model answered there (Node v0.29.0's ai plugin), and whether one is answering now.
// How fast its last model answered there (Node v0.29.0's ai plugin), and whether one is answering now.
ai_speed: typeof sensors.ai_tokens_per_s === 'number' && sensors.ai_tokens_per_s > 0 && typeof sensors.ai_model === 'string'
ai_speed: typeof sensors.ai_tokens_per_s === 'number' && sensors.ai_tokens_per_s > 0 && typeof sensors.ai_model === 'string'
? { model: sensors.ai_model.slice(0, 120), tokens_per_s: sensors.ai_tokens_per_s } : null,
? { model: sensors.ai_model.slice(0, 120), tokens_per_s: sensors.ai_tokens_per_s } : null,
new file mode 100644
new file mode 100644
@@ -0,0 +1,90 @@
@@ -0,0 +1,90 @@
+'use strict';
+// "Offer to the network" (the design's community machines; plan E4): a Node ai plugin advertises its owner's models,
+// hours, concurrency, price and audience in status.capabilities.ai.offer; Bot reports it as a user-owned provider offer
+// whose detail.kind is `ai`, and AI may send a community run to that machine even with "Let my AI use this computer's
+// models" off, because the offer is the opt-in. Without the offer the same run is refused. This is Bot's half: the
+// report Network validates and the command gate AI places through.
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+
+(async () => {
+ const reports = [];
+ const aiReports = [];
+ const reporter = { report: async (offers) => { reports.push(offers); return { source: 'bot', offers: offers.length, marked_down: 0 }; } };
+ const aiReporter = { report: async (offers) => { aiReports.push(offers); return { source: 'bot', offers: offers.length, marked_down: 0 }; } };
+ const t = await boot({ env: { BOT_ROUTINE_TICK_MS: '0', BOT_WEBHOOK_TICK_MS: '0' }, appOpts: { offerReporter: reporter, aiOfferReporter: aiReporter } });
+ const alex = t.network.newUser('alex');
+ let seq = 0;
+ const frame = (type, fields) => ({ v: 1, seq: ++seq, ts: Date.now(), type, ...fields });
+ const poll = async (fn, ms = 8000) => { const end = Date.now() + ms; while (Date.now() < end) { const v = await fn(); if (v) return v; await t.wait(30); } return null; };
+ const seen = [];
+ const OFFER = { enabled: true, models: ['llama3.1:8b'], hours: { start: '00:00', end: '23:59', tz: 'UTC' }, concurrency: 2,
+ price: { in_per_million: 10, out_per_million: 30 }, audience: 'followers', group: null, open_now: true, ready: true };
+
+ /** A Network-paired machine whose fake Node advertises an ai offer and answers one ai command. */
+ async function machine() {
+ const principal = t.network.addNode({ owner: alex.subject, ref: `worker:${alex.subject}`, name: 'gpu-box' });
+ const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });
+ const deviceId = (await c.waitFor((m) => m.type === 'hello')).device_id;
+ await c.waitFor((m) => m.type === 'config');
+ c.send(frame('status', { capabilities: { ai: { ai: { models: ['llama3.1:8b'], ready: true, offer: OFFER } } }, drivers: [], faults: [] }));
+ c.ws.on('message', (raw) => {
+ let m; try { m = JSON.parse(raw.toString()); } catch { return; }
+ if (m.type !== 'command') return;
+ seen.push(m);
+ if (m.kind === 'ai') c.send(frame('ack', { id: m.id, data: { model: m.value.model, message: { role: 'assistant', content: 'hello from the box' }, usage: { prompt_tokens: 8, completion_tokens: 4, total_tokens: 12 } } }));
+ else c.send(frame('nack', { id: m.id, fault_code: 'not_allowed', message: 'no' }));
+ });
+ await poll(async () => { const d = await t.db.maybe('SELECT capabilities FROM devices WHERE id = $1', [deviceId]); return d && d.capabilities.ai && d.capabilities.ai.ai && d.capabilities.ai.ai.offer; });
+ return { c, deviceId };
+ }
+
+ const cmd = (deviceId, body) => t.call('POST', `/api/v1/machines/${deviceId}/commands`, {
+ body, sub: 'svc:ai', cap: ['bot.machine.ai'], headers: { 'X-OV-Subject': alex.subject },
+ });
+
+ let mine;
+ await check('an ai offer becomes one user-owned provider offer with its settings', async () => {
+ mine = await machine();
+ const built = await t.app.locals.offers.buildAi();
+ const offer = built.find((o) => o.offer_id === `ai:${mine.deviceId}`);
+ assert.ok(offer, JSON.stringify(built));
+ assert.deepStrictEqual([offer.machine_id, offer.owner, offer.models], [mine.deviceId, alex.subject, ['llama3.1:8b']]);
+ assert.deepStrictEqual(offer.hours, { start: '00:00', end: '23:59', tz: 'UTC' });
+ assert.deepStrictEqual([offer.concurrency, offer.price, offer.audience], [2, { in_per_million: 10, out_per_million: 30 }, 'followers']);
+ assert.strictEqual(offer.ready, true);
+ assert.strictEqual(offer.health.status, 'up');
+ await t.app.locals.offers.publish();
+ assert.ok(aiReports.at(-1).some((o) => o.offer_id === `ai:${mine.deviceId}`), 'the AI offers are reported to Network');
+ assert.ok(reports.at(-1).some((o) => o.offer_id === `node:${mine.deviceId}`) === false, 'the worker report is separate');
+ const page = await (await fetch(`${t.base}/machines`, { headers: { Cookie: `ov_token=${t.network.signUser(alex)}` } })).text();
+ assert.ok(page.includes('A community machine sees the prompt'), 'the machine page says the privacy cost plainly');
+ assert.ok(page.includes('Offering its models to the network'));
+ });
+
+ await check('AI sends a community run to the offered machine, even with the own-AI switch off', async () => {
+ const r = await cmd(mine.deviceId, { id: 'c1', kind: 'ai', value: { model: 'community/llama3.1:8b', messages: [{ role: 'user', content: 'hello' }] } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual([r.json.result, r.json.data.message.content], ['ack', 'hello from the box']);
+ assert.strictEqual(seen.at(-1).value.model, 'community/llama3.1:8b', 'the community/ prefix marks it; the local model name is stripped by the plugin');
+ const row = await t.db.maybe('SELECT * FROM command_audit WHERE robot_id = $1 AND kind = $2 ORDER BY id DESC LIMIT 1', [mine.deviceId, 'ai']);
+ assert.strictEqual(row.result, 'ack', 'and it is audited as any machine command is');
+ });
+
+ await check('a machine without the offer still refuses a community run, and the owner switch has not moved', async () => {
+ const other = t.network.addNode({ owner: alex.subject, ref: `worker:${alex.subject}`, name: 'plain-box' });
+ const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(other)}` } });
+ const deviceId = (await c.waitFor((m) => m.type === 'hello')).device_id;
+ await c.waitFor((m) => m.type === 'config');
+ c.send(frame('status', { capabilities: { ai: { ai: { models: ['llama3.1:8b'], ready: true, offer: { enabled: false } } } }, drivers: [], faults: [] }));
+ await poll(async () => { const d = await t.db.maybe('SELECT capabilities FROM devices WHERE id = $1', [deviceId]); return d && d.capabilities.ai; });
+ const run = await cmd(deviceId, { kind: 'ai', value: { model: 'community/llama3.1:8b', messages: [{ role: 'user', content: 'x' }] } });
+ assert.deepStrictEqual([run.status, run.json.code], [403, 'bot.agent_not_allowed']);
+ assert.match(run.json.detail || '', /not offered/i);
+ const own = await cmd(deviceId, { kind: 'ai', value: { model: 'llama3.1:8b', messages: [{ role: 'user', content: 'x' }] } });
+ assert.deepStrictEqual([own.status, own.json.code], [403, 'bot.agent_not_allowed'], 'without either opt-in AI runs nothing there');
+ assert.ok(!(await t.app.locals.offers.buildAi()).some((o) => o.offer_id === `ai:${deviceId}`), 'and no offer is reported');
+ });
+
+ await done(t);
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -31,6 +31,9 @@ const { boot, check, done } = require('./helpers/app');
@@ -31,6 +31,9 @@ const { boot, check, done } = require('./helpers/app');
c.send(frame('status', { capabilities: { worker: { capabilities: classes.map((x) => `worker:${x}`), runtime_classes: classes } } }));
c.send(frame('status', { capabilities: { worker: { capabilities: classes.map((x) => `worker:${x}`), runtime_classes: classes } } }));
await poll(async () => { const d = await t.db.maybe('SELECT capabilities FROM devices WHERE id = $1', [deviceId]); return d && d.capabilities.worker; });
await poll(async () => { const d = await t.db.maybe('SELECT capabilities FROM devices WHERE id = $1', [deviceId]); return d && d.capabilities.worker; });
}
}
+ // Beat like a real Node while the socket is open: the later checks take longer than two missed heartbeats.
+ const beat = setInterval(() => { try { c.send(frame('heartbeat', { t: Date.now() })); } catch { /* closed */ } }, 100);
+ c.ws.on('close', () => clearInterval(beat));
return { principal, c, deviceId };
return { principal, c, deviceId };
}
}