openvibers/openvibe.ai Commits

An agent's loop runs on the person's own machine: chat completions with tools (L55 C3) (#265)

OP OpenVibers committed 11 days ago · 582543f950 · 7 files, +290 −32

* An agent's loop runs on the person's own machine: chat completions with tools

Plan L55 C3. A run on a node credential now serves the OpenAI-compatible
endpoint (operation complete): the conversation goes to the machine in the
shape bot.command ai takes (contracts 0.277.0: tool calls and results kept,
text only, at most 200 messages and never starting on an orphan result),
with the tools and tool_choice, and the machine's answer becomes the
completion's one choice, with the calls the model asked for. Only a machine
whose Node says computer.ai.tools is asked: machine any passes the others
over, and a named one without it is refused with what to update. Nothing is
spent; streamed, the answer arrives as one delta.

* Your computer on /you: a model at home, used only by the apps you allow

Plan L55 C3, consent by opt-in. A person adds a model on their own computer
next to their keys (owner self, provider node, machine any: whichever of
their computers is on with the model, the fastest first). No key, nothing
sealed, nothing spent, and never a fallback for other work. An app reaches
it only by name (x-openvibe-credential: node) and only once the person
allows that app for their computer's models; before that AI answers
ai.credential_not_allowed with where to allow it. The section lists their
computers from Bot (names, models, away, and a Node too old for agents).
allowed-keys names node when they have it, for the app's own chip.

Changes

ddiff --git a/server/app.js b/server/app.js
iindex 5d9302e..5f115f0 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -122,7 +122,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
if (credentials && preferences && grants) {
app.use(require('./me').meRouter({ credentials, preferences, grants, auth, registry, config, clock, log, personSessions, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
// The same settings as a page for people (GET /you, plain forms on AI's own person session).
-- app.use(require('./you-page').youPageRouter({ credentials, preferences, grants, personSessions, config, log }));
++ app.use(require('./you-page').youPageRouter({ credentials, preferences, grants, personSessions, config, log, machines: pool && pool.machines }));
}
app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env, clock, fetchImpl, log, vault, personSessions }));
app.use(runPageRouter({ runs, personSessions, log }));
ddiff --git a/server/credentials.js b/server/credentials.js
iindex 1771810..e935beb 100644
---- a/server/credentials.js
++++ b/server/credentials.js
@@@ -386,6 +386,13 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
/** A person's own credential for one provider (no key returned to the caller; server-side use only). */
async function selfForRun(subject, provider) {
++ if (provider === 'node') {
++ // Their own computer (plan L55 C3): the machine and the model, no key. Only an app they allowed reaches it.
++ const n = await getRow(SELF_OWNER, subject, 'node');
++ if (!n) return null;
++ await db.prepare('UPDATE subject_credentials SET last_used_at = ? WHERE owner = ? AND subject = ? AND provider = ?').run(iso(clock.now()), SELF_OWNER, subject, 'node');
++ return { providerKey: n.spend_key || providerKeyFor(SELF_OWNER, subject, 'node'), owner: SELF_OWNER, subject, provider: 'node', machine: n.machine, models: JSON.parse(n.models || '{}'), hint: '' };
++ }
if (!CREDENTIAL_PROVIDERS.includes(provider)) return null;
const r = await getRow(SELF_OWNER, subject, provider);
if (!r) return null;
@@@ -433,6 +440,28 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
return selfView(await getRow(SELF_OWNER, subject, provider));
}
++ /**
++ * The person's own computer as their model (plan L55 C3), kept with their keys: machine any (whichever of theirs is
++ * on with the model, the fastest first) or one dev_ id, and the model. No key, nothing sealed, nothing spent. It is
++ * never a fallback for other work (selfRows leaves it out): an app the person allowed asks for it by name
++ * (x-openvibe-credential: node).
++ */
++ async function getSelfNode(subject) {
++ if (!SUBJECT_RE.test(String(subject))) return null;
++ const r = await getRow(SELF_OWNER, subject, 'node');
++ return r ? { machine: r.machine, model: (JSON.parse(r.models || '{}')).chat || null, last_used_at: r.last_used_at || null, updated_at: r.updated_at } : null;
++ }
++ async function putSelfNode(subject, { machine = 'any', model } = {}) {
++ const m = String(model || '').trim();
++ if (!m || m.length > 120 || /\s/.test(m)) throw new AiError(422, 'input.invalid', 'Name the model as your computer lists it, for example qwen3:8b.');
++ await put(SELF_OWNER, subject, { provider: 'node', machine, models: { chat: m } }, 'node');
++ return await getSelfNode(subject);
++ }
++ async function removeSelfNode(subject) {
++ if (!SUBJECT_RE.test(String(subject))) return false;
++ return (await db.prepare('DELETE FROM subject_credentials WHERE owner = ? AND subject = ? AND provider = ?').run(SELF_OWNER, subject, 'node')).changes > 0;
++ }
++
async function removeSelf(subject, provider) {
if (!CREDENTIAL_PROVIDERS.includes(provider)) return false;
return (await db.prepare('DELETE FROM subject_credentials WHERE owner = ? AND subject = ? AND provider = ?').run(SELF_OWNER, subject, provider)).changes > 0;
@@@ -539,6 +568,7 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
const allowed = await grants.allowedProviders(subject, service);
if (!allowed.length) return [];
const have = new Set((await selfRows(subject)).map((r) => r.provider));
++ if (await getRow(SELF_OWNER, subject, 'node')) have.add('node'); // their own computer (L55 C3)
if (allowed.includes('*')) return [...have].sort();
return allowed.filter((p) => have.has(p)).sort();
}
@@@ -557,7 +587,7 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
return {
put, get, remove, admit, forRun, spentToday, providerKeyFor,
-- listSelf, hasSelf, selfForRun, selfForAllRuns, putSelf, removeSelf, testSelf, markFailed,
++ listSelf, hasSelf, selfForRun, selfForAllRuns, putSelf, removeSelf, testSelf, markFailed, getSelfNode, putSelfNode, removeSelfNode,
allowedProvidersWithKey, consentUse,
};
}
ddiff --git a/server/machines.js b/server/machines.js
iindex 98555a9..e85f7e5 100644
---- a/server/machines.js
++++ b/server/machines.js
@@@ -12,8 +12,13 @@
* hands over to the next. Never anyone else's machine, never another provider.
*
* Off without OV_BOT_INTERNAL_URL or a client secret: a credential with provider node is then refused, never sent
-- * elsewhere. → chat({ machine, subject, model, messages, maxTokens, temperature, signal, timeoutMs })
-- * -> { text, model, usage: { input, output, cached }, machine, tried: [{ machine, error }] }
++ * elsewhere. → chat({ machine, subject, model, messages, maxTokens, temperature, signal, timeoutMs, tools, toolChoice })
++ * -> { text, message, finish, model, usage: { input, output, cached }, machine, tried: [{ machine, error }] }
++ *
++ * Tools (contracts 0.277.0, plan L55 C3): an agent's loop on the person's own model. The command carries the tools and
++ * the calls already made; the machine hands back the calls its model asked for (message.tool_calls) and runs none. Only
++ * a machine whose Node says computer.ai.tools (v0.36.0) is asked: with machine any the others are passed over, and a
++ * named machine without it is refused with what to update.
*/
const { AiError } = require('./util');
@@@ -39,7 +44,7 @@ function createMachineModels({ config, env = process.env, fetchImpl = globalThis
const headersFor = (token, subject) => ({ Authorization: `Bearer ${token}`, 'X-OV-Subject': subject, Accept: 'application/json' });
/** The person's machines that may answer with `model`, the fastest at it first (Bot lists only those switched on). */
-- async function candidates(subject, model, signal) {
++ async function listed(subject, signal) {
const token = await tokenClient().getToken({ audience: 'openvibe.bot' });
let res;
try {
@@@ -49,13 +54,19 @@ function createMachineModels({ config, env = process.env, fetchImpl = globalThis
}
const body = await res.json().catch(() => null);
if (!res.ok) throw new AiError(res.status === 403 ? 403 : 502, (body && body.code) || 'machine.refused', String((body && (body.detail || body.title)) || `OpenVibe.Bot answered ${res.status}`).slice(0, 300));
++ return (body && body.machines) || [];
++ }
++
++ async function candidates(subject, model, signal, { tools = false } = {}) {
++ const machines = await listed(subject, signal);
const speedAt = (m) => {
const s = m.computer && m.computer.ai_speed;
return s && s.model === model && Number(s.tokens_per_s) > 0 ? Number(s.tokens_per_s) : 0;
};
-- return ((body && body.machines) || [])
++ return machines
.filter((m) => m && MACHINE_RE.test(String(m.id)) && m.online && m.ai_models !== false && m.computer && m.computer.ai
-- && m.computer.ai.ready !== false && Array.isArray(m.computer.ai.models) && m.computer.ai.models.includes(model))
++ && m.computer.ai.ready !== false && Array.isArray(m.computer.ai.models) && m.computer.ai.models.includes(model)
++ && (!tools || m.computer.ai.tools === true))
.sort((a, b) => speedAt(b) - speedAt(a))
.map((m) => m.id);
}
@@@ -64,9 +75,17 @@ function createMachineModels({ config, env = process.env, fetchImpl = globalThis
if (!enabled) throw new AiError(503, 'machine.unavailable', 'a person\'s own machine cannot be reached from this service (OV_BOT_INTERNAL_URL)');
const { machine, subject, model, signal = null } = args;
if (!(MACHINE_RE.test(String(machine)) || machine === ANY) || !SUBJECT_RE.test(String(subject))) throw new AiError(422, 'credential.invalid', 'a node credential names a dev_ machine of a usr_ person, or any of theirs');
-- if (machine !== ANY) return { ...(await once(machine, args)), machine, tried: [] };
-- const ids = (await candidates(subject, model, signal)).slice(0, MAX_TRIED);
-- if (!ids.length) throw new AiError(503, 'machine.none', `none of the person's own machines is on with ${String(model).slice(0, 120)} and lets their AI use it`);
++ const tools = Array.isArray(args.tools) && args.tools.length > 0;
++ if (machine !== ANY) {
++ if (tools) {
++ // A Node older than v0.36.0 would drop the tools and answer in words: say what to update instead.
++ const m = (await listed(subject, signal)).find((x) => x && x.id === machine);
++ if (m && m.computer && m.computer.ai && m.computer.ai.tools !== true) throw new AiError(422, 'machine.no_tools', 'this computer\'s OpenVibe Node cannot pass tools to its model yet: update it to v0.36.0 or newer');
++ }
++ return { ...(await once(machine, args)), machine, tried: [] };
++ }
++ const ids = (await candidates(subject, model, signal, { tools })).slice(0, MAX_TRIED);
++ if (!ids.length) throw new AiError(503, 'machine.none', `none of the person's own machines is on with ${String(model).slice(0, 120)} and lets their AI use it${tools ? ', with an OpenVibe Node that can pass tools (v0.36.0 or newer)' : ''}`);
const tried = [];
for (const id of ids) {
try {
@@@ -80,8 +99,9 @@ function createMachineModels({ config, env = process.env, fetchImpl = globalThis
throw new AiError(503, 'machine.none', `none of the person's own machines answered (${tried.map((x) => `${x.machine}: ${x.error}`).join('; ').slice(0, 400)})`);
}
-- async function once(machine, { subject, model, messages, maxTokens = null, temperature = null, signal = null, timeoutMs = 120000 }) {
-- const value = { model, messages, ...(maxTokens ? { max_tokens: Math.min(32768, Math.max(1, Math.round(maxTokens))) } : {}), ...(temperature != null ? { temperature: Math.min(2, Math.max(0, Number(temperature))) } : {}) };
++ async function once(machine, { subject, model, messages, maxTokens = null, temperature = null, signal = null, timeoutMs = 120000, tools = null, toolChoice = null }) {
++ 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))) } : {}),
++ ...(Array.isArray(tools) && tools.length ? { tools, ...(toolChoice === 'none' || toolChoice === 'auto' ? { tool_choice: toolChoice } : {}) } : {}) };
const token = await tokenClient().getToken({ audience: 'openvibe.bot' });
let res;
try {
@@@ -103,10 +123,28 @@ function createMachineModels({ config, env = process.env, fetchImpl = globalThis
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 } };
++ const said = String((d.message && d.message.content) || '');
++ const calls = d.message && Array.isArray(d.message.tool_calls) && d.message.tool_calls.length ? d.message.tool_calls : null;
++ // The whole message too, for a chat completion: the calls the model asked for, if any (none ran on the machine).
++ const message = { role: 'assistant', content: calls && !said.trim() ? null : said, ...(calls ? { tool_calls: calls } : {}) };
++ return { text: said.trim(), message, finish: typeof d.finish_reason === 'string' ? d.finish_reason : (calls ? 'tool_calls' : 'stop'), model: d.model || model, usage: { input: u.prompt_tokens || 0, output: u.completion_tokens || 0, cached: 0 } };
++ }
++
++ /**
++ * The person's computers that let their AI use their models, for their own settings page: name, whether it is on,
++ * its models and whether its Node can pass tools. [] when Bot cannot be reached.
++ */
++ async function mine(subject, signal = null) {
++ if (!enabled || !SUBJECT_RE.test(String(subject))) return [];
++ const list = await listed(subject, signal || AbortSignal.timeout(5000)).catch(() => []);
++ return list.filter((m) => m && MACHINE_RE.test(String(m.id)) && m.ai_models !== false && m.computer && m.computer.ai).map((m) => ({
++ id: m.id, name: String(m.name || 'A computer').slice(0, 80), online: !!m.online,
++ models: Array.isArray(m.computer.ai.models) ? m.computer.ai.models.filter((x) => typeof x === 'string').slice(0, 40) : [],
++ tools: m.computer.ai.tools === true,
++ }));
}
-- return { enabled, chat };
++ return { enabled, chat, mine };
}
module.exports = { createMachineModels };
ddiff --git a/server/providers/index.js b/server/providers/index.js
iindex f2f311e..d2b91b2 100644
---- a/server/providers/index.js
++++ b/server/providers/index.js
@@@ -70,6 +70,44 @@ function preferredModelFor(prefs, operation, req, ctx) {
const choice = prefs && prefs.defaults ? prefs.defaults[kind] : null;
return choice && choice !== 'auto' ? choice : null;
}
++/**
++ * A Chat Completions conversation in the shape bot.command@1's ai command takes (contracts 0.277.0): roles system, user,
++ * assistant and tool; text only (a picture part is left out); an assistant's tool calls and a tool message's call id
++ * kept. At most 200 messages: the first two (the instructions and the task) and the newest, never starting the newest
++ * part on a tool result whose call was left out.
++ */
++const MACHINE_MAX_MESSAGES = 200;
++const TOOL_NAME_RE = /^[A-Za-z0-9_.-]{1,64}$/;
++function machineMessages(list) {
++ const text = (c) => (typeof c === 'string' ? c : Array.isArray(c) ? c.filter((p) => p && p.type === 'text').map((p) => String(p.text || '')).join('\n') : '').slice(0, 65536);
++ let out = (Array.isArray(list) ? list : []).filter((m) => m && typeof m === 'object').map((m) => {
++ const role = ['system', 'user', 'assistant', 'tool'].includes(m.role) ? m.role : m.role === 'developer' ? 'system' : 'user';
++ const one = { role, content: text(m.content) };
++ if (role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) {
++ one.tool_calls = m.tool_calls.slice(0, 32).map((c, i) => {
++ const fn = (c && c.function) || {};
++ return { id: String((c && c.id) || `call_${i + 1}`).slice(0, 200), type: 'function', function: { name: String(fn.name || ''), arguments: typeof fn.arguments === 'string' ? fn.arguments.slice(0, 65536) : JSON.stringify(fn.arguments || {}) } };
++ });
++ }
++ if (role === 'tool') one.tool_call_id = String(m.tool_call_id || 'call_1').slice(0, 200);
++ return one;
++ });
++ if (out.length > MACHINE_MAX_MESSAGES) {
++ let tail = out.slice(-(MACHINE_MAX_MESSAGES - 2));
++ while (tail.length && tail[0].role === 'tool') tail = tail.slice(1);
++ out = [...out.slice(0, 2), ...tail];
++ }
++ return out;
++}
++/** The tools as the machine takes them: function tools with a plain name, at most 64; null when there are none. */
++function machineTools(tools) {
++ const out = (Array.isArray(tools) ? tools : [])
++ .filter((t) => t && t.type === 'function' && t.function && TOOL_NAME_RE.test(String(t.function.name || '')))
++ .slice(0, 64)
++ .map((t) => ({ type: 'function', function: { name: t.function.name, ...(typeof t.function.description === 'string' ? { description: t.function.description.slice(0, 4096) } : {}), ...(t.function.parameters && typeof t.function.parameters === 'object' ? { parameters: t.function.parameters } : {}) } }));
++ return out.length ? out : null;
++}
++
/** Can one stored key serve this operation? (executeWithCredential's own rule, checked before it is called.) */
function credentialCanServe(provider, operation, req) {
if (!CREDENTIAL_PROVIDERS.includes(provider)) return false;
@@@ -524,26 +562,36 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
/**
* 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>.
++ * The OpenAI-compatible endpoint (operation complete, plan L55 C3) passes its tools and the calls already made, so an
++ * agent's loop (Actor) runs on the person's own model; the machine's answer becomes the completion's one choice.
*/
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}`);
++ const completing = operation === 'complete';
++ if (!completing && !['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 = [
++ const o = completing ? (req.openai || {}) : null;
++ const messages = completing ? machineMessages(o.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 tools = completing ? machineTools(o.tools) : null;
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 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)),
++ ...(tools ? { tools, toolChoice: o.tool_choice === 'none' ? 'none' : 'auto' } : {}) });
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,
++ const message = result.message || { role: 'assistant', content: result.text };
++ const out = completing
++ ? { completion: { choices: [{ index: 0, message, finish_reason: result.finish || (message.tool_calls ? 'tool_calls' : 'stop') }], system_fingerprint: null }, text: result.text, json: null, model: result.model, usage }
++ : { text: result.text, json: null, model: result.model, usage };
++ return { result: out, 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: [cred.machine === 'any' ? 'the fastest of the person\'s own machines that answered, by their choice' : 'the person\'s own machine, by their choice'],
paid_by: 'your machine',
selected: { provider: 'node', machine: result.machine || cred.machine, model: result.model },
@@@ -952,7 +1000,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
throw new AiError(503, 'provider.unavailable', 'no provider on this route could answer', { tried, explain });
}
-- return { adapter, health, resetHealth, execute, executeWithCredential, offers, benchView, priceFor, costOf, rateCardsFor, providerStates, stats, statFor, candidates };
++ return { adapter, health, resetHealth, execute, executeWithCredential, offers, benchView, priceFor, costOf, rateCardsFor, providerStates, stats, statFor, candidates, machines };
}
--module.exports = { createProviderPool };
++module.exports = { createProviderPool, machineMessages, machineTools };
ddiff --git a/server/you-page.js b/server/you-page.js
iindex 298bc20..4701c76 100644
---- a/server/you-page.js
++++ b/server/you-page.js
@@@ -15,6 +15,12 @@
* POST /you/keys/:provider/test test it now
* POST /you/keys/:provider/remove forget it
* POST /you/preferences only_my_keys, free_first, price_ceiling_usd_per_mtok
++ * POST /you/computer a model on your own computer (model; any of your computers)
++ * POST /you/computer/remove forget it
++ *
++ * Your computer (plan L55 C3): a model the person runs at home, reached through OpenVibe.Bot and their OpenVibe Node,
++ * kept here like a key but with no key: the model, and any of their computers that lets their AI use its models. An
++ * app uses it only when the person allows that app below (provider node), and it never stands in for a key.
*/
const express = require('express');
const shell = require('openvibe-shared/shell');
@@@ -27,7 +33,9 @@ const SITE_NAME = 'OpenVibe.AI';
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
// The order people look for them in: the big labs, then the fast and cheap, then the routers, then their own.
const ORDER = ['anthropic', 'openai', 'gemini', 'deepseek', 'mistral', 'xai', 'groq', 'cerebras', 'together', 'fireworks', 'openrouter', 'minimax', 'custom'];
--const NAMES = { deepseek: 'DeepSeek', minimax: 'MiniMax', custom: 'Another OpenAI-compatible API' };
++const NAMES = { deepseek: 'DeepSeek', minimax: 'MiniMax', custom: 'Another OpenAI-compatible API', node: 'Your computer' };
++// What an app is allowed to use, in words: all your keys, one key, or your computer's models.
++const useWords = (provider) => (provider === '*' ? 'keys' : provider === 'node' ? 'computer\'s models' : `${providerName(provider)} key`);
const providerName = (id) => NAMES[id] || (byId(CREDENTIAL_PRESET[id]) || {}).name || id;
const keysUrl = (id) => (byId(CREDENTIAL_PRESET[id]) || {}).keys || null;
const ago = (iso) => {
@@@ -69,7 +77,7 @@ const STYLE = `<style>
.you-add summary::-webkit-details-marker{display:none}
.you-add form{display:flex;flex-wrap:wrap;gap:8px;align-items:flex-end;margin-top:10px}
.you-add label{display:grid;gap:4px;flex:1 1 220px;font-size:12.5px;font-weight:700;color:var(--text-secondary,#96a7c2)}
--.you input[type=password],.you input[type=url],.you input[type=number]{min-height:42px;box-sizing:border-box;width:100%;padding:0 12px;border-radius:10px;border:1px solid var(--border,#1f2d47);background:var(--bg-primary,#0b1220);color:var(--text-primary,#e6edf7);font:inherit;font-size:15px}
++.you input[type=password],.you input[type=url],.you input[type=number],.you input[type=text]{min-height:42px;box-sizing:border-box;width:100%;padding:0 12px;border-radius:10px;border:1px solid var(--border,#1f2d47);background:var(--bg-primary,#0b1220);color:var(--text-primary,#e6edf7);font:inherit;font-size:15px}
.you-pay{display:grid;gap:10px;padding:16px;border-radius:16px;border:1px solid var(--border,#1f2d47);background:var(--bg-secondary,#101828)}
.you-pay fieldset{margin:0;padding:0;border:0;display:grid;gap:8px}
.you-pay legend{margin-bottom:6px;font-weight:700}
@@@ -79,6 +87,11 @@ const STYLE = `<style>
.you-opt span{display:grid;gap:2px}.you-opt small{color:var(--text-secondary,#96a7c2);font-size:13px}
.you-row{display:flex;flex-wrap:wrap;gap:10px 18px;align-items:flex-end}
.you-row>label:not(.you-opt){display:grid;gap:4px;font-size:12.5px;font-weight:700;color:var(--text-secondary,#96a7c2)}
++.you-pc-form{display:flex;flex-wrap:wrap;gap:8px;align-items:flex-end;margin-top:12px}
++.you-pc-form label{display:grid;gap:4px;flex:1 1 220px;font-size:12.5px;font-weight:700;color:var(--text-secondary,#96a7c2)}
++.you-pcs{margin:0;padding-left:20px;color:var(--text-secondary,#96a7c2);font-size:14px;line-height:1.75}
++.you-pcs b{color:var(--text-primary,#e6edf7)}
++.you code{padding:1px 6px;border-radius:6px;background:var(--bg-primary,#0b1220);font-size:13px}
.you-new{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:8px}
.you-new>li{padding:12px 14px;border-radius:14px;border:1px solid var(--border,#1f2d47);background:var(--bg-secondary,#101828)}
.you-new>li:has(details[open]){grid-column:1/-1}
@@@ -128,7 +141,24 @@ function allowRequest(query) {
return { service, provider };
}
--function page({ siteUrl, keys = [], prefs = {}, apps = [], allow = null, ok = '', error = '' }) {
++/** Your computer: the saved model and where it runs, the form, and which of your computers can answer now. */
++function computerSection(pc, machines) {
++ const models = [...new Set(machines.flatMap((m) => m.models))].slice(0, 60);
++ const onNow = machines.filter((m) => m.online);
++ const line = (m) => `<li><b>${esc(m.name)}</b>${m.online ? '' : ' <span class="you-pill">away</span>'}${m.models.length ? ` · ${esc(m.models.slice(0, 6).join(', '))}${m.models.length > 6 ? ` and ${m.models.length - 6} more` : ''}` : ' · no models listed'}${m.tools ? '' : ' · <span class="you-pill">update OpenVibe Node to v0.36.0 for agents</span>'}</li>`;
++ const where = machines.length
++ ? `<ul class="you-pcs">${machines.map(line).join('')}</ul>`
++ : '<p class="you-empty">None of your computers lets OpenVibe use its models yet. On the computer, run a model server (Ollama, LM Studio or llama.cpp) and OpenVibe Node with <code>--ai</code>, then turn on &ldquo;Let my AI use this computer&rsquo;s models&rdquo; at <a href="https://openvibe.bot/machines">openvibe.bot/machines</a>.</p>';
++ const saved = pc ? `<ul class="you-keys"><li class="you-key"><div class="you-key-t"><b>Any of your computers</b><span class="you-pill${onNow.some((m) => m.models.includes(pc.model)) ? ' is-ok' : ''}">${esc(pc.model || 'no model')}</span></div>
++<div class="you-acts"><form method="post" action="/you/computer/remove"><button class="you-btn" type="submit">Remove</button></form></div>
++<div class="you-key-n">${onNow.some((m) => m.models.includes(pc.model)) ? 'On now, with this model' : 'None of your computers with this model is on right now'}${pc.last_used_at ? ` · used ${esc(ago(pc.last_used_at))}` : ''}</div></li></ul>` : '';
++ const form = `<form class="you-pc-form" method="post" action="/you/computer"><label>Model${pc ? ' (change it)' : ''}<input type="text" name="model" required maxlength="120" pattern="\\S+" list="you-pc-models" autocomplete="off" spellcheck="false" placeholder="${esc(models[0] || 'qwen3:8b')}" value="${esc(pc && pc.model ? pc.model : '')}"></label><datalist id="you-pc-models">${models.map((m) => `<option value="${esc(m)}">`).join('')}</datalist><button class="you-btn is-go" type="submit">Save</button></form>`;
++ return `<section aria-labelledby="you-pc"><h2 id="you-pc">Your computer</h2><p class="you-sub">A model you run at home answers your own work: no key, nothing spent, and the conversation stays between OpenVibe and your machine. Whichever of your computers is on with the model answers, the fastest first. Only the apps you allow below may use it.</p>
++${saved}${form}
++<h3 style="margin:18px 0 8px;font-size:15px">Your computers</h3>${where}</section>`;
++}
++
++function page({ siteUrl, keys = [], prefs = {}, apps = [], allow = null, ok = '', error = '', pc = null, machines = [] }) {
const mine = new Map(keys.map((k) => [k.provider, k]));
const working = keys.filter((k) => k.status === 'ok').length;
const keyForm = (id, k) => `<form method="post" action="/you/keys/${esc(id)}"><label>${k ? 'New key' : 'Key'}<input type="password" name="key" required minlength="8" maxlength="400" autocomplete="off" spellcheck="false" placeholder="${id === 'anthropic' ? 'sk-ant-…' : 'sk-…'}"></label>${id === 'custom' ? `<label>Base URL<input type="url" name="base_url" required placeholder="https://…/v1" autocomplete="off"></label>` : ''}<button class="you-btn is-go" type="submit">Save</button>${keysUrl(id) ? `<a class="you-btn" href="${esc(keysUrl(id))}" rel="noopener" target="_blank">Get a key ↗</a>` : ''}</form>`;
@@@ -148,11 +178,11 @@ ${note ? `<div class="you-key-n">${note}</div>` : ''}
const addCard = (id) => `<li><details><summary>${esc(providerName(id))}<span>Add</span></summary>${keyForm(id, null)}</details></li>`;
const only = prefs.only_my_keys === true;
// L55 C3: the apps the person allowed to use their own key, and the one that is asking.
-- const appRow = (g) => `<li class="you-key"><div class="you-key-t"><b>${esc(appName(g.service))}</b><span class="you-pill">${g.provider === '*' ? 'all your keys' : esc(providerName(g.provider))}</span></div>
++ const appRow = (g) => `<li class="you-key"><div class="you-key-t"><b>${esc(appName(g.service))}</b><span class="you-pill">${g.provider === '*' ? 'all your keys' : g.provider === 'node' ? 'your computer' : esc(providerName(g.provider))}</span></div>
<div class="you-acts"><form method="post" action="/you/apps/remove"><input type="hidden" name="service" value="${esc(g.service)}"><input type="hidden" name="provider" value="${esc(g.provider)}"><button class="you-btn" type="submit">Remove</button></form></div>
<div class="you-key-n">allowed ${esc(g.created_at ? ago(g.created_at) : 'just now')}</div></li>`;
const appList = apps.length ? `<ul class="you-keys">${apps.map(appRow).join('')}</ul>` : '<p class="you-empty">No app may use your keys yet. An app that needs one asks you here, and never takes it by itself.</p>';
-- const allowBlock = allow ? `<section aria-labelledby="you-allow"><h2 id="you-allow">Let ${esc(appName(allow.service))} use your ${allow.provider === '*' ? 'keys' : esc(providerName(allow.provider)) + ' key'}?</h2><p class="you-sub">${esc(appName(allow.service))} asked to run your work on your own key, so the call costs you nothing. No other app may use it, and you can take it away at any time.</p>
++ const allowBlock = allow ? `<section aria-labelledby="you-allow"><h2 id="you-allow">Let ${esc(appName(allow.service))} use your ${esc(useWords(allow.provider))}?</h2><p class="you-sub">${esc(appName(allow.service))} asked to run your work on ${allow.provider === 'node' ? 'a model on your own computer' : 'your own key'}, so the call costs you nothing. No other app may use it, and you can take it away at any time.</p>${allow.provider === 'node' && !pc ? '<p class="you-sub">Add the model under &ldquo;Your computer&rdquo; first, then allow it here.</p>' : ''}
<form method="post" action="/you/apps/allow"><input type="hidden" name="service" value="${esc(allow.service)}"><input type="hidden" name="provider" value="${esc(allow.provider)}"><button class="you-btn is-go" type="submit">Allow</button></form></section>` : '';
const body = `<p class="you-eye">Your AI</p><h1>Your keys, on every OpenVibe site.</h1>
<p class="you-lede">A key saved here works on every OpenVibe site and agent, with no fee, ever. It is sealed the moment it arrives; only its last four characters are ever shown again.</p>
@@@ -163,6 +193,7 @@ ${saved.length ? `<ul class="you-keys">${saved.map(row).join('')}</ul>` : '<p cl
<h3 style="margin:22px 0 10px;font-size:15px">Add a key</h3>
<ul class="you-new">${toAdd.map(addCard).join('')}</ul></section>
${allowBlock}
++${computerSection(pc, machines)}
<section aria-labelledby="you-pay"><h2 id="you-pay">How your AI is paid</h2><p class="you-sub">For every site and agent that runs AI for you.</p>
<form class="you-pay" method="post" action="/you/preferences">
<fieldset><legend>When none of your keys can do a job</legend>
@@@ -180,7 +211,7 @@ ${appList}</section>
return frame({ siteUrl, title: 'Your AI', body });
}
--function youPageRouter({ credentials, preferences, grants, personSessions, config, log = console }) {
++function youPageRouter({ credentials, preferences, grants, personSessions, config, log = console, machines = null }) {
const r = express.Router();
const form = express.urlencoded({ extended: false, limit: '4kb' });
const site = new URL(config.baseUrl).origin;
@@@ -215,12 +246,14 @@ function youPageRouter({ credentials, preferences, grants, personSessions, confi
headers(res);
const viewer = personSessions ? await personSessions.read(req) : null;
if (!viewer) return res.type('html').send(signedOut({ siteUrl: config.baseUrl }));
-- const [keys, prefs, apps] = await Promise.all([
++ const [keys, prefs, apps, pc, mine] = await Promise.all([
credentials.listSelf(viewer.subject).catch(() => []),
preferences.get(viewer.subject).catch(() => ({})),
grants.list(viewer.subject).catch(() => []),
++ credentials.getSelfNode ? credentials.getSelfNode(viewer.subject).catch(() => null) : null,
++ machines && machines.mine ? machines.mine(viewer.subject).catch(() => []) : [],
]);
-- res.type('html').send(page({ siteUrl: config.baseUrl, keys, prefs, apps, allow: allowRequest(req.query), ok: String(req.query.ok || '').slice(0, 200), error: String(req.query.error || '').slice(0, 200) }));
++ res.type('html').send(page({ siteUrl: config.baseUrl, keys, prefs, apps, pc, machines: mine, allow: allowRequest(req.query), ok: String(req.query.ok || '').slice(0, 200), error: String(req.query.error || '').slice(0, 200) }));
});
r.post('/you/keys/:provider', ...guard(async (req, res, subject) => {
const id = req.params.provider;
@@@ -247,7 +280,16 @@ function youPageRouter({ credentials, preferences, grants, personSessions, confi
const provider = String(body.provider == null ? '*' : body.provider).trim().toLowerCase() || '*';
if (!/^[a-z][a-z0-9._-]{1,39}$/.test(service) || !require('./key-grants').APPS.includes(service)) return back(res, { error: 'That is not an OpenVibe app that may use a key.' });
await grants.allow(subject, service, provider);
-- back(res, { ok: `${appName(service)} may use your ${provider === '*' ? 'keys' : providerName(provider) + ' key'} now.` });
++ back(res, { ok: `${appName(service)} may use your ${useWords(provider)} now.` });
++ }));
++ // Your computer (L55 C3): the model, on any of the person's computers. No key: nothing to seal or test here.
++ r.post('/you/computer', ...guard(async (req, res, subject) => {
++ const saved = await credentials.putSelfNode(subject, { machine: 'any', model: String((req.body || {}).model || '').trim() });
++ back(res, { ok: `Saved: ${saved.model}, on whichever of your computers is on with it.` });
++ }));
++ r.post('/you/computer/remove', ...guard(async (req, res, subject) => {
++ const gone = await credentials.removeSelfNode(subject);
++ back(res, gone ? { ok: 'Removed your computer\'s model. No app can use it now.' } : { error: 'There was nothing to remove.' });
}));
r.post('/you/apps/remove', ...guard(async (req, res, subject) => {
const body = req.body || {};
ddiff --git a/test/machine-credential.test.js b/test/machine-credential.test.js
iindex 6c86985..390f739 100644
---- a/test/machine-credential.test.js
++++ b/test/machine-credential.test.js
@@@ -20,6 +20,10 @@ const machineModels = {
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');
++ // With tools and no result yet, the model asks for one (the machine runs none).
++ if (args.tools && args.messages[args.messages.length - 1].role !== 'tool') {
++ return { text: '', message: { role: 'assistant', content: null, tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'web_search', arguments: '{"q":"mutex"}' } }] }, finish: 'tool_calls', model: args.model, usage: { input: 40, output: 12, cached: 0 } };
++ }
return { text: 'A lock that one thread holds at a time.', model: args.model, usage: { input: 30, output: 9, cached: 0 } };
},
};
@@@ -70,6 +74,50 @@ t.test('a run with it goes to that machine as the person, costs nothing, and say
refuse = false;
});
++t.test('an agent\'s loop on the person\'s machine (L55 C3): /v1/chat/completions passes the tools and the calls, and hands back the model\'s calls', async () => {
++ const tools = [{ type: 'function', function: { name: 'web_search', description: 'Search the web', parameters: { type: 'object', properties: { q: { type: 'string' } } } } }];
++ const ask = (messages, extra = {}) => request(h.base, 'POST', '/v1/chat/completions', { tok: chat, headers: { 'x-openvibe-credential': `node:${FRAN}` }, body: { model: 'openvibe', messages, tools, tool_choice: 'auto', ...extra } });
++ const first = [{ role: 'system', content: 'You are an agent.' }, { role: 'user', content: [{ type: 'text', text: 'What is a mutex?' }, { type: 'image_url', image_url: { url: 'data:image/png;base64,AAAA' } }] }];
++ let r = await ask(first);
++ assert.strictEqual(r.status, 200, r.text);
++ const choice = r.body.choices[0];
++ assert.strictEqual(choice.finish_reason, 'tool_calls');
++ assert.deepStrictEqual(choice.message.tool_calls, [{ id: 'call_1', type: 'function', function: { name: 'web_search', arguments: '{"q":"mutex"}' } }]);
++ assert.strictEqual(r.body.x_openvibe.cost_usd, 0, 'nothing spent');
++ let got = sent[sent.length - 1];
++ assert.deepStrictEqual([got.machine, got.subject, got.model, got.toolChoice], [MACHINE, FRAN, 'llama3.1:8b', 'auto']);
++ assert.deepStrictEqual(got.tools, tools, 'the tools as written');
++ assert.deepStrictEqual(got.messages[1], { role: 'user', content: 'What is a mutex?' }, 'text only: the picture part is left out');
++ // The caller ran the tool and sends its result; the model answers in words.
++ r = await ask([...first, choice.message, { role: 'tool', tool_call_id: 'call_1', content: 'A mutex is a lock.' }]);
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.choices[0].message.content, 'A lock that one thread holds at a time.');
++ assert.strictEqual(r.body.choices[0].finish_reason, 'stop');
++ got = sent[sent.length - 1];
++ assert.deepStrictEqual(got.messages.slice(2), [
++ { role: 'assistant', content: '', tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'web_search', arguments: '{"q":"mutex"}' } }] },
++ { role: 'tool', content: 'A mutex is a lock.', tool_call_id: 'call_1' },
++ ]);
++ // Streamed, the whole answer arrives as one delta (the machine cannot stream).
++ const s2 = await request(h.base, 'POST', '/v1/chat/completions', { tok: chat, headers: { 'x-openvibe-credential': `node:${FRAN}` }, body: { model: 'openvibe', messages: first, tools, stream: true } });
++ assert.match(s2.text, /"tool_calls":\[\{"index":0,"id":"call_1"/);
++ assert.match(s2.text, /data: \[DONE\]/);
++});
++
++t.test('machineMessages: the contract\'s shape, at most 200, never starting the newest part on an orphan tool result', async () => {
++ const { machineMessages, machineTools } = require('../server/providers');
++ const many = [{ role: 'system', content: 's' }, { role: 'user', content: 'u' }];
++ for (let i = 0; i < 150; i++) many.push({ role: 'assistant', content: null, tool_calls: [{ id: `c${i}`, type: 'function', function: { name: 'f', arguments: { a: i } } }] }, { role: 'tool', tool_call_id: `c${i}`, content: 'r' });
++ const out = machineMessages(many);
++ assert.ok(out.length <= 200);
++ assert.deepStrictEqual(out.slice(0, 2).map((m) => m.role), ['system', 'user']);
++ assert.strictEqual(out[2].role, 'assistant', 'the newest part starts on a call, not on its result');
++ assert.strictEqual(out[2].tool_calls[0].function.arguments, JSON.stringify({ a: out[2].tool_calls[0].id === 'c0' ? 0 : Number(out[2].tool_calls[0].id.slice(1)) }));
++ assert.deepStrictEqual(machineMessages([{ role: 'developer', content: 'd' }, { role: 'function', content: 'x' }]).map((m) => m.role), ['system', 'user']);
++ assert.strictEqual(machineTools([]), null);
++ assert.strictEqual(machineTools([{ type: 'function', function: { name: 'rm -rf' } }]), null);
++});
++
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 } } });
@@@ -94,7 +142,7 @@ t.test('server/machines.js: one POST to Bot as the person with a service token;
assert.strictEqual(m.enabled, true);
await assert.rejects(m.chat({ machine: 'dev_nope', subject: FRAN, model: 'm', messages: [] }), (e) => e.status === 422);
const out = await m.chat({ machine: MACHINE, subject: FRAN, model: 'llama3.1:8b', messages: [{ role: 'user', content: 'hi' }], maxTokens: 99999, temperature: 5, timeoutMs: 1000 });
-- assert.deepStrictEqual(out, { text: 'Hi.', model: 'llama3.1:8b', usage: { input: 12, output: 3, cached: 0 }, machine: MACHINE, tried: [] });
++ assert.deepStrictEqual(out, { text: 'Hi.', message: { role: 'assistant', content: ' Hi. ' }, finish: 'stop', model: 'llama3.1:8b', usage: { input: 12, output: 3, cached: 0 }, machine: MACHINE, tried: [] });
assert.strictEqual(calls[0].url, `http://bot.test/api/v1/machines/${MACHINE}/commands`);
assert.strictEqual(calls[0].init.headers.Authorization, 'Bearer svc-openvibe.bot');
assert.strictEqual(calls[0].init.headers['X-OV-Subject'], FRAN);
@@@ -140,6 +188,16 @@ t.test('machine any (contracts 0.221.0): the fastest of the person\'s own machin
listed = [];
await assert.rejects(m.chat({ machine: 'any', subject: FRAN, model: 'llama3.1:8b', messages: [] }), (e) => e.status === 503 && e.code === 'machine.none' && /is on with/.test(e.message));
await assert.rejects(m.chat({ machine: 'anyone', subject: FRAN, model: 'm', messages: [] }), (e) => e.status === 422);
++ // Tools (contracts 0.277.0): only a machine whose Node says it can pass them; a named one without it is refused.
++ listed = list.map((x) => (x.id === id('CC') ? { ...x, computer: { ...x.computer, ai: { ...x.computer.ai, tools: true } } } : x));
++ busy = new Set();
++ asked.length = 0;
++ const tools = [{ type: 'function', function: { name: 'web_search' } }];
++ const withTools = await m.chat({ machine: 'any', subject: FRAN, model: 'llama3.1:8b', messages: [{ role: 'user', content: 'hi' }], tools, toolChoice: 'auto' });
++ assert.deepStrictEqual([asked, withTools.machine], [[id('CC')], id('CC')], 'DD is faster but cannot pass tools');
++ await assert.rejects(m.chat({ machine: id('DD'), subject: FRAN, model: 'llama3.1:8b', messages: [{ role: 'user', content: 'hi' }], tools }), (e) => e.status === 422 && e.code === 'machine.no_tools' && /v0\.36\.0/.test(e.message));
++ listed = [];
++ await assert.rejects(m.chat({ machine: 'any', subject: FRAN, model: 'llama3.1:8b', messages: [], tools }), (e) => e.code === 'machine.none' && /pass tools/.test(e.message));
});
t.test('close', async () => { await h.close(); });
ddiff --git a/test/you-page.test.js b/test/you-page.test.js
iindex 93d255c..c275f78 100644
---- a/test/you-page.test.js
++++ b/test/you-page.test.js
@@@ -4,7 +4,7 @@
// sets how their AI is paid. Every POST needs this site's own origin signal; a key never comes back in the page.
const assert = require('assert');
const crypto = require('crypto');
--const { boot, suite } = require('./helpers');
++const { boot, suite, request, token, ALL } = require('./helpers');
const { SELF_OWNER } = require('../server/credentials');
const t = suite('you-page');
@@@ -18,13 +18,21 @@ const credentialFetch = async (url) => {
if (String(url).endsWith('/models')) return json({ data: [{ id: 'm-one' }, { id: 'm-two' }] });
return json({});
};
++// The person's computers as Bot lists them to AI (only those that let their AI use their models), and the answers.
++const MACHINE = 'dev_01M5YGM39VC2RRGDQPRD6D4SHD';
++const asked = [];
++const machineModels = {
++ enabled: true,
++ async mine() { return [{ id: MACHINE, name: 'Desk PC', online: true, models: ['qwen3:8b', 'llama3.1:8b'], tools: true }, { id: 'dev_01M5YGM39VC2RRGDQPRD6D4SHE', name: 'Old laptop', online: false, models: ['phi3'], tools: false }]; },
++ async chat(args) { asked.push(args); return { text: 'From your desk.', message: { role: 'assistant', content: 'From your desk.' }, finish: 'stop', model: args.model, usage: { input: 5, output: 3, cached: 0 }, machine: MACHINE, tried: [] }; },
++};
let h, cookie;
const page = (path, o = {}) => fetch(h.base + path, { redirect: 'manual', ...o, headers: { ...(o.headers || {}) } }).then(async (r) => ({ status: r.status, headers: r.headers, text: await r.text() }));
const post = (path, fields, headers = { 'Sec-Fetch-Site': 'same-origin' }) => page(path, { method: 'POST', body: new URLSearchParams(fields).toString(), headers: { 'content-type': 'application/x-www-form-urlencoded', Cookie: cookie, ...headers } });
const flash = (r) => Object.fromEntries(new URLSearchParams(String(r.headers.get('location') || '').split('?')[1] || ''));
t.test('boot', async () => {
-- h = await boot({ env: { AI_CREDENTIALS_KEY: KEY, AI_CREDENTIAL_TEST_TIMEOUT_MS: '80' }, credentialFetch, log: { log() {}, warn() {}, error() {} } });
++ h = await boot({ env: { AI_CREDENTIALS_KEY: KEY, AI_CREDENTIAL_TEST_TIMEOUT_MS: '80' }, credentialFetch, machineModels, log: { log() {}, warn() {}, error() {} } });
const raw = crypto.randomBytes(32).toString('base64url');
await h.db.exec("INSERT INTO person_sessions (id_hash, subject, username, created_at, expires_at) VALUES ($1, $2, 'dana', '2026-10-01', '2030-10-01')", [crypto.createHash('sha256').update(raw).digest('hex'), DANA]);
cookie = `ovai_person=${raw}`;
@@@ -97,5 +105,39 @@ t.test('another site, a missing origin signal or no session cannot change anythi
assert.ok(still, 'the key is still there');
});
++t.test('your computer (L55 C3): a model at home, no key; an app reaches it only once the person allows it', async () => {
++ let r = await page('/you', { headers: { Cookie: cookie } });
++ assert.ok(r.text.includes('<h2 id="you-pc">Your computer</h2>'));
++ assert.ok(r.text.includes('<b>Desk PC</b> · qwen3:8b, llama3.1:8b') && r.text.includes('<b>Old laptop</b> <span class="you-pill">away</span>'), 'its computers and their models');
++ assert.ok(r.text.includes('update OpenVibe Node to v0.36.0'), 'a Node that cannot pass tools says what to update');
++ assert.ok(r.text.includes('<option value="qwen3:8b">'), 'the models to pick from');
++ assert.ok(flash(await post('/you/computer', { model: 'two words' })).error, 'a model name has no spaces');
++ r = await post('/you/computer', { model: 'qwen3:8b' });
++ assert.match(flash(r).ok, /qwen3:8b/);
++ const row = await h.db.prepare('SELECT machine, models, key_enc FROM subject_credentials WHERE owner = ? AND subject = ? AND provider = ?').get(SELF_OWNER, DANA, 'node');
++ assert.deepStrictEqual([row.machine, JSON.parse(row.models).chat, row.key_enc], ['any', 'qwen3:8b', ''], 'any of their computers, the model, nothing sealed');
++ r = await page('/you', { headers: { Cookie: cookie } });
++ assert.ok(r.text.includes('<b>Any of your computers</b><span class="you-pill is-ok">qwen3:8b</span>') && r.text.includes('On now, with this model'));
++ // An app asks for it by name; before the person allows it, AI refuses and says where.
++ const actor = token('actor', ALL);
++ const ask = () => request(h.base, 'POST', '/v1/chat/completions', { tok: actor, headers: { 'x-openvibe-credential': `node:${DANA}` }, body: { model: 'openvibe', messages: [{ role: 'user', content: 'hello' }] } });
++ r = await ask();
++ assert.strictEqual(r.status, 403, r.text);
++ assert.strictEqual(r.body.error.code, 'ai.credential_not_allowed');
++ assert.strictEqual(asked.length, 0, 'nothing reached the computer');
++ r = await page('/you?allow=actor&provider=node', { headers: { Cookie: cookie } });
++ assert.ok(r.text.includes('use your computer&#39;s models?'), 'the ask in words');
++ assert.match(flash(await post('/you/apps/allow', { service: 'actor', provider: 'node' })).ok, /computer's models/);
++ r = await ask();
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.choices[0].message.content, 'From your desk.');
++ assert.deepStrictEqual([asked[0].machine, asked[0].subject, asked[0].model], ['any', DANA, 'qwen3:8b']);
++ assert.strictEqual(r.body.x_openvibe.cost_usd, 0);
++ assert.match(flash(await post('/you/computer/remove', {})).ok, /No app can use it/);
++ r = await ask();
++ assert.ok(r.status === 404 || r.status === 403, r.text);
++ assert.strictEqual(asked.length, 1, 'removed: nothing reaches the computer');
++});
++
t.test('close', async () => { await h.close(); });
t.run();