openvibers/openvibe.ai Commits

A node credential with machine any (contracts 0.221.0) runs on the fastest of the person's own machines that is on with the model, then the next; its explain names the one that answered and the ones skipped (#167)

OP OpenVibers committed 17 days ago · a3981af69a · 7 files, +107 −14

Changes

ddiff --git a/README.md b/README.md
iindex afda549..61c8ebc 100644
---- a/README.md
++++ b/README.md
@@@ -27,7 +27,8 @@ owns publication truth: Wiki, Blog, News, Live, … decide what to publish.
attribution quotas (`ai.quota.attribution.manage`)
- a person's own computer as their provider (credential provider `node`, contracts 0.220.0): a model on their own
machine (Ollama, LM Studio, llama.cpp through the OpenVibe Node), reached through OpenVibe.Bot with `bot.machine.ai`
-- for that person only, free and with no key (`server/machines.js`; Bot on this host by default, `OV_BOT_INTERNAL_URL`)
++ for that person only, free and with no key (`server/machines.js`; Bot on this host by default, `OV_BOT_INTERNAL_URL`);
++ machine `any` (contracts 0.221.0) uses the fastest of their machines that is on with the model, then the next
- the `ai.run.queued|succeeded|failed|cached` events and the `ai.preferences` / `ai.usage_summary`
user modules' AI side
@@@ -641,7 +642,7 @@ ai.openvibe.network notice from OpenVibe.Sites.
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
--- openvibe-contracts: v0.220.0
++- openvibe-contracts: v0.221.0
- openvibe-sdk: v0.65.0
- openvibe-shared: v4.15.2
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex cc012b4..cbe9e5d 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -7,7 +7,7 @@
"runtime": "node 22, express, postgresql",
"port": 4700,
"domain": "ai.openvibe.services (the old ai.openvibe.network address was retired on 2026-10-10); the service runs on openvibe-ovh (/opt/openvibe.ai, unit openvibe-ai) on 127.0.0.1:4700 behind nginx; OpenVibe.Live and OpenVibe.Network call it on loopback",
-- "contracts": "openvibe-contracts v0.220.0",
++ "contracts": "openvibe-contracts v0.221.0",
"sdk": "openvibe-sdk v0.65.0",
"shared": "openvibe-shared v4.15.2",
"capabilities": [
ddiff --git a/package-lock.json b/package-lock.json
iindex a33dbb8..b59c848 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -13,7 +13,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.220.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.221.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.65.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.15.2",
"pg": "^8.23.0"
@@@ -1265,9 +1265,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.220.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.220.0",
-- "integrity": "sha512-1M5oDLuzba0KNX73Q1cwYusHfC6GEUKeypRA2ojoBUlxJW3huP/7p0tCxF0L9WMKMfsR9PoXY2WDMp8UCao40A==",
++ "version": "0.221.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.221.0",
++ "integrity": "sha512-Y582bVcx0Ti5+zkN0zTBsXKZclHrEg2/DiGnvkowxE3d2Y+2yEYykMzUylXCzGuDpevZLlw27Uz2F/Qe2frnzA==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex c01a249..c69f784 100644
---- a/package.json
++++ b/package.json
@@@ -25,7 +25,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.220.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.221.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.65.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.15.2",
"pg": "^8.23.0"
ddiff --git a/server/machines.js b/server/machines.js
iindex dd0a68f..98555a9 100644
---- a/server/machines.js
++++ b/server/machines.js
@@@ -6,13 +6,20 @@
* own model server (OpenVibe Node's ai plugin: Ollama, LM Studio, llama.cpp). Bot checks that the machine is theirs and
* that they turned on "Let my AI use this computer's models"; the prompt never reaches anyone else's machine.
*
++ * Machine `any` (contracts 0.221.0): whichever of the person's own machines is on, lets their AI use its models and
++ * offers the model, from Bot's list for OpenVibe.AI (GET /api/v1/machines, only those with the switch on). The fastest
++ * at that model goes first (computer.ai_speed, OpenVibe.Node v0.29.0), then the others; one that is busy, away or fails
++ * hands over to the next. Never anyone else's machine, never another provider.
++ *
* Off without OV_BOT_INTERNAL_URL or a client secret: a credential with provider node is then refused, never sent
* elsewhere. → chat({ machine, subject, model, messages, maxTokens, temperature, signal, timeoutMs })
-- * -> { text, model, usage: { input, output, cached } }
++ * -> { text, model, usage: { input, output, cached }, machine, tried: [{ machine, error }] }
*/
const { AiError } = require('./util');
const MACHINE_RE = /^dev_[0-9A-HJKMNP-TV-Z]{26}$/;
++const ANY = 'any';
++const MAX_TRIED = 5; // machines tried for one answer with machine any
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
function createMachineModels({ config, env = process.env, fetchImpl = globalThis.fetch, tokens: override = null }) {
@@@ -29,16 +36,58 @@ function createMachineModels({ config, env = process.env, fetchImpl = globalThis
return tokens;
};
-- async function chat({ machine, subject, model, messages, maxTokens = null, temperature = null, signal = null, timeoutMs = 120000 }) {
++ const headersFor = (token, subject) => ({ Authorization: `Bearer ${token}`, 'X-OV-Subject': subject, Accept: 'application/json' });
++
++ /** The person's machines that may answer with `model`, the fastest at it first (Bot lists only those switched on). */
++ async function candidates(subject, model, signal) {
++ const token = await tokenClient().getToken({ audience: 'openvibe.bot' });
++ let res;
++ try {
++ res = await fetchImpl(`${base}/api/v1/machines`, { headers: headersFor(token, subject), signal: signal || AbortSignal.timeout(10000) });
++ } catch (err) {
++ throw new AiError(503, 'machine.unreachable', `OpenVibe.Bot did not answer: ${String(err && err.message || err).slice(0, 200)}`);
++ }
++ const body = await res.json().catch(() => null);
++ if (!res.ok) throw new AiError(res.status === 403 ? 403 : 502, (body && body.code) || 'machine.refused', String((body && (body.detail || body.title)) || `OpenVibe.Bot answered ${res.status}`).slice(0, 300));
++ const speedAt = (m) => {
++ const s = m.computer && m.computer.ai_speed;
++ return s && s.model === model && Number(s.tokens_per_s) > 0 ? Number(s.tokens_per_s) : 0;
++ };
++ return ((body && body.machines) || [])
++ .filter((m) => m && MACHINE_RE.test(String(m.id)) && m.online && m.ai_models !== false && m.computer && m.computer.ai
++ && m.computer.ai.ready !== false && Array.isArray(m.computer.ai.models) && m.computer.ai.models.includes(model))
++ .sort((a, b) => speedAt(b) - speedAt(a))
++ .map((m) => m.id);
++ }
++
++ async function chat(args) {
if (!enabled) throw new AiError(503, 'machine.unavailable', 'a person\'s own machine cannot be reached from this service (OV_BOT_INTERNAL_URL)');
-- if (!MACHINE_RE.test(String(machine)) || !SUBJECT_RE.test(String(subject))) throw new AiError(422, 'credential.invalid', 'a node credential names a dev_ machine of a usr_ person');
++ const { machine, subject, model, signal = null } = args;
++ if (!(MACHINE_RE.test(String(machine)) || machine === ANY) || !SUBJECT_RE.test(String(subject))) throw new AiError(422, 'credential.invalid', 'a node credential names a dev_ machine of a usr_ person, or any of theirs');
++ if (machine !== ANY) return { ...(await once(machine, args)), machine, tried: [] };
++ const ids = (await candidates(subject, model, signal)).slice(0, MAX_TRIED);
++ if (!ids.length) throw new AiError(503, 'machine.none', `none of the person's own machines is on with ${String(model).slice(0, 120)} and lets their AI use it`);
++ const tried = [];
++ for (const id of ids) {
++ try {
++ return { ...(await once(id, args)), machine: id, tried };
++ } catch (err) {
++ if (signal && signal.aborted) throw err;
++ if (err && err.status === 422) throw err;
++ tried.push({ machine: id, error: String((err && (err.code || err.message)) || err).slice(0, 120) });
++ }
++ }
++ throw new AiError(503, 'machine.none', `none of the person's own machines answered (${tried.map((x) => `${x.machine}: ${x.error}`).join('; ').slice(0, 400)})`);
++ }
++
++ async function once(machine, { subject, model, messages, maxTokens = null, temperature = null, signal = null, timeoutMs = 120000 }) {
const value = { model, messages, ...(maxTokens ? { max_tokens: Math.min(32768, Math.max(1, Math.round(maxTokens))) } : {}), ...(temperature != null ? { temperature: Math.min(2, Math.max(0, Number(temperature))) } : {}) };
const token = await tokenClient().getToken({ audience: 'openvibe.bot' });
let res;
try {
res = await fetchImpl(`${base}/api/v1/machines/${encodeURIComponent(machine)}/commands`, {
method: 'POST',
-- headers: { Authorization: `Bearer ${token}`, 'X-OV-Subject': subject, 'Content-Type': 'application/json', Accept: 'application/json' },
++ headers: { ...headersFor(token, subject), 'Content-Type': 'application/json' },
body: JSON.stringify({ kind: 'ai', value, ms: Math.min(600000, Math.max(5000, timeoutMs)) }),
signal: signal || AbortSignal.timeout(Math.min(610000, timeoutMs + 10000)),
});
ddiff --git a/server/providers/index.js b/server/providers/index.js
iindex fc7017c..b11a65c 100644
---- a/server/providers/index.js
++++ b/server/providers/index.js
@@@ -393,7 +393,9 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const latency = Date.now() - t0;
if (ctx.logRequest) ctx.logRequest({ ...base, model_key: result.model, status: 'ok', output_hash: sha256(result.text || ''), tokens_in: usage.input, tokens_out: usage.output, tokens_cached: 0, tokens_estimated: 0, cost_usd: 0, latency_ms: latency });
return { result: { text: result.text, json: null, model: result.model, usage }, provider: cred.providerKey, providerKind: 'node', model: result.model, fallbackUsed: false, usage, cost: 0, latencyMs: latency, synthetic: false, tried: [], startedAt: t0,
-- explain: { objective: 'own-machine', reasons: ['the person\'s own machine, by their choice'], selected: { provider: 'node', machine: cred.machine, model: result.model }, candidates: [] } };
++ explain: { objective: 'own-machine', reasons: [cred.machine === 'any' ? 'the fastest of the person\'s own machines that answered, by their choice' : 'the person\'s own machine, by their choice'],
++ selected: { provider: 'node', machine: result.machine || cred.machine, model: result.model },
++ candidates: (result.tried || []).map((x) => ({ provider: 'node', machine: x.machine, skipped: x.error })) } };
} catch (err) {
if (ctx.logRequest) ctx.logRequest({ ...base, status: 'error', latency_ms: Date.now() - t0, error: String(err && err.message || err).slice(0, 500) });
if (ctx.signal && ctx.signal.aborted) throw new AiError(409, 'run.cancelled', 'run was cancelled');
ddiff --git a/test/machine-credential.test.js b/test/machine-credential.test.js
iindex f2f1830..6c86985 100644
---- a/test/machine-credential.test.js
++++ b/test/machine-credential.test.js
@@@ -41,6 +41,13 @@ t.test('store a node credential: the machine, no key', async () => {
assert.ok(validate('ai.credential@1', r.body).valid, JSON.stringify(validate('ai.credential@1', r.body).errors));
const row = await h.db.prepare('SELECT key_enc, machine FROM subject_credentials WHERE owner = ? AND subject = ?').get('chat', FRAN);
assert.deepStrictEqual([row.key_enc, row.machine], ['', MACHINE], 'nothing sealed');
++ // Any of their machines (contracts 0.221.0), kept for another person: never "anyone".
++ const OTHER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP5';
++ assert.strictEqual((await request(h.base, 'PUT', `/api/v1/credentials/${OTHER}`, { tok: chat, body: { provider: 'node', machine: 'anyone', models: { chat: 'llama3.1:8b' } } })).status, 422);
++ r = await request(h.base, 'PUT', `/api/v1/credentials/${OTHER}`, { tok: chat, body: { provider: 'node', machine: 'any', models: { chat: 'llama3.1:8b' } } });
++ assert.deepStrictEqual([r.status, r.body.machine], [200, 'any'], r.text);
++ assert.ok(validate('ai.credential@1', r.body).valid);
++ assert.strictEqual((await request(h.base, 'DELETE', `/api/v1/credentials/${OTHER}`, { tok: chat })).status < 300, true);
});
t.test('a run with it goes to that machine as the person, costs nothing, and says where it ran', async () => {
@@@ -87,7 +94,7 @@ t.test('server/machines.js: one POST to Bot as the person with a service token;
assert.strictEqual(m.enabled, true);
await assert.rejects(m.chat({ machine: 'dev_nope', subject: FRAN, model: 'm', messages: [] }), (e) => e.status === 422);
const out = await m.chat({ machine: MACHINE, subject: FRAN, model: 'llama3.1:8b', messages: [{ role: 'user', content: 'hi' }], maxTokens: 99999, temperature: 5, timeoutMs: 1000 });
-- assert.deepStrictEqual(out, { text: 'Hi.', model: 'llama3.1:8b', usage: { input: 12, output: 3, cached: 0 } });
++ assert.deepStrictEqual(out, { text: 'Hi.', model: 'llama3.1:8b', usage: { input: 12, output: 3, cached: 0 }, machine: MACHINE, tried: [] });
assert.strictEqual(calls[0].url, `http://bot.test/api/v1/machines/${MACHINE}/commands`);
assert.strictEqual(calls[0].init.headers.Authorization, 'Bearer svc-openvibe.bot');
assert.strictEqual(calls[0].init.headers['X-OV-Subject'], FRAN);
@@@ -101,6 +108,40 @@ t.test('server/machines.js: one POST to Bot as the person with a service token;
await assert.rejects(m.chat({ machine: MACHINE, subject: FRAN, model: 'm', messages: [] }), (e) => e.status === 503 && e.code === 'machine.unreachable');
});
++t.test('machine any (contracts 0.221.0): the fastest of the person\'s own machines that is on with the model, then the next', async () => {
++ const { createMachineModels } = require('../server/machines');
++ const id = (c) => `dev_01M5YGM39VC2RRGDQPRD6D4S${c}`;
++ const list = [
++ { id: id('AA'), online: false, ai_models: true, computer: { ai: { models: ['llama3.1:8b'], ready: true } } },
++ { id: id('BB'), online: true, ai_models: true, computer: { ai: { models: ['qwen2.5:7b'], ready: true } } },
++ { id: id('CC'), online: true, ai_models: true, computer: { ai: { models: ['llama3.1:8b'], ready: true }, ai_speed: { model: 'llama3.1:8b', tokens_per_s: 6 } } },
++ { id: id('DD'), online: true, ai_models: true, computer: { ai: { models: ['llama3.1:8b'], ready: true }, ai_speed: { model: 'llama3.1:8b', tokens_per_s: 31 } } },
++ ];
++ const asked = [];
++ let busy = new Set([id('DD')]);
++ let listed = list;
++ const fetchImpl = async (url, init = {}) => {
++ if (url === 'http://bot.test/api/v1/machines') {
++ assert.strictEqual(init.headers['X-OV-Subject'], FRAN, 'as the person');
++ return { ok: true, status: 200, json: async () => ({ machines: listed }) };
++ }
++ const dev = /machines\/([^/]+)\/commands$/.exec(url)[1];
++ asked.push(dev);
++ const body = busy.has(dev) ? { result: 'nack', reason: 'the model is answering another request on this machine' }
++ : { result: 'ack', data: { model: 'llama3.1:8b', message: { content: `from ${dev}` }, usage: { prompt_tokens: 3, completion_tokens: 2 } } };
++ return { ok: true, status: 200, json: async () => body };
++ };
++ const m = createMachineModels({ config: { bot: { url: 'http://bot.test' } }, env: {}, fetchImpl, tokens: { getToken: async () => 'svc' } });
++ const out = await m.chat({ machine: 'any', subject: FRAN, model: 'llama3.1:8b', messages: [{ role: 'user', content: 'hi' }] });
++ assert.deepStrictEqual(asked, [id('DD'), id('CC')], 'the fastest first, the next when it is busy; never one that is off or lacks the model');
++ assert.deepStrictEqual([out.text, out.machine, out.tried.map((x) => x.machine)], [`from ${id('CC')}`, id('CC'), [id('DD')]]);
++ busy = new Set([id('DD'), id('CC')]);
++ await assert.rejects(m.chat({ machine: 'any', subject: FRAN, model: 'llama3.1:8b', messages: [] }), (e) => e.status === 503 && e.code === 'machine.none' && /answered/.test(e.message));
++ listed = [];
++ await assert.rejects(m.chat({ machine: 'any', subject: FRAN, model: 'llama3.1:8b', messages: [] }), (e) => e.status === 503 && e.code === 'machine.none' && /is on with/.test(e.message));
++ await assert.rejects(m.chat({ machine: 'anyone', subject: FRAN, model: 'm', messages: [] }), (e) => e.status === 422);
++});
++
t.test('close', async () => { await h.close(); });
t.run();