openvibers/openvibe.ai Commits
An agent's loop runs on the person's own machine: chat completions with tools (L55 C3) (#265)
Changes
@@@ -122,7 +122,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keysif (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 }));@@@ -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() }, fetcreturn 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() }, fetcconst 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() }, fetcreturn {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,};}@@@ -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 = globalThisconst 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 = globalThisif (!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 = globalThisthrow 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 = globalThisif (!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 };@@@ -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.nothrow 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 };@@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 “Let my AI use this computer’s models” 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 “Your computer” 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, confiheaders(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, conficonst 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 || {};@@@ -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 sayrefuse = 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 machinlisted = [];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(); });@@@ -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 anythiassert.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'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();
@@ -122,7 +122,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
@@ -122,7 +122,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
if (credentials && preferences && grants) {
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) }));
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).
// 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(consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env, clock, fetchImpl, log, vault, personSessions }));
app.use(runPageRouter({ runs, personSessions, log }));
app.use(runPageRouter({ runs, personSessions, log }));
@@ -386,6 +386,13 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
@@ -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). */
/** A person's own credential for one provider (no key returned to the caller; server-side use only). */
async function selfForRun(subject, provider) {
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;
if (!CREDENTIAL_PROVIDERS.includes(provider)) return null;
const r = await getRow(SELF_OWNER, subject, provider);
const r = await getRow(SELF_OWNER, subject, provider);
if (!r) return null;
if (!r) return null;
@@ -433,6 +440,28 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
@@ -433,6 +440,28 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
return selfView(await getRow(SELF_OWNER, subject, provider));
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) {
async function removeSelf(subject, provider) {
if (!CREDENTIAL_PROVIDERS.includes(provider)) return false;
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;
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
@@ -539,6 +568,7 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
const allowed = await grants.allowedProviders(subject, service);
const allowed = await grants.allowedProviders(subject, service);
if (!allowed.length) return [];
if (!allowed.length) return [];
const have = new Set((await selfRows(subject)).map((r) => r.provider));
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();
if (allowed.includes('*')) return [...have].sort();
return allowed.filter((p) => have.has(p)).sort();
return allowed.filter((p) => have.has(p)).sort();
}
}
@@ -557,7 +587,7 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
@@ -557,7 +587,7 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
return {
return {
put, get, remove, admit, forRun, spentToday, providerKeyFor,
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,
allowedProvidersWithKey, consentUse,
};
};
}
}
@@ -12,8 +12,13 @@
@@ -12,8 +12,13 @@
* hands over to the next. Never anyone else's machine, never another provider.
* 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
* 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');
const { AiError } = require('./util');
@@ -39,7 +44,7 @@ function createMachineModels({ config, env = process.env, fetchImpl = globalThis
@@ -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' });
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). */
/** 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' });
const token = await tokenClient().getToken({ audience: 'openvibe.bot' });
let res;
let res;
try {
try {
@@ -49,13 +54,19 @@ function createMachineModels({ config, env = process.env, fetchImpl = globalThis
@@ -49,13 +54,19 @@ function createMachineModels({ config, env = process.env, fetchImpl = globalThis
}
}
const body = await res.json().catch(() => null);
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));
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 speedAt = (m) => {
const s = m.computer && m.computer.ai_speed;
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 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
.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))
.sort((a, b) => speedAt(b) - speedAt(a))
.map((m) => m.id);
.map((m) => m.id);
}
}
@@ -64,9 +75,17 @@ function createMachineModels({ config, env = process.env, fetchImpl = globalThis
@@ -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)');
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;
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_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 = [];
const tried = [];
for (const id of ids) {
for (const id of ids) {
try {
try {
@@ -80,8 +99,9 @@ function createMachineModels({ config, env = process.env, fetchImpl = globalThis
@@ -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)})`);
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' });
const token = await tokenClient().getToken({ audience: 'openvibe.bot' });
let res;
let res;
try {
try {
@@ -103,10 +123,28 @@ function createMachineModels({ config, env = process.env, fetchImpl = globalThis
@@ -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)}`);
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 d = body.data || {};
const u = d.usage || {};
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 };
module.exports = { createMachineModels };
@@ -70,6 +70,44 @@ function preferredModelFor(prefs, operation, req, ctx) {
@@ -70,6 +70,44 @@ function preferredModelFor(prefs, operation, req, ctx) {
const choice = prefs && prefs.defaults ? prefs.defaults[kind] : null;
const choice = prefs && prefs.defaults ? prefs.defaults[kind] : null;
return choice && choice !== 'auto' ? choice : 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.) */
/** Can one stored key serve this operation? (executeWithCredential's own rule, checked before it is called.) */
function credentialCanServe(provider, operation, req) {
function credentialCanServe(provider, operation, req) {
if (!CREDENTIAL_PROVIDERS.includes(provider)) return false;
if (!CREDENTIAL_PROVIDERS.includes(provider)) return false;
@@ -524,26 +562,36 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -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
* 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>.
* 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 = {}) {
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');
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 wanted = ctx.role || ((/^live\.(chat|vision|director|summary|legacy)$/.exec(ctx.routeKey || '') || [])[1]) || null;
const model = (wanted && cred.models[wanted]) || cred.models.chat;
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)');
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 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 }] : []),
...(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 || '') })),
...(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 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();
const t0 = Date.now();
try {
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 usage = result.usage;
const latency = Date.now() - t0;
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 });
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'],
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',
paid_by: 'your machine',
selected: { provider: 'node', machine: result.machine || cred.machine, model: result.model },
selected: { provider: 'node', machine: result.machine || cred.machine, model: result.model },
@@ -952,7 +1000,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -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 });
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 };
@@ -15,6 +15,12 @@
@@ -15,6 +15,12 @@
* POST /you/keys/:provider/test test it now
* POST /you/keys/:provider/test test it now
* POST /you/keys/:provider/remove forget it
* POST /you/keys/:provider/remove forget it
* POST /you/preferences only_my_keys, free_first, price_ceiling_usd_per_mtok
* 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 express = require('express');
const shell = require('openvibe-shared/shell');
const shell = require('openvibe-shared/shell');
@@ -27,7 +33,9 @@ const SITE_NAME = 'OpenVibe.AI';
@@ -27,7 +33,9 @@ const SITE_NAME = 'OpenVibe.AI';
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
// The order people look for them in: the big labs, then the fast and cheap, then the routers, then their own.
// 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 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 providerName = (id) => NAMES[id] || (byId(CREDENTIAL_PRESET[id]) || {}).name || id;
const keysUrl = (id) => (byId(CREDENTIAL_PRESET[id]) || {}).keys || null;
const keysUrl = (id) => (byId(CREDENTIAL_PRESET[id]) || {}).keys || null;
const ago = (iso) => {
const ago = (iso) => {
@@ -69,7 +77,7 @@ const STYLE = `<style>
@@ -69,7 +77,7 @@ const STYLE = `<style>
.you-add summary::-webkit-details-marker{display:none}
.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 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-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{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 fieldset{margin:0;padding:0;border:0;display:grid;gap:8px}
.you-pay legend{margin-bottom:6px;font-weight:700}
.you-pay legend{margin-bottom:6px;font-weight:700}
@@ -79,6 +87,11 @@ const STYLE = `<style>
@@ -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-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{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-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{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{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}
.you-new>li:has(details[open]){grid-column:1/-1}
@@ -128,7 +141,24 @@ function allowRequest(query) {
@@ -128,7 +141,24 @@ function allowRequest(query) {
return { service, provider };
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 “Let my AI use this computer’s models” 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 mine = new Map(keys.map((k) => [k.provider, k]));
const working = keys.filter((k) => k.status === 'ok').length;
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>`;
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>` : ''}
@@ -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 addCard = (id) => `<li><details><summary>${esc(providerName(id))}<span>Add</span></summary>${keyForm(id, null)}</details></li>`;
const only = prefs.only_my_keys === true;
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.
// 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-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>`;
<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 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 “Your computer” 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>` : '';
<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>
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>
<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
@@ -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>
<h3 style="margin:22px 0 10px;font-size:15px">Add a key</h3>
<ul class="you-new">${toAdd.map(addCard).join('')}</ul></section>
<ul class="you-new">${toAdd.map(addCard).join('')}</ul></section>
${allowBlock}
${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>
<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">
<form class="you-pay" method="post" action="/you/preferences">
<fieldset><legend>When none of your keys can do a job</legend>
<fieldset><legend>When none of your keys can do a job</legend>
@@ -180,7 +211,7 @@ ${appList}</section>
@@ -180,7 +211,7 @@ ${appList}</section>
return frame({ siteUrl, title: 'Your AI', body });
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 r = express.Router();
const form = express.urlencoded({ extended: false, limit: '4kb' });
const form = express.urlencoded({ extended: false, limit: '4kb' });
const site = new URL(config.baseUrl).origin;
const site = new URL(config.baseUrl).origin;
@@ -215,12 +246,14 @@ function youPageRouter({ credentials, preferences, grants, personSessions, confi
@@ -215,12 +246,14 @@ function youPageRouter({ credentials, preferences, grants, personSessions, confi
headers(res);
headers(res);
const viewer = personSessions ? await personSessions.read(req) : null;
const viewer = personSessions ? await personSessions.read(req) : null;
if (!viewer) return res.type('html').send(signedOut({ siteUrl: config.baseUrl }));
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(() => []),
credentials.listSelf(viewer.subject).catch(() => []),
preferences.get(viewer.subject).catch(() => ({})),
preferences.get(viewer.subject).catch(() => ({})),
grants.list(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) => {
r.post('/you/keys/:provider', ...guard(async (req, res, subject) => {
const id = req.params.provider;
const id = req.params.provider;
@@ -247,7 +280,16 @@ function youPageRouter({ credentials, preferences, grants, personSessions, confi
@@ -247,7 +280,16 @@ function youPageRouter({ credentials, preferences, grants, personSessions, confi
const provider = String(body.provider == null ? '*' : body.provider).trim().toLowerCase() || '*';
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.' });
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);
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) => {
r.post('/you/apps/remove', ...guard(async (req, res, subject) => {
const body = req.body || {};
const body = req.body || {};
@@ -20,6 +20,10 @@ const machineModels = {
@@ -20,6 +20,10 @@ const machineModels = {
async chat(args) {
async chat(args) {
sent.push(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');
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 } };
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
@@ -70,6 +74,50 @@ t.test('a run with it goes to that machine as the person, costs nothing, and say
refuse = false;
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 () => {
t.test('text only: a picture is refused before anything is sent', async () => {
const before = sent.length;
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 } } });
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;
@@ -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);
assert.strictEqual(m.enabled, true);
await assert.rejects(m.chat({ machine: 'dev_nope', subject: FRAN, model: 'm', messages: [] }), (e) => e.status === 422);
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 });
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].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.Authorization, 'Bearer svc-openvibe.bot');
assert.strictEqual(calls[0].init.headers['X-OV-Subject'], FRAN);
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
@@ -140,6 +188,16 @@ t.test('machine any (contracts 0.221.0): the fastest of the person\'s own machin
listed = [];
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: '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);
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(); });
t.test('close', async () => { await h.close(); });
@@ -4,7 +4,7 @@
@@ -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.
// 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 assert = require('assert');
const crypto = require('crypto');
const crypto = require('crypto');
-const { boot, suite } = require('./helpers');
+const { boot, suite, request, token, ALL } = require('./helpers');
const { SELF_OWNER } = require('../server/credentials');
const { SELF_OWNER } = require('../server/credentials');
const t = suite('you-page');
const t = suite('you-page');
@@ -18,13 +18,21 @@ const credentialFetch = async (url) => {
@@ -18,13 +18,21 @@ const credentialFetch = async (url) => {
if (String(url).endsWith('/models')) return json({ data: [{ id: 'm-one' }, { id: 'm-two' }] });
if (String(url).endsWith('/models')) return json({ data: [{ id: 'm-one' }, { id: 'm-two' }] });
return json({});
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;
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 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 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] || ''));
const flash = (r) => Object.fromEntries(new URLSearchParams(String(r.headers.get('location') || '').split('?')[1] || ''));
t.test('boot', async () => {
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');
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]);
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}`;
cookie = `ovai_person=${raw}`;
@@ -97,5 +105,39 @@ t.test('another site, a missing origin signal or no session cannot change anythi
@@ -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');
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'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.test('close', async () => { await h.close(); });
t.run();
t.run();