openvibers/openvibe.ai Commits

Rename AI own-machine credentials to Link (#385)

OP OpenVibers committed 4 days ago · f6a4be5a45 · 15 files, +68 −48

* Rename own-machine credential provider to Link

* Keep local dependency link out of AI source

* Merge remote-tracking branch 'origin/main' into rename/link-1109-AI

* Merge remote-tracking branch 'origin/main' into rename/link-1109-AI

* Pin openvibe-contracts v0.372.0 and move the Link migration to 0053

Merges main (whose 0052_exchange_platform.sql took the number), so the Link
provider migration is 0053, still an expand. The Contracts pin moves to the
released Link tag.

Changes

ddiff --git a/README.md b/README.md
iindex 690d983..9f00e8b 100644
---- a/README.md
++++ b/README.md
@@@ -96,7 +96,7 @@ npm run dev # http://localhost:4700
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
`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 model
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.
ddiff --git a/deploy/nginx/ai.openvibe.services.conf b/deploy/nginx/ai.openvibe.services.conf
iindex 1a2eb93..5d24cf2 100644
---- a/deploy/nginx/ai.openvibe.services.conf
++++ b/deploy/nginx/ai.openvibe.services.conf
@@@ -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/ {
ddiff --git a/docs/ai-exchange-baseline.md b/docs/ai-exchange-baseline.md
iindex e654580..abafab9 100644
---- a/docs/ai-exchange-baseline.md
++++ b/docs/ai-exchange-baseline.md
@@@ -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.
ddiff --git a/docs/community-offers.md b/docs/community-offers.md
iindex 2604d2f..d1fcd31 100644
---- a/docs/community-offers.md
++++ b/docs/community-offers.md
@@@ -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 the
public 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 and
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`.
ddiff --git a/docs/memory-m5.md b/docs/memory-m5.md
iindex 6241913..ab875e7 100644
---- a/docs/memory-m5.md
++++ b/docs/memory-m5.md
@@@ -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.
- 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 an
import form. The format is the contract, not either client.
## Tests
ddiff --git a/docs/private-ai.md b/docs/private-ai.md
iindex ce95111..3e09749 100644
---- a/docs/private-ai.md
++++ b/docs/private-ai.md
@@@ -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 receipt
ddiff --git a/migrations/0053_link_credential_provider.sql b/migrations/0053_link_credential_provider.sql
nnew file mode 100644
iindex 0000000..9f0d203
---- /dev/null
++++ b/migrations/0053_link_credential_provider.sql
@@@ -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';
ddiff --git a/package-lock.json b/package-lock.json
iindex 4dbe895..bb96d03 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -13,7 +13,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.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",
ddiff --git a/package.json b/package.json
iindex 6009996..c41f963 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/server/credentials.js b/server/credentials.js
iindex 8413893..469331c 100644
---- a/server/credentials.js
++++ b/server/credentials.js
@@@ -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() }, fetc
async 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() }, fetc
const 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() }, fetc
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');
}
-- 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();
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() }, 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);
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() }, fetc
const allowed = await grants.allowedProviders(subject, service);
if (!allowed.length) return [];
const have = new Set((await selfRows(subject)).map((r) => r.provider));
-- if (await getRow(SELF_OWNER, subject, 'node')) have.add('node'); // their own computer (L55 C3)
++ if (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();
}
ddiff --git a/server/exchange/privacy.js b/server/exchange/privacy.js
iindex 3a136b2..85e0bb9 100644
---- a/server/exchange/privacy.js
++++ b/server/exchange/privacy.js
@@@ -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.
ddiff --git a/server/providers/index.js b/server/providers/index.js
iindex d23b0d5..a5f2883 100644
---- a/server/providers/index.js
++++ b/server/providers/index.js
@@@ -904,12 +904,12 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
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,
++ 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
ddiff --git a/server/runs.js b/server/runs.js
iindex 3e734fd..13012d0 100644
---- a/server/runs.js
++++ b/server/runs.js
@@@ -809,7 +809,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
async 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 };
ddiff --git a/test/link-brand.test.js b/test/link-brand.test.js
nnew file mode 100644
iindex 0000000..eaa904f
---- /dev/null
++++ b/test/link-brand.test.js
@@@ -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');
ddiff --git a/test/machine-credential.test.js b/test/machine-credential.test.js
iindex 8161f5e..413e116 100644
---- a/test/machine-credential.test.js
++++ b/test/machine-credential.test.js
@@@ -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 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 () => {
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\]/);
});