openvibers/openvibe.ai Commits
A person's own provider may be a model on their own machine (credential provider node, contracts 0.220.0): runs go through OpenVibe.Bot to that machine as the person, text only, nothing spent, never cached, no fallback; explain names the machine (#166)
Changes
@@@ -25,6 +25,9 @@ owns publication truth: Wiki, Blog, News, Live, … decide what to publish.[migrations/](migrations/))- people's own provider keys (`ai.credential.manage`), encrypted at rest, and per-streamerattribution 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`)- the `ai.run.queued|succeeded|failed|cached` events and the `ai.preferences` / `ai.usage_summary`user modules' AI side@@@ -638,7 +641,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.218.0++- openvibe-contracts: v0.220.0- openvibe-sdk: v0.65.0- openvibe-shared: v4.15.2<!-- versions:end -->@@@ -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.218.0",++ "contracts": "openvibe-contracts v0.220.0","sdk": "openvibe-sdk v0.65.0","shared": "openvibe-shared v4.15.2","capabilities": [nnew file mode 100644@@@ -0,0 +1,6 @@++-- phase: expand++-- AI on your own machines (contracts 0.220.0): a person's own provider may be their own machine, provider node with the++-- machine it runs on (a dev_ id on OpenVibe.Bot). It has no key: key_enc and key_hint stay empty for it.++ALTER TABLE subject_credentials DROP CONSTRAINT IF EXISTS subject_credentials_provider_check;++ALTER TABLE subject_credentials ADD CONSTRAINT subject_credentials_provider_check CHECK (provider IN ('openai', 'anthropic', 'node'));++ALTER TABLE subject_credentials ADD COLUMN IF NOT EXISTS machine text COLLATE "C";@@@ -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.218.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.220.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.218.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.218.0",-- "integrity": "sha512-/cgAdxibISceSva51oCYz/382iLsXW5RU6kt8Lyt5JWPiL6QhL0193boUZS9Ls4SxnEgT/fiAtMU3IJY4qMKwg==",++ "version": "0.220.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.220.0",++ "integrity": "sha512-1M5oDLuzba0KNX73Q1cwYusHfC6GEUKeypRA2ojoBUlxJW3huP/7p0tCxF0L9WMKMfsR9PoXY2WDMp8UCao40A==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -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.218.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.220.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"@@@ -226,6 +226,11 @@ function load(env = process.env) {intervalMs: Math.max(100, int(env.USAGE_SAMPLES_INTERVAL_MS, 2000)),},++ // ── A person's own machine as their model (server/machines.js; contracts 0.219.0/0.220.0): runs with a++ // credential of provider node go through OpenVibe.Bot (bot.machine.ai), on this host by default (as Actor reaches++ // it); OV_BOT_INTERNAL_URL= (empty) turns it off.++ bot: { url: trimUrl(env.OV_BOT_INTERNAL_URL != null ? env.OV_BOT_INTERNAL_URL : 'http://127.0.0.1:4630') },++// ── Tier budgets (T6 step 8, server/govern.js): openvibe-sdk/govern in Valkey, off unless AI_GOVERN_TIERS=1.govern: loadGovern(env, { bool, int, num: float }),};@@@ -13,6 +13,9 @@* subject (the additional data), so a row copied to another owner or subject does not decrypt. Without the key* configured the routes answer 503 and credentialed runs are refused. Spend is what the credential's requests* cost today (provider_key byo:<owner>:<subject>), checked against the optional budget_usd_per_day.++ *++ * Provider node (contracts 0.220.0) is the person's own machine instead: `machine` names it, there is no key (nothing is++ * sealed, no hint), and a run reaches it only through OpenVibe.Bot (server/machines.js), for that person only.*/const crypto = require('crypto');const express = require('express');@@@ -56,7 +59,8 @@ function createCredentials({ db, config, clock = { now: () => Date.now() } }) {async function view(row) {return {-- subject: row.subject, owner: row.owner, provider: row.provider, base_url: row.base_url || null, key_hint: row.key_hint,++ subject: row.subject, owner: row.owner, provider: row.provider, base_url: row.base_url || null,++ ...(row.provider === 'node' ? { machine: row.machine } : { key_hint: row.key_hint }),models: JSON.parse(row.models || '{}'), budget_usd_per_day: row.budget_usd_day == null ? null : row.budget_usd_day,spent_usd_today: Math.round(await spentToday(row.owner, row.subject) * 1e6) / 1e6,created_at: row.created_at, updated_at: row.updated_at, last_used_at: row.last_used_at || null,@@@ -70,8 +74,17 @@ function createCredentials({ db, config, clock = { now: () => Date.now() } }) {if (!v.valid) throw new AiError(422, 'input.invalid', 'body does not match ai.credential-put@1', { errors: v.errors });const at = iso(clock.now());const prev = await row(owner, subject);++ if (body.provider === 'node') {++ // The person's own machine: nothing to seal. Bot checks it is theirs and that they turned it on, at each run.++ await db.prepare(`INSERT INTO subject_credentials (owner, subject, provider, base_url, machine, key_enc, key_hint, models, budget_usd_day, created_at, updated_at)++ VALUES (?, ?, 'node', NULL, ?, '', '', ?, NULL, ?, ?)++ ON CONFLICT(owner, subject) DO UPDATE SET provider = excluded.provider, base_url = NULL, machine = excluded.machine, key_enc = '',++ key_hint = '', models = excluded.models, budget_usd_day = NULL, updated_at = excluded.updated_at`)++ .run(owner, subject, body.machine, JSON.stringify(body.models || {}), prev ? prev.created_at : at, at);++ return await view(await row(owner, subject));++ }// The stored key stays for a change of models or budget; a new provider or endpoint needs the key again.-- const sameEndpoint = prev && prev.provider === body.provider && (prev.base_url || null) === (body.base_url || null);++ const sameEndpoint = prev && prev.provider !== 'node' && prev.provider === body.provider && (prev.base_url || null) === (body.base_url || null);if (body.api_key == null && !sameEndpoint) {throw new AiError(400, 'credential.key_required', prev ? 'enter the key again to change the provider or its address' : 'api_key is required the first time');}@@@ -99,6 +112,7 @@ function createCredentials({ db, config, clock = { now: () => Date.now() } }) {async function admit(owner, subject) {const r = await row(owner, subject);if (!r) throw new AiError(404, 'credential.not_found', 'no credential stored for that subject');++ if (r.provider === 'node') return; // the person's own machine: no key, no spendsecretKey();if (r.budget_usd_day != null && r.budget_usd_day > 0 && await spentToday(owner, subject) >= r.budget_usd_day) {const tomorrow = new Date(iso(clock.now()).slice(0, 10) + 'T00:00:00.000Z').getTime() + 86400000;@@@ -111,6 +125,7 @@ function createCredentials({ db, config, clock = { now: () => Date.now() } }) {const r = await row(owner, subject);if (!r) throw new AiError(404, 'credential.not_found', 'the credential was deleted');await db.prepare('UPDATE subject_credentials SET last_used_at = ? WHERE owner = ? AND subject = ?').run(iso(clock.now()), owner, subject);++ if (r.provider === 'node') return { providerKey: providerKeyFor(owner, subject), provider: 'node', subject, machine: r.machine, models: JSON.parse(r.models || '{}') };return { providerKey: providerKeyFor(owner, subject), provider: r.provider, base_url: r.base_url || null, apiKey: open(r.key_enc, `${owner}|${subject}`), models: JSON.parse(r.models || '{}') };}@@@ -26,7 +26,7 @@ const { createApp } = require('./app');const { gracefulStop, telemetry } = require('openvibe-sdk/service');const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk/telemetry');--async function start({ config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null } = {}) {++async function start({ config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null, machineModels = null } = {}) {config = config || load(env);schemas.configure(config.schemaCache);// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.@@@ -35,7 +35,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now// Provider keys an owner stored from the console (server/vault.js): into this process before any provider is built.const vault = require('./vault').createVault({ db, config, clock, log });await vault.load().catch((err) => log.warn(`[Vault] ${err.message}`));-- const pool = createProviderPool({ db, registry, config, clock, fetchImpl, env, log, credentialFetch });++ const pool = createProviderPool({ db, registry, config, clock, fetchImpl, env, log, credentialFetch, machineModels });// T6 step 8: the SaaS tier budgets on openvibe-sdk/govern in Valkey (server/govern.js); off unless AI_GOVERN_TIERS=1.const tiers = createTiers({ config, valkey, clock, log });// §2.1.8: the provider free allowance per subject, priced off the pool's own rate cards (server/free-allowance.js);nnew file mode 100644@@@ -0,0 +1,63 @@++'use strict';++/**++ * A person's own machine as their model (AI on your own machines: contracts 0.219.0 bot.command@1 kind ai and the++ * capability bot.machine.ai; 0.220.0 the credential provider node). One chat completion goes through OpenVibe.Bot,++ * POST /api/v1/machines/:id/commands with kind ai, as OpenVibe.AI acting for the person (X-OV-Subject), to the machine's++ * 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.++ *++ * 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 } }++ */++const { AiError } = require('./util');++++const MACHINE_RE = /^dev_[0-9A-HJKMNP-TV-Z]{26}$/;++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++++function createMachineModels({ config, env = process.env, fetchImpl = globalThis.fetch, tokens: override = null }) {++ const base = String((config.bot && config.bot.url) || '').replace(/\/+$/, '');++ const secret = env.OV_OAUTH_CLIENT_SECRET || '';++ const enabled = Boolean(base && (override || secret));++ let tokens = override;++ const tokenClient = () => {++ if (!tokens) {++ const { createServiceTokenClient } = require('openvibe-sdk/auth');++ const network = String(config.networkInternalUrl || 'http://127.0.0.1:4000').replace(/\/+$/, '');++ tokens = createServiceTokenClient({ tokenUrl: `${network}/oauth/token`, clientId: env.OV_OAUTH_CLIENT_ID || 'ai', clientSecret: secret, scope: { 'openvibe.bot': 'bot.machine.ai' }, fetch: fetchImpl });++ }++ return tokens;++ };++++ async function chat({ machine, subject, model, messages, maxTokens = null, temperature = null, signal = null, timeoutMs = 120000 }) {++ 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 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' },++ body: JSON.stringify({ kind: 'ai', value, ms: Math.min(600000, Math.max(5000, timeoutMs)) }),++ signal: signal || AbortSignal.timeout(Math.min(610000, timeoutMs + 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) {++ const code = (body && body.code) || 'machine.refused';++ const why = (body && (body.detail || body.reason || body.title)) || `OpenVibe.Bot answered ${res.status}`;++ throw new AiError(res.status === 404 ? 404 : res.status === 403 ? 403 : 502, code, String(why).slice(0, 300));++ }++ if (!body || body.result !== 'ack') throw new AiError(502, 'machine.failed', `the machine did not answer: ${String((body && (body.reason || body.result)) || 'no answer').slice(0, 200)}`);++ const d = body.data || {};++ const u = d.usage || {};++ return { text: String((d.message && d.message.content) || '').trim(), model: d.model || model, usage: { input: u.prompt_tokens || 0, output: u.completion_tokens || 0, cached: 0 } };++ }++++ return { enabled, chat };++}++++module.exports = { createMachineModels };@@@ -23,7 +23,7 @@const placement = require('openvibe-sdk/placement');const contracts = require('openvibe-contracts');const { AiError, resolveSecret, sha256 } = require('../util');--const { retryable, sleep, ProviderError } = require('./common');++const { retryable, sleep, ProviderError, schemaInstruction } = require('./common');const { createStubProvider } = require('./stub');const { createOpenAiProvider } = require('./openai');const { createResponsesProvider } = require('./responses');@@@ -43,7 +43,9 @@ function rateLimited(err) {return (err && err.status === 429) || (err && err.code === 'provider.rate_limited');}--function createProviderPool({ db, registry, config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, credentialFetch = null }) {++function createProviderPool({ db, registry, config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, credentialFetch = null, machineModels = null }) {++ // A person's own machine as their model (credential provider node, server/machines.js).++ const machines = machineModels || require('../machines').createMachineModels({ config, env, fetchImpl });const cache = new Map(); // key -> { stamp, adapter }const stats = {}; // key -> { calls, failures, ... } (in memory; tests + /api/ready)@@@ -368,7 +370,39 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no* shared with anyone, chat operations only; the base URL they chose is reached through guardedFetch. One* request-log row per attempt under provider_key byo:<owner>:<subject>, priced by the model's list price.*/++ /**++ * A run on the person's own machine (credential provider node): one chat completion through OpenVibe.Bot to their++ * model server. Text only, no fallback, nothing spent; one request-log row under byo:<owner>:<subject>.++ */++ async function executeOnMachine(cred, operation, req, ctx = {}) {++ if (!['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'].includes(operation)) throw new AiError(422, 'credential.unsupported', `a person's own machine answers text only, not ${operation}`);++ if (req.image) throw new AiError(422, 'credential.unsupported', 'a person\'s own machine reads no pictures yet');++ const wanted = ctx.role || ((/^live\.(chat|vision|director|summary|legacy)$/.exec(ctx.routeKey || '') || [])[1]) || null;++ const model = (wanted && cred.models[wanted]) || cred.models.chat;++ if (!model) throw new AiError(422, 'credential.no_model', 'the person\'s own machine credential names no model (models.chat)');++ const sysText = [...(req.system || []).map((x) => x.text), req.json ? schemaInstruction(req.json) : ''].filter(Boolean).join('\n\n');++ const messages = [++ ...(sysText ? [{ role: 'system', content: sysText }] : []),++ ...(req.messages || []).map((m) => ({ role: m.role === 'assistant' ? 'assistant' : m.role === 'system' ? 'system' : 'user', content: Array.isArray(m.content) ? m.content.filter((p) => p && p.type === 'text').map((p) => p.text).join('\n') : String(m.content || '') })),++ ];++ const base = { operation, provider_key: cred.providerKey, model_key: model, route_key: ctx.routeKey || null, route_version: ctx.routeVersion || null, fallback: 0, prompt_hash: ctx.promptHash || null };++ const t0 = Date.now();++ try {++ const result = await machines.chat({ machine: cred.machine, subject: cred.subject, model, messages, maxTokens: req.maxTokens, temperature: req.temperature, signal: ctx.signal, timeoutMs: Math.max(5000, Math.min(req.timeoutMs || 120000, 300000)) });++ const usage = result.usage;++ 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: [] } };++ } 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');++ throw err;++ }++ }++async function executeWithCredential(cred, operation, req, ctx = {}) {++ if (cred.provider === 'node') return await executeOnMachine(cred, operation, req, ctx);// Text operations are all chat completions on the person's provider (templated workflows use generate).// A project's own provider (ADR-056 secret, ai.complete) passes a Chat Completions request through as written.const completing = operation === 'complete' && cred.provider === 'openai';@@@ -45,7 +45,7 @@ function tmpDir() {// A boot on a dir used before is a restart on the same database: its database stays open.const restartable = new Map();--async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research } = {}) {++async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research, machineModels } = {}) {const reuse = dir !== undefined;if (dir === undefined) dir = tmpDir();const fullEnv = {@@@ -59,7 +59,7 @@ async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, researchif (reuse) restartable.set(dir, testdb);// Research reads a canned web (test/fixtures/research-fetch.js) unless a test brings its own: no test reaches the internet.const researcher = research || require('../server/research').createResearch({ config: config.research, env: {}, fetchImpl: require('./fixtures/research-fetch').createResearchFetch().fetchImpl, log: silent });-- const h = await start({ config, db: testdb.db, env: fullEnv, log: silent, clock, fetchImpl, credentialFetch, research: researcher });++ const h = await start({ config, db: testdb.db, env: fullEnv, log: silent, clock, fetchImpl, credentialFetch, research: researcher, machineModels });const base = `http://127.0.0.1:${h.server.address().port}`;return { ...h, base, dir, env: fullEnv, testdb, stop: async () => { await h.close(); if (!reuse) await testdb.close(); } };}nnew file mode 100644@@@ -0,0 +1,106 @@++'use strict';++// A person's own machine as their model (AI on your own machines; contracts 0.219.0 kind ai, 0.220.0 credential++// provider node):++// - a service holding ai.credential.manage stores provider node with the machine; no key, no hint, nothing sealed;++// - a run naming it goes to that machine through OpenVibe.Bot as the person (server/machines.js), text only, nothing++// spent, logged under byo:<owner>:<subject>, its explain saying where it ran; never cached, never another provider;++// - Bot's refusal (the owner's switch off) is the run's error; a picture is refused before anything is sent.++const assert = require('assert');++const { boot, request, token, suite, ALL } = require('./helpers');++const { AiError } = require('../server/util');++++const t = suite('machine-credential');++const FRAN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP4';++const MACHINE = 'dev_01M5YGM39VC2RRGDQPRD6D4SHD';++const chat = token('chat', [...ALL, 'ai.credential.manage']);++const sent = [];++let refuse = false;++const machineModels = {++ enabled: true,++ async chat(args) {++ sent.push(args);++ if (refuse) throw new AiError(403, 'bot.agent_not_allowed', 'the owner has not let their AI use this computer\'s models');++ return { text: 'A lock that one thread holds at a time.', model: args.model, usage: { input: 30, output: 9, cached: 0 } };++ },++};++let h;++++t.test('boot', async () => {++ h = await boot({ env: { AI_CREDENTIALS_KEY: 'cd'.repeat(32) }, machineModels });++});++++t.test('store a node credential: the machine, no key', async () => {++ let r = await request(h.base, 'PUT', `/api/v1/credentials/${FRAN}`, { tok: chat, body: { provider: 'node', machine: MACHINE, api_key: 'sk-not-for-a-machine' } });++ assert.strictEqual(r.status, 422, 'no key for a machine');++ r = await request(h.base, 'PUT', `/api/v1/credentials/${FRAN}`, { tok: chat, body: { provider: 'node', models: { chat: 'llama3.1:8b' } } });++ assert.strictEqual(r.status, 422, 'a node credential names its machine');++ r = await request(h.base, 'PUT', `/api/v1/credentials/${FRAN}`, { tok: chat, body: { provider: 'node', machine: MACHINE, models: { chat: 'llama3.1:8b' } } });++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual([r.body.provider, r.body.machine, r.body.key_hint, r.body.models.chat], ['node', MACHINE, undefined, 'llama3.1:8b']);++ const { validate } = require('openvibe-contracts');++ 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');++});++++t.test('a run with it goes to that machine as the person, costs nothing, and says where it ran', async () => {++ const run = async () => await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: chat, body: { workflow: 'live.viewers.line', input: { role: 'chat', user: 'what is a mutex?' }, credential: { subject: FRAN } } });++ let r = await run();++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.body.run.status, 'succeeded', JSON.stringify(r.body.run));++ assert.strictEqual(r.body.run.output.text, 'A lock that one thread holds at a time.');++ assert.strictEqual(sent.length, 1);++ assert.deepStrictEqual([sent[0].machine, sent[0].subject, sent[0].model], [MACHINE, FRAN, 'llama3.1:8b']);++ assert.ok(sent[0].messages.some((m) => m.role === 'user' && /what is a mutex/.test(m.content)), 'the question goes to the machine');++ const reqs = await h.db.prepare('SELECT provider_key, status, cost_usd FROM requests WHERE run_id = ?').all(r.body.run.id);++ assert.deepStrictEqual(reqs.map((x) => [x.provider_key, x.status, Number(x.cost_usd)]), [[`byo:chat:${FRAN}`, 'ok', 0]]);++ r = await run();++ assert.strictEqual(sent.length, 2, 'never served from the cache');++ // The owner's switch off on Bot: the run fails with Bot's reason, and nothing else answers instead.++ refuse = true;++ r = await run();++ assert.ok(r.body.code === 'bot.agent_not_allowed' || (r.body.run && r.body.run.status === 'failed'), r.text);++ refuse = false;++});++++t.test('text only: a picture is refused before anything is sent', async () => {++ const before = sent.length;++ const r = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: chat, body: { workflow: 'live.viewers.line', input: { role: 'vision', user: 'what is this?', image_url: 'data:image/png;base64,iVBORw0KGgo=' }, credential: { subject: FRAN } } });++ assert.ok(r.status >= 400 || (r.body.run && r.body.run.status === 'failed'), r.text);++ assert.strictEqual(sent.length, before, 'nothing reached the machine');++});++++t.test('server/machines.js: one POST to Bot as the person with a service token; Bot\'s answers become the run\'s', async () => {++ const { createMachineModels } = require('../server/machines');++ const calls = [];++ let answer = { status: 200, body: { result: 'ack', data: { model: 'llama3.1:8b', message: { role: 'assistant', content: ' Hi. ' }, usage: { prompt_tokens: 12, completion_tokens: 3 } } } };++ const fetchImpl = async (url, init) => {++ calls.push({ url, init });++ if (answer === 'down') throw new Error('connect ECONNREFUSED');++ return { ok: answer.status < 400, status: answer.status, json: async () => answer.body };++ };++ const tokens = { getToken: async ({ audience }) => `svc-${audience}` };++ const off = createMachineModels({ config: { bot: { url: '' } }, env: {}, fetchImpl, tokens });++ assert.strictEqual(off.enabled, false);++ await assert.rejects(off.chat({ machine: MACHINE, subject: FRAN, model: 'm', messages: [] }), (e) => e.code === 'machine.unavailable' && e.status === 503);++ const m = createMachineModels({ config: { bot: { url: 'http://bot.test/' } }, env: {}, fetchImpl, tokens });++ 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.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);++ const body = JSON.parse(calls[0].init.body);++ assert.deepStrictEqual([body.kind, body.value.max_tokens, body.value.temperature, body.ms], ['ai', 32768, 2, 5000], 'clamped');++ answer = { status: 403, body: { code: 'bot.agent_not_allowed', detail: 'switched off' } };++ await assert.rejects(m.chat({ machine: MACHINE, subject: FRAN, model: 'm', messages: [] }), (e) => e.status === 403 && e.code === 'bot.agent_not_allowed');++ answer = { status: 200, body: { result: 'nack', reason: 'no model server answered' } };++ await assert.rejects(m.chat({ machine: MACHINE, subject: FRAN, model: 'm', messages: [] }), (e) => e.status === 502 && e.code === 'machine.failed' && /no model server/.test(e.message));++ answer = 'down';++ await assert.rejects(m.chat({ machine: MACHINE, subject: FRAN, model: 'm', messages: [] }), (e) => e.status === 503 && e.code === 'machine.unreachable');++});++++t.test('close', async () => { await h.close(); });++++t.run();
@@ -25,6 +25,9 @@ owns publication truth: Wiki, Blog, News, Live, … decide what to publish.
@@ -25,6 +25,9 @@ owns publication truth: Wiki, Blog, News, Live, … decide what to publish.
[migrations/](migrations/))
[migrations/](migrations/))
- people's own provider keys (`ai.credential.manage`), encrypted at rest, and per-streamer
- people's own provider keys (`ai.credential.manage`), encrypted at rest, and per-streamer
attribution quotas (`ai.quota.attribution.manage`)
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`)
- the `ai.run.queued|succeeded|failed|cached` events and the `ai.preferences` / `ai.usage_summary`
- the `ai.run.queued|succeeded|failed|cached` events and the `ai.preferences` / `ai.usage_summary`
user modules' AI side
user modules' AI side
@@ -638,7 +641,7 @@ ai.openvibe.network notice from OpenVibe.Sites.
@@ -638,7 +641,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).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.218.0
+- openvibe-contracts: v0.220.0
- openvibe-sdk: v0.65.0
- openvibe-sdk: v0.65.0
- openvibe-shared: v4.15.2
- openvibe-shared: v4.15.2
<!-- versions:end -->
<!-- versions:end -->
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
"runtime": "node 22, express, postgresql",
"runtime": "node 22, express, postgresql",
"port": 4700,
"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",
"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.218.0",
+ "contracts": "openvibe-contracts v0.220.0",
"sdk": "openvibe-sdk v0.65.0",
"sdk": "openvibe-sdk v0.65.0",
"shared": "openvibe-shared v4.15.2",
"shared": "openvibe-shared v4.15.2",
"capabilities": [
"capabilities": [
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
+-- phase: expand
+-- AI on your own machines (contracts 0.220.0): a person's own provider may be their own machine, provider node with the
+-- machine it runs on (a dev_ id on OpenVibe.Bot). It has no key: key_enc and key_hint stay empty for it.
+ALTER TABLE subject_credentials DROP CONSTRAINT IF EXISTS subject_credentials_provider_check;
+ALTER TABLE subject_credentials ADD CONSTRAINT subject_credentials_provider_check CHECK (provider IN ('openai', 'anthropic', 'node'));
+ALTER TABLE subject_credentials ADD COLUMN IF NOT EXISTS machine text COLLATE "C";
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.218.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.220.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.65.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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.15.2",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -1265,9 +1265,9 @@
@@ -1265,9 +1265,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.218.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.218.0",
- "integrity": "sha512-/cgAdxibISceSva51oCYz/382iLsXW5RU6kt8Lyt5JWPiL6QhL0193boUZS9Ls4SxnEgT/fiAtMU3IJY4qMKwg==",
+ "version": "0.220.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.220.0",
+ "integrity": "sha512-1M5oDLuzba0KNX73Q1cwYusHfC6GEUKeypRA2ojoBUlxJW3huP/7p0tCxF0L9WMKMfsR9PoXY2WDMp8UCao40A==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.218.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.220.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.65.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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.15.2",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -226,6 +226,11 @@ function load(env = process.env) {
@@ -226,6 +226,11 @@ function load(env = process.env) {
intervalMs: Math.max(100, int(env.USAGE_SAMPLES_INTERVAL_MS, 2000)),
intervalMs: Math.max(100, int(env.USAGE_SAMPLES_INTERVAL_MS, 2000)),
},
},
+ // ── A person's own machine as their model (server/machines.js; contracts 0.219.0/0.220.0): runs with a
+ // credential of provider node go through OpenVibe.Bot (bot.machine.ai), on this host by default (as Actor reaches
+ // it); OV_BOT_INTERNAL_URL= (empty) turns it off.
+ bot: { url: trimUrl(env.OV_BOT_INTERNAL_URL != null ? env.OV_BOT_INTERNAL_URL : 'http://127.0.0.1:4630') },
+
// ── Tier budgets (T6 step 8, server/govern.js): openvibe-sdk/govern in Valkey, off unless AI_GOVERN_TIERS=1.
// ── Tier budgets (T6 step 8, server/govern.js): openvibe-sdk/govern in Valkey, off unless AI_GOVERN_TIERS=1.
govern: loadGovern(env, { bool, int, num: float }),
govern: loadGovern(env, { bool, int, num: float }),
};
};
@@ -13,6 +13,9 @@
@@ -13,6 +13,9 @@
* subject (the additional data), so a row copied to another owner or subject does not decrypt. Without the key
* subject (the additional data), so a row copied to another owner or subject does not decrypt. Without the key
* configured the routes answer 503 and credentialed runs are refused. Spend is what the credential's requests
* configured the routes answer 503 and credentialed runs are refused. Spend is what the credential's requests
* cost today (provider_key byo:<owner>:<subject>), checked against the optional budget_usd_per_day.
* cost today (provider_key byo:<owner>:<subject>), checked against the optional budget_usd_per_day.
+ *
+ * Provider node (contracts 0.220.0) is the person's own machine instead: `machine` names it, there is no key (nothing is
+ * sealed, no hint), and a run reaches it only through OpenVibe.Bot (server/machines.js), for that person only.
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
const express = require('express');
const express = require('express');
@@ -56,7 +59,8 @@ function createCredentials({ db, config, clock = { now: () => Date.now() } }) {
@@ -56,7 +59,8 @@ function createCredentials({ db, config, clock = { now: () => Date.now() } }) {
async function view(row) {
async function view(row) {
return {
return {
- subject: row.subject, owner: row.owner, provider: row.provider, base_url: row.base_url || null, key_hint: row.key_hint,
+ subject: row.subject, owner: row.owner, provider: row.provider, base_url: row.base_url || null,
+ ...(row.provider === 'node' ? { machine: row.machine } : { key_hint: row.key_hint }),
models: JSON.parse(row.models || '{}'), budget_usd_per_day: row.budget_usd_day == null ? null : row.budget_usd_day,
models: JSON.parse(row.models || '{}'), budget_usd_per_day: row.budget_usd_day == null ? null : row.budget_usd_day,
spent_usd_today: Math.round(await spentToday(row.owner, row.subject) * 1e6) / 1e6,
spent_usd_today: Math.round(await spentToday(row.owner, row.subject) * 1e6) / 1e6,
created_at: row.created_at, updated_at: row.updated_at, last_used_at: row.last_used_at || null,
created_at: row.created_at, updated_at: row.updated_at, last_used_at: row.last_used_at || null,
@@ -70,8 +74,17 @@ function createCredentials({ db, config, clock = { now: () => Date.now() } }) {
@@ -70,8 +74,17 @@ function createCredentials({ db, config, clock = { now: () => Date.now() } }) {
if (!v.valid) throw new AiError(422, 'input.invalid', 'body does not match ai.credential-put@1', { errors: v.errors });
if (!v.valid) throw new AiError(422, 'input.invalid', 'body does not match ai.credential-put@1', { errors: v.errors });
const at = iso(clock.now());
const at = iso(clock.now());
const prev = await row(owner, subject);
const prev = await row(owner, subject);
+ if (body.provider === 'node') {
+ // The person's own machine: nothing to seal. Bot checks it is theirs and that they turned it on, at each run.
+ await db.prepare(`INSERT INTO subject_credentials (owner, subject, provider, base_url, machine, key_enc, key_hint, models, budget_usd_day, created_at, updated_at)
+ VALUES (?, ?, 'node', NULL, ?, '', '', ?, NULL, ?, ?)
+ ON CONFLICT(owner, subject) DO UPDATE SET provider = excluded.provider, base_url = NULL, machine = excluded.machine, key_enc = '',
+ key_hint = '', models = excluded.models, budget_usd_day = NULL, updated_at = excluded.updated_at`)
+ .run(owner, subject, body.machine, JSON.stringify(body.models || {}), prev ? prev.created_at : at, at);
+ return await view(await row(owner, subject));
+ }
// The stored key stays for a change of models or budget; a new provider or endpoint needs the key again.
// The stored key stays for a change of models or budget; a new provider or endpoint needs the key again.
- const sameEndpoint = prev && prev.provider === body.provider && (prev.base_url || null) === (body.base_url || null);
+ const sameEndpoint = prev && prev.provider !== 'node' && prev.provider === body.provider && (prev.base_url || null) === (body.base_url || null);
if (body.api_key == null && !sameEndpoint) {
if (body.api_key == null && !sameEndpoint) {
throw new AiError(400, 'credential.key_required', prev ? 'enter the key again to change the provider or its address' : 'api_key is required the first time');
throw new AiError(400, 'credential.key_required', prev ? 'enter the key again to change the provider or its address' : 'api_key is required the first time');
}
}
@@ -99,6 +112,7 @@ function createCredentials({ db, config, clock = { now: () => Date.now() } }) {
@@ -99,6 +112,7 @@ function createCredentials({ db, config, clock = { now: () => Date.now() } }) {
async function admit(owner, subject) {
async function admit(owner, subject) {
const r = await row(owner, subject);
const r = await row(owner, subject);
if (!r) throw new AiError(404, 'credential.not_found', 'no credential stored for that subject');
if (!r) throw new AiError(404, 'credential.not_found', 'no credential stored for that subject');
+ if (r.provider === 'node') return; // the person's own machine: no key, no spend
secretKey();
secretKey();
if (r.budget_usd_day != null && r.budget_usd_day > 0 && await spentToday(owner, subject) >= r.budget_usd_day) {
if (r.budget_usd_day != null && r.budget_usd_day > 0 && await spentToday(owner, subject) >= r.budget_usd_day) {
const tomorrow = new Date(iso(clock.now()).slice(0, 10) + 'T00:00:00.000Z').getTime() + 86400000;
const tomorrow = new Date(iso(clock.now()).slice(0, 10) + 'T00:00:00.000Z').getTime() + 86400000;
@@ -111,6 +125,7 @@ function createCredentials({ db, config, clock = { now: () => Date.now() } }) {
@@ -111,6 +125,7 @@ function createCredentials({ db, config, clock = { now: () => Date.now() } }) {
const r = await row(owner, subject);
const r = await row(owner, subject);
if (!r) throw new AiError(404, 'credential.not_found', 'the credential was deleted');
if (!r) throw new AiError(404, 'credential.not_found', 'the credential was deleted');
await db.prepare('UPDATE subject_credentials SET last_used_at = ? WHERE owner = ? AND subject = ?').run(iso(clock.now()), owner, subject);
await db.prepare('UPDATE subject_credentials SET last_used_at = ? WHERE owner = ? AND subject = ?').run(iso(clock.now()), owner, subject);
+ if (r.provider === 'node') return { providerKey: providerKeyFor(owner, subject), provider: 'node', subject, machine: r.machine, models: JSON.parse(r.models || '{}') };
return { providerKey: providerKeyFor(owner, subject), provider: r.provider, base_url: r.base_url || null, apiKey: open(r.key_enc, `${owner}|${subject}`), models: JSON.parse(r.models || '{}') };
return { providerKey: providerKeyFor(owner, subject), provider: r.provider, base_url: r.base_url || null, apiKey: open(r.key_enc, `${owner}|${subject}`), models: JSON.parse(r.models || '{}') };
}
}
@@ -26,7 +26,7 @@ const { createApp } = require('./app');
@@ -26,7 +26,7 @@ const { createApp } = require('./app');
const { gracefulStop, telemetry } = require('openvibe-sdk/service');
const { gracefulStop, telemetry } = require('openvibe-sdk/service');
const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk/telemetry');
const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk/telemetry');
-async function start({ config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null } = {}) {
+async function start({ config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null, machineModels = null } = {}) {
config = config || load(env);
config = config || load(env);
schemas.configure(config.schemaCache);
schemas.configure(config.schemaCache);
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.
@@ -35,7 +35,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -35,7 +35,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
// Provider keys an owner stored from the console (server/vault.js): into this process before any provider is built.
// Provider keys an owner stored from the console (server/vault.js): into this process before any provider is built.
const vault = require('./vault').createVault({ db, config, clock, log });
const vault = require('./vault').createVault({ db, config, clock, log });
await vault.load().catch((err) => log.warn(`[Vault] ${err.message}`));
await vault.load().catch((err) => log.warn(`[Vault] ${err.message}`));
- const pool = createProviderPool({ db, registry, config, clock, fetchImpl, env, log, credentialFetch });
+ const pool = createProviderPool({ db, registry, config, clock, fetchImpl, env, log, credentialFetch, machineModels });
// T6 step 8: the SaaS tier budgets on openvibe-sdk/govern in Valkey (server/govern.js); off unless AI_GOVERN_TIERS=1.
// T6 step 8: the SaaS tier budgets on openvibe-sdk/govern in Valkey (server/govern.js); off unless AI_GOVERN_TIERS=1.
const tiers = createTiers({ config, valkey, clock, log });
const tiers = createTiers({ config, valkey, clock, log });
// §2.1.8: the provider free allowance per subject, priced off the pool's own rate cards (server/free-allowance.js);
// §2.1.8: the provider free allowance per subject, priced off the pool's own rate cards (server/free-allowance.js);
new file mode 100644
new file mode 100644
@@ -0,0 +1,63 @@
@@ -0,0 +1,63 @@
+'use strict';
+/**
+ * A person's own machine as their model (AI on your own machines: contracts 0.219.0 bot.command@1 kind ai and the
+ * capability bot.machine.ai; 0.220.0 the credential provider node). One chat completion goes through OpenVibe.Bot,
+ * POST /api/v1/machines/:id/commands with kind ai, as OpenVibe.AI acting for the person (X-OV-Subject), to the machine's
+ * 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.
+ *
+ * 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 } }
+ */
+const { AiError } = require('./util');
+
+const MACHINE_RE = /^dev_[0-9A-HJKMNP-TV-Z]{26}$/;
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+function createMachineModels({ config, env = process.env, fetchImpl = globalThis.fetch, tokens: override = null }) {
+ const base = String((config.bot && config.bot.url) || '').replace(/\/+$/, '');
+ const secret = env.OV_OAUTH_CLIENT_SECRET || '';
+ const enabled = Boolean(base && (override || secret));
+ let tokens = override;
+ const tokenClient = () => {
+ if (!tokens) {
+ const { createServiceTokenClient } = require('openvibe-sdk/auth');
+ const network = String(config.networkInternalUrl || 'http://127.0.0.1:4000').replace(/\/+$/, '');
+ tokens = createServiceTokenClient({ tokenUrl: `${network}/oauth/token`, clientId: env.OV_OAUTH_CLIENT_ID || 'ai', clientSecret: secret, scope: { 'openvibe.bot': 'bot.machine.ai' }, fetch: fetchImpl });
+ }
+ return tokens;
+ };
+
+ async function chat({ machine, subject, model, messages, maxTokens = null, temperature = null, signal = null, timeoutMs = 120000 }) {
+ 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 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' },
+ body: JSON.stringify({ kind: 'ai', value, ms: Math.min(600000, Math.max(5000, timeoutMs)) }),
+ signal: signal || AbortSignal.timeout(Math.min(610000, timeoutMs + 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) {
+ const code = (body && body.code) || 'machine.refused';
+ const why = (body && (body.detail || body.reason || body.title)) || `OpenVibe.Bot answered ${res.status}`;
+ throw new AiError(res.status === 404 ? 404 : res.status === 403 ? 403 : 502, code, String(why).slice(0, 300));
+ }
+ if (!body || body.result !== 'ack') throw new AiError(502, 'machine.failed', `the machine did not answer: ${String((body && (body.reason || body.result)) || 'no answer').slice(0, 200)}`);
+ const d = body.data || {};
+ const u = d.usage || {};
+ return { text: String((d.message && d.message.content) || '').trim(), model: d.model || model, usage: { input: u.prompt_tokens || 0, output: u.completion_tokens || 0, cached: 0 } };
+ }
+
+ return { enabled, chat };
+}
+
+module.exports = { createMachineModels };
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
const placement = require('openvibe-sdk/placement');
const placement = require('openvibe-sdk/placement');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const { AiError, resolveSecret, sha256 } = require('../util');
const { AiError, resolveSecret, sha256 } = require('../util');
-const { retryable, sleep, ProviderError } = require('./common');
+const { retryable, sleep, ProviderError, schemaInstruction } = require('./common');
const { createStubProvider } = require('./stub');
const { createStubProvider } = require('./stub');
const { createOpenAiProvider } = require('./openai');
const { createOpenAiProvider } = require('./openai');
const { createResponsesProvider } = require('./responses');
const { createResponsesProvider } = require('./responses');
@@ -43,7 +43,9 @@ function rateLimited(err) {
@@ -43,7 +43,9 @@ function rateLimited(err) {
return (err && err.status === 429) || (err && err.code === 'provider.rate_limited');
return (err && err.status === 429) || (err && err.code === 'provider.rate_limited');
}
}
-function createProviderPool({ db, registry, config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, credentialFetch = null }) {
+function createProviderPool({ db, registry, config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, credentialFetch = null, machineModels = null }) {
+ // A person's own machine as their model (credential provider node, server/machines.js).
+ const machines = machineModels || require('../machines').createMachineModels({ config, env, fetchImpl });
const cache = new Map(); // key -> { stamp, adapter }
const cache = new Map(); // key -> { stamp, adapter }
const stats = {}; // key -> { calls, failures, ... } (in memory; tests + /api/ready)
const stats = {}; // key -> { calls, failures, ... } (in memory; tests + /api/ready)
@@ -368,7 +370,39 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -368,7 +370,39 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
* shared with anyone, chat operations only; the base URL they chose is reached through guardedFetch. One
* shared with anyone, chat operations only; the base URL they chose is reached through guardedFetch. One
* request-log row per attempt under provider_key byo:<owner>:<subject>, priced by the model's list price.
* request-log row per attempt under provider_key byo:<owner>:<subject>, priced by the model's list price.
*/
*/
+ /**
+ * A run on the person's own machine (credential provider node): one chat completion through OpenVibe.Bot to their
+ * model server. Text only, no fallback, nothing spent; one request-log row under byo:<owner>:<subject>.
+ */
+ async function executeOnMachine(cred, operation, req, ctx = {}) {
+ if (!['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'].includes(operation)) throw new AiError(422, 'credential.unsupported', `a person's own machine answers text only, not ${operation}`);
+ if (req.image) throw new AiError(422, 'credential.unsupported', 'a person\'s own machine reads no pictures yet');
+ const wanted = ctx.role || ((/^live\.(chat|vision|director|summary|legacy)$/.exec(ctx.routeKey || '') || [])[1]) || null;
+ const model = (wanted && cred.models[wanted]) || cred.models.chat;
+ if (!model) throw new AiError(422, 'credential.no_model', 'the person\'s own machine credential names no model (models.chat)');
+ const sysText = [...(req.system || []).map((x) => x.text), req.json ? schemaInstruction(req.json) : ''].filter(Boolean).join('\n\n');
+ const messages = [
+ ...(sysText ? [{ role: 'system', content: sysText }] : []),
+ ...(req.messages || []).map((m) => ({ role: m.role === 'assistant' ? 'assistant' : m.role === 'system' ? 'system' : 'user', content: Array.isArray(m.content) ? m.content.filter((p) => p && p.type === 'text').map((p) => p.text).join('\n') : String(m.content || '') })),
+ ];
+ const base = { operation, provider_key: cred.providerKey, model_key: model, route_key: ctx.routeKey || null, route_version: ctx.routeVersion || null, fallback: 0, prompt_hash: ctx.promptHash || null };
+ const t0 = Date.now();
+ try {
+ const result = await machines.chat({ machine: cred.machine, subject: cred.subject, model, messages, maxTokens: req.maxTokens, temperature: req.temperature, signal: ctx.signal, timeoutMs: Math.max(5000, Math.min(req.timeoutMs || 120000, 300000)) });
+ const usage = result.usage;
+ 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: [] } };
+ } 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');
+ throw err;
+ }
+ }
+
async function executeWithCredential(cred, operation, req, ctx = {}) {
async function executeWithCredential(cred, operation, req, ctx = {}) {
+ if (cred.provider === 'node') return await executeOnMachine(cred, operation, req, ctx);
// Text operations are all chat completions on the person's provider (templated workflows use generate).
// Text operations are all chat completions on the person's provider (templated workflows use generate).
// A project's own provider (ADR-056 secret, ai.complete) passes a Chat Completions request through as written.
// A project's own provider (ADR-056 secret, ai.complete) passes a Chat Completions request through as written.
const completing = operation === 'complete' && cred.provider === 'openai';
const completing = operation === 'complete' && cred.provider === 'openai';
@@ -45,7 +45,7 @@ function tmpDir() {
@@ -45,7 +45,7 @@ function tmpDir() {
// A boot on a dir used before is a restart on the same database: its database stays open.
// A boot on a dir used before is a restart on the same database: its database stays open.
const restartable = new Map();
const restartable = new Map();
-async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research } = {}) {
+async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research, machineModels } = {}) {
const reuse = dir !== undefined;
const reuse = dir !== undefined;
if (dir === undefined) dir = tmpDir();
if (dir === undefined) dir = tmpDir();
const fullEnv = {
const fullEnv = {
@@ -59,7 +59,7 @@ async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research
@@ -59,7 +59,7 @@ async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research
if (reuse) restartable.set(dir, testdb);
if (reuse) restartable.set(dir, testdb);
// Research reads a canned web (test/fixtures/research-fetch.js) unless a test brings its own: no test reaches the internet.
// Research reads a canned web (test/fixtures/research-fetch.js) unless a test brings its own: no test reaches the internet.
const researcher = research || require('../server/research').createResearch({ config: config.research, env: {}, fetchImpl: require('./fixtures/research-fetch').createResearchFetch().fetchImpl, log: silent });
const researcher = research || require('../server/research').createResearch({ config: config.research, env: {}, fetchImpl: require('./fixtures/research-fetch').createResearchFetch().fetchImpl, log: silent });
- const h = await start({ config, db: testdb.db, env: fullEnv, log: silent, clock, fetchImpl, credentialFetch, research: researcher });
+ const h = await start({ config, db: testdb.db, env: fullEnv, log: silent, clock, fetchImpl, credentialFetch, research: researcher, machineModels });
const base = `http://127.0.0.1:${h.server.address().port}`;
const base = `http://127.0.0.1:${h.server.address().port}`;
return { ...h, base, dir, env: fullEnv, testdb, stop: async () => { await h.close(); if (!reuse) await testdb.close(); } };
return { ...h, base, dir, env: fullEnv, testdb, stop: async () => { await h.close(); if (!reuse) await testdb.close(); } };
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,106 @@
@@ -0,0 +1,106 @@
+'use strict';
+// A person's own machine as their model (AI on your own machines; contracts 0.219.0 kind ai, 0.220.0 credential
+// provider node):
+// - a service holding ai.credential.manage stores provider node with the machine; no key, no hint, nothing sealed;
+// - a run naming it goes to that machine through OpenVibe.Bot as the person (server/machines.js), text only, nothing
+// spent, logged under byo:<owner>:<subject>, its explain saying where it ran; never cached, never another provider;
+// - Bot's refusal (the owner's switch off) is the run's error; a picture is refused before anything is sent.
+const assert = require('assert');
+const { boot, request, token, suite, ALL } = require('./helpers');
+const { AiError } = require('../server/util');
+
+const t = suite('machine-credential');
+const FRAN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP4';
+const MACHINE = 'dev_01M5YGM39VC2RRGDQPRD6D4SHD';
+const chat = token('chat', [...ALL, 'ai.credential.manage']);
+const sent = [];
+let refuse = false;
+const machineModels = {
+ enabled: true,
+ async chat(args) {
+ sent.push(args);
+ if (refuse) throw new AiError(403, 'bot.agent_not_allowed', 'the owner has not let their AI use this computer\'s models');
+ return { text: 'A lock that one thread holds at a time.', model: args.model, usage: { input: 30, output: 9, cached: 0 } };
+ },
+};
+let h;
+
+t.test('boot', async () => {
+ h = await boot({ env: { AI_CREDENTIALS_KEY: 'cd'.repeat(32) }, machineModels });
+});
+
+t.test('store a node credential: the machine, no key', async () => {
+ let r = await request(h.base, 'PUT', `/api/v1/credentials/${FRAN}`, { tok: chat, body: { provider: 'node', machine: MACHINE, api_key: 'sk-not-for-a-machine' } });
+ assert.strictEqual(r.status, 422, 'no key for a machine');
+ r = await request(h.base, 'PUT', `/api/v1/credentials/${FRAN}`, { tok: chat, body: { provider: 'node', models: { chat: 'llama3.1:8b' } } });
+ assert.strictEqual(r.status, 422, 'a node credential names its machine');
+ r = await request(h.base, 'PUT', `/api/v1/credentials/${FRAN}`, { tok: chat, body: { provider: 'node', machine: MACHINE, models: { chat: 'llama3.1:8b' } } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual([r.body.provider, r.body.machine, r.body.key_hint, r.body.models.chat], ['node', MACHINE, undefined, 'llama3.1:8b']);
+ const { validate } = require('openvibe-contracts');
+ 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');
+});
+
+t.test('a run with it goes to that machine as the person, costs nothing, and says where it ran', async () => {
+ const run = async () => await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: chat, body: { workflow: 'live.viewers.line', input: { role: 'chat', user: 'what is a mutex?' }, credential: { subject: FRAN } } });
+ let r = await run();
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.body.run.status, 'succeeded', JSON.stringify(r.body.run));
+ assert.strictEqual(r.body.run.output.text, 'A lock that one thread holds at a time.');
+ assert.strictEqual(sent.length, 1);
+ assert.deepStrictEqual([sent[0].machine, sent[0].subject, sent[0].model], [MACHINE, FRAN, 'llama3.1:8b']);
+ assert.ok(sent[0].messages.some((m) => m.role === 'user' && /what is a mutex/.test(m.content)), 'the question goes to the machine');
+ const reqs = await h.db.prepare('SELECT provider_key, status, cost_usd FROM requests WHERE run_id = ?').all(r.body.run.id);
+ assert.deepStrictEqual(reqs.map((x) => [x.provider_key, x.status, Number(x.cost_usd)]), [[`byo:chat:${FRAN}`, 'ok', 0]]);
+ r = await run();
+ assert.strictEqual(sent.length, 2, 'never served from the cache');
+ // The owner's switch off on Bot: the run fails with Bot's reason, and nothing else answers instead.
+ refuse = true;
+ r = await run();
+ assert.ok(r.body.code === 'bot.agent_not_allowed' || (r.body.run && r.body.run.status === 'failed'), r.text);
+ refuse = false;
+});
+
+t.test('text only: a picture is refused before anything is sent', async () => {
+ const before = sent.length;
+ const r = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: chat, body: { workflow: 'live.viewers.line', input: { role: 'vision', user: 'what is this?', image_url: 'data:image/png;base64,iVBORw0KGgo=' }, credential: { subject: FRAN } } });
+ assert.ok(r.status >= 400 || (r.body.run && r.body.run.status === 'failed'), r.text);
+ assert.strictEqual(sent.length, before, 'nothing reached the machine');
+});
+
+t.test('server/machines.js: one POST to Bot as the person with a service token; Bot\'s answers become the run\'s', async () => {
+ const { createMachineModels } = require('../server/machines');
+ const calls = [];
+ let answer = { status: 200, body: { result: 'ack', data: { model: 'llama3.1:8b', message: { role: 'assistant', content: ' Hi. ' }, usage: { prompt_tokens: 12, completion_tokens: 3 } } } };
+ const fetchImpl = async (url, init) => {
+ calls.push({ url, init });
+ if (answer === 'down') throw new Error('connect ECONNREFUSED');
+ return { ok: answer.status < 400, status: answer.status, json: async () => answer.body };
+ };
+ const tokens = { getToken: async ({ audience }) => `svc-${audience}` };
+ const off = createMachineModels({ config: { bot: { url: '' } }, env: {}, fetchImpl, tokens });
+ assert.strictEqual(off.enabled, false);
+ await assert.rejects(off.chat({ machine: MACHINE, subject: FRAN, model: 'm', messages: [] }), (e) => e.code === 'machine.unavailable' && e.status === 503);
+ const m = createMachineModels({ config: { bot: { url: 'http://bot.test/' } }, env: {}, fetchImpl, tokens });
+ 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.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);
+ const body = JSON.parse(calls[0].init.body);
+ assert.deepStrictEqual([body.kind, body.value.max_tokens, body.value.temperature, body.ms], ['ai', 32768, 2, 5000], 'clamped');
+ answer = { status: 403, body: { code: 'bot.agent_not_allowed', detail: 'switched off' } };
+ await assert.rejects(m.chat({ machine: MACHINE, subject: FRAN, model: 'm', messages: [] }), (e) => e.status === 403 && e.code === 'bot.agent_not_allowed');
+ answer = { status: 200, body: { result: 'nack', reason: 'no model server answered' } };
+ await assert.rejects(m.chat({ machine: MACHINE, subject: FRAN, model: 'm', messages: [] }), (e) => e.status === 502 && e.code === 'machine.failed' && /no model server/.test(e.message));
+ answer = 'down';
+ await assert.rejects(m.chat({ machine: MACHINE, subject: FRAN, model: 'm', messages: [] }), (e) => e.status === 503 && e.code === 'machine.unreachable');
+});
+
+t.test('close', async () => { await h.close(); });
+
+t.run();