openvibers/openvibe.ai Commits
Rename AI own-machine credentials to Link (#385)
Changes
@@@ -96,7 +96,7 @@ npm run dev # http://localhost:4700npm test # stub provider, temp databases, local servers only — no network```--Node 22 (CI pins 22.22.1). Callers need an OpenVibe.Network service token with audience++Link 22 (CI pins 22.22.1). Callers need an OpenVibe.Network service token with audience`openvibe.ai`; the Network public key is fetched from `/api/.well-known/jwks` (or pinned with`OV_NETWORK_PUBLIC_KEY`).@@@ -781,7 +781,7 @@ logged or stored.### The CLI--`openvibe-node exchange serve --model qwen3:32b --public-url https://…/v1` (OpenVibe Link) serves the local model++`openvibe-link exchange serve --model qwen3:32b --public-url https://…/v1` (OpenVibe Link) serves the local modelthrough an OpenAI-compatible listener and registers it on the existing endpoint-seller API with the person's`OPENVIBE_DEVELOPER_KEY`; Ctrl-C removes the offer. See that repo's README.@@@ -118,7 +118,7 @@ server {proxy_set_header X-Forwarded-Proto $scheme;}-- # ── OpenVibe Memory (2026-10-30): a person's own token from their Node (`openvibe-node memory upload`) or the++ # ── OpenVibe Memory (2026-10-30): a person's own token from their Node (`openvibe-link memory upload`) or the# Memory page. Every route is token-guarded in the app (server/memory/index.js): a person acts only for# themselves, a service only searches (audited) and suggests. Batches are at most 200 items.location /api/v1/memory/ {@@@ -31,8 +31,8 @@ This document records the current state of the repositories that implement (or w| OpenVibe.Actor | `main` | `41d802a8f1140fa979185a4528ec59d12d888bfa` | 0.1.0 | v0.269.0 | v0.82.0 | v4.109.2 |Notes:--- All repos except Node use npm `package.json` versions and tarball pins.--- Node uses Go modules; the latest tag at refresh was `v0.38.0`.++- All repos except Link use npm `package.json` versions and tarball pins.++- Link uses Go modules; the latest tag at refresh was `v0.38.0`.- Shared and SDK are pinned uniformly at v4.109.2 and v0.82.0 respectively across the matrix repos that consume them.- Contracts pins diverge by repo (Network v0.280.0, AI v0.262.0, Billing/Services v0.222.0, etc.). This is consistent with the PLAN note that repos deliberately pin different versions.@@@ -21,7 +21,7 @@ capacity per request — because the machine sees the prompt.hours, a credit price and an audience.4. **AI** (`server/community/`) reads `GET /internal/ai/offers`, verifies the machine, and places only opted-in requests.--A second source needs no Node at all: a standalone OpenAI-compatible server is registered by its own address on the++A second source needs no Link at all: a standalone OpenAI-compatible server is registered by its own address on thepublic seller API (see **Community endpoints** below) and joins the same list, verification and ledger.## Opting in@@@ -133,5 +133,5 @@ list (models, price, hours, health; never an owner subject or machine id).buyer identity, the timeout/concurrency), canaries, fingerprint, free tier and self-dealing for an endpoint.- `node test/community-endpoint-api.test.js` — the seller API: the owner from the developer key, refusals, list andremove.--- Network: `node test/run.js ai-offers`; Bot: `node test/community-ai.test.js`; Node: `python3 -m unittest discover -s++- Network: `node test/run.js ai-offers`; Bot: `node test/community-ai.test.js`; Link: `python3 -m unittest discover -splugins/ai/tests`.@@@ -102,7 +102,7 @@ forwards the person's token, the method, the body and `If-Match` to AI, and keepanother account without carrying anyone's identity, and a profile is derived from the notes and rebuilt.- The round trip is proved in `test/memory-m5.test.js`: export A's store, import it as B, and compare text, kind,scope and source; importing the same file again changes nothing.--- The Link writes and reads the same file (`openvibe-node memory export|import`); Codes offers a download and an++- The Link writes and reads the same file (`openvibe-link memory export|import`); Codes offers a download and animport form. The format is the contract, not either client.## Tests@@@ -10,7 +10,7 @@ API both use one definition of private here; nothing in Exchange re-implements i| label | when || --- | --- || `private` | the provider's own terms say zero retention **and** no training, or the offer is a person's own machine (a Link credential), or it is the host's own deployment (`metadata.local`). |--| `community` | the offer runs on a community machine (a person's hardware, Node's "Offer to the network"): that machine sees the prompt. |++| `community` | the offer runs on a community machine (a person's hardware, Link's "Offer to the network"): that machine sees the prompt. || `anonymized` | everything else, including a provider that declares no policy (`known: false` says so). |The label is written on the run's `explain.privacy` (every surface that reads a run shows it), on the receiptnnew file mode 100644@@@ -0,0 +1,10 @@++-- phase: expand++-- The owner-owned machine provider is Link. Rewrite the few existing rows in place;++-- encrypted credentials belong to other providers and are not touched.++ALTER TABLE subject_credentials DROP CONSTRAINT IF EXISTS subject_credentials_provider_check;++ALTER TABLE subject_credentials ADD CONSTRAINT subject_credentials_provider_check++ CHECK (provider IN ('openai', 'anthropic', 'node', 'link', 'deepseek', 'minimax', 'openrouter', 'custom',++ 'gemini', 'mistral', 'xai', 'groq', 'cerebras', 'together', 'fireworks'));++UPDATE subject_credentials++SET provider = 'link', spend_key = regexp_replace(spend_key, ':node$', ':link')++WHERE provider = 'node';@@@ -13,7 +13,7 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.372.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.123.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.173.0","pg": "^8.23.0"@@@ -1543,9 +1543,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.338.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",-- "integrity": "sha512-75FD6IxMsXoud3NFXKDs0/D0rhJtF2D+WL8M92ArIyabkUnM/sZvw6E6L97EqwQ3O7M/kB+uwsNSxGudbil+ng==",++ "version": "0.372.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.372.0",++ "integrity": "sha512-HnfBnkhfMXYNbW4NnpTAMqZc0uV9d4XvwA20VaZXZFbBTPZiClYfoZ0/TWWWh24BFANx9TSzC+4jE0WDHKuNhA==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -29,7 +29,7 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.372.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.123.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.173.0","pg": "^8.23.0"@@@ -69,7 +69,7 @@ const { CREDENTIAL_PROVIDERS, credentialProvider } = require('./console/presets'const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;/** The reserved owner of a key the person holds themselves (L36 Y1). */const SELF_OWNER = 'self';--const LEGACY_PROVIDERS = ['openai', 'anthropic', 'node', 'deepseek', 'minimax', 'openrouter', 'custom'];++const LEGACY_PROVIDERS = ['openai', 'anthropic', 'link', 'deepseek', 'minimax', 'openrouter', 'custom'];const PROVIDERS = [...new Set([...LEGACY_PROVIDERS, ...CREDENTIAL_PROVIDERS])];/** Where each provider answers when the person stored no base_url: the ONE table in server/console/presets.js. */const PROVIDER_BASE_URL = Object.fromEntries(CREDENTIAL_PROVIDERS@@@ -207,7 +207,7 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetcasync function view(row) {return {subject: row.subject, owner: row.owner, provider: row.provider, base_url: row.base_url || null,-- ...(row.provider === 'node' ? { machine: row.machine } : { key_hint: row.key_hint }),++ ...(row.provider === 'link' ? { machine: row.machine } : { key_hint: row.key_hint }),models: JSON.parse(row.models || '{}'), budget_usd_per_day: row.budget_usd_day == null ? null : row.budget_usd_day,spent_usd_today: Math.round(await spentToday(row) * 1e6) / 1e6,created_at: row.created_at, updated_at: row.updated_at, last_used_at: row.last_used_at || null,@@@ -240,22 +240,22 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetcconst at = iso(clock.now());const makeDefault = provider === null;const prev = await getRow(owner, subject, body.provider);-- if (body.provider === 'node') {++ if (body.provider === 'link') {// The person's own machine: nothing to seal. Bot checks it is theirs and that they turned it on, at each run.await db.tx(async () => {-- if (makeDefault) await db.prepare('UPDATE subject_credentials SET is_default = false WHERE owner = ? AND subject = ? AND provider <> ?').run(owner, subject, 'node');++ if (makeDefault) await db.prepare('UPDATE subject_credentials SET is_default = false WHERE owner = ? AND subject = ? AND provider <> ?').run(owner, subject, 'link');await db.prepare(`INSERT INTO subject_credentials (owner, subject, provider, base_url, machine, key_enc, key_hint, models, budget_usd_day, is_default, spend_key, created_at, updated_at)-- VALUES (?, ?, 'node', NULL, ?, '', '', ?, NULL, ?, ?, ?, ?)++ VALUES (?, ?, 'link', NULL, ?, '', '', ?, NULL, ?, ?, ?, ?)ON CONFLICT(owner, subject, provider) DO UPDATE SET base_url = NULL, machine = excluded.machine, key_enc = '',key_hint = '', models = excluded.models, budget_usd_day = NULL, is_default = subject_credentials.is_default OR excluded.is_default, updated_at = excluded.updated_at`).run(owner, subject, body.machine, JSON.stringify(body.models || {}), makeDefault,-- (prev && prev.spend_key) || (makeDefault ? providerKeyFor(owner, subject) : providerKeyFor(owner, subject, 'node')), prev ? prev.created_at : at, at);++ (prev && prev.spend_key) || (makeDefault ? providerKeyFor(owner, subject) : providerKeyFor(owner, subject, 'link')), prev ? prev.created_at : at, at);});-- return await view(await getRow(owner, subject, 'node'));++ return await view(await getRow(owner, subject, 'link'));}// The stored key stays for a change of models or budget; a new provider or endpoint needs the key again.const newKey = body.key != null ? String(body.key) : (body.api_key != null ? String(body.api_key) : null);-- const sameEndpoint = prev && prev.provider !== 'node' && (prev.base_url || null) === (body.base_url || null);++ const sameEndpoint = prev && prev.provider !== 'link' && (prev.base_url || null) === (body.base_url || null);if (newKey == null && !sameEndpoint) {throw new AiError(400, 'credential.key_required', prev ? 'enter the key again to change the provider or its address' : 'key is required the first time');}@@@ -340,7 +340,7 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetcif (found.refused) throw new AiError(403, 'ai.credential_not_allowed', allowHint(owner, provider));throw new AiError(404, 'credential.not_found', provider ? `no ${provider} credential stored for that subject` : 'no credential stored for that subject');}-- if (r.provider === 'node') return; // the person's own machine: no key, no spend++ if (r.provider === 'link') return; // the person's own machine: no key, no spendsecretKey();if (r.budget_usd_day != null && r.budget_usd_day > 0 && await spentToday(r) >= r.budget_usd_day) {const tomorrow = new Date(iso(clock.now()).slice(0, 10) + 'T00:00:00.000Z').getTime() + 86400000;@@@ -366,7 +366,7 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetcawait db.prepare('UPDATE subject_credentials SET last_used_at = ? WHERE owner = ? AND subject = ? AND provider = ?').run(iso(clock.now()), owner, subject, r.provider);const providerKey = r.spend_key || providerKeyFor(owner, subject);const hint = String(r.key_hint || '').slice(-4);-- if (r.provider === 'node') return { providerKey, owner: r.owner, subject, provider: 'node', machine: r.machine, models: JSON.parse(r.models || '{}'), hint };++ if (r.provider === 'link') return { providerKey, owner: r.owner, subject, provider: 'link', machine: r.machine, models: JSON.parse(r.models || '{}'), hint };return { providerKey, owner: r.owner, subject, provider: r.provider, base_url: r.base_url || PROVIDER_BASE_URL[r.provider] || null, apiKey: await openRow(r), models: JSON.parse(r.models || '{}'), hint };}@@@ -375,7 +375,7 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc/** Every self row for one person, newest first. */async function selfRows(subject) {if (!SUBJECT_RE.test(String(subject))) return [];-- return await db.prepare("SELECT * FROM subject_credentials WHERE owner = ? AND subject = ? AND provider <> 'node' ORDER BY provider").all(SELF_OWNER, subject);++ return await db.prepare("SELECT * FROM subject_credentials WHERE owner = ? AND subject = ? AND provider <> 'link' ORDER BY provider").all(SELF_OWNER, subject);}async function listSelf(subject) {return (await selfRows(subject)).map(selfView);@@@ -396,12 +396,12 @@ 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') {++ if (provider === 'link') {// 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');++ const n = await getRow(SELF_OWNER, subject, 'link');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: '' };++ await db.prepare('UPDATE subject_credentials SET last_used_at = ? WHERE owner = ? AND subject = ? AND provider = ?').run(iso(clock.now()), SELF_OWNER, subject, 'link');++ return { providerKey: n.spend_key || providerKeyFor(SELF_OWNER, subject, 'link'), owner: SELF_OWNER, subject, provider: 'link', machine: n.machine, models: JSON.parse(n.models || '{}'), hint: '' };}if (!CREDENTIAL_PROVIDERS.includes(provider)) return null;const r = await getRow(SELF_OWNER, subject, provider);@@@ -458,18 +458,18 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc*/async function getSelfNode(subject) {if (!SUBJECT_RE.test(String(subject))) return null;-- const r = await getRow(SELF_OWNER, subject, 'node');++ const r = await getRow(SELF_OWNER, subject, 'link');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');++ await put(SELF_OWNER, subject, { provider: 'link', machine, models: { chat: m } }, 'link');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;++ return (await db.prepare('DELETE FROM subject_credentials WHERE owner = ? AND subject = ? AND provider = ?').run(SELF_OWNER, subject, 'link')).changes > 0;}async function removeSelf(subject, provider) {@@@ -578,7 +578,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 (await getRow(SELF_OWNER, subject, 'link')) have.add('link'); // their own computer (L55 C3)if (allowed.includes('*')) return [...have].sort();return allowed.filter((p) => have.has(p)).sort();}@@@ -47,7 +47,7 @@ function dataPolicyOf(record) {*/function labelFor({ record = null, credential = null } = {}) {// A person's own machine (a Link credential): the prompt never left their hardware.-- if (credential && credential.provider === 'node') {++ if (credential && credential.provider === 'link') {return { label: 'private', retention: 'none', training: false, region: null, trust: 'own_machine', known: true, source: 'your own machine' };}// The host's own deployment (the local models OpenVibe runs itself): nothing leaves the machine it manages.@@@ -904,12 +904,12 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noconst 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,++ return { result: out, provider: cred.providerKey, providerKind: 'link', 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 },-- candidates: (result.tried || []).map((x) => ({ provider: 'node', machine: x.machine, skipped: x.error })),-- attempts: [{ key: 'your machine', provider: 'node', model: result.model, why: 'your own machine', status: 'ok', error: null }],++ selected: { provider: 'link', machine: result.machine || cred.machine, model: result.model },++ candidates: (result.tried || []).map((x) => ({ provider: 'link', machine: x.machine, skipped: x.error })),++ attempts: [{ key: 'your machine', provider: 'link', model: result.model, why: 'your own machine', status: 'ok', error: null }],openvibe: ownFacts } };} catch (err) {if (ctx.logRequest) ctx.logRequest({ ...base, status: 'error', latency_ms: Date.now() - t0, error: String(err && err.message || err).slice(0, 500) });@@@ -919,7 +919,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no}async function executeWithCredential(cred, operation, req, ctx = {}) {-- if (cred.provider === 'node') return await executeOnMachine(cred, operation, req, ctx);++ if (cred.provider === 'link') return await executeOnMachine(cred, operation, req, ctx);// Text operations are all chat completions on the person's provider (templated workflows use generate).// A project's own provider (ADR-056 secret, ai.complete) passes a Chat Completions request through as written.// The OpenAI-format providers: a Chat Completions request is passed through as written (Codes C3: OpenRouter@@@ -809,7 +809,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowasync function payerOf({ row, providerKey, opts }) {if (opts.credential_subject) {const cred = await credentials.get(row.requester_id, opts.credential_subject, opts.credential_provider || null).catch(() => null);-- if (cred && cred.provider === 'node') return { paid_by: 'own_machine', detail: cred.machine || 'node', provider: 'node' };++ if (cred && cred.provider === 'link') return { paid_by: 'own_machine', detail: cred.machine || 'link', provider: 'link' };const name = (cred && cred.provider) || 'own';const hint = cred && cred.key_hint ? String(cred.key_hint) : '';return { paid_by: 'own_key', detail: hint ? `${name} ...${hint}` : name, provider: name };nnew file mode 100644@@@ -0,0 +1,10 @@++'use strict';++const assert = require('node:assert/strict');++const { spawnSync } = require('node:child_process');++const path = require('node:path');++const root = path.resolve(__dirname, '..');++const oldName = ['OpenVibe[ .-]No' + 'de', 'openvibe-' + 'node', 'no' + 'd_', 'node[.]openvibe[.]network', 'node:lnk_', 'openvibe[.](bot|my|network)/mach' + 'ines'].join('|');++const result = spawnSync('git', ['grep', '-n', '-I', '-i', '-E', oldName, '--', '.', ':!migrations/**', ':!test/link-brand.test.js'], { cwd: root, encoding: 'utf8' });++assert.ok(result.status === 0 || result.status === 1, result.stderr || 'git grep failed');++assert.equal(result.status, 1, `Old Link brand or principal returned:\n${(result.stdout || '').split('\n').slice(0, 10).join('\n')}`);++console.log(' ok old Link brand and principal IDs are absent');@@@ -34,21 +34,21 @@ t.test('boot', async () => {});t.test('store a node credential: the machine, no key', async () => {-- let r = await request(h.base, 'PUT', `/api/v1/credentials/${FRAN}`, { tok: chat, body: { provider: 'node', machine: MACHINE, api_key: 'sk-not-for-a-machine' } });++ let r = await request(h.base, 'PUT', `/api/v1/credentials/${FRAN}`, { tok: chat, body: { provider: 'link', machine: MACHINE, api_key: 'sk-not-for-a-machine' } });assert.strictEqual(r.status, 422, 'no key for a machine');-- r = await request(h.base, 'PUT', `/api/v1/credentials/${FRAN}`, { tok: chat, body: { provider: 'node', models: { chat: 'llama3.1:8b' } } });++ r = await request(h.base, 'PUT', `/api/v1/credentials/${FRAN}`, { tok: chat, body: { provider: 'link', models: { chat: 'llama3.1:8b' } } });assert.strictEqual(r.status, 422, 'a node credential names its machine');-- r = await request(h.base, 'PUT', `/api/v1/credentials/${FRAN}`, { tok: chat, body: { provider: 'node', machine: MACHINE, models: { chat: 'llama3.1:8b' } } });++ r = await request(h.base, 'PUT', `/api/v1/credentials/${FRAN}`, { tok: chat, body: { provider: 'link', machine: MACHINE, models: { chat: 'llama3.1:8b' } } });assert.strictEqual(r.status, 200, r.text);-- assert.deepStrictEqual([r.body.provider, r.body.machine, r.body.key_hint, r.body.models.chat], ['node', MACHINE, undefined, 'llama3.1:8b']);++ assert.deepStrictEqual([r.body.provider, r.body.machine, r.body.key_hint, r.body.models.chat], ['link', MACHINE, undefined, 'llama3.1:8b']);const { validate } = require('openvibe-contracts');assert.ok(validate('ai.credential@1', r.body).valid, JSON.stringify(validate('ai.credential@1', r.body).errors));const row = await h.db.prepare('SELECT key_enc, machine FROM subject_credentials WHERE owner = ? AND subject = ?').get('chat', FRAN);assert.deepStrictEqual([row.key_enc, row.machine], ['', MACHINE], 'nothing sealed');// Any of their machines (contracts 0.221.0), kept for another person: never "anyone".const OTHER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP5';-- assert.strictEqual((await request(h.base, 'PUT', `/api/v1/credentials/${OTHER}`, { tok: chat, body: { provider: 'node', machine: 'anyone', models: { chat: 'llama3.1:8b' } } })).status, 422);-- r = await request(h.base, 'PUT', `/api/v1/credentials/${OTHER}`, { tok: chat, body: { provider: 'node', machine: 'any', models: { chat: 'llama3.1:8b' } } });++ assert.strictEqual((await request(h.base, 'PUT', `/api/v1/credentials/${OTHER}`, { tok: chat, body: { provider: 'link', machine: 'anyone', models: { chat: 'llama3.1:8b' } } })).status, 422);++ r = await request(h.base, 'PUT', `/api/v1/credentials/${OTHER}`, { tok: chat, body: { provider: 'link', machine: 'any', models: { chat: 'llama3.1:8b' } } });assert.deepStrictEqual([r.status, r.body.machine], [200, 'any'], r.text);assert.ok(validate('ai.credential@1', r.body).valid);assert.strictEqual((await request(h.base, 'DELETE', `/api/v1/credentials/${OTHER}`, { tok: chat })).status < 300, true);@@@ -76,7 +76,7 @@ t.test('a run with it goes to that machine as the person, costs nothing, and sayt.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 ask = (messages, extra = {}) => request(h.base, 'POST', '/v1/chat/completions', { tok: chat, headers: { 'x-openvibe-credential': `link:${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);@@@ -99,7 +99,7 @@ t.test('an agent\'s loop on the person\'s machine (L55 C3): /v1/chat/completions{ 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 } });++ const s2 = await request(h.base, 'POST', '/v1/chat/completions', { tok: chat, headers: { 'x-openvibe-credential': `link:${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\]/);});
@@ -96,7 +96,7 @@ npm run dev # http://localhost:4700
@@ -96,7 +96,7 @@ npm run dev # http://localhost:4700
npm test # stub provider, temp databases, local servers only — no network
npm test # stub provider, temp databases, local servers only — no network
```
```
-Node 22 (CI pins 22.22.1). Callers need an OpenVibe.Network service token with audience
+Link 22 (CI pins 22.22.1). Callers need an OpenVibe.Network service token with audience
`openvibe.ai`; the Network public key is fetched from `/api/.well-known/jwks` (or pinned with
`openvibe.ai`; the Network public key is fetched from `/api/.well-known/jwks` (or pinned with
`OV_NETWORK_PUBLIC_KEY`).
`OV_NETWORK_PUBLIC_KEY`).
@@ -781,7 +781,7 @@ logged or stored.
@@ -781,7 +781,7 @@ logged or stored.
### The CLI
### The CLI
-`openvibe-node exchange serve --model qwen3:32b --public-url https://…/v1` (OpenVibe Link) serves the local model
+`openvibe-link exchange serve --model qwen3:32b --public-url https://…/v1` (OpenVibe Link) serves the local model
through an OpenAI-compatible listener and registers it on the existing endpoint-seller API with the person's
through an OpenAI-compatible listener and registers it on the existing endpoint-seller API with the person's
`OPENVIBE_DEVELOPER_KEY`; Ctrl-C removes the offer. See that repo's README.
`OPENVIBE_DEVELOPER_KEY`; Ctrl-C removes the offer. See that repo's README.
@@ -118,7 +118,7 @@ server {
@@ -118,7 +118,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
- # ── OpenVibe Memory (2026-10-30): a person's own token from their Node (`openvibe-node memory upload`) or the
+ # ── OpenVibe Memory (2026-10-30): a person's own token from their Node (`openvibe-link memory upload`) or the
# Memory page. Every route is token-guarded in the app (server/memory/index.js): a person acts only for
# Memory page. Every route is token-guarded in the app (server/memory/index.js): a person acts only for
# themselves, a service only searches (audited) and suggests. Batches are at most 200 items.
# themselves, a service only searches (audited) and suggests. Batches are at most 200 items.
location /api/v1/memory/ {
location /api/v1/memory/ {
@@ -31,8 +31,8 @@ This document records the current state of the repositories that implement (or w
@@ -31,8 +31,8 @@ This document records the current state of the repositories that implement (or w
| OpenVibe.Actor | `main` | `41d802a8f1140fa979185a4528ec59d12d888bfa` | 0.1.0 | v0.269.0 | v0.82.0 | v4.109.2 |
| OpenVibe.Actor | `main` | `41d802a8f1140fa979185a4528ec59d12d888bfa` | 0.1.0 | v0.269.0 | v0.82.0 | v4.109.2 |
Notes:
Notes:
-- All repos except Node use npm `package.json` versions and tarball pins.
-- Node uses Go modules; the latest tag at refresh was `v0.38.0`.
+- All repos except Link use npm `package.json` versions and tarball pins.
+- Link uses Go modules; the latest tag at refresh was `v0.38.0`.
- Shared and SDK are pinned uniformly at v4.109.2 and v0.82.0 respectively across the matrix repos that consume them.
- Shared and SDK are pinned uniformly at v4.109.2 and v0.82.0 respectively across the matrix repos that consume them.
- Contracts pins diverge by repo (Network v0.280.0, AI v0.262.0, Billing/Services v0.222.0, etc.). This is consistent with the PLAN note that repos deliberately pin different versions.
- Contracts pins diverge by repo (Network v0.280.0, AI v0.262.0, Billing/Services v0.222.0, etc.). This is consistent with the PLAN note that repos deliberately pin different versions.
@@ -21,7 +21,7 @@ capacity per request — because the machine sees the prompt.
@@ -21,7 +21,7 @@ capacity per request — because the machine sees the prompt.
hours, a credit price and an audience.
hours, a credit price and an audience.
4. **AI** (`server/community/`) reads `GET /internal/ai/offers`, verifies the machine, and places only opted-in requests.
4. **AI** (`server/community/`) reads `GET /internal/ai/offers`, verifies the machine, and places only opted-in requests.
-A second source needs no Node at all: a standalone OpenAI-compatible server is registered by its own address on the
+A second source needs no Link at all: a standalone OpenAI-compatible server is registered by its own address on the
public seller API (see **Community endpoints** below) and joins the same list, verification and ledger.
public seller API (see **Community endpoints** below) and joins the same list, verification and ledger.
## Opting in
## Opting in
@@ -133,5 +133,5 @@ list (models, price, hours, health; never an owner subject or machine id).
@@ -133,5 +133,5 @@ list (models, price, hours, health; never an owner subject or machine id).
buyer identity, the timeout/concurrency), canaries, fingerprint, free tier and self-dealing for an endpoint.
buyer identity, the timeout/concurrency), canaries, fingerprint, free tier and self-dealing for an endpoint.
- `node test/community-endpoint-api.test.js` — the seller API: the owner from the developer key, refusals, list and
- `node test/community-endpoint-api.test.js` — the seller API: the owner from the developer key, refusals, list and
remove.
remove.
-- Network: `node test/run.js ai-offers`; Bot: `node test/community-ai.test.js`; Node: `python3 -m unittest discover -s
+- Network: `node test/run.js ai-offers`; Bot: `node test/community-ai.test.js`; Link: `python3 -m unittest discover -s
plugins/ai/tests`.
plugins/ai/tests`.
@@ -102,7 +102,7 @@ forwards the person's token, the method, the body and `If-Match` to AI, and keep
@@ -102,7 +102,7 @@ forwards the person's token, the method, the body and `If-Match` to AI, and keep
another account without carrying anyone's identity, and a profile is derived from the notes and rebuilt.
another account without carrying anyone's identity, and a profile is derived from the notes and rebuilt.
- The round trip is proved in `test/memory-m5.test.js`: export A's store, import it as B, and compare text, kind,
- The round trip is proved in `test/memory-m5.test.js`: export A's store, import it as B, and compare text, kind,
scope and source; importing the same file again changes nothing.
scope and source; importing the same file again changes nothing.
-- The Link writes and reads the same file (`openvibe-node memory export|import`); Codes offers a download and an
+- The Link writes and reads the same file (`openvibe-link memory export|import`); Codes offers a download and an
import form. The format is the contract, not either client.
import form. The format is the contract, not either client.
## Tests
## Tests
@@ -10,7 +10,7 @@ API both use one definition of private here; nothing in Exchange re-implements i
@@ -10,7 +10,7 @@ API both use one definition of private here; nothing in Exchange re-implements i
| label | when |
| label | when |
| --- | --- |
| --- | --- |
| `private` | the provider's own terms say zero retention **and** no training, or the offer is a person's own machine (a Link credential), or it is the host's own deployment (`metadata.local`). |
| `private` | the provider's own terms say zero retention **and** no training, or the offer is a person's own machine (a Link credential), or it is the host's own deployment (`metadata.local`). |
-| `community` | the offer runs on a community machine (a person's hardware, Node's "Offer to the network"): that machine sees the prompt. |
+| `community` | the offer runs on a community machine (a person's hardware, Link's "Offer to the network"): that machine sees the prompt. |
| `anonymized` | everything else, including a provider that declares no policy (`known: false` says so). |
| `anonymized` | everything else, including a provider that declares no policy (`known: false` says so). |
The label is written on the run's `explain.privacy` (every surface that reads a run shows it), on the receipt
The label is written on the run's `explain.privacy` (every surface that reads a run shows it), on the receipt
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+-- phase: expand
+-- The owner-owned machine provider is Link. Rewrite the few existing rows in place;
+-- encrypted credentials belong to other providers and are not touched.
+ALTER TABLE subject_credentials DROP CONSTRAINT IF EXISTS subject_credentials_provider_check;
+ALTER TABLE subject_credentials ADD CONSTRAINT subject_credentials_provider_check
+ CHECK (provider IN ('openai', 'anthropic', 'node', 'link', 'deepseek', 'minimax', 'openrouter', 'custom',
+ 'gemini', 'mistral', 'xai', 'groq', 'cerebras', 'together', 'fireworks'));
+UPDATE subject_credentials
+SET provider = 'link', spend_key = regexp_replace(spend_key, ':node$', ':link')
+WHERE provider = 'node';
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.372.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.123.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.123.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.173.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.173.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -1543,9 +1543,9 @@
@@ -1543,9 +1543,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.338.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
- "integrity": "sha512-75FD6IxMsXoud3NFXKDs0/D0rhJtF2D+WL8M92ArIyabkUnM/sZvw6E6L97EqwQ3O7M/kB+uwsNSxGudbil+ng==",
+ "version": "0.372.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.372.0",
+ "integrity": "sha512-HnfBnkhfMXYNbW4NnpTAMqZc0uV9d4XvwA20VaZXZFbBTPZiClYfoZ0/TWWWh24BFANx9TSzC+4jE0WDHKuNhA==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -29,7 +29,7 @@
@@ -29,7 +29,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.372.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.123.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.123.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.173.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.173.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -69,7 +69,7 @@ const { CREDENTIAL_PROVIDERS, credentialProvider } = require('./console/presets'
@@ -69,7 +69,7 @@ const { CREDENTIAL_PROVIDERS, credentialProvider } = require('./console/presets'
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
/** The reserved owner of a key the person holds themselves (L36 Y1). */
/** The reserved owner of a key the person holds themselves (L36 Y1). */
const SELF_OWNER = 'self';
const SELF_OWNER = 'self';
-const LEGACY_PROVIDERS = ['openai', 'anthropic', 'node', 'deepseek', 'minimax', 'openrouter', 'custom'];
+const LEGACY_PROVIDERS = ['openai', 'anthropic', 'link', 'deepseek', 'minimax', 'openrouter', 'custom'];
const PROVIDERS = [...new Set([...LEGACY_PROVIDERS, ...CREDENTIAL_PROVIDERS])];
const PROVIDERS = [...new Set([...LEGACY_PROVIDERS, ...CREDENTIAL_PROVIDERS])];
/** Where each provider answers when the person stored no base_url: the ONE table in server/console/presets.js. */
/** Where each provider answers when the person stored no base_url: the ONE table in server/console/presets.js. */
const PROVIDER_BASE_URL = Object.fromEntries(CREDENTIAL_PROVIDERS
const PROVIDER_BASE_URL = Object.fromEntries(CREDENTIAL_PROVIDERS
@@ -207,7 +207,7 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
@@ -207,7 +207,7 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
async function view(row) {
async function view(row) {
return {
return {
subject: row.subject, owner: row.owner, provider: row.provider, base_url: row.base_url || null,
subject: row.subject, owner: row.owner, provider: row.provider, base_url: row.base_url || null,
- ...(row.provider === 'node' ? { machine: row.machine } : { key_hint: row.key_hint }),
+ ...(row.provider === 'link' ? { machine: row.machine } : { key_hint: row.key_hint }),
models: JSON.parse(row.models || '{}'), budget_usd_per_day: row.budget_usd_day == null ? null : row.budget_usd_day,
models: JSON.parse(row.models || '{}'), budget_usd_per_day: row.budget_usd_day == null ? null : row.budget_usd_day,
spent_usd_today: Math.round(await spentToday(row) * 1e6) / 1e6,
spent_usd_today: Math.round(await spentToday(row) * 1e6) / 1e6,
created_at: row.created_at, updated_at: row.updated_at, last_used_at: row.last_used_at || null,
created_at: row.created_at, updated_at: row.updated_at, last_used_at: row.last_used_at || null,
@@ -240,22 +240,22 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
@@ -240,22 +240,22 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
const at = iso(clock.now());
const at = iso(clock.now());
const makeDefault = provider === null;
const makeDefault = provider === null;
const prev = await getRow(owner, subject, body.provider);
const prev = await getRow(owner, subject, body.provider);
- if (body.provider === 'node') {
+ if (body.provider === 'link') {
// The person's own machine: nothing to seal. Bot checks it is theirs and that they turned it on, at each run.
// The person's own machine: nothing to seal. Bot checks it is theirs and that they turned it on, at each run.
await db.tx(async () => {
await db.tx(async () => {
- if (makeDefault) await db.prepare('UPDATE subject_credentials SET is_default = false WHERE owner = ? AND subject = ? AND provider <> ?').run(owner, subject, 'node');
+ if (makeDefault) await db.prepare('UPDATE subject_credentials SET is_default = false WHERE owner = ? AND subject = ? AND provider <> ?').run(owner, subject, 'link');
await db.prepare(`INSERT INTO subject_credentials (owner, subject, provider, base_url, machine, key_enc, key_hint, models, budget_usd_day, is_default, spend_key, created_at, updated_at)
await db.prepare(`INSERT INTO subject_credentials (owner, subject, provider, base_url, machine, key_enc, key_hint, models, budget_usd_day, is_default, spend_key, created_at, updated_at)
- VALUES (?, ?, 'node', NULL, ?, '', '', ?, NULL, ?, ?, ?, ?)
+ VALUES (?, ?, 'link', NULL, ?, '', '', ?, NULL, ?, ?, ?, ?)
ON CONFLICT(owner, subject, provider) DO UPDATE SET base_url = NULL, machine = excluded.machine, key_enc = '',
ON CONFLICT(owner, subject, provider) DO UPDATE SET base_url = NULL, machine = excluded.machine, key_enc = '',
key_hint = '', models = excluded.models, budget_usd_day = NULL, is_default = subject_credentials.is_default OR excluded.is_default, updated_at = excluded.updated_at`)
key_hint = '', models = excluded.models, budget_usd_day = NULL, is_default = subject_credentials.is_default OR excluded.is_default, updated_at = excluded.updated_at`)
.run(owner, subject, body.machine, JSON.stringify(body.models || {}), makeDefault,
.run(owner, subject, body.machine, JSON.stringify(body.models || {}), makeDefault,
- (prev && prev.spend_key) || (makeDefault ? providerKeyFor(owner, subject) : providerKeyFor(owner, subject, 'node')), prev ? prev.created_at : at, at);
+ (prev && prev.spend_key) || (makeDefault ? providerKeyFor(owner, subject) : providerKeyFor(owner, subject, 'link')), prev ? prev.created_at : at, at);
});
});
- return await view(await getRow(owner, subject, 'node'));
+ return await view(await getRow(owner, subject, 'link'));
}
}
// The stored key stays for a change of models or budget; a new provider or endpoint needs the key again.
// The stored key stays for a change of models or budget; a new provider or endpoint needs the key again.
const newKey = body.key != null ? String(body.key) : (body.api_key != null ? String(body.api_key) : null);
const newKey = body.key != null ? String(body.key) : (body.api_key != null ? String(body.api_key) : null);
- const sameEndpoint = prev && prev.provider !== 'node' && (prev.base_url || null) === (body.base_url || null);
+ const sameEndpoint = prev && prev.provider !== 'link' && (prev.base_url || null) === (body.base_url || null);
if (newKey == null && !sameEndpoint) {
if (newKey == null && !sameEndpoint) {
throw new AiError(400, 'credential.key_required', prev ? 'enter the key again to change the provider or its address' : 'key is required the first time');
throw new AiError(400, 'credential.key_required', prev ? 'enter the key again to change the provider or its address' : 'key is required the first time');
}
}
@@ -340,7 +340,7 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
@@ -340,7 +340,7 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
if (found.refused) throw new AiError(403, 'ai.credential_not_allowed', allowHint(owner, provider));
if (found.refused) throw new AiError(403, 'ai.credential_not_allowed', allowHint(owner, provider));
throw new AiError(404, 'credential.not_found', provider ? `no ${provider} credential stored for that subject` : 'no credential stored for that subject');
throw new AiError(404, 'credential.not_found', provider ? `no ${provider} credential stored for that subject` : 'no credential stored for that subject');
}
}
- if (r.provider === 'node') return; // the person's own machine: no key, no spend
+ if (r.provider === 'link') return; // the person's own machine: no key, no spend
secretKey();
secretKey();
if (r.budget_usd_day != null && r.budget_usd_day > 0 && await spentToday(r) >= r.budget_usd_day) {
if (r.budget_usd_day != null && r.budget_usd_day > 0 && await spentToday(r) >= r.budget_usd_day) {
const tomorrow = new Date(iso(clock.now()).slice(0, 10) + 'T00:00:00.000Z').getTime() + 86400000;
const tomorrow = new Date(iso(clock.now()).slice(0, 10) + 'T00:00:00.000Z').getTime() + 86400000;
@@ -366,7 +366,7 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
@@ -366,7 +366,7 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
await db.prepare('UPDATE subject_credentials SET last_used_at = ? WHERE owner = ? AND subject = ? AND provider = ?').run(iso(clock.now()), owner, subject, r.provider);
await db.prepare('UPDATE subject_credentials SET last_used_at = ? WHERE owner = ? AND subject = ? AND provider = ?').run(iso(clock.now()), owner, subject, r.provider);
const providerKey = r.spend_key || providerKeyFor(owner, subject);
const providerKey = r.spend_key || providerKeyFor(owner, subject);
const hint = String(r.key_hint || '').slice(-4);
const hint = String(r.key_hint || '').slice(-4);
- if (r.provider === 'node') return { providerKey, owner: r.owner, subject, provider: 'node', machine: r.machine, models: JSON.parse(r.models || '{}'), hint };
+ if (r.provider === 'link') return { providerKey, owner: r.owner, subject, provider: 'link', machine: r.machine, models: JSON.parse(r.models || '{}'), hint };
return { providerKey, owner: r.owner, subject, provider: r.provider, base_url: r.base_url || PROVIDER_BASE_URL[r.provider] || null, apiKey: await openRow(r), models: JSON.parse(r.models || '{}'), hint };
return { providerKey, owner: r.owner, subject, provider: r.provider, base_url: r.base_url || PROVIDER_BASE_URL[r.provider] || null, apiKey: await openRow(r), models: JSON.parse(r.models || '{}'), hint };
}
}
@@ -375,7 +375,7 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
@@ -375,7 +375,7 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
/** Every self row for one person, newest first. */
/** Every self row for one person, newest first. */
async function selfRows(subject) {
async function selfRows(subject) {
if (!SUBJECT_RE.test(String(subject))) return [];
if (!SUBJECT_RE.test(String(subject))) return [];
- return await db.prepare("SELECT * FROM subject_credentials WHERE owner = ? AND subject = ? AND provider <> 'node' ORDER BY provider").all(SELF_OWNER, subject);
+ return await db.prepare("SELECT * FROM subject_credentials WHERE owner = ? AND subject = ? AND provider <> 'link' ORDER BY provider").all(SELF_OWNER, subject);
}
}
async function listSelf(subject) {
async function listSelf(subject) {
return (await selfRows(subject)).map(selfView);
return (await selfRows(subject)).map(selfView);
@@ -396,12 +396,12 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
@@ -396,12 +396,12 @@ 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') {
+ if (provider === 'link') {
// Their own computer (plan L55 C3): the machine and the model, no key. Only an app they allowed reaches it.
// 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');
+ const n = await getRow(SELF_OWNER, subject, 'link');
if (!n) return null;
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: '' };
+ await db.prepare('UPDATE subject_credentials SET last_used_at = ? WHERE owner = ? AND subject = ? AND provider = ?').run(iso(clock.now()), SELF_OWNER, subject, 'link');
+ return { providerKey: n.spend_key || providerKeyFor(SELF_OWNER, subject, 'link'), owner: SELF_OWNER, subject, provider: 'link', 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);
@@ -458,18 +458,18 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
@@ -458,18 +458,18 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
*/
*/
async function getSelfNode(subject) {
async function getSelfNode(subject) {
if (!SUBJECT_RE.test(String(subject))) return null;
if (!SUBJECT_RE.test(String(subject))) return null;
- const r = await getRow(SELF_OWNER, subject, 'node');
+ const r = await getRow(SELF_OWNER, subject, 'link');
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;
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 } = {}) {
async function putSelfNode(subject, { machine = 'any', model } = {}) {
const m = String(model || '').trim();
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.');
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');
+ await put(SELF_OWNER, subject, { provider: 'link', machine, models: { chat: m } }, 'link');
return await getSelfNode(subject);
return await getSelfNode(subject);
}
}
async function removeSelfNode(subject) {
async function removeSelfNode(subject) {
if (!SUBJECT_RE.test(String(subject))) return false;
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;
+ return (await db.prepare('DELETE FROM subject_credentials WHERE owner = ? AND subject = ? AND provider = ?').run(SELF_OWNER, subject, 'link')).changes > 0;
}
}
async function removeSelf(subject, provider) {
async function removeSelf(subject, provider) {
@@ -578,7 +578,7 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
@@ -578,7 +578,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 (await getRow(SELF_OWNER, subject, 'link')) have.add('link'); // 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();
}
}
@@ -47,7 +47,7 @@ function dataPolicyOf(record) {
@@ -47,7 +47,7 @@ function dataPolicyOf(record) {
*/
*/
function labelFor({ record = null, credential = null } = {}) {
function labelFor({ record = null, credential = null } = {}) {
// A person's own machine (a Link credential): the prompt never left their hardware.
// A person's own machine (a Link credential): the prompt never left their hardware.
- if (credential && credential.provider === 'node') {
+ if (credential && credential.provider === 'link') {
return { label: 'private', retention: 'none', training: false, region: null, trust: 'own_machine', known: true, source: 'your own machine' };
return { label: 'private', retention: 'none', training: false, region: null, trust: 'own_machine', known: true, source: 'your own machine' };
}
}
// The host's own deployment (the local models OpenVibe runs itself): nothing leaves the machine it manages.
// The host's own deployment (the local models OpenVibe runs itself): nothing leaves the machine it manages.
@@ -904,12 +904,12 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -904,12 +904,12 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const out = completing
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 }
? { 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 };
: { 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,
+ return { result: out, provider: cred.providerKey, providerKind: 'link', 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 },
- candidates: (result.tried || []).map((x) => ({ provider: 'node', machine: x.machine, skipped: x.error })),
- attempts: [{ key: 'your machine', provider: 'node', model: result.model, why: 'your own machine', status: 'ok', error: null }],
+ selected: { provider: 'link', machine: result.machine || cred.machine, model: result.model },
+ candidates: (result.tried || []).map((x) => ({ provider: 'link', machine: x.machine, skipped: x.error })),
+ attempts: [{ key: 'your machine', provider: 'link', model: result.model, why: 'your own machine', status: 'ok', error: null }],
openvibe: ownFacts } };
openvibe: ownFacts } };
} catch (err) {
} catch (err) {
if (ctx.logRequest) ctx.logRequest({ ...base, status: 'error', latency_ms: Date.now() - t0, error: String(err && err.message || err).slice(0, 500) });
if (ctx.logRequest) ctx.logRequest({ ...base, status: 'error', latency_ms: Date.now() - t0, error: String(err && err.message || err).slice(0, 500) });
@@ -919,7 +919,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -919,7 +919,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
}
}
async function executeWithCredential(cred, operation, req, ctx = {}) {
async function executeWithCredential(cred, operation, req, ctx = {}) {
- if (cred.provider === 'node') return await executeOnMachine(cred, operation, req, ctx);
+ if (cred.provider === 'link') return await executeOnMachine(cred, operation, req, ctx);
// Text operations are all chat completions on the person's provider (templated workflows use generate).
// Text operations are all chat completions on the person's provider (templated workflows use generate).
// A project's own provider (ADR-056 secret, ai.complete) passes a Chat Completions request through as written.
// A project's own provider (ADR-056 secret, ai.complete) passes a Chat Completions request through as written.
// The OpenAI-format providers: a Chat Completions request is passed through as written (Codes C3: OpenRouter
// The OpenAI-format providers: a Chat Completions request is passed through as written (Codes C3: OpenRouter
@@ -809,7 +809,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -809,7 +809,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
async function payerOf({ row, providerKey, opts }) {
async function payerOf({ row, providerKey, opts }) {
if (opts.credential_subject) {
if (opts.credential_subject) {
const cred = await credentials.get(row.requester_id, opts.credential_subject, opts.credential_provider || null).catch(() => null);
const cred = await credentials.get(row.requester_id, opts.credential_subject, opts.credential_provider || null).catch(() => null);
- if (cred && cred.provider === 'node') return { paid_by: 'own_machine', detail: cred.machine || 'node', provider: 'node' };
+ if (cred && cred.provider === 'link') return { paid_by: 'own_machine', detail: cred.machine || 'link', provider: 'link' };
const name = (cred && cred.provider) || 'own';
const name = (cred && cred.provider) || 'own';
const hint = cred && cred.key_hint ? String(cred.key_hint) : '';
const hint = cred && cred.key_hint ? String(cred.key_hint) : '';
return { paid_by: 'own_key', detail: hint ? `${name} ...${hint}` : name, provider: name };
return { paid_by: 'own_key', detail: hint ? `${name} ...${hint}` : name, provider: name };
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+'use strict';
+const assert = require('node:assert/strict');
+const { spawnSync } = require('node:child_process');
+const path = require('node:path');
+const root = path.resolve(__dirname, '..');
+const oldName = ['OpenVibe[ .-]No' + 'de', 'openvibe-' + 'node', 'no' + 'd_', 'node[.]openvibe[.]network', 'node:lnk_', 'openvibe[.](bot|my|network)/mach' + 'ines'].join('|');
+const result = spawnSync('git', ['grep', '-n', '-I', '-i', '-E', oldName, '--', '.', ':!migrations/**', ':!test/link-brand.test.js'], { cwd: root, encoding: 'utf8' });
+assert.ok(result.status === 0 || result.status === 1, result.stderr || 'git grep failed');
+assert.equal(result.status, 1, `Old Link brand or principal returned:\n${(result.stdout || '').split('\n').slice(0, 10).join('\n')}`);
+console.log(' ok old Link brand and principal IDs are absent');
@@ -34,21 +34,21 @@ t.test('boot', async () => {
@@ -34,21 +34,21 @@ t.test('boot', async () => {
});
});
t.test('store a node credential: the machine, no key', async () => {
t.test('store a node credential: the machine, no key', async () => {
- let r = await request(h.base, 'PUT', `/api/v1/credentials/${FRAN}`, { tok: chat, body: { provider: 'node', machine: MACHINE, api_key: 'sk-not-for-a-machine' } });
+ let r = await request(h.base, 'PUT', `/api/v1/credentials/${FRAN}`, { tok: chat, body: { provider: 'link', machine: MACHINE, api_key: 'sk-not-for-a-machine' } });
assert.strictEqual(r.status, 422, 'no key for a machine');
assert.strictEqual(r.status, 422, 'no key for a machine');
- r = await request(h.base, 'PUT', `/api/v1/credentials/${FRAN}`, { tok: chat, body: { provider: 'node', models: { chat: 'llama3.1:8b' } } });
+ r = await request(h.base, 'PUT', `/api/v1/credentials/${FRAN}`, { tok: chat, body: { provider: 'link', models: { chat: 'llama3.1:8b' } } });
assert.strictEqual(r.status, 422, 'a node credential names its machine');
assert.strictEqual(r.status, 422, 'a node credential names its machine');
- r = await request(h.base, 'PUT', `/api/v1/credentials/${FRAN}`, { tok: chat, body: { provider: 'node', machine: MACHINE, models: { chat: 'llama3.1:8b' } } });
+ r = await request(h.base, 'PUT', `/api/v1/credentials/${FRAN}`, { tok: chat, body: { provider: 'link', machine: MACHINE, models: { chat: 'llama3.1:8b' } } });
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
- assert.deepStrictEqual([r.body.provider, r.body.machine, r.body.key_hint, r.body.models.chat], ['node', MACHINE, undefined, 'llama3.1:8b']);
+ assert.deepStrictEqual([r.body.provider, r.body.machine, r.body.key_hint, r.body.models.chat], ['link', MACHINE, undefined, 'llama3.1:8b']);
const { validate } = require('openvibe-contracts');
const { validate } = require('openvibe-contracts');
assert.ok(validate('ai.credential@1', r.body).valid, JSON.stringify(validate('ai.credential@1', r.body).errors));
assert.ok(validate('ai.credential@1', r.body).valid, JSON.stringify(validate('ai.credential@1', r.body).errors));
const row = await h.db.prepare('SELECT key_enc, machine FROM subject_credentials WHERE owner = ? AND subject = ?').get('chat', FRAN);
const row = await h.db.prepare('SELECT key_enc, machine FROM subject_credentials WHERE owner = ? AND subject = ?').get('chat', FRAN);
assert.deepStrictEqual([row.key_enc, row.machine], ['', MACHINE], 'nothing sealed');
assert.deepStrictEqual([row.key_enc, row.machine], ['', MACHINE], 'nothing sealed');
// Any of their machines (contracts 0.221.0), kept for another person: never "anyone".
// Any of their machines (contracts 0.221.0), kept for another person: never "anyone".
const OTHER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP5';
const OTHER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP5';
- assert.strictEqual((await request(h.base, 'PUT', `/api/v1/credentials/${OTHER}`, { tok: chat, body: { provider: 'node', machine: 'anyone', models: { chat: 'llama3.1:8b' } } })).status, 422);
- r = await request(h.base, 'PUT', `/api/v1/credentials/${OTHER}`, { tok: chat, body: { provider: 'node', machine: 'any', models: { chat: 'llama3.1:8b' } } });
+ assert.strictEqual((await request(h.base, 'PUT', `/api/v1/credentials/${OTHER}`, { tok: chat, body: { provider: 'link', machine: 'anyone', models: { chat: 'llama3.1:8b' } } })).status, 422);
+ r = await request(h.base, 'PUT', `/api/v1/credentials/${OTHER}`, { tok: chat, body: { provider: 'link', machine: 'any', models: { chat: 'llama3.1:8b' } } });
assert.deepStrictEqual([r.status, r.body.machine], [200, 'any'], r.text);
assert.deepStrictEqual([r.status, r.body.machine], [200, 'any'], r.text);
assert.ok(validate('ai.credential@1', r.body).valid);
assert.ok(validate('ai.credential@1', r.body).valid);
assert.strictEqual((await request(h.base, 'DELETE', `/api/v1/credentials/${OTHER}`, { tok: chat })).status < 300, true);
assert.strictEqual((await request(h.base, 'DELETE', `/api/v1/credentials/${OTHER}`, { tok: chat })).status < 300, true);
@@ -76,7 +76,7 @@ t.test('a run with it goes to that machine as the person, costs nothing, and say
@@ -76,7 +76,7 @@ t.test('a run with it goes to that machine as the person, costs nothing, and say
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 () => {
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 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 ask = (messages, extra = {}) => request(h.base, 'POST', '/v1/chat/completions', { tok: chat, headers: { 'x-openvibe-credential': `link:${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' } }] }];
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);
let r = await ask(first);
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
@@ -99,7 +99,7 @@ t.test('an agent\'s loop on the person\'s machine (L55 C3): /v1/chat/completions
@@ -99,7 +99,7 @@ t.test('an agent\'s loop on the person\'s machine (L55 C3): /v1/chat/completions
{ role: 'tool', content: 'A mutex is a lock.', tool_call_id: 'call_1' },
{ 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).
// 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 } });
+ const s2 = await request(h.base, 'POST', '/v1/chat/completions', { tok: chat, headers: { 'x-openvibe-credential': `link:${FRAN}` }, body: { model: 'openvibe', messages: first, tools, stream: true } });
assert.match(s2.text, /"tool_calls":\[\{"index":0,"id":"call_1"/);
assert.match(s2.text, /"tool_calls":\[\{"index":0,"id":"call_1"/);
assert.match(s2.text, /data: \[DONE\]/);
assert.match(s2.text, /data: \[DONE\]/);
});
});