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)

OP OpenVibers committed 17 days ago · fe5817b024 · 12 files, +247 −15

Changes

ddiff --git a/README.md b/README.md
iindex dd0cc95..afda549 100644
---- a/README.md
++++ b/README.md
@@@ -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-streamer
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`
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 -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 56ca14c..cc012b4 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.218.0",
++ "contracts": "openvibe-contracts v0.220.0",
"sdk": "openvibe-sdk v0.65.0",
"shared": "openvibe-shared v4.15.2",
"capabilities": [
ddiff --git a/migrations/0011_credential_machine.sql b/migrations/0011_credential_machine.sql
nnew file mode 100644
iindex 0000000..8d93725
---- /dev/null
++++ b/migrations/0011_credential_machine.sql
@@@ -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";
ddiff --git a/package-lock.json b/package-lock.json
iindex c524a7a..a33dbb8 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.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",
ddiff --git a/package.json b/package.json
iindex 2dc2bf4..c01a249 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.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"
ddiff --git a/server/config.js b/server/config.js
iindex dd5af7c..1cbb50f 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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 }),
};
ddiff --git a/server/credentials.js b/server/credentials.js
iindex 9014720..4c049bb 100644
---- a/server/credentials.js
++++ b/server/credentials.js
@@@ -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 spend
secretKey();
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 || '{}') };
}
ddiff --git a/server/index.js b/server/index.js
iindex ead9cb8..835b2ae 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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);
ddiff --git a/server/machines.js b/server/machines.js
nnew file mode 100644
iindex 0000000..dd0a68f
---- /dev/null
++++ b/server/machines.js
@@@ -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 };
ddiff --git a/server/providers/index.js b/server/providers/index.js
iindex 841f44d..fc7017c 100644
---- a/server/providers/index.js
++++ b/server/providers/index.js
@@@ -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';
ddiff --git a/test/helpers.js b/test/helpers.js
iindex ed1a3d3..79e96ae 100644
---- a/test/helpers.js
++++ b/test/helpers.js
@@@ -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, research
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.
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(); } };
}
ddiff --git a/test/machine-credential.test.js b/test/machine-credential.test.js
nnew file mode 100644
iindex 0000000..f2f1830
---- /dev/null
++++ b/test/machine-credential.test.js
@@@ -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();