openvibers/openvibe.ai Commits
Community AI capacity: opt-in placement, canaries, spot re-runs and seller credits (#303)
Changes
nnew file mode 100644@@@ -0,0 +1,83 @@++# Community AI offers ("Offer to the network")++++The design's community machines (`EXCHANGE-REMAKE-2026-11-06.md`, plan §3, stage 4): a person turns on **Offer to the++network** in OpenVibe.Node's `ai` plugin and their local models (Ollama, LM Studio, llama.cpp) or GPU become capacity++OpenVibe.AI can place inference on. It is opt-in twice — the owner turns the offer on, and the caller asks for community++capacity per request — because the machine sees the prompt.++++## The chain++++1. **Node** (`plugins/ai`, 0.3.0) advertises `status.capabilities.ai.offer`: `enabled`, `models`, `hours`,++ `concurrency`, `price` (credits per million in/out), `audience`, `open_now`, `ready`. It enforces `enabled`, `hours`,++ `models` and `concurrency` on the machine, and accepts a community command only as a `community/<model>` request,++ which it strips before the local server sees it.++2. **Bot** stores the Node's capabilities and reports every machine with an enabled offer to Network:++ `POST /internal/ai/offers/report` (source `bot`, `network.resource.report`). A community run reaches a machine even++ with "Let my AI use this computer's models" off, because the offer itself is the opt-in; the machine still only++ accepts `kind: ai`.++3. **Network** (`server/registry/ai-offers.js`, `migrations/0039_ai_offers.sql`) validates the offer strictly and keeps++ it as a user-owned offer: owner required, no public route, `network.registry.read` to read the whole set. It is a++ dedicated table because `platform.resource-offer@1` closes `detail` for kind node/provider and cannot carry models,++ 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.++++## Opting in++++`POST /v1/chat/completions` / `POST /v1/messages` (and the direct runs API) take either header++`x-openvibe-community: 1` or body `x_openvibe: { "community": true }`. Without it the community layer is never called.++An exact model id (`org/model@revision`) and a BYOK run never go to a community machine; only free/auto routes may.++++The answer carries `x_openvibe.community` (machine, owner, model, price, verification, reputation, credits) and the++response header `X-OpenVibe-Community: 1`. A failure on the community machine falls back to the ordinary network, and++that answer carries no community label.++++## Trust before traffic++++- **Canaries**: three known-answer prompts. A wrong answer marks the offer `failed`, reputation 0, and it is never++ chosen again until an operator resets it.++- **Model fingerprint**: the model id the machine's own server reports must match the declared model (a quantization++ suffix is allowed).++- **Spot re-runs**: about 1% (`AI_COMMUNITY_SPOT_RATE`, default 0.01) of answers are re-run on a verified machine++ serving the same model and compared by token overlap. A mismatch fails the offer and the request falls back.++- **Reputation**: canaries 40, spot pass rate 40, observed uptime 20.++- **New machines** serve the free tier only (`ctx.creditsRequired`: a run with no Balance payer) until verified.++ Verified tokens earn credits; new/free-tier tokens are recorded with 0 credits.++++## Earnings and /sell++++`community_usage` records tokens in/out, latency, the price used and `credits_micro` (1 credit = 1,000,000 micro), only++for verified tokens.++++- **Self-dealing is zero.** A run whose requester is the offer's owner, or an account merged into it (Network++ `identity.subject.resolve`), is `self_served`: 0 credits, labelled in the receipt and in `/sell`.++- **Pending, not spendable.** Credits are earned only against a charge to the requester's Balance in the same request.++ Until requester charging exists, every verified row is `pending`; `spendable` is reserved for a future Balance charge++ id. `/sell` shows both and says plainly that pending credits cannot be spent.++- **Daily cap until trusted.** Until a machine has 7 days of verified service (`AI_COMMUNITY_TRUST_DAYS`), its seller++ earns at most `AI_COMMUNITY_DAILY_CAP_CREDITS` (100) a day; the row is `capped` when the cap binds.++- **Reputation resets on failures.** A canary/spot failure rate over 10% sets reputation to 0; a failed offer is 0.++- **Kudos for volunteers.** A price-0 offer earns kudos per 1,000 verified tokens (`AI_COMMUNITY_KUDOS_PER_1K`), in an++ append-only `community_kudos_ledger` with the same who/why/request audit shape as the credits. Kudos never expire and++ are never cash: they buy queue priority (`sqrt(balance)`, capped at 10) and a larger free output allowance++ (10 tokens per kudo, capped at 1M), both read by the router.++++`GET /api/v1/community/sell` (a service with `ai.run.read` or `ai.usage.read`, acting for the owner in `X-OV-Subject`)++returns the owner's offers with tokens served, tokens/s per model, uptime, verification, reputation, pending/spendable++credits, self-served requests, the daily cap, kudos and the seller card. `GET /api/v1/community/offers` is the public++list (models, price, hours, health; never an owner subject or machine id).++++## Configuration++++- `AI_COMMUNITY` (default on): with no `OV_OAUTH_CLIENT_SECRET` or `OV_BOT_INTERNAL_URL` the list is empty.++- `AI_COMMUNITY_REFRESH_MS` (30000), `AI_COMMUNITY_CANARY_MAX_AGE_MS` (6h), `AI_COMMUNITY_CANARY_TIMEOUT_MS` (30000),++ `AI_COMMUNITY_SPOT_RATE` (0.01).++- Network grants AI `network.registry.read` and `network.follows.read` (the `followers` audience).++++## Tests++++- `node test/community.test.js` — opt-in, free-tier-only, canaries, fingerprint, followers/groups, earnings and /sell.++- `node test/community-run.test.js` — the run path: no opt-in reaches nothing; with it the answer is labelled; a failed++ machine falls back to the network.++- `node test/community-api.test.js` — the dashboard route and the public offers route.++- Network: `node test/run.js ai-offers`; Bot: `node test/community-ai.test.js`; Node: `python3 -m unittest discover -s++ plugins/ai/tests`.nnew file mode 100644@@@ -0,0 +1,73 @@++-- phase: expand++-- Community AI offers (the design's "Offer to the network"; plan E4). A person's Node offers its local models to the++-- router; the offer itself lives in OpenVibe.Network's resource registry (a user-owned provider offer whose detail.kind++-- is `ai`), and this table keeps what only AI knows: whether the machine passed its canaries and model fingerprint, its++-- spot re-runs against a trusted machine, its reputation, and what it served. One row per offer and model, because a++-- canary proves one model. The usage rows are the seller's ledger: tokens × the owner's credits-per-million price in++-- integer micro-credits (1 credit = 1,000,000 micro), recorded only for verified tokens; nothing here moves cash.++CREATE TABLE IF NOT EXISTS community_offers (++ offer_key text PRIMARY KEY, -- <offer_id>|<model>++ offer_id text NOT NULL,++ machine_id text NOT NULL,++ owner_subject text NOT NULL,++ model text NOT NULL,++ models jsonb NOT NULL,++ price_in numeric(20, 6) NOT NULL CHECK (price_in >= 0),++ price_out numeric(20, 6) NOT NULL CHECK (price_out >= 0),++ status text NOT NULL DEFAULT 'new' CHECK (status IN ('new', 'verified', 'failed', 'paused')),++ canary_passes integer NOT NULL DEFAULT 0 CHECK (canary_passes >= 0),++ canary_failures integer NOT NULL DEFAULT 0 CHECK (canary_failures >= 0),++ spot_runs integer NOT NULL DEFAULT 0 CHECK (spot_runs >= 0),++ spot_failures integer NOT NULL DEFAULT 0 CHECK (spot_failures >= 0),++ reputation integer NOT NULL DEFAULT 0 CHECK (reputation BETWEEN 0 AND 100),++ checks integer NOT NULL DEFAULT 0 CHECK (checks >= 0),++ up_checks integer NOT NULL DEFAULT 0 CHECK (up_checks >= 0),++ first_verified_at timestamptz,++ last_canary_at timestamptz,++ last_spot_at timestamptz,++ failure_reason text,++ created_at timestamptz NOT NULL,++ updated_at timestamptz NOT NULL++);++CREATE INDEX IF NOT EXISTS community_offers_owner ON community_offers (owner_subject, updated_at DESC);++CREATE INDEX IF NOT EXISTS community_offers_status ON community_offers (status);++++CREATE TABLE IF NOT EXISTS community_usage (++ id text PRIMARY KEY,++ offer_key text NOT NULL REFERENCES community_offers (offer_key) ON DELETE CASCADE,++ offer_id text NOT NULL,++ machine_id text NOT NULL,++ owner_subject text NOT NULL,++ model text NOT NULL,++ request_id text NOT NULL,++ requester_subject text COLLATE "C",++ tokens_in bigint NOT NULL DEFAULT 0 CHECK (tokens_in >= 0),++ tokens_out bigint NOT NULL DEFAULT 0 CHECK (tokens_out >= 0),++ latency_ms integer CHECK (latency_ms IS NULL OR latency_ms >= 0),++ credits_micro bigint NOT NULL DEFAULT 0 CHECK (credits_micro >= 0),++ verified boolean NOT NULL DEFAULT false,++ self_served boolean NOT NULL DEFAULT false,++ earning_status text NOT NULL DEFAULT 'pending' CHECK (earning_status IN ('pending', 'spendable', 'self', 'capped', 'free')),++ charge_id text COLLATE "C",++ created_at timestamptz NOT NULL,++ UNIQUE (offer_key, request_id)++);++CREATE INDEX IF NOT EXISTS community_usage_owner ON community_usage (owner_subject, created_at DESC);++CREATE INDEX IF NOT EXISTS community_usage_offer ON community_usage (offer_key, created_at DESC);++++-- Kudos for volunteers (price 0): an append-only ledger, the same audit shape as the seller credits (who, why, which++-- offer and request, when), never cash and never expiring. A positive row is earned by serving verified tokens; a++-- negative row spends them on queue priority or a bigger free limit.++CREATE TABLE IF NOT EXISTS community_kudos_ledger (++ id text PRIMARY KEY,++ owner_subject text COLLATE "C" NOT NULL CHECK (owner_subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),++ delta integer NOT NULL CHECK (delta <> 0),++ reason text COLLATE "C" NOT NULL,++ offer_key text COLLATE "C",++ offer_id text COLLATE "C",++ request_id text COLLATE "C",++ balance_after integer NOT NULL CHECK (balance_after >= 0),++ created_at timestamptz NOT NULL++);++CREATE UNIQUE INDEX IF NOT EXISTS community_kudos_served ON community_kudos_ledger (offer_key, request_id) WHERE reason = 'served';++CREATE INDEX IF NOT EXISTS community_kudos_owner ON community_kudos_ledger (owner_subject, created_at DESC);nnew file mode 100644@@@ -0,0 +1,49 @@++'use strict';++/**++ * The community AI API (design: "Offer to the network"; plan E4), what OpenVibe.Exchange's /sell page shows and what++ * the public "Run on people's machines" row lists:++ *++ * GET /api/v1/community/offers public: the live community offers (models, price, hours, health, audience);++ * never an owner subject or a machine id++ * GET /api/v1/community/sell ai.run.read or ai.usage.read, acting for the owner in X-OV-Subject:++ * tokens served, earnings in credits, uptime, verification and reputation++ *++ * The seller's ledger is OpenVibe.AI's (server/community): tokens × the owner's credits-per-million price, recorded++ * in integer micro-credits; nothing here moves money.++ */++const express = require('express');++const { http } = require('openvibe-contracts');++const { CAPS } = require('../auth');++++const OWNER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++++function communityRouter({ community, auth, log = console }) {++ const r = express.Router();++ const send = (res, status, body) => res.status(status).set('Cache-Control', 'no-store').json(body);++++ r.get('/community/offers', async (req, res) => {++ try {++ res.set('Cache-Control', 'public, max-age=30').json({ offers: await community.publicOffers(), generated_at: new Date().toISOString() });++ } catch (err) {++ log.warn(`[ai] community offers: ${err.message}`);++ http.sendProblem(res, 503, 'community.unavailable', { detail: 'community offers are not available right now' });++ }++ });++++ r.get('/community/sell', auth.requireCap(CAPS.runRead, CAPS.usageRead), async (req, res) => {++ const owner = String(req.get('x-ov-subject') || req.query.owner || '');++ if (!OWNER.test(owner)) return http.sendProblem(res, 422, 'input.invalid', { detail: 'a community seller is named by X-OV-Subject (a usr_ subject)' });++ // A service acts for the owner it names; a person's own token is not accepted on this route (auth.requireCap).++ try {++ res.json(await community.sell(owner));++ } catch (err) {++ if (err && err.status) return http.sendProblem(res, err.status, err.code || 'input.invalid', { detail: err.detail || err.message });++ log.warn(`[ai] community sell ${owner}: ${err.message}`);++ http.sendProblem(res, 503, 'community.unavailable', { detail: 'the seller dashboard is not available right now' });++ }++ });++++ return r;++}++++module.exports = { communityRouter };@@@ -196,6 +196,8 @@ function messagesRouter({ runs, auth, config, registry, log = console }) {}}let body = { workflow: 'ai.messages', input };++ // Opt in to community capacity: the caller accepts that a person's machine sees the prompt.++ if (req.get('x-openvibe-community') === '1' || (b.x_openvibe && b.x_openvibe.community === true)) body.community = true;// L37: the soft preference (provider, and a model if the caller wants one). A wish, not a pin.if (b.prefer) body = { ...body, prefer: b.prefer };if (req.principal.kind === 'app') body = { ...body, attribution: appAttribution(req.principal) };@@@ -244,11 +246,14 @@ function messagesRouter({ runs, auth, config, registry, log = console }) {},};const disclosure = await models.disclosureOf({ registry, config, run });-- if (live) live.route(disclosure);++ // A community answer is labelled even with the model catalog flag off: the prompt ran on a person's machine.++ const community = (disclosure && disclosure.community) || (run.explain && run.explain.community) || null;++ if (live) live.route(disclosure || (community ? { community } : null));++ if (community && !live) res.setHeader('X-OpenVibe-Community', '1');// D8': the route disclosure joins the existing x_openvibe object (run, provider, fallback, cost), never a second object.const x = { run_id: run.id, provider: run.provenance && run.provenance.provider, fallback_used: Boolean(run.provenance && run.provenance.fallback_used),-- cost_usd: run.usage.cost_usd, synthetic: Boolean(run.synthetic) };-- const xOpenVibe = disclosure ? { ...x, ...disclosure } : null;++ cost_usd: run.usage.cost_usd, synthetic: Boolean(run.synthetic), ...(community ? { community } : {}) };++ const xOpenVibe = (disclosure || community) ? { ...x, ...(disclosure || {}) } : null;if (live) {// The events already went out as they arrived; an answer that came in one piece (an upstream that cannot// stream, a replayed run) is sent as the events it is. finish() sends the held message_stop with the whole@@@ -178,6 +178,9 @@ function openaiRouter({ runs, auth, config, registry, log = console }) {// AI Exchange F: the caller's liability cap for the Balance hold (an OpenVibe extension, like x_openvibe).if (b.max_cost_usd != null) input.max_cost_usd = b.max_cost_usd;let body = { workflow: 'ai.complete', input };++ // Opt in to community capacity (the design's "Offer to the network"): the caller accepts that a person's machine++ // sees the prompt. Off unless asked for, per request.++ if (req.get('x-openvibe-community') === '1' || (b.x_openvibe && b.x_openvibe.community === true)) body.community = true;// L37: the soft preference (provider, and a model if the caller wants one). A wish, not a pin.if (b.prefer) body = { ...body, prefer: b.prefer };if (req.principal.kind === 'app') body = { ...body, attribution: appAttribution(req.principal) };@@@ -219,12 +222,17 @@ function openaiRouter({ runs, auth, config, registry, log = console }) {return openaiError(res, 504, `the completion is still running; GET /api/v1/runs/${run.id}`, 'run.timeout');}const disclosure = await models.disclosureOf({ registry, config, run });-- if (live) live.route(disclosure);++ // A community answer is labelled even with the model catalog flag off: the caller must see that the prompt ran++ // on a person's machine.++ const community = (disclosure && disclosure.community) || (run.explain && run.explain.community) || null;++ if (community && !live) res.setHeader('X-OpenVibe-Community', '1');++ if (live) live.route(disclosure || (community ? { community } : null));const created = Math.floor(Date.parse(run.created_at || new Date().toISOString()) / 1000) || Math.floor(Date.now() / 1000);const id = `chatcmpl-${run.id}`;const model = run.output.model || (run.provenance && run.provenance.model) || 'openvibe';const usage = { prompt_tokens: run.usage.tokens_in || 0, completion_tokens: run.usage.tokens_out || 0, total_tokens: (run.usage.tokens_in || 0) + (run.usage.tokens_out || 0) };-- const x = { run_id: run.id, provider: run.provenance && run.provenance.provider, fallback_used: Boolean(run.provenance && run.provenance.fallback_used), cost_usd: run.usage.cost_usd, synthetic: Boolean(run.synthetic) };++ const x = { run_id: run.id, provider: run.provenance && run.provenance.provider, fallback_used: Boolean(run.provenance && run.provenance.fallback_used), cost_usd: run.usage.cost_usd, synthetic: Boolean(run.synthetic),++ ...(community ? { community } : {}) };if (!live) res.setHeader('X-OpenVibe-Run-Id', run.id); // a stream sent it with its headersif (live) {const send = (o) => live.write(o);@@@ -24,7 +24,7 @@ const pkg = require('../package.json');const { adminRoutes } = require('openvibe-shared/config');--function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null }) {++function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, community = null }) {const app = express();// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.require('openvibe-shared/agent-layer').mount(app, { descriptor: require('./agent-descriptor') });@@@ -151,6 +151,9 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keysif (credentials) app.use(require('./credentials').credentialsRouter({ credentials, auth, registry, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));// The budget of a Codes run token (Codes C3): Codes registers it when it mints the token.if (runBudgets) app.use(require('./api/run-budgets').runBudgetsRouter({ runBudgets, auth, log, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));++ // Community capacity (design: "Offer to the network"): the public offers Exchange lists and the seller's /sell++ // dashboard, an API for Exchange. Nothing here writes an offer; the offer belongs to Network and the Node owner.++ if (community) app.use('/api/v1', require('./api/community').communityRouter({ community, auth, log }));app.use(adminRouter({ config, db, registry, pool, quotas, cache, runs, auth, settings, ops, log }));// The operator console (/console, /auth/*): Network staff, server-rendered, no scripts (server/console).// Person sessions are separate, read-only and open one run receipt each (server/person-session.js).nnew file mode 100644@@@ -0,0 +1,683 @@++'use strict';++/**++ * Community AI offers (the design's "Offer to the network"; plan E4): a person's Node runs a local model server and++ * advertises models, hours, concurrency, a price in credits per million tokens and who may use it. OpenVibe.Network's++ * resource registry holds the offer (a user-owned provider offer whose detail.kind is `ai`); this module reads that++ * internal list, checks trust before traffic, places only requests that opt in to community capacity, and records what++ * the machine served.++ *++ * Trust before traffic (plan §3):++ * - canaries: known-answer prompts; any wrong answer marks the offer failed and stops traffic;++ * - a model fingerprint: the model id the machine's own server reports must be the one the owner declared;++ * - spot re-runs: about 1% of answers are re-run on a trusted machine serving the same model and compared;++ * - reputation: canaries, spot runs and observed uptime, 0-100.++ * A new machine serves only the free tier (a run with no Balance payer, ctx.creditsRequired) until verified. The answer++ * always carries `openvibe.community`, so a caller can see it came from a person's machine. The prompt is visible to++ * that machine, which is why only opted-in requests ever go there.++ *++ * Money: a self-served run (the requester is the owner, or merged into it) earns 0. Verified credits are `pending`++ * until the requester's own Balance charge exists in the same request (`spendable`), so none are spendable yet. A++ * seller is capped daily until 7 days of verified service, and a canary/spot failure rate over 10% resets reputation.++ * A volunteer (price 0) earns kudos in an append-only ledger; they never expire, buy queue priority and a bigger free++ * output allowance, and are never cash.++ *++ * createCommunity({ db, config, env, machines, clock, fetchImpl, log, tokens, random })++ * .enabled++ * .offers() the live offers (refreshed from Network at most refreshMs)++ * .choose({ ctx, operation }) one eligible {offer, model, key}, or null++ * .execute({ route, operation, req, ctx }) one answer from a community machine, or null when none is eligible++ * .sell(owner) the /sell dashboard: tokens served, credits, verification and reputation++ * .publicOffers() what Exchange may show (never the owner subject)++ * .verify(offer, model) run a canary round now (execute does it when the last one is stale)++ */++const { createServiceTokenClient } = require('openvibe-sdk/auth');++const { AiError, sha256 } = require('../util');++++const TEXT_OPS = new Set(['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'complete', 'messages']);++const USR = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const CANARY_PROMPTS = [++ { messages: [{ role: 'user', content: 'Reply with exactly this and nothing else: OPENVIBE-CANARY-7F3A' }], expect: 'OPENVIBE-CANARY-7F3A' },++ { messages: [{ role: 'user', content: 'What is 17 + 25? Reply with just the number.' }], expect: '42' },++ { messages: [{ role: 'user', content: 'Repeat this exact word: canary-ok' }], expect: 'canary-ok' },++];++const MAX_MESSAGES = 200;++const MAX_CONTENT = 65536;++++const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));++const norm = (s) => String(s == null ? '' : s).toLowerCase().replace(/\s+/g, ' ').trim();++const textOf = (c) => (typeof c === 'string' ? c : Array.isArray(c) ? c.filter((p) => p && p.type === 'text').map((p) => String(p.text || '')).join('\n') : '').slice(0, MAX_CONTENT);++const nowIso = (clock) => new Date(clock.now()).toISOString();++++/** A conversation as the machine's ai command takes it; text only, first two and newest kept when long. */++function messagesOf(req, operation) {++ let list;++ if (operation === 'complete' && req.openai && Array.isArray(req.openai.messages)) list = req.openai.messages;++ else {++ const system = typeof req.system === 'string' ? [{ role: 'system', content: req.system }]++ : Array.isArray(req.system) ? req.system.map((x) => ({ role: 'system', content: typeof x === 'string' ? x : textOf(x && (x.text != null ? x.text : x.content)) })) : [];++ list = [...system, ...(req.messages || [])];++ }++ const out = list.filter((m) => m && typeof m === 'object').map((m) => {++ const role = ['system', 'user', 'assistant', 'tool'].includes(m.role) ? m.role : m.role === 'developer' ? 'system' : 'user';++ const one = { role, content: textOf(m.content) };++ if (role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) {++ one.tool_calls = m.tool_calls.slice(0, 32).map((c, i) => ({ id: String((c && c.id) || `call_${i + 1}`).slice(0, 200), type: 'function',++ function: { name: String((c && c.function && c.function.name) || ''), arguments: typeof (c && c.function && c.function.arguments) === 'string' ? c.function.arguments.slice(0, MAX_CONTENT) : JSON.stringify((c && c.function && c.function.arguments) || {}) } }));++ }++ if (role === 'tool') one.tool_call_id = String(m.tool_call_id || 'call_1').slice(0, 200);++ return one;++ });++ if (out.length <= MAX_MESSAGES) return out;++ let tail = out.slice(-(MAX_MESSAGES - 2));++ while (tail.length && tail[0].role === 'tool') tail = tail.slice(1);++ return [...out.slice(0, 2), ...tail];++}++++/** The functions a completion may ask for, in the machine's shape (the plugin passes them through, runs none). */++function toolsOf(req) {++ const tools = req.openai && Array.isArray(req.openai.tools) ? req.openai.tools : null;++ if (!tools) return null;++ const out = tools.filter((t) => t && t.type === 'function' && t.function && /^[A-Za-z0-9_.-]{1,64}$/.test(String(t.function.name || '')))++ .slice(0, 64).map((t) => ({ type: 'function', function: { name: t.function.name,++ ...(typeof t.function.description === 'string' ? { description: t.function.description.slice(0, 4096) } : {}),++ ...(t.function.parameters && typeof t.function.parameters === 'object' ? { parameters: t.function.parameters } : {}) } }));++ return out.length ? out : null;++}++++/** Is an offer open now, in its own time zone? start == end is all day; an overnight window wraps. */++function withinHours(offer, now = Date.now()) {++ const h = offer && offer.hours;++ if (!h || !/^([01][0-9]|2[0-3]):[0-5][0-9]$/.test(String(h.start)) || !/^([01][0-9]|2[0-3]):[0-5][0-9]$/.test(String(h.end))) return false;++ let clock;++ try {++ clock = new Intl.DateTimeFormat('en-GB', { timeZone: h.tz || 'UTC', hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(now));++ } catch { return false; }++ const minute = Number(clock.slice(0, 2)) * 60 + Number(clock.slice(3, 5));++ const start = Number(h.start.slice(0, 2)) * 60 + Number(h.start.slice(3, 5));++ const end = Number(h.end.slice(0, 2)) * 60 + Number(h.end.slice(3, 5));++ if (start === end) return true;++ return start < end ? minute >= start && minute < end : minute >= start || minute < end;++}++++/** The model id a server reports matches the declared one, allowing a quantization suffix the server adds. */++function fingerprintMatches(reported, declared) {++ const a = norm(reported), b = norm(declared);++ if (!a || !b) return false;++ if (a === b) return true;++ const base = (s) => s.replace(/[:\-](q[0-9].*|gguf|awq|gptq|instruct)$/i, '').trim();++ return base(a) === base(b);++}++++/** Token-overlap similarity for a spot re-run: a different sampling of the same model still shares most words. */++function similar(a, b) {++ const words = (s) => new Set(norm(s).split(/[^a-z0-9]+/).filter(Boolean));++ const x = words(a), y = words(b);++ if (!x.size || !y.size) return false;++ let same = 0;++ for (const w of x) if (y.has(w)) same++;++ return same / Math.max(x.size, y.size) >= 0.5;++}++++function createCommunity({ db, config, env = process.env, machines = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, tokens = null, random = Math.random }) {++ const cfg = (config && config.community) || {};++ const network = String((config && config.networkInternalUrl) || '').replace(/\/+$/, '');++ const secret = env.OV_OAUTH_CLIENT_SECRET || '';++ const clientId = (config && config.console && config.console.clientId) || env.OV_OAUTH_CLIENT_ID || 'ai';++ let registryTokens = tokens;++ if (!registryTokens && secret && network) {++ registryTokens = createServiceTokenClient({ tokenUrl: `${network}/oauth/token`, clientId, clientSecret: secret, scope: { 'openvibe.network': 'network.registry.read' }, fetch: fetchImpl });++ }++ let followTokens = tokens;++ if (!followTokens && secret && network) {++ followTokens = createServiceTokenClient({ tokenUrl: `${network}/oauth/token`, clientId, clientSecret: secret, scope: { 'openvibe.network': 'network.follows.read' }, fetch: fetchImpl });++ }++ let identityTokens = tokens;++ if (!identityTokens && secret && network) {++ identityTokens = createServiceTokenClient({ tokenUrl: `${network}/oauth/token`, clientId, clientSecret: secret, scope: { 'openvibe.network': 'identity.subject.resolve' }, fetch: fetchImpl });++ }++ const enabled = Boolean(cfg.enabled && db && machines && network && registryTokens);++ let cache = { at: 0, offers: [] };++ let refreshing = null;++ const followersCache = new Map();++ const aliasCache = new Map(); // subject -> survivor subject (account merges, 5 minutes)++++ const keyOf = (offer, model) => `${offer.offer_id}|${model}`;++++ /** One Network offer doc as this module uses it. Null for anything that is not a complete AI offer. */++ function normalize(doc) {++ if (!doc || typeof doc !== 'object') return null;++ // Network's /internal/ai/offers returns the offer whole; accept a platform.resource-offer@1 wrapper too, for a++ // reader of the older shape (the doc's detail.kind identifies it).++ const d = doc.detail && doc.detail.kind === 'ai' ? doc.detail : doc;++ if (d.kind && d.kind !== 'ai') return null;++ const owner = doc.owner || d.owner;++ if (typeof d.machine_id !== 'string' || typeof owner !== 'string' || !USR.test(owner)) return null;++ if (!Array.isArray(d.models) || !d.models.length || !d.hours || !d.price) return null;++ const price = d.price;++ return {++ offer_id: String(doc.offer_id || d.offer_id), machine_id: d.machine_id, owner,++ models: d.models.filter((m) => typeof m === 'string' && m).slice(0, 40),++ hours: { start: String(d.hours.start || ''), end: String(d.hours.end || ''), tz: String(d.hours.tz || 'UTC') },++ concurrency: Number(d.concurrency) || 1,++ price: { in_per_million: Number(price.in_per_million) || 0, out_per_million: Number(price.out_per_million) || 0 },++ audience: d.audience, group: d.group || null, ready: d.ready === true,++ health: (doc.health && doc.health.status) || 'down',++ };++ }++++ async function fetchOffers() {++ if (!enabled) return [];++ const token = await registryTokens.getToken({ audience: 'openvibe.network' });++ let res;++ try {++ res = await fetchImpl(`${network}/internal/ai/offers`, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, signal: AbortSignal.timeout(8000) });++ } catch (err) {++ throw new AiError(503, 'community.unavailable', `Network did not answer for community offers: ${String(err && err.message || err).slice(0, 200)}`);++ }++ const body = await res.json().catch(() => null);++ if (!res.ok) throw new AiError(503, 'community.unavailable', `Network answered ${res.status} for community offers`);++ const offers = ((body && body.offers) || []).map(normalize).filter(Boolean);++ await noteHealth(offers);++ return offers;++ }++++ /** Keep a simple uptime denominator: each refresh is one observation of each offer and model. */++ async function noteHealth(offers) {++ for (const offer of offers) {++ for (const model of offer.models) {++ try {++ const key = keyOf(offer, model);++ await db.prepare(`INSERT INTO community_offers (offer_key, offer_id, machine_id, owner_subject, model, models, price_in, price_out, status, checks, up_checks, created_at, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'new', 1, ?, ?, ?)++ ON CONFLICT (offer_key) DO UPDATE SET models = excluded.models, price_in = excluded.price_in, price_out = excluded.price_out,++ checks = community_offers.checks + 1, up_checks = community_offers.up_checks + excluded.up_checks, updated_at = excluded.updated_at`)++ .run(key, offer.offer_id, offer.machine_id, offer.owner, model, JSON.stringify(offer.models), offer.price.in_per_million, offer.price.out_per_million,++ offer.health === 'up' ? 1 : 0, nowIso(clock), nowIso(clock));++ await refreshReputation(key);++ } catch (err) { log.warn(`[community] health ${keyOf(offer, model)}: ${err.message}`); }++ }++ }++ }++++ /** The live community offers, from the last Network read (no more than refreshMs old). */++ async function offers({ force = false } = {}) {++ if (!enabled) return [];++ if (!force && cache.offers.length && clock.now() - cache.at < (cfg.refreshMs || 30000)) return cache.offers;++ if (!refreshing) {++ refreshing = fetchOffers().then((list) => { cache = { at: clock.now(), offers: list }; return list; })++ .catch((err) => { log.warn(`[community] ${err.message}`); return cache.offers; })++ .finally(() => { refreshing = null; });++ }++ return await refreshing;++ }++++ async function stateOf(key) {++ return await db.prepare('SELECT * FROM community_offers WHERE offer_key = ?').get(key);++ }++++ /**++ * The reputation: canaries 40, spot re-runs 40, observed uptime 20. A failed offer is 0, and a canary/spot failure++ * rate over 10% resets it to 0 even if the offer is still up: a machine that fails often is not trusted.++ */++ async function refreshReputation(key) {++ const s = await stateOf(key);++ if (!s) return;++ const failures = (Number(s.canary_failures) || 0) + (Number(s.spot_failures) || 0);++ const attempts = (Number(s.canary_passes) || 0) + failures + (Number(s.spot_runs) || 0);++ const failureRate = attempts ? failures / attempts : 0;++ let score = 0;++ if (s.status !== 'failed' && failureRate <= 0.10) {++ score += s.status === 'verified' ? 40 : 10;++ const spots = Number(s.spot_runs) || 0;++ score += spots ? Math.round(40 * (1 - (Number(s.spot_failures) || 0) / spots)) : 20;++ const checks = Number(s.checks) || 0;++ score += checks ? Math.round(20 * (Number(s.up_checks) || 0) / checks) : 0;++ }++ score = clamp(score, 0, 100);++ await db.prepare('UPDATE community_offers SET reputation = ?, updated_at = ? WHERE offer_key = ?').run(score, nowIso(clock), key);++ }++++ /** Record one canary round. Successful: verified; a wrong answer: failed, traffic stops. */++ async function markCanary(key, { ok, reason = null }) {++ const at = nowIso(clock);++ if (ok) {++ await db.prepare(`UPDATE community_offers SET canary_passes = canary_passes + 1, last_canary_at = ?, first_verified_at = COALESCE(first_verified_at, ?),++ status = CASE WHEN status = 'failed' THEN status ELSE 'verified' END, failure_reason = NULL, updated_at = ? WHERE offer_key = ?`).run(at, at, at, key);++ } else {++ await db.prepare(`UPDATE community_offers SET canary_failures = canary_failures + 1, last_canary_at = ?, status = 'failed', reputation = 0, failure_reason = ?, updated_at = ?++ WHERE offer_key = ?`).run(at, String(reason || 'canary_failed').slice(0, 300), at, key);++ }++ await refreshReputation(key);++ }++++ /** Low-level: one chat completion on the person's machine, through Bot, as its owner. */++ async function dispatch(offer, model, messages, { maxTokens = 512, temperature = null, tools = null, timeoutMs = 60000, requestId = null } = {}) {++ if (!machines || !machines.enabled) throw new AiError(503, 'community.unavailable', 'OpenVibe.Bot is not configured, so a community machine cannot be reached');++ try {++ return await machines.chat({++ machine: offer.machine_id, subject: offer.owner, model, messages, maxTokens, temperature, tools,++ toolChoice: tools ? 'auto' : null, community: true,++ timeoutMs: Math.max(5000, Math.min(300000, timeoutMs)), signal: AbortSignal.timeout(Math.max(5000, timeoutMs) + 10000),++ });++ } catch (err) {++ const code = err && err.code;++ if (code === 'not_ready' || code === 'machine.busy') throw new AiError(503, 'community.busy', String(err.message || 'the machine is busy'));++ if (code === 'not_allowed') throw new AiError(409, 'community.not_open', String(err.message || 'the machine is not offering its models now'));++ throw err;++ }++ }++++ /** A canary round and the model fingerprint: the model the machine's server reports must be the one declared. */++ async function verify(offer, model) {++ const key = keyOf(offer, model);++ await db.prepare(`INSERT INTO community_offers (offer_key, offer_id, machine_id, owner_subject, model, models, price_in, price_out, status, created_at, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'new', ?, ?) ON CONFLICT (offer_key) DO NOTHING`)++ .run(key, offer.offer_id, offer.machine_id, offer.owner, model, JSON.stringify(offer.models), offer.price.in_per_million, offer.price.out_per_million, nowIso(clock), nowIso(clock));++ let lastModel = null;++ for (const canary of CANARY_PROMPTS) {++ let answer;++ try {++ answer = await dispatch(offer, model, canary.messages, { maxTokens: 64, temperature: 0, timeoutMs: cfg.canaryTimeoutMs || 30000, requestId: `canary-${key}` });++ } catch (err) {++ if (err && (err.code === 'community.busy' || err.code === 'community.not_open')) throw err;++ await markCanary(key, { ok: false, reason: `canary_call: ${String(err && err.message || err).slice(0, 200)}` });++ throw new AiError(502, 'community.verification_failed', `community machine ${offer.machine_id} failed its canary: ${String(err && err.message || err).slice(0, 200)}`);++ }++ lastModel = answer.model || lastModel;++ if (!norm(answer.text).includes(norm(canary.expect))) {++ await markCanary(key, { ok: false, reason: `canary_answer: expected ${canary.expect}` });++ throw new AiError(502, 'community.verification_failed', `community machine ${offer.machine_id} failed its canary prompt`);++ }++ }++ if (!fingerprintMatches(lastModel, model)) {++ await markCanary(key, { ok: false, reason: `fingerprint: ${String(lastModel).slice(0, 100)} is not ${model}` });++ throw new AiError(502, 'community.verification_failed', `community machine ${offer.machine_id} answers as ${String(lastModel).slice(0, 100)}, not ${model}`);++ }++ await markCanary(key, { ok: true });++ return true;++ }++++ /** Verify when there is no recent successful canary; a failed offer is never retried by traffic. */++ async function ensure(offer, model) {++ const key = keyOf(offer, model);++ const s = await stateOf(key);++ if (s && s.status === 'failed') throw new AiError(503, 'community.verification_failed', `community machine ${offer.machine_id} failed verification: ${s.failure_reason || 'no reason recorded'}`);++ if (s && s.status === 'verified' && s.last_canary_at && clock.now() - Date.parse(s.last_canary_at) < (cfg.canaryMaxAgeMs || 6 * 60 * 60 * 1000)) return;++ await verify(offer, model);++ }++++ async function followers(owner) {++ const hit = followersCache.get(owner);++ if (hit && clock.now() - hit.at < 60000) return hit.subjects;++ if (!followTokens) return new Set();++ const token = await followTokens.getToken({ audience: 'openvibe.network' });++ const subjects = new Set();++ let cursor = null;++ for (let page = 0; page < 20; page++) {++ const url = `${network}/api/v1/follows/channel/${encodeURIComponent(owner)}/followers?limit=200${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`;++ let res;++ try { res = await fetchImpl(url, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, signal: AbortSignal.timeout(5000) }); } catch { break; }++ if (!res.ok) break;++ const body = await res.json().catch(() => null);++ for (const f of (body && body.follows) || []) if (f && f.follower) subjects.add(String(f.follower));++ cursor = (body && body.next_cursor) || null;++ if (!cursor) break;++ }++ followersCache.set(owner, { at: clock.now(), subjects });++ return subjects;++ }++++ /** The survivor a subject was merged into (ADR-029), or the subject itself; Network is the authority. */++ async function survivor(subject) {++ if (!subject || !USR.test(String(subject))) return subject || null;++ const hit = aliasCache.get(subject);++ if (hit && clock.now() - hit.at < 300000) return hit.subject;++ let resolved = subject;++ if (identityTokens) {++ try {++ const token = await identityTokens.getToken({ audience: 'openvibe.network' });++ const res = await fetchImpl(`${network}/internal/identity/resolve?subject_id=${encodeURIComponent(subject)}`, {++ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, signal: AbortSignal.timeout(5000),++ });++ if (res.ok) {++ const body = await res.json().catch(() => null);++ const id = body && body.subject && body.subject.id;++ if (id && USR.test(String(id))) resolved = String(id);++ }++ } catch { /* Network down: the subject itself is the safe answer; a merge made it conservative */ }++ }++ aliasCache.set(subject, { at: clock.now(), subject: resolved });++ return resolved;++ }++++ /** The person a run is for: the on-behalf-of subject, else a user requester. A service/app has none. */++ function requesterOf(ctx = {}) {++ if (ctx.onBehalfOf && USR.test(String(ctx.onBehalfOf))) return String(ctx.onBehalfOf);++ if (ctx.requesterType === 'user' && ctx.requesterId && USR.test(String(ctx.requesterId))) return String(ctx.requesterId);++ return null;++ }++++ /** Who may use this offer: everyone, the owner's followers, or the caller's group. */++ async function audienceAllows(offer, ctx = {}) {++ if (offer.audience === 'everyone') return true;++ if (offer.audience === 'group') return Boolean(ctx.communityGroup && String(ctx.communityGroup) === String(offer.group));++ const person = ctx.onBehalfOf || null;++ if (!person || !USR.test(String(person))) return false;++ return (await followers(offer.owner)).has(String(person));++ }++++ /** The free tier: a run no Balance payer holds (a first-party service, a guest-free run). New machines serve only it. */++ const freeTier = (ctx = {}) => Boolean(ctx.creditsRequired || ctx.communityFree === true);++++ /** The eligible offers for one request, cheapest first within the objective, new machines only for the free tier. */++ async function eligible({ ctx = {}, operation = 'chat', route = null } = {}) {++ if (!enabled || !ctx.communityOptIn) return [];++ if (!TEXT_OPS.has(operation)) return [];++ if (ctx.exact) return [];++ const list = await offers();++ const out = [];++ for (const offer of list) {++ // The Node's own ready flag may be a few minutes stale (status is sent on changes); the hours are read here++ // too, so a request outside the owner's window never reaches the machine.++ if (offer.health === 'down' || offer.ready !== true || !withinHours(offer, clock.now())) continue;++ for (const model of offer.models) {++ const key = keyOf(offer, model);++ const s = await stateOf(key);++ if (s && s.status === 'failed') continue;++ if ((!s || s.status === 'new') && !freeTier(ctx)) continue;++ if (ctx.communityModel && String(ctx.communityModel) !== model) continue;++ if (!(await audienceAllows(offer, ctx))) continue;++ if (ctx.communityMaxPriceIn != null && offer.price.in_per_million > Number(ctx.communityMaxPriceIn)) continue;++ if (ctx.communityMaxPriceOut != null && offer.price.out_per_million > Number(ctx.communityMaxPriceOut)) continue;++ out.push({ offer, model, key, status: (s && s.status) || 'new', reputation: s ? Number(s.reputation) || 0 : 0 });++ }++ }++ const objective = ((route || ctx.route) && (route || ctx.route).constraints && (route || ctx.route).constraints.objective) || 'balanced';++ out.sort((a, b) => {++ if (objective === 'lowest-latency') return b.reputation - a.reputation;++ const pa = a.offer.price.in_per_million + a.offer.price.out_per_million;++ const pb = b.offer.price.in_per_million + b.offer.price.out_per_million;++ return pa - pb || b.reputation - a.reputation;++ });++ return out;++ }++++ async function choose(args) {++ const list = await eligible(args);++ return list.length ? list[0] : null;++ }++++ /** About 1%: the same prompt on a trusted machine with the same model, compared. No trusted peer, no spot. */++ async function spotCheck(chosen, messages, answer) {++ if (!(cfg.spotRate > 0) || random() >= cfg.spotRate) return;++ const peers = (await offers()).filter((o) => o.offer_id !== chosen.offer.offer_id && o.health === 'up' && o.ready && o.models.includes(chosen.model));++ const trusted = [];++ for (const peer of peers) {++ const s = await stateOf(keyOf(peer, chosen.model));++ if (s && s.status === 'verified') trusted.push(peer);++ }++ if (!trusted.length) return;++ const peer = trusted[0];++ let again;++ try { again = await dispatch(peer, chosen.model, messages, { maxTokens: 512, temperature: 0, timeoutMs: cfg.canaryTimeoutMs || 30000, requestId: `spot-${chosen.key}-${clock.now()}` }); }++ catch { return; }++ const at = nowIso(clock);++ if (!similar(answer.text, again.text)) {++ await db.prepare(`UPDATE community_offers SET spot_runs = spot_runs + 1, spot_failures = spot_failures + 1, last_spot_at = ?, status = 'failed', reputation = 0,++ failure_reason = ?, updated_at = ? WHERE offer_key = ?`).run(at, `spot_mismatch against ${peer.machine_id}`, at, chosen.key);++ await refreshReputation(chosen.key);++ throw new AiError(502, 'community.verification_failed', `community machine ${chosen.offer.machine_id} disagreed with a trusted machine on the spot re-run`);++ }++ await db.prepare(`UPDATE community_offers SET spot_runs = spot_runs + 1, last_spot_at = ?, updated_at = ? WHERE offer_key = ?`).run(at, at, chosen.key);++ await refreshReputation(chosen.key);++ }++++ /**++ * The seller ledger for one served request. Credits are recorded in integer micro-credits (1 credit = 1,000,000).++ * - a run whose requester is the offer's owner, or an account merged into it, is self-served: 0 credits, labelled;++ * - a new/free-tier machine's tokens are `free`;++ * - verified tokens are `pending` until the requester's own Balance was charged in the same request (then++ * `spendable`, with the charge id); until requester charging exists they never become spendable;++ * - until the machine has `trustDays` of verified service a seller earns at most the daily cap;++ * - a volunteer (price 0) earns kudos instead, never cash.++ */++ async function recordUsage(chosen, { requestId, usage, verified, requester = null, latencyMs = null, chargeId = null }) {++ const tin = Math.max(0, Math.trunc(Number(usage && usage.input) || 0));++ const tout = Math.max(0, Math.trunc(Number(usage && usage.output) || 0));++ const owner = await survivor(chosen.offer.owner);++ const person = requester ? await survivor(requester) : null;++ const selfServed = Boolean(person && owner && person === owner);++ let credits = verified && !selfServed ? Math.round(tin * chosen.offer.price.in_per_million + tout * chosen.offer.price.out_per_million) : 0;++ let earning = !verified ? 'free' : selfServed ? 'self' : (chargeId ? 'spendable' : 'pending');++ let capped = false;++ const state = await stateOf(chosen.key);++ if (credits > 0 && state && state.first_verified_at) {++ const first = Date.parse(state.first_verified_at);++ const trustEnds = first + (cfg.trustDays || 7) * 86400000;++ if (Number.isFinite(first) && clock.now() < trustEnds) {++ const dayStart = new Date(clock.now());++ dayStart.setUTCHours(0, 0, 0, 0);++ const row = await db.prepare(`SELECT COALESCE(SUM(credits_micro), 0)::bigint AS earned FROM community_usage++ WHERE owner_subject = ? AND created_at >= ? AND earning_status IN ('pending', 'spendable', 'capped')`)++ .get(owner, dayStart.toISOString());++ const remaining = Math.max(0, Number(cfg.dailyEarningCapMicro == null ? 100000000 : cfg.dailyEarningCapMicro) - Number(row.earned));++ if (credits > remaining) { credits = remaining; capped = true; earning = 'capped'; }++ }++ }++ const request = String(requestId || `usage-${clock.now()}`);++ const id = `cu_${sha256(`${chosen.key}|${request}`).slice(0, 26)}`;++ let kudos = 0;++ try {++ await db.tx(async () => {++ await db.prepare(`INSERT INTO community_usage (id, offer_key, offer_id, machine_id, owner_subject, model, request_id, requester_subject,++ tokens_in, tokens_out, latency_ms, credits_micro, verified, self_served, earning_status, charge_id, created_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (offer_key, request_id) DO NOTHING`)++ .run(id, chosen.key, chosen.offer.offer_id, chosen.offer.machine_id, owner, chosen.model, request, person,++ tin, tout, latencyMs == null ? null : Math.max(0, Math.round(latencyMs)), credits, Boolean(verified), selfServed, earning,++ chargeId || null, nowIso(clock));++ if (verified && !selfServed && chosen.offer.price.in_per_million === 0 && chosen.offer.price.out_per_million === 0 && tin + tout > 0) {++ kudos = Math.max(1, Math.floor((tin + tout) / 1000)) * (cfg.kudosPerThousandTokens || 1);++ const balance = await db.prepare(`SELECT COALESCE(SUM(delta), 0) AS balance FROM community_kudos_ledger WHERE owner_subject = ?`).get(owner);++ const after = Number(balance.balance) + kudos;++ const kid = `ku_${sha256(`${chosen.key}|${request}|kudos`).slice(0, 26)}`;++ await db.prepare(`INSERT INTO community_kudos_ledger (id, owner_subject, delta, reason, offer_key, offer_id, request_id, balance_after, created_at)++ VALUES (?, ?, ?, 'served', ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`)++ .run(kid, owner, kudos, chosen.key, chosen.offer.offer_id, request, after, nowIso(clock));++ }++ });++ } catch (err) { log.warn(`[community] usage ${chosen.key}: ${err.message}`); }++ return { tokens_in: tin, tokens_out: tout, credits_micro: credits, earning_status: earning, self_served: selfServed, capped, kudos };++ }++++ /**++ * One opted-in answer from a community machine: canaries first (unless recently verified), then the request, then++ * the spot check, then the seller's ledger. → the provider-pool result, or null when no offer is eligible.++ */++ async function execute({ route = null, operation = 'chat', req = {}, ctx = {} } = {}) {++ const chosen = await choose({ ctx, operation, route });++ if (!chosen) return null;++ await ensure(chosen.offer, chosen.model);++ // The canary round may just have verified it: this first answer already earns and is no longer free-tier only.++ const fresh = await stateOf(chosen.key);++ if (fresh) { chosen.status = fresh.status; chosen.reputation = Number(fresh.reputation) || 0; }++ const messages = messagesOf(req, operation);++ if (!messages.length) return null;++ const tools = operation === 'complete' ? toolsOf(req) : null;++ const timeoutMs = Math.max(5000, Math.min(Number(req.timeoutMs) || 60000, 300000));++ const requestId = String(ctx.runId || `community-${clock.now()}`);++ const requester = requesterOf(ctx);++ const selfServed = Boolean(requester && (await survivor(requester)) === (await survivor(chosen.offer.owner)));++ const facts = {++ resolved_model: `community/${chosen.model}`, revision: null, deployment: `community:${chosen.offer.machine_id}`,++ provider_access: 'COMMUNITY', rate_version: 'community', policy: 'community',++ community: { machine_id: chosen.offer.machine_id, owner: chosen.offer.owner, model: chosen.model, price: chosen.offer.price,++ verification: chosen.status, reputation: chosen.reputation, free_tier: freeTier(ctx), self_served: selfServed },++ };++ if (typeof ctx.onRoute === 'function') {++ const routed = await ctx.onRoute(facts, { model: chosen.model, maxTokens: req.maxTokens, paid: false, retail: null });++ if (routed && routed.skip) return null;++ }++ const t0 = clock.now();++ let answer;++ try {++ answer = await dispatch(chosen.offer, chosen.model, messages, {++ maxTokens: Math.max(1, Math.min(Number(req.maxTokens) || 1024, 32768)),++ temperature: req.temperature == null ? null : Number(req.temperature),++ tools, timeoutMs, requestId,++ });++ } catch (err) {++ if (err && (err.code === 'community.busy' || err.code === 'community.not_open')) return null;++ throw err;++ }++ const usage = answer.usage || { input: 0, output: 0, cached: 0 };++ await spotCheck(chosen, messages, answer);++ const latencyMs = clock.now() - t0;++ const recorded = await recordUsage(chosen, { requestId, usage, verified: chosen.status !== 'new', requester, latencyMs,++ chargeId: ctx.requesterChargeId || null });++ const text = answer.text || '';++ const message = answer.message || { role: 'assistant', content: text };++ const result = operation === 'complete'++ ? { completion: { choices: [{ index: 0, message, finish_reason: answer.finish || (message.tool_calls ? 'tool_calls' : 'stop') }], system_fingerprint: null }, text, json: null, model: answer.model || chosen.model, usage }++ : operation === 'messages'++ ? { message: { role: 'assistant', content: [{ type: 'text', text }], model: answer.model || chosen.model, usage }, model: answer.model || chosen.model, usage }++ : { text, json: null, model: answer.model || chosen.model, usage };++ const community = { ...facts.community, verification: chosen.status, reputation: chosen.reputation,++ credits_micro: recorded.credits_micro, earning_status: recorded.earning_status, self_served: recorded.self_served, kudos: recorded.kudos };++ const explain = {++ objective: 'community', reasons: [recorded.self_served++ ? 'self-served: the requester owns this machine, so the answer earns no credits'++ : `a community machine offered ${chosen.model}${chosen.offer.price.in_per_million || chosen.offer.price.out_per_million ? ` at ${chosen.offer.price.in_per_million}/${chosen.offer.price.out_per_million} credits per million` : ' for free'}`],++ selected: `community:${chosen.offer.machine_id}/${chosen.model}`, paid_by: 'community', candidates: [],++ attempts: [{ key: 'community', provider: `community:${chosen.offer.machine_id}`, model: chosen.model, why: 'an opted-in request placed on a verified community machine', status: 'ok', error: null }],++ community, openvibe: facts,++ };++ return {++ result, provider: `community:${chosen.offer.machine_id}`, providerKind: 'community', model: answer.model || chosen.model,++ fallbackUsed: false, usage, cost: 0, latencyMs, ttftMs: null, synthetic: false, tried: [], startedAt: t0, explain,++ };++ }++++ /** A volunteer's kudos and what they buy: queue priority now, a bigger free limit, never cash or expiry. */++ async function kudosBenefits(subject) {++ const id = String(subject || '').replace(/^user:/, '');++ if (!USR.test(id)) return { balance: 0, queue_priority: 0, free_allowance_bonus_tokens: 0 };++ const row = await db.prepare('SELECT COALESCE(SUM(delta), 0) AS balance FROM community_kudos_ledger WHERE owner_subject = ?').get(id);++ const balance = Math.max(0, Number(row.balance) || 0);++ return { balance, queue_priority: Math.min(10, Math.floor(Math.sqrt(balance))), free_allowance_bonus_tokens: Math.min(1000000, balance * 10) };++ }++++ /** The free-limit bonus for a subject (the free allowance asks for this when it claims a card's free share). */++ async function kudosBonus(subject) {++ return (await kudosBenefits(subject)).free_allowance_bonus_tokens;++ }++++ /** Queue priority a run's person bought with kudos; 0 for a service/app or someone with none. */++ async function queuePriority(subject) {++ return (await kudosBenefits(subject)).queue_priority;++ }++++ /** The /sell dashboard: this owner's offers, what they served, what they earned (credits, never cash). */++ async function sell(owner) {++ if (!USR.test(String(owner))) throw new AiError(422, 'input.invalid', 'owner must be a usr_ subject');++ const list = (await offers({ force: true })).filter((o) => o.owner === owner);++ const rows = await db.prepare(`SELECT offer_key, offer_id, machine_id, models, price_in, price_out, status, canary_passes, canary_failures, spot_runs, spot_failures,++ reputation, first_verified_at, last_canary_at, last_spot_at, failure_reason, checks, up_checks++ FROM community_offers WHERE owner_subject = ? ORDER BY updated_at DESC`).all(owner);++ const byKey = new Map(rows.map((r) => [r.offer_key, r]));++ // The dashboard keeps answering from the ledger when Network is unreachable: an offer with state but no live++ // doc is shown from its last recorded settings, marked unknown health.++ const seen = new Set(list.map((o) => o.offer_id));++ for (const r of rows) {++ if (seen.has(r.offer_id)) continue;++ seen.add(r.offer_id);++ const models = Array.isArray(r.models) ? r.models : (() => { try { return JSON.parse(r.models); } catch { return []; } })();++ list.push({ offer_id: r.offer_id, machine_id: r.machine_id, models, hours: null, concurrency: null,++ price: { in_per_million: Number(r.price_in), out_per_million: Number(r.price_out) }, audience: null, group: null, health: 'unknown', ready: false });++ }++ const usage = await db.prepare(`SELECT offer_key, model, COUNT(*)::int AS requests,++ COALESCE(SUM(tokens_in),0)::bigint AS tokens_in, COALESCE(SUM(tokens_out),0)::bigint AS tokens_out,++ COALESCE(SUM(latency_ms),0)::bigint AS latency_ms,++ COALESCE(SUM(credits_micro) FILTER (WHERE earning_status IN ('pending','capped')),0)::bigint AS pending_micro,++ COALESCE(SUM(credits_micro) FILTER (WHERE earning_status = 'spendable'),0)::bigint AS spendable_micro,++ COUNT(*) FILTER (WHERE self_served)::int AS self_requests++ FROM community_usage WHERE owner_subject = ? GROUP BY offer_key, model ORDER BY offer_key`).all(owner);++ const totals = { requests: 0, tokens_in: 0, tokens_out: 0, pending_credits_micro: 0, spendable_credits_micro: 0, self_served_requests: 0 };++ const served = usage.map((u) => {++ const tokensIn = Number(u.tokens_in), tokensOut = Number(u.tokens_out);++ const pending = Number(u.pending_micro), spendable = Number(u.spendable_micro);++ const latency = Number(u.latency_ms);++ totals.requests += Number(u.requests); totals.tokens_in += tokensIn; totals.tokens_out += tokensOut;++ totals.pending_credits_micro += pending; totals.spendable_credits_micro += spendable; totals.self_served_requests += Number(u.self_requests);++ return { offer_key: u.offer_key, model: u.model, requests: Number(u.requests), tokens_in: tokensIn, tokens_out: tokensOut,++ tokens_per_s: latency > 0 ? Math.round((tokensOut / (latency / 1000)) * 10) / 10 : null,++ pending_credits_micro: pending, spendable_credits_micro: spendable, self_served_requests: Number(u.self_requests) };++ });++ const dayStart = new Date(clock.now()); dayStart.setUTCHours(0, 0, 0, 0);++ const kudosRow = await db.prepare('SELECT COALESCE(SUM(delta),0) AS balance FROM community_kudos_ledger WHERE owner_subject = ?').get(owner);++ const kudos = Math.max(0, Number(kudosRow.balance) || 0);++ const offerViews = await Promise.all(list.map(async (o) => {++ const openNow = o.hours ? withinHours(o, clock.now()) : null;++ const out = { offer_id: o.offer_id, machine_id: o.machine_id, models: o.models, hours: o.hours, concurrency: o.concurrency,++ price: o.price, audience: o.audience, group: o.group, health: o.health, open_now: openNow, ready: o.ready && openNow !== false,++ verification: null, reputation: 0, free_only: true, verified: false,++ tokens_per_s: Math.max(0, ...served.filter((x) => x.offer_key.startsWith(`${o.offer_id}|`)).map((x) => x.tokens_per_s || 0)) || null };++ const verified = o.models.map((m) => byKey.get(`${o.offer_id}|${m}`)).filter(Boolean);++ if (verified.length) {++ const s = verified.find((x) => x.status === 'verified') || verified[0];++ const canaries = verified.reduce((n, x) => n + Number(x.canary_passes), 0);++ const failures = verified.reduce((n, x) => n + Number(x.canary_failures), 0);++ const spots = verified.reduce((n, x) => n + Number(x.spot_runs), 0);++ const spotFails = verified.reduce((n, x) => n + Number(x.spot_failures), 0);++ const checks = verified.reduce((n, x) => n + Number(x.checks), 0);++ const up = verified.reduce((n, x) => n + Number(x.up_checks), 0);++ const attempts = canaries + failures + spots + spotFails;++ out.verification = { status: s.status, canary_passes: canaries, canary_failures: failures, spot_runs: spots, spot_failures: spotFails,++ failure_rate: attempts ? Math.round((failures + spotFails) / attempts * 1000) / 10 : 0,++ last_canary_at: s.last_canary_at, last_spot_at: s.last_spot_at, first_verified_at: s.first_verified_at, failure_reason: s.failure_reason };++ out.reputation = Number(s.reputation) || 0;++ out.free_only = s.status !== 'verified';++ out.verified = s.status === 'verified';++ out.uptime_pct = checks ? Math.round((up / checks) * 1000) / 10 : null;++ // The daily cap holds until the machine has trustDays of verified service.++ const first = s.first_verified_at ? Date.parse(s.first_verified_at) : NaN;++ if (Number.isFinite(first) && clock.now() < first + (cfg.trustDays || 7) * 86400000) {++ const today = await db.prepare(`SELECT COALESCE(SUM(credits_micro),0)::bigint AS earned FROM community_usage++ WHERE owner_subject = ? AND created_at >= ? AND earning_status IN ('pending','spendable','capped')`).get(owner, dayStart.toISOString());++ out.daily_cap = { credits_micro: cfg.dailyEarningCapMicro == null ? 100000000 : cfg.dailyEarningCapMicro,++ earned_today_micro: Number(today.earned), trust_ends_at: new Date(first + (cfg.trustDays || 7) * 86400000).toISOString() };++ }++ }++ return out;++ }));++ const paid = (x) => (x.price.in_per_million || x.price.out_per_million) > 0;++ return {++ owner, generated_at: nowIso(clock), offers: offerViews, served,++ totals: { ...totals, pending_credits: totals.pending_credits_micro / 1e6, spendable_credits: totals.spendable_credits_micro / 1e6 },++ kudos: { balance: kudos, never_expires: true, never_cash: true,++ benefits: { queue_priority: Math.min(10, Math.floor(Math.sqrt(kudos))), free_allowance_bonus_tokens: Math.min(1000000, kudos * 10) } },++ seller_card: { tokens_per_s: Math.max(0, ...served.map((x) => x.tokens_per_s || 0)) || null,++ uptime_pct: offerViews.find((o) => o.uptime_pct != null)?.uptime_pct ?? null,++ verified: offerViews.some((o) => o.verified),++ volunteer: offerViews.some((o) => !paid(o)) },++ note: 'Credits are pending, not spendable, until the requester was charged in the same request; credits earned by the offer owner are self-served and 0. Cash payouts come when the owner turns money on; nothing here moves money. Kudos for volunteers never expire and are never cash: they buy queue priority and a bigger free limit.',++ };++ }++++++ /** What Exchange may show about community capacity: no owner subject, no machine id, no prompt. */++ async function publicOffers() {++ const list = await offers();++ return list.map((o) => {++ const openNow = withinHours(o, clock.now());++ return {++ // The public id is a stable hash: Exchange can list and link an offer without publishing which machine it is.++ offer_id: `community_${sha256(o.offer_id).slice(0, 16)}`, models: o.models, hours: o.hours, concurrency: o.concurrency,++ price: o.price, audience: o.audience, health: o.health, open_now: openNow, ready: o.ready && openNow,++ };++ });++ }++++ return { enabled, offers, choose, execute, verify, sell, publicOffers, kudosBenefits, kudosBonus, queuePriority, _test: { normalize, withinHours, refreshReputation, fingerprintMatches, similar, messagesOf, freeTier } };++}++++module.exports = { createCommunity, CANARY_PROMPTS, withinHours, fingerprintMatches, similar, messagesOf };@@@ -338,6 +338,24 @@ function load(env = process.env) {// it); OV_BOT_INTERNAL_URL= (empty) turns it off.bot: { url: trimUrl(env.OV_BOT_INTERNAL_URL != null ? env.OV_BOT_INTERNAL_URL : 'http://127.0.0.1:4630') },++ // ── Community AI offers (the design's "Offer to the network"; plan E4): requests that opt in to community++ // capacity may run on a person's Node, only after its canaries and model fingerprint pass. AI reads the offers++ // from Network's resource registry (network.registry.read), dispatches through Bot (bot.machine.ai), and keeps++ // the verification and earnings ledger. On by default: without a Network service secret the list is empty.++ community: {++ enabled: bool(env.AI_COMMUNITY, true),++ refreshMs: Math.max(1000, int(env.AI_COMMUNITY_REFRESH_MS, 30000)),++ canaryMaxAgeMs: Math.max(60000, int(env.AI_COMMUNITY_CANARY_MAX_AGE_MS, 6 * 60 * 60 * 1000)),++ canaryTimeoutMs: Math.max(2000, Math.min(120000, int(env.AI_COMMUNITY_CANARY_TIMEOUT_MS, 30000))),++ // About 1% of community answers are re-run on a trusted machine with the same model; a mismatch stops traffic.++ spotRate: Math.max(0, Math.min(1, float(env.AI_COMMUNITY_SPOT_RATE, 0.01))),++ // Until a machine has this many days of verified service, a seller earns at most the daily cap. Credits are++ // pending (not spendable) until the requester's own Balance charge exists; kudos are the volunteer reward.++ trustDays: Math.max(1, Math.min(365, int(env.AI_COMMUNITY_TRUST_DAYS, 7))),++ dailyEarningCapMicro: Math.max(0, Math.min(1e15, int(env.AI_COMMUNITY_DAILY_CAP_CREDITS, 100) * 1e6)),++ kudosPerThousandTokens: Math.max(1, Math.min(1000, int(env.AI_COMMUNITY_KUDOS_PER_1K, 1))),++ },++// ── Tier budgets (T6 step 8, server/govern.js): openvibe-sdk/govern in Valkey, off unless AI_GOVERN_TIERS=1.govern: loadGovern(env, { bool, int, num: float }),};@@@ -46,7 +46,7 @@ function periodFor(card, now) {return { start: Number.isFinite(from) ? from : 0, end: Number.isFinite(until) ? until : NEVER };}--function createFreeAllowance(db, { cardsFor, clock = { now: () => Date.now() }, store = null }) {++function createFreeAllowance(db, { cardsFor, clock = { now: () => Date.now() }, store = null, bonusFor = null }) {const open = db.prepare(`INSERT INTO free_allowance_usage (subject, provider, metric, period_start, period_end, free_used, updated_at)VALUES (?, ?, ?, ?, ?, 0, ?) ON CONFLICT (subject, provider, metric, period_start) DO NOTHING`);const lock = db.prepare('SELECT free_used FROM free_allowance_usage WHERE subject = ? AND provider = ? AND metric = ? AND period_start = ? FOR UPDATE');@@@ -83,7 +83,9 @@ function createFreeAllowance(db, { cardsFor, clock = { now: () => Date.now() },await db.tx(async () => {for (const kind of KINDS) { // one fixed order, so two claims lock rows in the same orderconst c = cards[kind];-- const cap = Math.floor(Number(c && c.free_allowance) || 0);++ // A volunteer's kudos buy a bigger output allowance; the bonus is one per claim, never a cash value.++ const bonus = kind === 'out' && bonusFor ? Math.max(0, Math.floor(Number(await bonusFor(subject)) || 0)) : 0;++ const cap = Math.floor(Number(c && c.free_allowance) || 0) + bonus;const q = Math.max(0, Math.floor(Number(usage[kind]) || 0));const k = byKind[kind];if (c) k.cost = (q / c.unit_size) * c.unit_price_usd; // the whole quantity's list price, free or not@@@ -32,7 +32,7 @@ const { AiError } = require('./util');const { gracefulStop, telemetry } = require('openvibe-sdk/service');const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk/telemetry');--async function start({ config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null, machineModels = null, variantEvidence = null } = {}) {++async function start({ config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null, machineModels = null, variantEvidence = null, community: givenCommunity = null } = {}) {config = config || load(env);schemas.configure(config.schemaCache);// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.@@@ -56,14 +56,20 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowconst vault = require('./vault').createVault({ settings, clock, log });await require('./vault').migrate({ db, config, settings, log }).catch((err) => log.warn(`[Vault] migration failed: ${err.message}`));await vault.load().catch((err) => log.warn(`[Vault] ${err.message}`));++ // A person's own machine as their model (server/machines.js), shared with community offers: both reach a machine++ // only through OpenVibe.Bot's bot.machine.ai.++ const machines = machineModels || require('./machines').createMachineModels({ config, env, fetchImpl });++ // Community AI offers (the design's "Offer to the network"; plan E4): read Network's user-owned offers, verify a++ // machine before traffic, and keep its seller ledger. Empty without a Network service secret or Bot.++ const community = givenCommunity || require('./community').createCommunity({ db, config, env, machines, clock, fetchImpl, log });// BENCH-11: Bench's ratings as routing evidence, and the opted-in tie rule inside placement.const bench = createBenchEvidence({ db, registry, config, clock, fetchImpl, log });-- const pool = createProviderPool({ db, registry, config, clock, fetchImpl, env, log, credentialFetch, machineModels, bench, variantEvidence });++ const pool = createProviderPool({ db, registry, config, clock, fetchImpl, env, log, credentialFetch, machineModels: machines, community, bench, variantEvidence });// T6 step 8: the SaaS tier budgets on openvibe-sdk/govern in Valkey (server/govern.js); off unless AI_GOVERN_TIERS=1.const tiers = createTiers({ config, valkey, clock, log });// §2.1.8: the provider free allowance per subject, priced off the pool's own rate cards (server/free-allowance.js);// with the tiers on, its counter is in their Valkey store.-- const freeAllowance = createFreeAllowance(db, { cardsFor: pool.rateCardsFor, clock, store: tiers.freeStore });++ const freeAllowance = createFreeAllowance(db, { cardsFor: pool.rateCardsFor, clock, store: tiers.freeStore, bonusFor: (subject) => community.kudosBonus(subject) });const quotas = createQuotas(db, { clock, registry, freeAllowance, tiers });// The budget a Codes run token carries (Codes C3), and what that run has spent.const runBudgets = createRunBudgets(db, { clock });@@@ -103,7 +109,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowstartSettlements() { return credits ? credits.startSettlements() : null; },stopSettlements() { return credits ? credits.stopSettlements() : undefined; },};-- const runs = createRuns({ db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, preferences, tiers, projectSecrets, pool, runBudgets, credits: creditsRef });++ const runs = createRuns({ db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, preferences, tiers, projectSecrets, pool, runBudgets, credits: creditsRef, community });await seed({ registry, quotas, config, env, db });// The seed creates or moves providers from the environment: publish their origins (the schema and every key's// bind follow), so a key is bound to the host it is for from the first boot.@@@ -129,7 +135,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowclientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,}) : null;const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });-- const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory });++ const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, community });let jwks = null;let keyLoaded = Promise.resolve(null);if (!keys.pinned && config.nodeEnv !== 'test') {@@@ -201,7 +207,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowif (!givenDb) await db.close();}-- return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, bench, benchEvents, memory, memoryJobs, credits: creditsRef, settings, vault, close };++ return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, bench, benchEvents, memory, memoryJobs, credits: creditsRef, settings, vault, community, close };}if (require.main === module) {@@@ -99,8 +99,12 @@ function createMachineModels({ config, env = process.env, fetchImpl = globalThisthrow new AiError(503, 'machine.none', `none of the person's own machines answered (${tried.map((x) => `${x.machine}: ${x.error}`).join('; ').slice(0, 400)})`);}-- async function once(machine, { subject, model, messages, maxTokens = null, temperature = null, signal = null, timeoutMs = 120000, tools = null, toolChoice = null }) {-- const value = { model, messages, ...(maxTokens ? { max_tokens: Math.min(32768, Math.max(1, Math.round(maxTokens))) } : {}), ...(temperature != null ? { temperature: Math.min(2, Math.max(0, Number(temperature))) } : {}),++ async function once(machine, { subject, model, messages, maxTokens = null, temperature = null, signal = null, timeoutMs = 120000, tools = null, toolChoice = null, community = false }) {++ // The ack wire has no extra field for "this is the machine's offer to the network" (bot.command@1's value is++ // closed), so the model carries the community/ prefix; the Node ai plugin strips it before its model server++ // sees it and enforces the offer. The prefix never reaches the local model.++ const asked = community && !String(model).startsWith('community/') ? `community/${model}` : model;++ const value = { model: asked, messages, ...(maxTokens ? { max_tokens: Math.min(32768, Math.max(1, Math.round(maxTokens))) } : {}), ...(temperature != null ? { temperature: Math.min(2, Math.max(0, Number(temperature))) } : {}),...(Array.isArray(tools) && tools.length ? { tools, ...(toolChoice === 'none' || toolChoice === 'auto' ? { tool_choice: toolChoice } : {}) } : {}) };const token = await tokenClient().getToken({ audience: 'openvibe.bot' });let res;@@@ -354,6 +354,19 @@ function openapi() {};}++// ── Community AI offers (the design's "Offer to the network") ───────────────────────────────++route('get', '/community/offers', {++ operationId: 'communityOffers', summary: 'Live community offers for Exchange', tags: ['community'],++ description: 'No token. Models, price in credits per million, hours, concurrency, audience, health and open_now; never an owner subject or a machine id (offer_id is a stable hash).',++ responses: { 200: json('The live community offers'), 503: problem },++});++route('get', '/community/sell', {++ operationId: 'communitySell', summary: 'The seller dashboard for Exchange', tags: ['community'], security: SERVICE,++ description: 'ai.run.read or ai.usage.read, acting for the owner in X-OV-Subject. Tokens served, credits recorded (1 credit = 1,000,000 micro), uptime, verification (canaries, spot re-runs) and reputation; nothing here moves money.',++ parameters: [{ name: 'X-OV-Subject', in: 'header', required: true, schema: str({ pattern: '^usr_[0-9A-HJKMNP-TV-Z]{26}$' }), description: 'The owner whose offers and ledger these are' }],++ responses: { 200: json('The owner\'s offers and totals'), 422: problem, 503: problem, ...errors([401, 403]) },++});++/** The documented routes as "METHOD /api/v1/path": what the app must mount. */function routes() {return Object.entries(PATHS).flatMap(([p, item]) => Object.keys(item).map((method) => `${method.toUpperCase()} ${MOUNT}${p}`)).sort();@@@ -134,7 +134,7 @@ function selfModelFor(cred, operation, req, ctx, prefs) {return { model, kind, why };}--function createProviderPool({ db, registry, config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, credentialFetch = null, machineModels = null, bench = null, variantEvidence = null }) {++function createProviderPool({ db, registry, config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, credentialFetch = null, machineModels = null, community = null, bench = null, variantEvidence = null }) {// A person's own machine as their model (credential provider node, server/machines.js).const machines = machineModels || require('../machines').createMachineModels({ config, env, fetchImpl });const cache = new Map(); // key -> { stamp, adapter }@@@ -883,6 +883,19 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no}}++ // Community capacity (design: "Offer to the network"; plan E4): only a request that opted in may run on a++ // person's machine, never an exact pin, never a BYOK run. The canaries run before the first answer; a failure++ // stops that machine and the ordinary network answers instead. The answer is labelled in explain.openvibe.++ if (community && ctx.communityOptIn && !ctx.credential && !ctx.byok && !ctx.exact) {++ try {++ const out = await community.execute({ route, operation, req, ctx });++ if (out) return out;++ } catch (err) {++ const why = String((err && (err.detail || err.message)) || err).slice(0, 300);++ tried.push({ provider: 'community', error: why });++ log.warn(`[ai] community ${operation} failed, falling back to the network: ${why}`);++ }++ }// CHECKPOINT D3: an exact id runs only its own artifact. The candidates are the catalog's deployments of// that id, filtered first by commercial access and what the request needs; no other model is ever added.const exact = ctx.exact || null;@@@ -103,7 +103,7 @@ function routePinRequest(value, principal, wf) {return { provider, model };}--function createRuns({ db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, preferences: personPreferences = null, tiers = null, projectSecrets = null, pool = null, runBudgets = null, credits = null }) {++function createRuns({ db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, preferences: personPreferences = null, tiers = null, projectSecrets = null, pool = null, runBudgets = null, credits = null, community = null }) {const inflight = new Map(); // run id -> { controller, promise, release, caller }const queue = []; // run ids waiting for the workerlet active = 0;@@@ -234,6 +234,9 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now}}const options = body.options && typeof body.options === 'object' ? body.options : {};++ // Community capacity is opt-in per request: a person's machine sees the prompt, so only a caller that says so++ // may be placed there. The OpenAI and Messages routes set body.community from x-openvibe-community.++ const communityOptIn = body.community === true || options.community === true;// A person's own key (WS-O task 2): only the service that stored it, within its daily budget; never cached.// A person's own key: the default one, or the provider the run names (Codes C3: one credential per provider).const credentialSubject = body.credential && typeof body.credential === 'object' ? String(body.credential.subject || '') : null;@@@ -279,6 +282,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now// A credential or a project's own provider never reaches managed supply: no Balance hold for that run....(credentialSubject !== null || projectProvider ? { byok: true } : {}),...(maxCostNano != null ? { maxCostNano: String(maxCostNano) } : {}),++ ...(communityOptIn ? { communityOptIn: true } : {}),...(pinned ? { routePin: pinned } : {}),// POST /v1/chat/completions with stream: true: the upstream's deltas, as they arrive (kept in memory only)....(typeof onDelta === 'function' ? { onDelta } : {}),@@@ -298,11 +302,11 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowsource_service: requester.type === 'service' ? requester.id : null,target: body.target ? JSON.stringify(body.target) : null, target_key: ctx.targetKey,input: null, input_hash: inputHash, idempotency_key: idem, trace_id: trace, request_id: requestId, retry_of: retryOf,-- options: JSON.stringify({ cache: options.cache !== false && credentialSubject === null && !projectProvider && !pinned && !selfKeys, debug: Boolean(options.debug), input_retained: kept.full,++ options: JSON.stringify({ cache: options.cache !== false && credentialSubject === null && !projectProvider && !pinned && !selfKeys && !communityOptIn, debug: Boolean(options.debug), input_retained: kept.full,...(credentialSubject !== null ? { credential_subject: credentialSubject } : {}), ...(credentialProvider !== null ? { credential_provider: credentialProvider } : {}),...(selfKeys ? { self_subject: personSubject } : {}),...(projectProvider ? { project_provider: projectProvider } : {}), ...(pinned ? { pinned_offer: pinned } : {}),-- ...(prefer ? { prefer } : {}) }), created_at: now,++ ...(communityOptIn ? { community: true } : {}), ...(prefer ? { prefer } : {}) }), created_at: now,};base.input = stableStringify(kept.value);@@@ -310,7 +314,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now// A pinned run is never served from (or written to) the shared cache: the caller asked for THIS model,// and another model's answer under the same prompt would silently break the pin. A run that may use the// person's own key is never cached either: their key must be used, and the answer is theirs, not the scope's.-- const scope = options.cache === false || credentialSubject !== null || projectProvider || pinned || selfKeys ? null : cache.scopeFor(wf.cache_mode, ctx);++ const scope = options.cache === false || credentialSubject !== null || projectProvider || pinned || selfKeys || communityOptIn ? null : cache.scopeFor(wf.cache_mode, ctx);let cacheKey = null;if (scope) {// Some workflows key the cache on less than their whole input (hooks.js CACHE_INPUT: a signed URL's signature).@@@ -343,6 +347,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now// Queue caps, then quota: both before any provider call (a refused run is not counted).const caller = `${requester.type}:${requester.id}`;++ // Kudos bought with volunteer service start this run earlier in the queue (never cash, never a bypass of limits).++ const priority = community && typeof community.queuePriority === 'function' ? await community.queuePriority(ctx.onBehalfOf).catch(() => 0) : 0;admit(caller);// A run with a person's own key spends their money, not the shared quotas (its own budget was checked above).// The tier budgets (server/govern.js, AI_GOVERN_TIERS) hold first, so a run they refuse is not counted by reserve().@@@ -379,7 +385,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowconst gate = new Promise((r) => { release = r; });// Whatever the run's end, a tier hold it did not settle (accounted) goes back.const promise = gate.then(async () => { try { await execute(id, { controller, reserved, scope, ctx, input, hold }); } finally { if (hold) await tiers.release(hold); } }).catch((err) => { log.error(`[runs] ${id}: ${err.stack || err}`); });-- inflight.set(id, { controller, promise, release, caller });++ inflight.set(id, { controller, promise, release, caller, priority });queue.push(id);pump();return { run: await get(id), created: true, promise };@@@ -397,7 +403,14 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowfunction pump() {while (active < config.runs.maxConcurrent && queue.length) {-- const id = queue.shift();++ // The highest kudos priority first; equal priorities keep their arrival order.++ let at = 0;++ for (let i = 1; i < queue.length; i++) {++ const have = inflight.get(queue[at]);++ const next = inflight.get(queue[i]);++ if (next && (!have || (next.priority || 0) > (have.priority || 0))) at = i;++ }++ const [id] = queue.splice(at, 1);const h = inflight.get(id);if (!h) continue;active++;@@@ -590,6 +603,9 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowconst changed = await credentials.markFailed(failure.owner, failure.subject, failure.provider, failure.error);if (changed) await events.credentialFailed({ subject: failure.subject, provider: failure.provider, error: failure.error, owner: failure.owner });} : undefined;++ // With Credits off there is no Balance payer at all, so every run is the free tier for a new community machine;++ // with it on only a run that could not hold a paid offer is.++ const communityFree = !(credits && credits.enabled) || Boolean(ctx.creditsRequired);const r = await engine.execute(wf, row, input, { signal: controller.signal, logRequest, debugRaw: Boolean(config.debugRawLog && opts.debug), inputHash: row.input_hash, preferences,// A developer app's run (ADR-014): the provider pool excludes a person's byok key, and the run is// limited to free and local capacity when it is a sandbox run or when no tier budget governs its@@@ -599,6 +615,9 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowrequesterType: ctx.requesterType, requesterId: ctx.requesterId,// AI Exchange F: no payer on this run (and not BYOK): the pool skips paid offers with a card.creditsRequired: Boolean(ctx.creditsRequired),++ // Opted in to community capacity: the pool may place this run on a person's machine.++ communityOptIn: Boolean(ctx.communityOptIn),++ communityFree,...(ctx.routePin ? { routePin: ctx.routePin } : {}),// L37: the soft preference the caller made (provider, and a model if it named one).prefer: opts.prefer || null,nnew file mode 100644@@@ -0,0 +1,43 @@++'use strict';++// The community AI API Exchange reads (design: "Offer to the network"): /api/v1/community/offers is public and never++// names an owner; /api/v1/community/sell is the seller's dashboard (tokens, credits, uptime, verification,++// reputation) for the owner a service names in X-OV-Subject with ai.run.read.++const assert = require('assert');++const { boot, request, token, suite } = require('./helpers');++++const OWNER = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1';++++const t = suite('community-api');++++t.test('the seller dashboard is guarded and reads the recorded tokens and credits', async () => {++ const h = await boot({ env: { AI_COMMUNITY: 'false' } });++ try {++ const key = `ai:dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2D1|llama3.1:8b`;++ await h.db.prepare(`INSERT INTO community_offers (offer_key, offer_id, machine_id, owner_subject, model, models, price_in, price_out, status,++ canary_passes, last_canary_at, checks, up_checks, reputation, created_at, updated_at)++ VALUES (?, ?, ?, ?, 'llama3.1:8b', '["llama3.1:8b"]', 10, 30, 'verified', 3, now(), 4, 4, 90, now(), now())`)++ .run(key, 'ai:dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2D1', 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2D1', OWNER);++ await h.db.prepare(`INSERT INTO community_usage (id, offer_key, offer_id, machine_id, owner_subject, model, request_id, tokens_in, tokens_out, credits_micro, verified, earning_status, created_at)++ VALUES ('cu_test', ?, 'ai:dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2D1', 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2D1', ?, 'llama3.1:8b', 'run-1', 1000, 500, 25000, true, 'pending', now())`)++ .run(key, OWNER);++ const anon = await request(h.base, 'GET', '/api/v1/community/sell', { headers: { 'X-OV-Subject': OWNER } });++ assert.strictEqual(anon.status, 401);++ const tok = token('exchange', ['ai.run.read']);++ const out = await request(h.base, 'GET', '/api/v1/community/sell', { tok, headers: { 'X-OV-Subject': OWNER } });++ assert.strictEqual(out.status, 200, out.text);++ assert.strictEqual(out.body.owner, OWNER);++ assert.deepStrictEqual([out.body.totals.tokens_in, out.body.totals.tokens_out, out.body.totals.pending_credits_micro], [1000, 500, 25000]);++ assert.strictEqual(out.body.totals.pending_credits, 0.025);++ assert.strictEqual(out.body.totals.spendable_credits_micro, 0, 'not spendable until the requester was charged');++ assert.strictEqual(out.body.offers[0].verification.status, 'verified');++ assert.strictEqual(out.body.offers[0].uptime_pct, 100);++ assert.strictEqual(out.body.offers[0].reputation, 90);++ const bad = await request(h.base, 'GET', '/api/v1/community/sell', { tok, headers: { 'X-OV-Subject': 'not-a-subject' } });++ assert.strictEqual(bad.status, 422);++ const pub = await request(h.base, 'GET', '/api/v1/community/offers');++ assert.strictEqual(pub.status, 200);++ assert.deepStrictEqual(pub.body.offers, [], 'with community offers off, the public list is empty');++ } finally { await h.stop(); }++});++++t.run();nnew file mode 100644@@@ -0,0 +1,76 @@++'use strict';++// Opting a request in to community capacity (design: "Offer to the network"): x-openvibe-community: 1 is the only++// thing that lets the router place a run on a person's machine, the answer is labelled in x_openvibe.community and++// X-OpenVibe-Community, and a request without it never reaches the community layer.++const assert = require('assert');++const { boot, request, token, suite, ALL } = require('./helpers');++++const t = suite('community-run');++const OWNER = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1';++let h;++let calls = [];++++const result = (ctx) => ({++ result: { completion: { choices: [{ index: 0, message: { role: 'assistant', content: 'from a community box' }, finish_reason: 'stop' }], system_fingerprint: null },++ text: 'from a community box', json: null, model: 'llama3.1:8b', usage: { input: 10, output: 5 } },++ provider: 'community:dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2D1', providerKind: 'community', model: 'llama3.1:8b', fallbackUsed: false,++ usage: { input: 10, output: 5 }, cost: 0, latencyMs: 1, ttftMs: null, synthetic: false, tried: [], startedAt: 0,++ explain: { objective: 'community', reasons: ['a community machine offered llama3.1:8b'], selected: 'community:dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2D1/llama3.1:8b',++ paid_by: 'community', candidates: [], attempts: [], community: { machine_id: 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2D1', owner: OWNER, model: 'llama3.1:8b', verification: 'verified', reputation: 50 },++ openvibe: { resolved_model: 'community/llama3.1:8b', revision: null, deployment: 'community:dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2D1', provider_access: 'COMMUNITY',++ rate_version: 'community', policy: 'community', community: { machine_id: 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2D1', owner: OWNER, model: 'llama3.1:8b',++ price: { in_per_million: 10, out_per_million: 30 }, verification: 'verified', reputation: 50, free_tier: true } } },++});++++t.test('boot', async () => {++ h = await boot({ community: {++ enabled: true, publicOffers: async () => [], sell: async () => ({ offers: [], totals: {} }),++ execute: async ({ ctx }) => { calls.push(ctx); return result(ctx); },++ } });++});++++t.test('a request without the opt-in never reaches a person\'s machine', async () => {++ calls = [];++ const r = await request(h.base, 'POST', '/v1/chat/completions', { tok: token('live', ALL), body: { model: 'openvibe/auto', messages: [{ role: 'user', content: 'hi' }] } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(calls.length, 0);++ assert.strictEqual(r.headers.get('x-openvibe-community'), null);++});++++t.test('x-openvibe-community places it and labels the answer', async () => {++ calls = [];++ const r = await request(h.base, 'POST', '/v1/chat/completions', { tok: token('live', ALL), headers: { 'x-openvibe-community': '1' },++ body: { model: 'openvibe/auto', messages: [{ role: 'user', content: 'hi' }] } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(calls.length, 1);++ assert.strictEqual(calls[0].communityOptIn, true);++ assert.strictEqual(r.headers.get('x-openvibe-community'), '1');++ assert.strictEqual(r.body.x_openvibe.community.owner, OWNER);++ assert.strictEqual(r.body.x_openvibe.community.verification, 'verified');++ assert.strictEqual(r.body.choices[0].message.content, 'from a community box');++});++++t.test('a failed community machine falls back to the network and is not labelled', async () => {++ calls = [];++ const before = h.community;++ // The real pool catches the failure and answers from the ordinary route.++ const saved = before.execute;++ before.execute = async () => { const err = new Error('community machine failed its canary'); err.code = 'community.verification_failed'; err.status = 502; throw err; };++ try {++ const r = await request(h.base, 'POST', '/v1/chat/completions', { tok: token('live', ALL), headers: { 'x-openvibe-community': '1' },++ body: { model: 'openvibe/auto', messages: [{ role: 'user', content: 'hi' }] } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.headers.get('x-openvibe-community'), null);++ assert.ok(r.body.x_openvibe.community == null, 'no community label on a network answer');++ } finally { before.execute = saved; }++});++++t.test('the body flag says the same as the header', async () => {++ calls = [];++ const r = await request(h.base, 'POST', '/v1/chat/completions', { tok: token('live', ALL),++ body: { model: 'openvibe/auto', x_openvibe: { community: true }, messages: [{ role: 'user', content: 'hi' }] } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(calls.length, 1);++});++++t.run();nnew file mode 100644@@@ -0,0 +1,234 @@++'use strict';++// Community AI offers (the design's "Offer to the network"; plan E4): only an opted-in request may run on a person's++// machine; a new machine serves the free tier only; canaries and the model fingerprint verify it before traffic; a++// failed canary stops it; verified tokens are recorded in credits (never cash); /sell reads them back.++const assert = require('assert');++const { boot, suite } = require('./helpers');++const { createCommunity, withinHours, fingerprintMatches, similar } = require('../server/community');++++const OWNER = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1';++const OTHER = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B2';++const MACHINE = 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2D1';++const t = suite('community');++++function offerDoc(over = {}) {++ return {++ offer_id: `ai:${MACHINE}`, machine_id: MACHINE, owner: OWNER,++ models: ['llama3.1:8b'], hours: { start: '00:00', end: '23:59', tz: 'UTC' }, concurrency: 1,++ price: { in_per_million: 10, out_per_million: 30 }, audience: 'everyone', group: null, ready: true,++ health: { status: 'up' }, ...over,++ };++}++++/** A fake Node model server: canaries answer correctly, everything else echoes the last user message. */++function fakeMachines({ canary = true, spotAnswer = null } = {}) {++ const calls = [];++ return {++ enabled: true, calls,++ async chat({ machine, model, messages, community }) {++ calls.push({ machine, model, messages, community });++ const last = messages[messages.length - 1].content;++ let text;++ if (last.includes('OPENVIBE-CANARY')) text = 'OPENVIBE-CANARY-7F3A';++ else if (last.includes('17 + 25')) text = canary ? '42' : '41';++ else if (last.includes('canary-ok')) text = 'canary-ok';++ else text = spotAnswer != null ? spotAnswer : `You said: ${last}`;++ return { text, message: { role: 'assistant', content: text }, model, usage: { input: 10, output: 5 }, finish: 'stop' };++ },++ };++}++++async function communityFor(db, machines, { docs = [offerDoc()], followers = [], merges = {}, dailyCapMicro = 100000000 } = {}) {++ const fetchImpl = async (url) => {++ if (url.includes('/internal/ai/offers')) return { ok: true, json: async () => ({ offers: docs }) };++ if (url.includes('/followers')) return { ok: true, json: async () => ({ follows: followers.map((f) => ({ follower: f })), next_cursor: null }) };++ if (url.includes('/internal/identity/resolve')) {++ const id = decodeURIComponent(String(url).split('subject_id=')[1] || '');++ const survivor = merges[id] || id;++ return { ok: true, json: async () => ({ subject: { id: survivor } }) };++ }++ return { ok: false, status: 404, json: async () => null };++ };++ let ms = Date.parse('2026-11-06T12:00:00Z');++ return createCommunity({++ db, machines, fetchImpl, clock: { now: () => ms }, random: () => 0,++ config: { networkInternalUrl: 'http://network.test', console: { clientId: 'ai' }, community: { enabled: true, refreshMs: 1000, canaryMaxAgeMs: 21600000, spotRate: 0, canaryTimeoutMs: 5000, trustDays: 7, dailyEarningCapMicro: dailyCapMicro, kudosPerThousandTokens: 1 } },++ env: {}, tokens: { getToken: async () => 'test-token' }, log: { warn() {}, error() {} },++ });++}++++t.test('only an opted-in request is placed on a community machine', async () => {++ const h = await boot({ env: { AI_COMMUNITY: 'false' } });++ try {++ const machines = fakeMachines();++ const c = await communityFor(h.db, machines);++ assert.strictEqual(await c.execute({ operation: 'chat', req: { messages: [{ role: 'user', content: 'hi' }] }, ctx: { runId: 'x' } }), null);++ assert.strictEqual(machines.calls.length, 0, 'nothing reaches a person\'s machine unless the request opts in');++ assert.ok(await c.choose({ ctx: { communityOptIn: true, creditsRequired: true }, operation: 'chat' }));++ } finally { await h.stop(); }++});++++t.test('a new machine serves the free tier only until verified', async () => {++ const h = await boot({ env: { AI_COMMUNITY: 'false' } });++ try {++ const c = await communityFor(h.db, fakeMachines());++ assert.strictEqual(await c.choose({ ctx: { communityOptIn: true, creditsRequired: false }, operation: 'chat' }), null, 'not a paid run');++ const free = await c.choose({ ctx: { communityOptIn: true, creditsRequired: true }, operation: 'chat' });++ assert.ok(free, 'a free-tier run may use it');++ const out = await c.execute({ operation: 'chat', req: { messages: [{ role: 'user', content: 'hello' }] }, ctx: { communityOptIn: true, creditsRequired: true, runId: 'r1' } });++ assert.ok(out.provider.startsWith('community:'), out.provider);++ assert.strictEqual(out.explain.community.machine_id, MACHINE);++ assert.strictEqual(out.explain.openvibe.community.owner, OWNER, 'the answer says whose machine it came from');++ const verified = await c.offers();++ assert.ok(verified.length);++ assert.ok(await c.choose({ ctx: { communityOptIn: true, creditsRequired: false }, operation: 'chat' }), 'verified: a paid run may use it');++ const sell = await c.sell(OWNER);++ assert.strictEqual(sell.totals.requests, 1, 'the served request is on the seller dashboard');++ assert.deepStrictEqual([sell.totals.tokens_in, sell.totals.tokens_out], [10, 5]);++ assert.ok(sell.totals.pending_credits_micro > 0, 'verified tokens are recorded as pending credits');++ assert.strictEqual(sell.totals.spendable_credits_micro, 0, 'nothing is spendable until the requester was charged');++ assert.match(sell.note, /pending, not spendable/);++ assert.strictEqual(sell.offers[0].verification.status, 'verified');++ assert.strictEqual(sell.offers[0].free_only, false);++ } finally { await h.stop(); }++});++++t.test('the Anthropic messages operation gets an Anthropic message back', async () => {++ const h = await boot({ env: { AI_COMMUNITY: 'false' } });++ try {++ const c = await communityFor(h.db, fakeMachines());++ const out = await c.execute({ operation: 'messages', req: { system: 'Be brief.', messages: [{ role: 'user', content: 'hello' }] },++ ctx: { communityOptIn: true, creditsRequired: true, runId: 'rm1' } });++ assert.ok(out, 'a community answer');++ assert.deepStrictEqual(out.result.message.content, [{ type: 'text', text: 'You said: hello' }]);++ assert.strictEqual(out.result.message.role, 'assistant');++ } finally { await h.stop(); }++});++++t.test('a wrong canary fails verification and stops that machine', async () => {++ const h = await boot({ env: { AI_COMMUNITY: 'false' } });++ try {++ const machines = fakeMachines({ canary: false });++ const c = await communityFor(h.db, machines);++ const ctx = { communityOptIn: true, creditsRequired: true, runId: 'r2' };++ await assert.rejects(() => c.execute({ operation: 'chat', req: { messages: [{ role: 'user', content: 'hi' }] }, ctx }),++ (err) => err.code === 'community.verification_failed');++ assert.strictEqual(await c.choose({ ctx, operation: 'chat' }), null, 'a failed machine is never chosen again');++ const sell = await c.sell(OWNER);++ assert.strictEqual(sell.offers[0].verification.status, 'failed');++ assert.strictEqual(sell.offers[0].reputation, 0);++ assert.strictEqual(sell.totals.pending_credits_micro, 0, 'a failed canary earns nothing');++ } finally { await h.stop(); }++});++++t.test('a model fingerprint must be the model the owner declared', async () => {++ const h = await boot({ env: { AI_COMMUNITY: 'false' } });++ try {++ const machines = fakeMachines();++ machines.chat = async (args) => ({ ...(await fakeMachines().chat(args)), model: 'some-other-model:7b' });++ const c = await communityFor(h.db, machines);++ await assert.rejects(() => c.execute({ operation: 'chat', req: { messages: [{ role: 'user', content: 'hi' }] }, ctx: { communityOptIn: true, creditsRequired: true, runId: 'r3' } }),++ (err) => err.code === 'community.verification_failed');++ assert.ok(fingerprintMatches('llama3.1:8b-q4', 'llama3.1:8b'));++ assert.ok(!fingerprintMatches('qwen2.5:7b', 'llama3.1:8b'));++ assert.ok(similar('A lock one thread holds at a time.', 'a lock one thread holds at a time'));++ } finally { await h.stop(); }++});++++t.test('the offer hours are checked by the router too, in the owner time zone', async () => {++ const h = { start: '09:00', end: '17:00', tz: 'UTC' };++ const at = (iso) => Date.parse(iso);++ assert.ok(withinHours({ hours: h }, at('2026-11-06T12:00:00Z')));++ assert.ok(!withinHours({ hours: h }, at('2026-11-06T02:00:00Z')));++ assert.ok(withinHours({ hours: { start: '22:00', end: '06:00', tz: 'UTC' } }, at('2026-11-06T23:00:00Z')), 'overnight wraps');++ assert.ok(!withinHours({ hours: { start: '22:00', end: '06:00', tz: 'UTC' } }, at('2026-11-06T12:00:00Z')));++ assert.ok(withinHours({ hours: { start: '00:00', end: '00:00', tz: 'UTC' } }, at('2026-11-06T12:00:00Z')), 'start == end is all day');++});++++t.test('a run by the offer owner is self-served: zero credits, labelled', async () => {++ const h = await boot({ env: { AI_COMMUNITY: 'false' } });++ try {++ const c = await communityFor(h.db, fakeMachines());++ const out = await c.execute({ operation: 'chat', req: { messages: [{ role: 'user', content: 'hi' }] },++ ctx: { communityOptIn: true, creditsRequired: true, onBehalfOf: OWNER, runId: 'self1' } });++ assert.strictEqual(out.explain.community.self_served, true);++ assert.strictEqual(out.explain.community.credits_micro, 0);++ assert.match(out.explain.reasons[0], /self-served/);++ const sell = await c.sell(OWNER);++ assert.strictEqual(sell.totals.self_served_requests, 1);++ assert.strictEqual(sell.totals.pending_credits_micro, 0);++ } finally { await h.stop(); }++});++++t.test('a requester merged into the owner is self-served too', async () => {++ const h = await boot({ env: { AI_COMMUNITY: 'false' } });++ try {++ const c = await communityFor(h.db, fakeMachines(), { merges: { [OTHER]: OWNER } });++ const out = await c.execute({ operation: 'chat', req: { messages: [{ role: 'user', content: 'hi' }] },++ ctx: { communityOptIn: true, creditsRequired: true, onBehalfOf: OTHER, runId: 'merged1' } });++ assert.strictEqual(out.explain.community.self_served, true);++ assert.strictEqual(out.explain.community.credits_micro, 0);++ } finally { await h.stop(); }++});++++t.test('a new machine\'s seller earnings are capped by the day', async () => {++ const h = await boot({ env: { AI_COMMUNITY: 'false' } });++ try {++ const c = await communityFor(h.db, fakeMachines(), { dailyCapMicro: 1 });++ const out = await c.execute({ operation: 'chat', req: { messages: [{ role: 'user', content: 'hi' }] },++ ctx: { communityOptIn: true, creditsRequired: true, onBehalfOf: OTHER, runId: 'cap1' } });++ assert.strictEqual(out.explain.community.earning_status, 'capped');++ assert.strictEqual(out.explain.community.credits_micro, 1, 'the daily cap is one micro-credit');++ const sell = await c.sell(OWNER);++ assert.strictEqual(sell.offers[0].daily_cap.earned_today_micro, 1);++ assert.ok(sell.offers[0].daily_cap.trust_ends_at);++ } finally { await h.stop(); }++});++++t.test('a volunteer earns kudos, never cash', async () => {++ const h = await boot({ env: { AI_COMMUNITY: 'false' } });++ try {++ const c = await communityFor(h.db, fakeMachines(), { docs: [offerDoc({ price: { in_per_million: 0, out_per_million: 0 } })] });++ const out = await c.execute({ operation: 'chat', req: { messages: [{ role: 'user', content: 'hi' }] },++ ctx: { communityOptIn: true, creditsRequired: true, onBehalfOf: OTHER, runId: 'kudos1' } });++ assert.ok(out.explain.community.kudos > 0);++ const sell = await c.sell(OWNER);++ assert.ok(sell.kudos.balance > 0);++ assert.strictEqual(sell.kudos.never_cash, true);++ assert.strictEqual(sell.kudos.never_expires, true);++ assert.ok(sell.kudos.benefits.queue_priority >= 0);++ assert.ok(sell.kudos.benefits.free_allowance_bonus_tokens > 0);++ assert.strictEqual(await c.queuePriority(OWNER), sell.kudos.benefits.queue_priority);++ assert.strictEqual(await c.kudosBonus(`user:${OWNER}`), sell.kudos.benefits.free_allowance_bonus_tokens);++ assert.strictEqual(sell.totals.pending_credits_micro, 0, 'a volunteer earns no credits');++ } finally { await h.stop(); }++});++++t.test('a canary/spot failure rate resets reputation', async () => {++ const h = await boot({ env: { AI_COMMUNITY: 'false' } });++ try {++ const c = await communityFor(h.db, fakeMachines());++ const key = `${offerDoc().offer_id}|llama3.1:8b`;++ await h.db.prepare(`INSERT INTO community_offers (offer_key, offer_id, machine_id, owner_subject, model, models, price_in, price_out, status,++ canary_passes, canary_failures, checks, up_checks, reputation, created_at, updated_at)++ VALUES (?, ?, ?, ?, 'llama3.1:8b', '["llama3.1:8b"]', 10, 30, 'verified', 9, 2, 4, 4, 90, now(), now())`)++ .run(key, offerDoc().offer_id, MACHINE, OWNER);++ await c._test.refreshReputation(key);++ const row = await h.db.prepare('SELECT reputation FROM community_offers WHERE offer_key = ?').get(key);++ assert.strictEqual(Number(row.reputation), 0, '2 failures out of 11 attempts resets it');++ } finally { await h.stop(); }++});++++t.test('followers and groups are enforced, everyone is open', async () => {++ const h = await boot({ env: { AI_COMMUNITY: 'false' } });++ try {++ const c = await communityFor(h.db, fakeMachines(), { docs: [offerDoc({ audience: 'followers' })], followers: [OTHER] });++ assert.ok(await c.choose({ ctx: { communityOptIn: true, creditsRequired: true, onBehalfOf: OTHER }, operation: 'chat' }), 'a follower may use it');++ assert.strictEqual(await c.choose({ ctx: { communityOptIn: true, creditsRequired: true, onBehalfOf: OWNER }, operation: 'chat' }), null, 'someone else may not');++ const g = await communityFor(h.db, fakeMachines(), { docs: [offerDoc({ audience: 'group', group: 'team-a' })] });++ assert.ok(await g.choose({ ctx: { communityOptIn: true, creditsRequired: true, communityGroup: 'team-a' }, operation: 'chat' }));++ assert.strictEqual(await g.choose({ ctx: { communityOptIn: true, creditsRequired: true, communityGroup: 'team-b' }, operation: 'chat' }), null);++ } finally { await h.stop(); }++});++++t.run();@@@ -57,6 +57,13 @@ t.test('free up to the allowance per metric, then fully priced; the real periodfor (const r of rows) assert.deepStrictEqual([r.period_start, r.period_end], [Date.UTC(2026, 9, 4), Date.UTC(2026, 9, 5)]);});++t.test('a volunteer\'s kudos buy a bigger output allowance', async () => {++ await clear();++ const fa = createFreeAllowance(tdb.db, { cardsFor: cardsWith({ free_allowance: 100, reset_period: 'day' }), bonusFor: async () => 40 });++ const c = await fa.claim('s1', 'p', 'm', { out: 200 }, at(2026, 10, 4));++ assert.strictEqual(c.byKind.out.tokens, 140, 'the card\'s 100 plus the 40 kudos tokens');++});++t.test('the counter resets at the period boundary: day and month; none never resets', async () => {await clear();const day = createFreeAllowance(tdb.db, { cardsFor: cardsWith({ free_allowance: 100, reset_period: 'day' }) });@@@ -45,7 +45,7 @@ function tmpDir() {// A boot on a dir used before is a restart on the same database: its database stays open.const restartable = new Map();--async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research, machineModels, log, variantEvidence } = {}) {++async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research, machineModels, log, variantEvidence, community } = {}) {const reuse = dir !== undefined;if (dir === undefined) dir = tmpDir();const fullEnv = {@@@ -59,7 +59,7 @@ async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, researchif (reuse) restartable.set(dir, testdb);// Research reads a canned web (test/fixtures/research-fetch.js) unless a test brings its own: no test reaches the internet.const researcher = research || require('../server/research').createResearch({ config: config.research, env: {}, fetchImpl: require('./fixtures/research-fetch').createResearchFetch().fetchImpl, log: silent });-- const h = await start({ config, db: testdb.db, env: fullEnv, log: log || silent, clock, fetchImpl, credentialFetch, research: researcher, machineModels, variantEvidence });++ const h = await start({ config, db: testdb.db, env: fullEnv, log: log || silent, clock, fetchImpl, credentialFetch, research: researcher, machineModels, variantEvidence, community });const base = `http://127.0.0.1:${h.server.address().port}`;return { ...h, base, dir, env: fullEnv, testdb, stop: async () => { await h.close(); if (!reuse) await testdb.close(); } };}
new file mode 100644
new file mode 100644
@@ -0,0 +1,83 @@
@@ -0,0 +1,83 @@
+# Community AI offers ("Offer to the network")
+
+The design's community machines (`EXCHANGE-REMAKE-2026-11-06.md`, plan §3, stage 4): a person turns on **Offer to the
+network** in OpenVibe.Node's `ai` plugin and their local models (Ollama, LM Studio, llama.cpp) or GPU become capacity
+OpenVibe.AI can place inference on. It is opt-in twice — the owner turns the offer on, and the caller asks for community
+capacity per request — because the machine sees the prompt.
+
+## The chain
+
+1. **Node** (`plugins/ai`, 0.3.0) advertises `status.capabilities.ai.offer`: `enabled`, `models`, `hours`,
+ `concurrency`, `price` (credits per million in/out), `audience`, `open_now`, `ready`. It enforces `enabled`, `hours`,
+ `models` and `concurrency` on the machine, and accepts a community command only as a `community/<model>` request,
+ which it strips before the local server sees it.
+2. **Bot** stores the Node's capabilities and reports every machine with an enabled offer to Network:
+ `POST /internal/ai/offers/report` (source `bot`, `network.resource.report`). A community run reaches a machine even
+ with "Let my AI use this computer's models" off, because the offer itself is the opt-in; the machine still only
+ accepts `kind: ai`.
+3. **Network** (`server/registry/ai-offers.js`, `migrations/0039_ai_offers.sql`) validates the offer strictly and keeps
+ it as a user-owned offer: owner required, no public route, `network.registry.read` to read the whole set. It is a
+ dedicated table because `platform.resource-offer@1` closes `detail` for kind node/provider and cannot carry models,
+ 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.
+
+## Opting in
+
+`POST /v1/chat/completions` / `POST /v1/messages` (and the direct runs API) take either header
+`x-openvibe-community: 1` or body `x_openvibe: { "community": true }`. Without it the community layer is never called.
+An exact model id (`org/model@revision`) and a BYOK run never go to a community machine; only free/auto routes may.
+
+The answer carries `x_openvibe.community` (machine, owner, model, price, verification, reputation, credits) and the
+response header `X-OpenVibe-Community: 1`. A failure on the community machine falls back to the ordinary network, and
+that answer carries no community label.
+
+## Trust before traffic
+
+- **Canaries**: three known-answer prompts. A wrong answer marks the offer `failed`, reputation 0, and it is never
+ chosen again until an operator resets it.
+- **Model fingerprint**: the model id the machine's own server reports must match the declared model (a quantization
+ suffix is allowed).
+- **Spot re-runs**: about 1% (`AI_COMMUNITY_SPOT_RATE`, default 0.01) of answers are re-run on a verified machine
+ serving the same model and compared by token overlap. A mismatch fails the offer and the request falls back.
+- **Reputation**: canaries 40, spot pass rate 40, observed uptime 20.
+- **New machines** serve the free tier only (`ctx.creditsRequired`: a run with no Balance payer) until verified.
+ Verified tokens earn credits; new/free-tier tokens are recorded with 0 credits.
+
+## Earnings and /sell
+
+`community_usage` records tokens in/out, latency, the price used and `credits_micro` (1 credit = 1,000,000 micro), only
+for verified tokens.
+
+- **Self-dealing is zero.** A run whose requester is the offer's owner, or an account merged into it (Network
+ `identity.subject.resolve`), is `self_served`: 0 credits, labelled in the receipt and in `/sell`.
+- **Pending, not spendable.** Credits are earned only against a charge to the requester's Balance in the same request.
+ Until requester charging exists, every verified row is `pending`; `spendable` is reserved for a future Balance charge
+ id. `/sell` shows both and says plainly that pending credits cannot be spent.
+- **Daily cap until trusted.** Until a machine has 7 days of verified service (`AI_COMMUNITY_TRUST_DAYS`), its seller
+ earns at most `AI_COMMUNITY_DAILY_CAP_CREDITS` (100) a day; the row is `capped` when the cap binds.
+- **Reputation resets on failures.** A canary/spot failure rate over 10% sets reputation to 0; a failed offer is 0.
+- **Kudos for volunteers.** A price-0 offer earns kudos per 1,000 verified tokens (`AI_COMMUNITY_KUDOS_PER_1K`), in an
+ append-only `community_kudos_ledger` with the same who/why/request audit shape as the credits. Kudos never expire and
+ are never cash: they buy queue priority (`sqrt(balance)`, capped at 10) and a larger free output allowance
+ (10 tokens per kudo, capped at 1M), both read by the router.
+
+`GET /api/v1/community/sell` (a service with `ai.run.read` or `ai.usage.read`, acting for the owner in `X-OV-Subject`)
+returns the owner's offers with tokens served, tokens/s per model, uptime, verification, reputation, pending/spendable
+credits, self-served requests, the daily cap, kudos and the seller card. `GET /api/v1/community/offers` is the public
+list (models, price, hours, health; never an owner subject or machine id).
+
+## Configuration
+
+- `AI_COMMUNITY` (default on): with no `OV_OAUTH_CLIENT_SECRET` or `OV_BOT_INTERNAL_URL` the list is empty.
+- `AI_COMMUNITY_REFRESH_MS` (30000), `AI_COMMUNITY_CANARY_MAX_AGE_MS` (6h), `AI_COMMUNITY_CANARY_TIMEOUT_MS` (30000),
+ `AI_COMMUNITY_SPOT_RATE` (0.01).
+- Network grants AI `network.registry.read` and `network.follows.read` (the `followers` audience).
+
+## Tests
+
+- `node test/community.test.js` — opt-in, free-tier-only, canaries, fingerprint, followers/groups, earnings and /sell.
+- `node test/community-run.test.js` — the run path: no opt-in reaches nothing; with it the answer is labelled; a failed
+ machine falls back to the network.
+- `node test/community-api.test.js` — the dashboard route and the public offers route.
+- Network: `node test/run.js ai-offers`; Bot: `node test/community-ai.test.js`; Node: `python3 -m unittest discover -s
+ plugins/ai/tests`.
new file mode 100644
new file mode 100644
@@ -0,0 +1,73 @@
@@ -0,0 +1,73 @@
+-- phase: expand
+-- Community AI offers (the design's "Offer to the network"; plan E4). A person's Node offers its local models to the
+-- router; the offer itself lives in OpenVibe.Network's resource registry (a user-owned provider offer whose detail.kind
+-- is `ai`), and this table keeps what only AI knows: whether the machine passed its canaries and model fingerprint, its
+-- spot re-runs against a trusted machine, its reputation, and what it served. One row per offer and model, because a
+-- canary proves one model. The usage rows are the seller's ledger: tokens × the owner's credits-per-million price in
+-- integer micro-credits (1 credit = 1,000,000 micro), recorded only for verified tokens; nothing here moves cash.
+CREATE TABLE IF NOT EXISTS community_offers (
+ offer_key text PRIMARY KEY, -- <offer_id>|<model>
+ offer_id text NOT NULL,
+ machine_id text NOT NULL,
+ owner_subject text NOT NULL,
+ model text NOT NULL,
+ models jsonb NOT NULL,
+ price_in numeric(20, 6) NOT NULL CHECK (price_in >= 0),
+ price_out numeric(20, 6) NOT NULL CHECK (price_out >= 0),
+ status text NOT NULL DEFAULT 'new' CHECK (status IN ('new', 'verified', 'failed', 'paused')),
+ canary_passes integer NOT NULL DEFAULT 0 CHECK (canary_passes >= 0),
+ canary_failures integer NOT NULL DEFAULT 0 CHECK (canary_failures >= 0),
+ spot_runs integer NOT NULL DEFAULT 0 CHECK (spot_runs >= 0),
+ spot_failures integer NOT NULL DEFAULT 0 CHECK (spot_failures >= 0),
+ reputation integer NOT NULL DEFAULT 0 CHECK (reputation BETWEEN 0 AND 100),
+ checks integer NOT NULL DEFAULT 0 CHECK (checks >= 0),
+ up_checks integer NOT NULL DEFAULT 0 CHECK (up_checks >= 0),
+ first_verified_at timestamptz,
+ last_canary_at timestamptz,
+ last_spot_at timestamptz,
+ failure_reason text,
+ created_at timestamptz NOT NULL,
+ updated_at timestamptz NOT NULL
+);
+CREATE INDEX IF NOT EXISTS community_offers_owner ON community_offers (owner_subject, updated_at DESC);
+CREATE INDEX IF NOT EXISTS community_offers_status ON community_offers (status);
+
+CREATE TABLE IF NOT EXISTS community_usage (
+ id text PRIMARY KEY,
+ offer_key text NOT NULL REFERENCES community_offers (offer_key) ON DELETE CASCADE,
+ offer_id text NOT NULL,
+ machine_id text NOT NULL,
+ owner_subject text NOT NULL,
+ model text NOT NULL,
+ request_id text NOT NULL,
+ requester_subject text COLLATE "C",
+ tokens_in bigint NOT NULL DEFAULT 0 CHECK (tokens_in >= 0),
+ tokens_out bigint NOT NULL DEFAULT 0 CHECK (tokens_out >= 0),
+ latency_ms integer CHECK (latency_ms IS NULL OR latency_ms >= 0),
+ credits_micro bigint NOT NULL DEFAULT 0 CHECK (credits_micro >= 0),
+ verified boolean NOT NULL DEFAULT false,
+ self_served boolean NOT NULL DEFAULT false,
+ earning_status text NOT NULL DEFAULT 'pending' CHECK (earning_status IN ('pending', 'spendable', 'self', 'capped', 'free')),
+ charge_id text COLLATE "C",
+ created_at timestamptz NOT NULL,
+ UNIQUE (offer_key, request_id)
+);
+CREATE INDEX IF NOT EXISTS community_usage_owner ON community_usage (owner_subject, created_at DESC);
+CREATE INDEX IF NOT EXISTS community_usage_offer ON community_usage (offer_key, created_at DESC);
+
+-- Kudos for volunteers (price 0): an append-only ledger, the same audit shape as the seller credits (who, why, which
+-- offer and request, when), never cash and never expiring. A positive row is earned by serving verified tokens; a
+-- negative row spends them on queue priority or a bigger free limit.
+CREATE TABLE IF NOT EXISTS community_kudos_ledger (
+ id text PRIMARY KEY,
+ owner_subject text COLLATE "C" NOT NULL CHECK (owner_subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
+ delta integer NOT NULL CHECK (delta <> 0),
+ reason text COLLATE "C" NOT NULL,
+ offer_key text COLLATE "C",
+ offer_id text COLLATE "C",
+ request_id text COLLATE "C",
+ balance_after integer NOT NULL CHECK (balance_after >= 0),
+ created_at timestamptz NOT NULL
+);
+CREATE UNIQUE INDEX IF NOT EXISTS community_kudos_served ON community_kudos_ledger (offer_key, request_id) WHERE reason = 'served';
+CREATE INDEX IF NOT EXISTS community_kudos_owner ON community_kudos_ledger (owner_subject, created_at DESC);
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
+'use strict';
+/**
+ * The community AI API (design: "Offer to the network"; plan E4), what OpenVibe.Exchange's /sell page shows and what
+ * the public "Run on people's machines" row lists:
+ *
+ * GET /api/v1/community/offers public: the live community offers (models, price, hours, health, audience);
+ * never an owner subject or a machine id
+ * GET /api/v1/community/sell ai.run.read or ai.usage.read, acting for the owner in X-OV-Subject:
+ * tokens served, earnings in credits, uptime, verification and reputation
+ *
+ * The seller's ledger is OpenVibe.AI's (server/community): tokens × the owner's credits-per-million price, recorded
+ * in integer micro-credits; nothing here moves money.
+ */
+const express = require('express');
+const { http } = require('openvibe-contracts');
+const { CAPS } = require('../auth');
+
+const OWNER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+function communityRouter({ community, auth, log = console }) {
+ const r = express.Router();
+ const send = (res, status, body) => res.status(status).set('Cache-Control', 'no-store').json(body);
+
+ r.get('/community/offers', async (req, res) => {
+ try {
+ res.set('Cache-Control', 'public, max-age=30').json({ offers: await community.publicOffers(), generated_at: new Date().toISOString() });
+ } catch (err) {
+ log.warn(`[ai] community offers: ${err.message}`);
+ http.sendProblem(res, 503, 'community.unavailable', { detail: 'community offers are not available right now' });
+ }
+ });
+
+ r.get('/community/sell', auth.requireCap(CAPS.runRead, CAPS.usageRead), async (req, res) => {
+ const owner = String(req.get('x-ov-subject') || req.query.owner || '');
+ if (!OWNER.test(owner)) return http.sendProblem(res, 422, 'input.invalid', { detail: 'a community seller is named by X-OV-Subject (a usr_ subject)' });
+ // A service acts for the owner it names; a person's own token is not accepted on this route (auth.requireCap).
+ try {
+ res.json(await community.sell(owner));
+ } catch (err) {
+ if (err && err.status) return http.sendProblem(res, err.status, err.code || 'input.invalid', { detail: err.detail || err.message });
+ log.warn(`[ai] community sell ${owner}: ${err.message}`);
+ http.sendProblem(res, 503, 'community.unavailable', { detail: 'the seller dashboard is not available right now' });
+ }
+ });
+
+ return r;
+}
+
+module.exports = { communityRouter };
@@ -196,6 +196,8 @@ function messagesRouter({ runs, auth, config, registry, log = console }) {
@@ -196,6 +196,8 @@ function messagesRouter({ runs, auth, config, registry, log = console }) {
}
}
}
}
let body = { workflow: 'ai.messages', input };
let body = { workflow: 'ai.messages', input };
+ // Opt in to community capacity: the caller accepts that a person's machine sees the prompt.
+ if (req.get('x-openvibe-community') === '1' || (b.x_openvibe && b.x_openvibe.community === true)) body.community = true;
// L37: the soft preference (provider, and a model if the caller wants one). A wish, not a pin.
// L37: the soft preference (provider, and a model if the caller wants one). A wish, not a pin.
if (b.prefer) body = { ...body, prefer: b.prefer };
if (b.prefer) body = { ...body, prefer: b.prefer };
if (req.principal.kind === 'app') body = { ...body, attribution: appAttribution(req.principal) };
if (req.principal.kind === 'app') body = { ...body, attribution: appAttribution(req.principal) };
@@ -244,11 +246,14 @@ function messagesRouter({ runs, auth, config, registry, log = console }) {
@@ -244,11 +246,14 @@ function messagesRouter({ runs, auth, config, registry, log = console }) {
},
},
};
};
const disclosure = await models.disclosureOf({ registry, config, run });
const disclosure = await models.disclosureOf({ registry, config, run });
- if (live) live.route(disclosure);
+ // A community answer is labelled even with the model catalog flag off: the prompt ran on a person's machine.
+ const community = (disclosure && disclosure.community) || (run.explain && run.explain.community) || null;
+ if (live) live.route(disclosure || (community ? { community } : null));
+ if (community && !live) res.setHeader('X-OpenVibe-Community', '1');
// D8': the route disclosure joins the existing x_openvibe object (run, provider, fallback, cost), never a second object.
// D8': the route disclosure joins the existing x_openvibe object (run, provider, fallback, cost), never a second object.
const x = { run_id: run.id, provider: run.provenance && run.provenance.provider, fallback_used: Boolean(run.provenance && run.provenance.fallback_used),
const x = { run_id: run.id, provider: run.provenance && run.provenance.provider, fallback_used: Boolean(run.provenance && run.provenance.fallback_used),
- cost_usd: run.usage.cost_usd, synthetic: Boolean(run.synthetic) };
- const xOpenVibe = disclosure ? { ...x, ...disclosure } : null;
+ cost_usd: run.usage.cost_usd, synthetic: Boolean(run.synthetic), ...(community ? { community } : {}) };
+ const xOpenVibe = (disclosure || community) ? { ...x, ...(disclosure || {}) } : null;
if (live) {
if (live) {
// The events already went out as they arrived; an answer that came in one piece (an upstream that cannot
// The events already went out as they arrived; an answer that came in one piece (an upstream that cannot
// stream, a replayed run) is sent as the events it is. finish() sends the held message_stop with the whole
// stream, a replayed run) is sent as the events it is. finish() sends the held message_stop with the whole
@@ -178,6 +178,9 @@ function openaiRouter({ runs, auth, config, registry, log = console }) {
@@ -178,6 +178,9 @@ function openaiRouter({ runs, auth, config, registry, log = console }) {
// AI Exchange F: the caller's liability cap for the Balance hold (an OpenVibe extension, like x_openvibe).
// AI Exchange F: the caller's liability cap for the Balance hold (an OpenVibe extension, like x_openvibe).
if (b.max_cost_usd != null) input.max_cost_usd = b.max_cost_usd;
if (b.max_cost_usd != null) input.max_cost_usd = b.max_cost_usd;
let body = { workflow: 'ai.complete', input };
let body = { workflow: 'ai.complete', input };
+ // Opt in to community capacity (the design's "Offer to the network"): the caller accepts that a person's machine
+ // sees the prompt. Off unless asked for, per request.
+ if (req.get('x-openvibe-community') === '1' || (b.x_openvibe && b.x_openvibe.community === true)) body.community = true;
// L37: the soft preference (provider, and a model if the caller wants one). A wish, not a pin.
// L37: the soft preference (provider, and a model if the caller wants one). A wish, not a pin.
if (b.prefer) body = { ...body, prefer: b.prefer };
if (b.prefer) body = { ...body, prefer: b.prefer };
if (req.principal.kind === 'app') body = { ...body, attribution: appAttribution(req.principal) };
if (req.principal.kind === 'app') body = { ...body, attribution: appAttribution(req.principal) };
@@ -219,12 +222,17 @@ function openaiRouter({ runs, auth, config, registry, log = console }) {
@@ -219,12 +222,17 @@ function openaiRouter({ runs, auth, config, registry, log = console }) {
return openaiError(res, 504, `the completion is still running; GET /api/v1/runs/${run.id}`, 'run.timeout');
return openaiError(res, 504, `the completion is still running; GET /api/v1/runs/${run.id}`, 'run.timeout');
}
}
const disclosure = await models.disclosureOf({ registry, config, run });
const disclosure = await models.disclosureOf({ registry, config, run });
- if (live) live.route(disclosure);
+ // A community answer is labelled even with the model catalog flag off: the caller must see that the prompt ran
+ // on a person's machine.
+ const community = (disclosure && disclosure.community) || (run.explain && run.explain.community) || null;
+ if (community && !live) res.setHeader('X-OpenVibe-Community', '1');
+ if (live) live.route(disclosure || (community ? { community } : null));
const created = Math.floor(Date.parse(run.created_at || new Date().toISOString()) / 1000) || Math.floor(Date.now() / 1000);
const created = Math.floor(Date.parse(run.created_at || new Date().toISOString()) / 1000) || Math.floor(Date.now() / 1000);
const id = `chatcmpl-${run.id}`;
const id = `chatcmpl-${run.id}`;
const model = run.output.model || (run.provenance && run.provenance.model) || 'openvibe';
const model = run.output.model || (run.provenance && run.provenance.model) || 'openvibe';
const usage = { prompt_tokens: run.usage.tokens_in || 0, completion_tokens: run.usage.tokens_out || 0, total_tokens: (run.usage.tokens_in || 0) + (run.usage.tokens_out || 0) };
const usage = { prompt_tokens: run.usage.tokens_in || 0, completion_tokens: run.usage.tokens_out || 0, total_tokens: (run.usage.tokens_in || 0) + (run.usage.tokens_out || 0) };
- const x = { run_id: run.id, provider: run.provenance && run.provenance.provider, fallback_used: Boolean(run.provenance && run.provenance.fallback_used), cost_usd: run.usage.cost_usd, synthetic: Boolean(run.synthetic) };
+ const x = { run_id: run.id, provider: run.provenance && run.provenance.provider, fallback_used: Boolean(run.provenance && run.provenance.fallback_used), cost_usd: run.usage.cost_usd, synthetic: Boolean(run.synthetic),
+ ...(community ? { community } : {}) };
if (!live) res.setHeader('X-OpenVibe-Run-Id', run.id); // a stream sent it with its headers
if (!live) res.setHeader('X-OpenVibe-Run-Id', run.id); // a stream sent it with its headers
if (live) {
if (live) {
const send = (o) => live.write(o);
const send = (o) => live.write(o);
@@ -24,7 +24,7 @@ const pkg = require('../package.json');
@@ -24,7 +24,7 @@ const pkg = require('../package.json');
const { adminRoutes } = require('openvibe-shared/config');
const { adminRoutes } = require('openvibe-shared/config');
-function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null }) {
+function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, community = null }) {
const app = express();
const app = express();
// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.
// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.
require('openvibe-shared/agent-layer').mount(app, { descriptor: require('./agent-descriptor') });
require('openvibe-shared/agent-layer').mount(app, { descriptor: require('./agent-descriptor') });
@@ -151,6 +151,9 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
@@ -151,6 +151,9 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
if (credentials) app.use(require('./credentials').credentialsRouter({ credentials, auth, registry, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
if (credentials) app.use(require('./credentials').credentialsRouter({ credentials, auth, registry, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
// The budget of a Codes run token (Codes C3): Codes registers it when it mints the token.
// The budget of a Codes run token (Codes C3): Codes registers it when it mints the token.
if (runBudgets) app.use(require('./api/run-budgets').runBudgetsRouter({ runBudgets, auth, log, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
if (runBudgets) app.use(require('./api/run-budgets').runBudgetsRouter({ runBudgets, auth, log, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
+ // Community capacity (design: "Offer to the network"): the public offers Exchange lists and the seller's /sell
+ // dashboard, an API for Exchange. Nothing here writes an offer; the offer belongs to Network and the Node owner.
+ if (community) app.use('/api/v1', require('./api/community').communityRouter({ community, auth, log }));
app.use(adminRouter({ config, db, registry, pool, quotas, cache, runs, auth, settings, ops, log }));
app.use(adminRouter({ config, db, registry, pool, quotas, cache, runs, auth, settings, ops, log }));
// The operator console (/console, /auth/*): Network staff, server-rendered, no scripts (server/console).
// The operator console (/console, /auth/*): Network staff, server-rendered, no scripts (server/console).
// Person sessions are separate, read-only and open one run receipt each (server/person-session.js).
// Person sessions are separate, read-only and open one run receipt each (server/person-session.js).
new file mode 100644
new file mode 100644
@@ -0,0 +1,683 @@
@@ -0,0 +1,683 @@
+'use strict';
+/**
+ * Community AI offers (the design's "Offer to the network"; plan E4): a person's Node runs a local model server and
+ * advertises models, hours, concurrency, a price in credits per million tokens and who may use it. OpenVibe.Network's
+ * resource registry holds the offer (a user-owned provider offer whose detail.kind is `ai`); this module reads that
+ * internal list, checks trust before traffic, places only requests that opt in to community capacity, and records what
+ * the machine served.
+ *
+ * Trust before traffic (plan §3):
+ * - canaries: known-answer prompts; any wrong answer marks the offer failed and stops traffic;
+ * - a model fingerprint: the model id the machine's own server reports must be the one the owner declared;
+ * - spot re-runs: about 1% of answers are re-run on a trusted machine serving the same model and compared;
+ * - reputation: canaries, spot runs and observed uptime, 0-100.
+ * A new machine serves only the free tier (a run with no Balance payer, ctx.creditsRequired) until verified. The answer
+ * always carries `openvibe.community`, so a caller can see it came from a person's machine. The prompt is visible to
+ * that machine, which is why only opted-in requests ever go there.
+ *
+ * Money: a self-served run (the requester is the owner, or merged into it) earns 0. Verified credits are `pending`
+ * until the requester's own Balance charge exists in the same request (`spendable`), so none are spendable yet. A
+ * seller is capped daily until 7 days of verified service, and a canary/spot failure rate over 10% resets reputation.
+ * A volunteer (price 0) earns kudos in an append-only ledger; they never expire, buy queue priority and a bigger free
+ * output allowance, and are never cash.
+ *
+ * createCommunity({ db, config, env, machines, clock, fetchImpl, log, tokens, random })
+ * .enabled
+ * .offers() the live offers (refreshed from Network at most refreshMs)
+ * .choose({ ctx, operation }) one eligible {offer, model, key}, or null
+ * .execute({ route, operation, req, ctx }) one answer from a community machine, or null when none is eligible
+ * .sell(owner) the /sell dashboard: tokens served, credits, verification and reputation
+ * .publicOffers() what Exchange may show (never the owner subject)
+ * .verify(offer, model) run a canary round now (execute does it when the last one is stale)
+ */
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
+const { AiError, sha256 } = require('../util');
+
+const TEXT_OPS = new Set(['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'complete', 'messages']);
+const USR = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const CANARY_PROMPTS = [
+ { messages: [{ role: 'user', content: 'Reply with exactly this and nothing else: OPENVIBE-CANARY-7F3A' }], expect: 'OPENVIBE-CANARY-7F3A' },
+ { messages: [{ role: 'user', content: 'What is 17 + 25? Reply with just the number.' }], expect: '42' },
+ { messages: [{ role: 'user', content: 'Repeat this exact word: canary-ok' }], expect: 'canary-ok' },
+];
+const MAX_MESSAGES = 200;
+const MAX_CONTENT = 65536;
+
+const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
+const norm = (s) => String(s == null ? '' : s).toLowerCase().replace(/\s+/g, ' ').trim();
+const textOf = (c) => (typeof c === 'string' ? c : Array.isArray(c) ? c.filter((p) => p && p.type === 'text').map((p) => String(p.text || '')).join('\n') : '').slice(0, MAX_CONTENT);
+const nowIso = (clock) => new Date(clock.now()).toISOString();
+
+/** A conversation as the machine's ai command takes it; text only, first two and newest kept when long. */
+function messagesOf(req, operation) {
+ let list;
+ if (operation === 'complete' && req.openai && Array.isArray(req.openai.messages)) list = req.openai.messages;
+ else {
+ const system = typeof req.system === 'string' ? [{ role: 'system', content: req.system }]
+ : Array.isArray(req.system) ? req.system.map((x) => ({ role: 'system', content: typeof x === 'string' ? x : textOf(x && (x.text != null ? x.text : x.content)) })) : [];
+ list = [...system, ...(req.messages || [])];
+ }
+ const out = list.filter((m) => m && typeof m === 'object').map((m) => {
+ const role = ['system', 'user', 'assistant', 'tool'].includes(m.role) ? m.role : m.role === 'developer' ? 'system' : 'user';
+ const one = { role, content: textOf(m.content) };
+ if (role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) {
+ one.tool_calls = m.tool_calls.slice(0, 32).map((c, i) => ({ id: String((c && c.id) || `call_${i + 1}`).slice(0, 200), type: 'function',
+ function: { name: String((c && c.function && c.function.name) || ''), arguments: typeof (c && c.function && c.function.arguments) === 'string' ? c.function.arguments.slice(0, MAX_CONTENT) : JSON.stringify((c && c.function && c.function.arguments) || {}) } }));
+ }
+ if (role === 'tool') one.tool_call_id = String(m.tool_call_id || 'call_1').slice(0, 200);
+ return one;
+ });
+ if (out.length <= MAX_MESSAGES) return out;
+ let tail = out.slice(-(MAX_MESSAGES - 2));
+ while (tail.length && tail[0].role === 'tool') tail = tail.slice(1);
+ return [...out.slice(0, 2), ...tail];
+}
+
+/** The functions a completion may ask for, in the machine's shape (the plugin passes them through, runs none). */
+function toolsOf(req) {
+ const tools = req.openai && Array.isArray(req.openai.tools) ? req.openai.tools : null;
+ if (!tools) return null;
+ const out = tools.filter((t) => t && t.type === 'function' && t.function && /^[A-Za-z0-9_.-]{1,64}$/.test(String(t.function.name || '')))
+ .slice(0, 64).map((t) => ({ type: 'function', function: { name: t.function.name,
+ ...(typeof t.function.description === 'string' ? { description: t.function.description.slice(0, 4096) } : {}),
+ ...(t.function.parameters && typeof t.function.parameters === 'object' ? { parameters: t.function.parameters } : {}) } }));
+ return out.length ? out : null;
+}
+
+/** Is an offer open now, in its own time zone? start == end is all day; an overnight window wraps. */
+function withinHours(offer, now = Date.now()) {
+ const h = offer && offer.hours;
+ if (!h || !/^([01][0-9]|2[0-3]):[0-5][0-9]$/.test(String(h.start)) || !/^([01][0-9]|2[0-3]):[0-5][0-9]$/.test(String(h.end))) return false;
+ let clock;
+ try {
+ clock = new Intl.DateTimeFormat('en-GB', { timeZone: h.tz || 'UTC', hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(now));
+ } catch { return false; }
+ const minute = Number(clock.slice(0, 2)) * 60 + Number(clock.slice(3, 5));
+ const start = Number(h.start.slice(0, 2)) * 60 + Number(h.start.slice(3, 5));
+ const end = Number(h.end.slice(0, 2)) * 60 + Number(h.end.slice(3, 5));
+ if (start === end) return true;
+ return start < end ? minute >= start && minute < end : minute >= start || minute < end;
+}
+
+/** The model id a server reports matches the declared one, allowing a quantization suffix the server adds. */
+function fingerprintMatches(reported, declared) {
+ const a = norm(reported), b = norm(declared);
+ if (!a || !b) return false;
+ if (a === b) return true;
+ const base = (s) => s.replace(/[:\-](q[0-9].*|gguf|awq|gptq|instruct)$/i, '').trim();
+ return base(a) === base(b);
+}
+
+/** Token-overlap similarity for a spot re-run: a different sampling of the same model still shares most words. */
+function similar(a, b) {
+ const words = (s) => new Set(norm(s).split(/[^a-z0-9]+/).filter(Boolean));
+ const x = words(a), y = words(b);
+ if (!x.size || !y.size) return false;
+ let same = 0;
+ for (const w of x) if (y.has(w)) same++;
+ return same / Math.max(x.size, y.size) >= 0.5;
+}
+
+function createCommunity({ db, config, env = process.env, machines = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, tokens = null, random = Math.random }) {
+ const cfg = (config && config.community) || {};
+ const network = String((config && config.networkInternalUrl) || '').replace(/\/+$/, '');
+ const secret = env.OV_OAUTH_CLIENT_SECRET || '';
+ const clientId = (config && config.console && config.console.clientId) || env.OV_OAUTH_CLIENT_ID || 'ai';
+ let registryTokens = tokens;
+ if (!registryTokens && secret && network) {
+ registryTokens = createServiceTokenClient({ tokenUrl: `${network}/oauth/token`, clientId, clientSecret: secret, scope: { 'openvibe.network': 'network.registry.read' }, fetch: fetchImpl });
+ }
+ let followTokens = tokens;
+ if (!followTokens && secret && network) {
+ followTokens = createServiceTokenClient({ tokenUrl: `${network}/oauth/token`, clientId, clientSecret: secret, scope: { 'openvibe.network': 'network.follows.read' }, fetch: fetchImpl });
+ }
+ let identityTokens = tokens;
+ if (!identityTokens && secret && network) {
+ identityTokens = createServiceTokenClient({ tokenUrl: `${network}/oauth/token`, clientId, clientSecret: secret, scope: { 'openvibe.network': 'identity.subject.resolve' }, fetch: fetchImpl });
+ }
+ const enabled = Boolean(cfg.enabled && db && machines && network && registryTokens);
+ let cache = { at: 0, offers: [] };
+ let refreshing = null;
+ const followersCache = new Map();
+ const aliasCache = new Map(); // subject -> survivor subject (account merges, 5 minutes)
+
+ const keyOf = (offer, model) => `${offer.offer_id}|${model}`;
+
+ /** One Network offer doc as this module uses it. Null for anything that is not a complete AI offer. */
+ function normalize(doc) {
+ if (!doc || typeof doc !== 'object') return null;
+ // Network's /internal/ai/offers returns the offer whole; accept a platform.resource-offer@1 wrapper too, for a
+ // reader of the older shape (the doc's detail.kind identifies it).
+ const d = doc.detail && doc.detail.kind === 'ai' ? doc.detail : doc;
+ if (d.kind && d.kind !== 'ai') return null;
+ const owner = doc.owner || d.owner;
+ if (typeof d.machine_id !== 'string' || typeof owner !== 'string' || !USR.test(owner)) return null;
+ if (!Array.isArray(d.models) || !d.models.length || !d.hours || !d.price) return null;
+ const price = d.price;
+ return {
+ offer_id: String(doc.offer_id || d.offer_id), machine_id: d.machine_id, owner,
+ models: d.models.filter((m) => typeof m === 'string' && m).slice(0, 40),
+ hours: { start: String(d.hours.start || ''), end: String(d.hours.end || ''), tz: String(d.hours.tz || 'UTC') },
+ concurrency: Number(d.concurrency) || 1,
+ price: { in_per_million: Number(price.in_per_million) || 0, out_per_million: Number(price.out_per_million) || 0 },
+ audience: d.audience, group: d.group || null, ready: d.ready === true,
+ health: (doc.health && doc.health.status) || 'down',
+ };
+ }
+
+ async function fetchOffers() {
+ if (!enabled) return [];
+ const token = await registryTokens.getToken({ audience: 'openvibe.network' });
+ let res;
+ try {
+ res = await fetchImpl(`${network}/internal/ai/offers`, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, signal: AbortSignal.timeout(8000) });
+ } catch (err) {
+ throw new AiError(503, 'community.unavailable', `Network did not answer for community offers: ${String(err && err.message || err).slice(0, 200)}`);
+ }
+ const body = await res.json().catch(() => null);
+ if (!res.ok) throw new AiError(503, 'community.unavailable', `Network answered ${res.status} for community offers`);
+ const offers = ((body && body.offers) || []).map(normalize).filter(Boolean);
+ await noteHealth(offers);
+ return offers;
+ }
+
+ /** Keep a simple uptime denominator: each refresh is one observation of each offer and model. */
+ async function noteHealth(offers) {
+ for (const offer of offers) {
+ for (const model of offer.models) {
+ try {
+ const key = keyOf(offer, model);
+ await db.prepare(`INSERT INTO community_offers (offer_key, offer_id, machine_id, owner_subject, model, models, price_in, price_out, status, checks, up_checks, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'new', 1, ?, ?, ?)
+ ON CONFLICT (offer_key) DO UPDATE SET models = excluded.models, price_in = excluded.price_in, price_out = excluded.price_out,
+ checks = community_offers.checks + 1, up_checks = community_offers.up_checks + excluded.up_checks, updated_at = excluded.updated_at`)
+ .run(key, offer.offer_id, offer.machine_id, offer.owner, model, JSON.stringify(offer.models), offer.price.in_per_million, offer.price.out_per_million,
+ offer.health === 'up' ? 1 : 0, nowIso(clock), nowIso(clock));
+ await refreshReputation(key);
+ } catch (err) { log.warn(`[community] health ${keyOf(offer, model)}: ${err.message}`); }
+ }
+ }
+ }
+
+ /** The live community offers, from the last Network read (no more than refreshMs old). */
+ async function offers({ force = false } = {}) {
+ if (!enabled) return [];
+ if (!force && cache.offers.length && clock.now() - cache.at < (cfg.refreshMs || 30000)) return cache.offers;
+ if (!refreshing) {
+ refreshing = fetchOffers().then((list) => { cache = { at: clock.now(), offers: list }; return list; })
+ .catch((err) => { log.warn(`[community] ${err.message}`); return cache.offers; })
+ .finally(() => { refreshing = null; });
+ }
+ return await refreshing;
+ }
+
+ async function stateOf(key) {
+ return await db.prepare('SELECT * FROM community_offers WHERE offer_key = ?').get(key);
+ }
+
+ /**
+ * The reputation: canaries 40, spot re-runs 40, observed uptime 20. A failed offer is 0, and a canary/spot failure
+ * rate over 10% resets it to 0 even if the offer is still up: a machine that fails often is not trusted.
+ */
+ async function refreshReputation(key) {
+ const s = await stateOf(key);
+ if (!s) return;
+ const failures = (Number(s.canary_failures) || 0) + (Number(s.spot_failures) || 0);
+ const attempts = (Number(s.canary_passes) || 0) + failures + (Number(s.spot_runs) || 0);
+ const failureRate = attempts ? failures / attempts : 0;
+ let score = 0;
+ if (s.status !== 'failed' && failureRate <= 0.10) {
+ score += s.status === 'verified' ? 40 : 10;
+ const spots = Number(s.spot_runs) || 0;
+ score += spots ? Math.round(40 * (1 - (Number(s.spot_failures) || 0) / spots)) : 20;
+ const checks = Number(s.checks) || 0;
+ score += checks ? Math.round(20 * (Number(s.up_checks) || 0) / checks) : 0;
+ }
+ score = clamp(score, 0, 100);
+ await db.prepare('UPDATE community_offers SET reputation = ?, updated_at = ? WHERE offer_key = ?').run(score, nowIso(clock), key);
+ }
+
+ /** Record one canary round. Successful: verified; a wrong answer: failed, traffic stops. */
+ async function markCanary(key, { ok, reason = null }) {
+ const at = nowIso(clock);
+ if (ok) {
+ await db.prepare(`UPDATE community_offers SET canary_passes = canary_passes + 1, last_canary_at = ?, first_verified_at = COALESCE(first_verified_at, ?),
+ status = CASE WHEN status = 'failed' THEN status ELSE 'verified' END, failure_reason = NULL, updated_at = ? WHERE offer_key = ?`).run(at, at, at, key);
+ } else {
+ await db.prepare(`UPDATE community_offers SET canary_failures = canary_failures + 1, last_canary_at = ?, status = 'failed', reputation = 0, failure_reason = ?, updated_at = ?
+ WHERE offer_key = ?`).run(at, String(reason || 'canary_failed').slice(0, 300), at, key);
+ }
+ await refreshReputation(key);
+ }
+
+ /** Low-level: one chat completion on the person's machine, through Bot, as its owner. */
+ async function dispatch(offer, model, messages, { maxTokens = 512, temperature = null, tools = null, timeoutMs = 60000, requestId = null } = {}) {
+ if (!machines || !machines.enabled) throw new AiError(503, 'community.unavailable', 'OpenVibe.Bot is not configured, so a community machine cannot be reached');
+ try {
+ return await machines.chat({
+ machine: offer.machine_id, subject: offer.owner, model, messages, maxTokens, temperature, tools,
+ toolChoice: tools ? 'auto' : null, community: true,
+ timeoutMs: Math.max(5000, Math.min(300000, timeoutMs)), signal: AbortSignal.timeout(Math.max(5000, timeoutMs) + 10000),
+ });
+ } catch (err) {
+ const code = err && err.code;
+ if (code === 'not_ready' || code === 'machine.busy') throw new AiError(503, 'community.busy', String(err.message || 'the machine is busy'));
+ if (code === 'not_allowed') throw new AiError(409, 'community.not_open', String(err.message || 'the machine is not offering its models now'));
+ throw err;
+ }
+ }
+
+ /** A canary round and the model fingerprint: the model the machine's server reports must be the one declared. */
+ async function verify(offer, model) {
+ const key = keyOf(offer, model);
+ await db.prepare(`INSERT INTO community_offers (offer_key, offer_id, machine_id, owner_subject, model, models, price_in, price_out, status, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'new', ?, ?) ON CONFLICT (offer_key) DO NOTHING`)
+ .run(key, offer.offer_id, offer.machine_id, offer.owner, model, JSON.stringify(offer.models), offer.price.in_per_million, offer.price.out_per_million, nowIso(clock), nowIso(clock));
+ let lastModel = null;
+ for (const canary of CANARY_PROMPTS) {
+ let answer;
+ try {
+ answer = await dispatch(offer, model, canary.messages, { maxTokens: 64, temperature: 0, timeoutMs: cfg.canaryTimeoutMs || 30000, requestId: `canary-${key}` });
+ } catch (err) {
+ if (err && (err.code === 'community.busy' || err.code === 'community.not_open')) throw err;
+ await markCanary(key, { ok: false, reason: `canary_call: ${String(err && err.message || err).slice(0, 200)}` });
+ throw new AiError(502, 'community.verification_failed', `community machine ${offer.machine_id} failed its canary: ${String(err && err.message || err).slice(0, 200)}`);
+ }
+ lastModel = answer.model || lastModel;
+ if (!norm(answer.text).includes(norm(canary.expect))) {
+ await markCanary(key, { ok: false, reason: `canary_answer: expected ${canary.expect}` });
+ throw new AiError(502, 'community.verification_failed', `community machine ${offer.machine_id} failed its canary prompt`);
+ }
+ }
+ if (!fingerprintMatches(lastModel, model)) {
+ await markCanary(key, { ok: false, reason: `fingerprint: ${String(lastModel).slice(0, 100)} is not ${model}` });
+ throw new AiError(502, 'community.verification_failed', `community machine ${offer.machine_id} answers as ${String(lastModel).slice(0, 100)}, not ${model}`);
+ }
+ await markCanary(key, { ok: true });
+ return true;
+ }
+
+ /** Verify when there is no recent successful canary; a failed offer is never retried by traffic. */
+ async function ensure(offer, model) {
+ const key = keyOf(offer, model);
+ const s = await stateOf(key);
+ if (s && s.status === 'failed') throw new AiError(503, 'community.verification_failed', `community machine ${offer.machine_id} failed verification: ${s.failure_reason || 'no reason recorded'}`);
+ if (s && s.status === 'verified' && s.last_canary_at && clock.now() - Date.parse(s.last_canary_at) < (cfg.canaryMaxAgeMs || 6 * 60 * 60 * 1000)) return;
+ await verify(offer, model);
+ }
+
+ async function followers(owner) {
+ const hit = followersCache.get(owner);
+ if (hit && clock.now() - hit.at < 60000) return hit.subjects;
+ if (!followTokens) return new Set();
+ const token = await followTokens.getToken({ audience: 'openvibe.network' });
+ const subjects = new Set();
+ let cursor = null;
+ for (let page = 0; page < 20; page++) {
+ const url = `${network}/api/v1/follows/channel/${encodeURIComponent(owner)}/followers?limit=200${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`;
+ let res;
+ try { res = await fetchImpl(url, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, signal: AbortSignal.timeout(5000) }); } catch { break; }
+ if (!res.ok) break;
+ const body = await res.json().catch(() => null);
+ for (const f of (body && body.follows) || []) if (f && f.follower) subjects.add(String(f.follower));
+ cursor = (body && body.next_cursor) || null;
+ if (!cursor) break;
+ }
+ followersCache.set(owner, { at: clock.now(), subjects });
+ return subjects;
+ }
+
+ /** The survivor a subject was merged into (ADR-029), or the subject itself; Network is the authority. */
+ async function survivor(subject) {
+ if (!subject || !USR.test(String(subject))) return subject || null;
+ const hit = aliasCache.get(subject);
+ if (hit && clock.now() - hit.at < 300000) return hit.subject;
+ let resolved = subject;
+ if (identityTokens) {
+ try {
+ const token = await identityTokens.getToken({ audience: 'openvibe.network' });
+ const res = await fetchImpl(`${network}/internal/identity/resolve?subject_id=${encodeURIComponent(subject)}`, {
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, signal: AbortSignal.timeout(5000),
+ });
+ if (res.ok) {
+ const body = await res.json().catch(() => null);
+ const id = body && body.subject && body.subject.id;
+ if (id && USR.test(String(id))) resolved = String(id);
+ }
+ } catch { /* Network down: the subject itself is the safe answer; a merge made it conservative */ }
+ }
+ aliasCache.set(subject, { at: clock.now(), subject: resolved });
+ return resolved;
+ }
+
+ /** The person a run is for: the on-behalf-of subject, else a user requester. A service/app has none. */
+ function requesterOf(ctx = {}) {
+ if (ctx.onBehalfOf && USR.test(String(ctx.onBehalfOf))) return String(ctx.onBehalfOf);
+ if (ctx.requesterType === 'user' && ctx.requesterId && USR.test(String(ctx.requesterId))) return String(ctx.requesterId);
+ return null;
+ }
+
+ /** Who may use this offer: everyone, the owner's followers, or the caller's group. */
+ async function audienceAllows(offer, ctx = {}) {
+ if (offer.audience === 'everyone') return true;
+ if (offer.audience === 'group') return Boolean(ctx.communityGroup && String(ctx.communityGroup) === String(offer.group));
+ const person = ctx.onBehalfOf || null;
+ if (!person || !USR.test(String(person))) return false;
+ return (await followers(offer.owner)).has(String(person));
+ }
+
+ /** The free tier: a run no Balance payer holds (a first-party service, a guest-free run). New machines serve only it. */
+ const freeTier = (ctx = {}) => Boolean(ctx.creditsRequired || ctx.communityFree === true);
+
+ /** The eligible offers for one request, cheapest first within the objective, new machines only for the free tier. */
+ async function eligible({ ctx = {}, operation = 'chat', route = null } = {}) {
+ if (!enabled || !ctx.communityOptIn) return [];
+ if (!TEXT_OPS.has(operation)) return [];
+ if (ctx.exact) return [];
+ const list = await offers();
+ const out = [];
+ for (const offer of list) {
+ // The Node's own ready flag may be a few minutes stale (status is sent on changes); the hours are read here
+ // too, so a request outside the owner's window never reaches the machine.
+ if (offer.health === 'down' || offer.ready !== true || !withinHours(offer, clock.now())) continue;
+ for (const model of offer.models) {
+ const key = keyOf(offer, model);
+ const s = await stateOf(key);
+ if (s && s.status === 'failed') continue;
+ if ((!s || s.status === 'new') && !freeTier(ctx)) continue;
+ if (ctx.communityModel && String(ctx.communityModel) !== model) continue;
+ if (!(await audienceAllows(offer, ctx))) continue;
+ if (ctx.communityMaxPriceIn != null && offer.price.in_per_million > Number(ctx.communityMaxPriceIn)) continue;
+ if (ctx.communityMaxPriceOut != null && offer.price.out_per_million > Number(ctx.communityMaxPriceOut)) continue;
+ out.push({ offer, model, key, status: (s && s.status) || 'new', reputation: s ? Number(s.reputation) || 0 : 0 });
+ }
+ }
+ const objective = ((route || ctx.route) && (route || ctx.route).constraints && (route || ctx.route).constraints.objective) || 'balanced';
+ out.sort((a, b) => {
+ if (objective === 'lowest-latency') return b.reputation - a.reputation;
+ const pa = a.offer.price.in_per_million + a.offer.price.out_per_million;
+ const pb = b.offer.price.in_per_million + b.offer.price.out_per_million;
+ return pa - pb || b.reputation - a.reputation;
+ });
+ return out;
+ }
+
+ async function choose(args) {
+ const list = await eligible(args);
+ return list.length ? list[0] : null;
+ }
+
+ /** About 1%: the same prompt on a trusted machine with the same model, compared. No trusted peer, no spot. */
+ async function spotCheck(chosen, messages, answer) {
+ if (!(cfg.spotRate > 0) || random() >= cfg.spotRate) return;
+ const peers = (await offers()).filter((o) => o.offer_id !== chosen.offer.offer_id && o.health === 'up' && o.ready && o.models.includes(chosen.model));
+ const trusted = [];
+ for (const peer of peers) {
+ const s = await stateOf(keyOf(peer, chosen.model));
+ if (s && s.status === 'verified') trusted.push(peer);
+ }
+ if (!trusted.length) return;
+ const peer = trusted[0];
+ let again;
+ try { again = await dispatch(peer, chosen.model, messages, { maxTokens: 512, temperature: 0, timeoutMs: cfg.canaryTimeoutMs || 30000, requestId: `spot-${chosen.key}-${clock.now()}` }); }
+ catch { return; }
+ const at = nowIso(clock);
+ if (!similar(answer.text, again.text)) {
+ await db.prepare(`UPDATE community_offers SET spot_runs = spot_runs + 1, spot_failures = spot_failures + 1, last_spot_at = ?, status = 'failed', reputation = 0,
+ failure_reason = ?, updated_at = ? WHERE offer_key = ?`).run(at, `spot_mismatch against ${peer.machine_id}`, at, chosen.key);
+ await refreshReputation(chosen.key);
+ throw new AiError(502, 'community.verification_failed', `community machine ${chosen.offer.machine_id} disagreed with a trusted machine on the spot re-run`);
+ }
+ await db.prepare(`UPDATE community_offers SET spot_runs = spot_runs + 1, last_spot_at = ?, updated_at = ? WHERE offer_key = ?`).run(at, at, chosen.key);
+ await refreshReputation(chosen.key);
+ }
+
+ /**
+ * The seller ledger for one served request. Credits are recorded in integer micro-credits (1 credit = 1,000,000).
+ * - a run whose requester is the offer's owner, or an account merged into it, is self-served: 0 credits, labelled;
+ * - a new/free-tier machine's tokens are `free`;
+ * - verified tokens are `pending` until the requester's own Balance was charged in the same request (then
+ * `spendable`, with the charge id); until requester charging exists they never become spendable;
+ * - until the machine has `trustDays` of verified service a seller earns at most the daily cap;
+ * - a volunteer (price 0) earns kudos instead, never cash.
+ */
+ async function recordUsage(chosen, { requestId, usage, verified, requester = null, latencyMs = null, chargeId = null }) {
+ const tin = Math.max(0, Math.trunc(Number(usage && usage.input) || 0));
+ const tout = Math.max(0, Math.trunc(Number(usage && usage.output) || 0));
+ const owner = await survivor(chosen.offer.owner);
+ const person = requester ? await survivor(requester) : null;
+ const selfServed = Boolean(person && owner && person === owner);
+ let credits = verified && !selfServed ? Math.round(tin * chosen.offer.price.in_per_million + tout * chosen.offer.price.out_per_million) : 0;
+ let earning = !verified ? 'free' : selfServed ? 'self' : (chargeId ? 'spendable' : 'pending');
+ let capped = false;
+ const state = await stateOf(chosen.key);
+ if (credits > 0 && state && state.first_verified_at) {
+ const first = Date.parse(state.first_verified_at);
+ const trustEnds = first + (cfg.trustDays || 7) * 86400000;
+ if (Number.isFinite(first) && clock.now() < trustEnds) {
+ const dayStart = new Date(clock.now());
+ dayStart.setUTCHours(0, 0, 0, 0);
+ const row = await db.prepare(`SELECT COALESCE(SUM(credits_micro), 0)::bigint AS earned FROM community_usage
+ WHERE owner_subject = ? AND created_at >= ? AND earning_status IN ('pending', 'spendable', 'capped')`)
+ .get(owner, dayStart.toISOString());
+ const remaining = Math.max(0, Number(cfg.dailyEarningCapMicro == null ? 100000000 : cfg.dailyEarningCapMicro) - Number(row.earned));
+ if (credits > remaining) { credits = remaining; capped = true; earning = 'capped'; }
+ }
+ }
+ const request = String(requestId || `usage-${clock.now()}`);
+ const id = `cu_${sha256(`${chosen.key}|${request}`).slice(0, 26)}`;
+ let kudos = 0;
+ try {
+ await db.tx(async () => {
+ await db.prepare(`INSERT INTO community_usage (id, offer_key, offer_id, machine_id, owner_subject, model, request_id, requester_subject,
+ tokens_in, tokens_out, latency_ms, credits_micro, verified, self_served, earning_status, charge_id, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (offer_key, request_id) DO NOTHING`)
+ .run(id, chosen.key, chosen.offer.offer_id, chosen.offer.machine_id, owner, chosen.model, request, person,
+ tin, tout, latencyMs == null ? null : Math.max(0, Math.round(latencyMs)), credits, Boolean(verified), selfServed, earning,
+ chargeId || null, nowIso(clock));
+ if (verified && !selfServed && chosen.offer.price.in_per_million === 0 && chosen.offer.price.out_per_million === 0 && tin + tout > 0) {
+ kudos = Math.max(1, Math.floor((tin + tout) / 1000)) * (cfg.kudosPerThousandTokens || 1);
+ const balance = await db.prepare(`SELECT COALESCE(SUM(delta), 0) AS balance FROM community_kudos_ledger WHERE owner_subject = ?`).get(owner);
+ const after = Number(balance.balance) + kudos;
+ const kid = `ku_${sha256(`${chosen.key}|${request}|kudos`).slice(0, 26)}`;
+ await db.prepare(`INSERT INTO community_kudos_ledger (id, owner_subject, delta, reason, offer_key, offer_id, request_id, balance_after, created_at)
+ VALUES (?, ?, ?, 'served', ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`)
+ .run(kid, owner, kudos, chosen.key, chosen.offer.offer_id, request, after, nowIso(clock));
+ }
+ });
+ } catch (err) { log.warn(`[community] usage ${chosen.key}: ${err.message}`); }
+ return { tokens_in: tin, tokens_out: tout, credits_micro: credits, earning_status: earning, self_served: selfServed, capped, kudos };
+ }
+
+ /**
+ * One opted-in answer from a community machine: canaries first (unless recently verified), then the request, then
+ * the spot check, then the seller's ledger. → the provider-pool result, or null when no offer is eligible.
+ */
+ async function execute({ route = null, operation = 'chat', req = {}, ctx = {} } = {}) {
+ const chosen = await choose({ ctx, operation, route });
+ if (!chosen) return null;
+ await ensure(chosen.offer, chosen.model);
+ // The canary round may just have verified it: this first answer already earns and is no longer free-tier only.
+ const fresh = await stateOf(chosen.key);
+ if (fresh) { chosen.status = fresh.status; chosen.reputation = Number(fresh.reputation) || 0; }
+ const messages = messagesOf(req, operation);
+ if (!messages.length) return null;
+ const tools = operation === 'complete' ? toolsOf(req) : null;
+ const timeoutMs = Math.max(5000, Math.min(Number(req.timeoutMs) || 60000, 300000));
+ const requestId = String(ctx.runId || `community-${clock.now()}`);
+ const requester = requesterOf(ctx);
+ const selfServed = Boolean(requester && (await survivor(requester)) === (await survivor(chosen.offer.owner)));
+ const facts = {
+ resolved_model: `community/${chosen.model}`, revision: null, deployment: `community:${chosen.offer.machine_id}`,
+ provider_access: 'COMMUNITY', rate_version: 'community', policy: 'community',
+ community: { machine_id: chosen.offer.machine_id, owner: chosen.offer.owner, model: chosen.model, price: chosen.offer.price,
+ verification: chosen.status, reputation: chosen.reputation, free_tier: freeTier(ctx), self_served: selfServed },
+ };
+ if (typeof ctx.onRoute === 'function') {
+ const routed = await ctx.onRoute(facts, { model: chosen.model, maxTokens: req.maxTokens, paid: false, retail: null });
+ if (routed && routed.skip) return null;
+ }
+ const t0 = clock.now();
+ let answer;
+ try {
+ answer = await dispatch(chosen.offer, chosen.model, messages, {
+ maxTokens: Math.max(1, Math.min(Number(req.maxTokens) || 1024, 32768)),
+ temperature: req.temperature == null ? null : Number(req.temperature),
+ tools, timeoutMs, requestId,
+ });
+ } catch (err) {
+ if (err && (err.code === 'community.busy' || err.code === 'community.not_open')) return null;
+ throw err;
+ }
+ const usage = answer.usage || { input: 0, output: 0, cached: 0 };
+ await spotCheck(chosen, messages, answer);
+ const latencyMs = clock.now() - t0;
+ const recorded = await recordUsage(chosen, { requestId, usage, verified: chosen.status !== 'new', requester, latencyMs,
+ chargeId: ctx.requesterChargeId || null });
+ const text = answer.text || '';
+ const message = answer.message || { role: 'assistant', content: text };
+ const result = operation === 'complete'
+ ? { completion: { choices: [{ index: 0, message, finish_reason: answer.finish || (message.tool_calls ? 'tool_calls' : 'stop') }], system_fingerprint: null }, text, json: null, model: answer.model || chosen.model, usage }
+ : operation === 'messages'
+ ? { message: { role: 'assistant', content: [{ type: 'text', text }], model: answer.model || chosen.model, usage }, model: answer.model || chosen.model, usage }
+ : { text, json: null, model: answer.model || chosen.model, usage };
+ const community = { ...facts.community, verification: chosen.status, reputation: chosen.reputation,
+ credits_micro: recorded.credits_micro, earning_status: recorded.earning_status, self_served: recorded.self_served, kudos: recorded.kudos };
+ const explain = {
+ objective: 'community', reasons: [recorded.self_served
+ ? 'self-served: the requester owns this machine, so the answer earns no credits'
+ : `a community machine offered ${chosen.model}${chosen.offer.price.in_per_million || chosen.offer.price.out_per_million ? ` at ${chosen.offer.price.in_per_million}/${chosen.offer.price.out_per_million} credits per million` : ' for free'}`],
+ selected: `community:${chosen.offer.machine_id}/${chosen.model}`, paid_by: 'community', candidates: [],
+ attempts: [{ key: 'community', provider: `community:${chosen.offer.machine_id}`, model: chosen.model, why: 'an opted-in request placed on a verified community machine', status: 'ok', error: null }],
+ community, openvibe: facts,
+ };
+ return {
+ result, provider: `community:${chosen.offer.machine_id}`, providerKind: 'community', model: answer.model || chosen.model,
+ fallbackUsed: false, usage, cost: 0, latencyMs, ttftMs: null, synthetic: false, tried: [], startedAt: t0, explain,
+ };
+ }
+
+ /** A volunteer's kudos and what they buy: queue priority now, a bigger free limit, never cash or expiry. */
+ async function kudosBenefits(subject) {
+ const id = String(subject || '').replace(/^user:/, '');
+ if (!USR.test(id)) return { balance: 0, queue_priority: 0, free_allowance_bonus_tokens: 0 };
+ const row = await db.prepare('SELECT COALESCE(SUM(delta), 0) AS balance FROM community_kudos_ledger WHERE owner_subject = ?').get(id);
+ const balance = Math.max(0, Number(row.balance) || 0);
+ return { balance, queue_priority: Math.min(10, Math.floor(Math.sqrt(balance))), free_allowance_bonus_tokens: Math.min(1000000, balance * 10) };
+ }
+
+ /** The free-limit bonus for a subject (the free allowance asks for this when it claims a card's free share). */
+ async function kudosBonus(subject) {
+ return (await kudosBenefits(subject)).free_allowance_bonus_tokens;
+ }
+
+ /** Queue priority a run's person bought with kudos; 0 for a service/app or someone with none. */
+ async function queuePriority(subject) {
+ return (await kudosBenefits(subject)).queue_priority;
+ }
+
+ /** The /sell dashboard: this owner's offers, what they served, what they earned (credits, never cash). */
+ async function sell(owner) {
+ if (!USR.test(String(owner))) throw new AiError(422, 'input.invalid', 'owner must be a usr_ subject');
+ const list = (await offers({ force: true })).filter((o) => o.owner === owner);
+ const rows = await db.prepare(`SELECT offer_key, offer_id, machine_id, models, price_in, price_out, status, canary_passes, canary_failures, spot_runs, spot_failures,
+ reputation, first_verified_at, last_canary_at, last_spot_at, failure_reason, checks, up_checks
+ FROM community_offers WHERE owner_subject = ? ORDER BY updated_at DESC`).all(owner);
+ const byKey = new Map(rows.map((r) => [r.offer_key, r]));
+ // The dashboard keeps answering from the ledger when Network is unreachable: an offer with state but no live
+ // doc is shown from its last recorded settings, marked unknown health.
+ const seen = new Set(list.map((o) => o.offer_id));
+ for (const r of rows) {
+ if (seen.has(r.offer_id)) continue;
+ seen.add(r.offer_id);
+ const models = Array.isArray(r.models) ? r.models : (() => { try { return JSON.parse(r.models); } catch { return []; } })();
+ list.push({ offer_id: r.offer_id, machine_id: r.machine_id, models, hours: null, concurrency: null,
+ price: { in_per_million: Number(r.price_in), out_per_million: Number(r.price_out) }, audience: null, group: null, health: 'unknown', ready: false });
+ }
+ const usage = await db.prepare(`SELECT offer_key, model, COUNT(*)::int AS requests,
+ COALESCE(SUM(tokens_in),0)::bigint AS tokens_in, COALESCE(SUM(tokens_out),0)::bigint AS tokens_out,
+ COALESCE(SUM(latency_ms),0)::bigint AS latency_ms,
+ COALESCE(SUM(credits_micro) FILTER (WHERE earning_status IN ('pending','capped')),0)::bigint AS pending_micro,
+ COALESCE(SUM(credits_micro) FILTER (WHERE earning_status = 'spendable'),0)::bigint AS spendable_micro,
+ COUNT(*) FILTER (WHERE self_served)::int AS self_requests
+ FROM community_usage WHERE owner_subject = ? GROUP BY offer_key, model ORDER BY offer_key`).all(owner);
+ const totals = { requests: 0, tokens_in: 0, tokens_out: 0, pending_credits_micro: 0, spendable_credits_micro: 0, self_served_requests: 0 };
+ const served = usage.map((u) => {
+ const tokensIn = Number(u.tokens_in), tokensOut = Number(u.tokens_out);
+ const pending = Number(u.pending_micro), spendable = Number(u.spendable_micro);
+ const latency = Number(u.latency_ms);
+ totals.requests += Number(u.requests); totals.tokens_in += tokensIn; totals.tokens_out += tokensOut;
+ totals.pending_credits_micro += pending; totals.spendable_credits_micro += spendable; totals.self_served_requests += Number(u.self_requests);
+ return { offer_key: u.offer_key, model: u.model, requests: Number(u.requests), tokens_in: tokensIn, tokens_out: tokensOut,
+ tokens_per_s: latency > 0 ? Math.round((tokensOut / (latency / 1000)) * 10) / 10 : null,
+ pending_credits_micro: pending, spendable_credits_micro: spendable, self_served_requests: Number(u.self_requests) };
+ });
+ const dayStart = new Date(clock.now()); dayStart.setUTCHours(0, 0, 0, 0);
+ const kudosRow = await db.prepare('SELECT COALESCE(SUM(delta),0) AS balance FROM community_kudos_ledger WHERE owner_subject = ?').get(owner);
+ const kudos = Math.max(0, Number(kudosRow.balance) || 0);
+ const offerViews = await Promise.all(list.map(async (o) => {
+ const openNow = o.hours ? withinHours(o, clock.now()) : null;
+ const out = { offer_id: o.offer_id, machine_id: o.machine_id, models: o.models, hours: o.hours, concurrency: o.concurrency,
+ price: o.price, audience: o.audience, group: o.group, health: o.health, open_now: openNow, ready: o.ready && openNow !== false,
+ verification: null, reputation: 0, free_only: true, verified: false,
+ tokens_per_s: Math.max(0, ...served.filter((x) => x.offer_key.startsWith(`${o.offer_id}|`)).map((x) => x.tokens_per_s || 0)) || null };
+ const verified = o.models.map((m) => byKey.get(`${o.offer_id}|${m}`)).filter(Boolean);
+ if (verified.length) {
+ const s = verified.find((x) => x.status === 'verified') || verified[0];
+ const canaries = verified.reduce((n, x) => n + Number(x.canary_passes), 0);
+ const failures = verified.reduce((n, x) => n + Number(x.canary_failures), 0);
+ const spots = verified.reduce((n, x) => n + Number(x.spot_runs), 0);
+ const spotFails = verified.reduce((n, x) => n + Number(x.spot_failures), 0);
+ const checks = verified.reduce((n, x) => n + Number(x.checks), 0);
+ const up = verified.reduce((n, x) => n + Number(x.up_checks), 0);
+ const attempts = canaries + failures + spots + spotFails;
+ out.verification = { status: s.status, canary_passes: canaries, canary_failures: failures, spot_runs: spots, spot_failures: spotFails,
+ failure_rate: attempts ? Math.round((failures + spotFails) / attempts * 1000) / 10 : 0,
+ last_canary_at: s.last_canary_at, last_spot_at: s.last_spot_at, first_verified_at: s.first_verified_at, failure_reason: s.failure_reason };
+ out.reputation = Number(s.reputation) || 0;
+ out.free_only = s.status !== 'verified';
+ out.verified = s.status === 'verified';
+ out.uptime_pct = checks ? Math.round((up / checks) * 1000) / 10 : null;
+ // The daily cap holds until the machine has trustDays of verified service.
+ const first = s.first_verified_at ? Date.parse(s.first_verified_at) : NaN;
+ if (Number.isFinite(first) && clock.now() < first + (cfg.trustDays || 7) * 86400000) {
+ const today = await db.prepare(`SELECT COALESCE(SUM(credits_micro),0)::bigint AS earned FROM community_usage
+ WHERE owner_subject = ? AND created_at >= ? AND earning_status IN ('pending','spendable','capped')`).get(owner, dayStart.toISOString());
+ out.daily_cap = { credits_micro: cfg.dailyEarningCapMicro == null ? 100000000 : cfg.dailyEarningCapMicro,
+ earned_today_micro: Number(today.earned), trust_ends_at: new Date(first + (cfg.trustDays || 7) * 86400000).toISOString() };
+ }
+ }
+ return out;
+ }));
+ const paid = (x) => (x.price.in_per_million || x.price.out_per_million) > 0;
+ return {
+ owner, generated_at: nowIso(clock), offers: offerViews, served,
+ totals: { ...totals, pending_credits: totals.pending_credits_micro / 1e6, spendable_credits: totals.spendable_credits_micro / 1e6 },
+ kudos: { balance: kudos, never_expires: true, never_cash: true,
+ benefits: { queue_priority: Math.min(10, Math.floor(Math.sqrt(kudos))), free_allowance_bonus_tokens: Math.min(1000000, kudos * 10) } },
+ seller_card: { tokens_per_s: Math.max(0, ...served.map((x) => x.tokens_per_s || 0)) || null,
+ uptime_pct: offerViews.find((o) => o.uptime_pct != null)?.uptime_pct ?? null,
+ verified: offerViews.some((o) => o.verified),
+ volunteer: offerViews.some((o) => !paid(o)) },
+ note: 'Credits are pending, not spendable, until the requester was charged in the same request; credits earned by the offer owner are self-served and 0. Cash payouts come when the owner turns money on; nothing here moves money. Kudos for volunteers never expire and are never cash: they buy queue priority and a bigger free limit.',
+ };
+ }
+
+
+ /** What Exchange may show about community capacity: no owner subject, no machine id, no prompt. */
+ async function publicOffers() {
+ const list = await offers();
+ return list.map((o) => {
+ const openNow = withinHours(o, clock.now());
+ return {
+ // The public id is a stable hash: Exchange can list and link an offer without publishing which machine it is.
+ offer_id: `community_${sha256(o.offer_id).slice(0, 16)}`, models: o.models, hours: o.hours, concurrency: o.concurrency,
+ price: o.price, audience: o.audience, health: o.health, open_now: openNow, ready: o.ready && openNow,
+ };
+ });
+ }
+
+ return { enabled, offers, choose, execute, verify, sell, publicOffers, kudosBenefits, kudosBonus, queuePriority, _test: { normalize, withinHours, refreshReputation, fingerprintMatches, similar, messagesOf, freeTier } };
+}
+
+module.exports = { createCommunity, CANARY_PROMPTS, withinHours, fingerprintMatches, similar, messagesOf };
@@ -338,6 +338,24 @@ function load(env = process.env) {
@@ -338,6 +338,24 @@ function load(env = process.env) {
// it); OV_BOT_INTERNAL_URL= (empty) turns it off.
// it); OV_BOT_INTERNAL_URL= (empty) turns it off.
bot: { url: trimUrl(env.OV_BOT_INTERNAL_URL != null ? env.OV_BOT_INTERNAL_URL : 'http://127.0.0.1:4630') },
bot: { url: trimUrl(env.OV_BOT_INTERNAL_URL != null ? env.OV_BOT_INTERNAL_URL : 'http://127.0.0.1:4630') },
+ // ── Community AI offers (the design's "Offer to the network"; plan E4): requests that opt in to community
+ // capacity may run on a person's Node, only after its canaries and model fingerprint pass. AI reads the offers
+ // from Network's resource registry (network.registry.read), dispatches through Bot (bot.machine.ai), and keeps
+ // the verification and earnings ledger. On by default: without a Network service secret the list is empty.
+ community: {
+ enabled: bool(env.AI_COMMUNITY, true),
+ refreshMs: Math.max(1000, int(env.AI_COMMUNITY_REFRESH_MS, 30000)),
+ canaryMaxAgeMs: Math.max(60000, int(env.AI_COMMUNITY_CANARY_MAX_AGE_MS, 6 * 60 * 60 * 1000)),
+ canaryTimeoutMs: Math.max(2000, Math.min(120000, int(env.AI_COMMUNITY_CANARY_TIMEOUT_MS, 30000))),
+ // About 1% of community answers are re-run on a trusted machine with the same model; a mismatch stops traffic.
+ spotRate: Math.max(0, Math.min(1, float(env.AI_COMMUNITY_SPOT_RATE, 0.01))),
+ // Until a machine has this many days of verified service, a seller earns at most the daily cap. Credits are
+ // pending (not spendable) until the requester's own Balance charge exists; kudos are the volunteer reward.
+ trustDays: Math.max(1, Math.min(365, int(env.AI_COMMUNITY_TRUST_DAYS, 7))),
+ dailyEarningCapMicro: Math.max(0, Math.min(1e15, int(env.AI_COMMUNITY_DAILY_CAP_CREDITS, 100) * 1e6)),
+ kudosPerThousandTokens: Math.max(1, Math.min(1000, int(env.AI_COMMUNITY_KUDOS_PER_1K, 1))),
+ },
+
// ── Tier budgets (T6 step 8, server/govern.js): openvibe-sdk/govern in Valkey, off unless AI_GOVERN_TIERS=1.
// ── Tier budgets (T6 step 8, server/govern.js): openvibe-sdk/govern in Valkey, off unless AI_GOVERN_TIERS=1.
govern: loadGovern(env, { bool, int, num: float }),
govern: loadGovern(env, { bool, int, num: float }),
};
};
@@ -46,7 +46,7 @@ function periodFor(card, now) {
@@ -46,7 +46,7 @@ function periodFor(card, now) {
return { start: Number.isFinite(from) ? from : 0, end: Number.isFinite(until) ? until : NEVER };
return { start: Number.isFinite(from) ? from : 0, end: Number.isFinite(until) ? until : NEVER };
}
}
-function createFreeAllowance(db, { cardsFor, clock = { now: () => Date.now() }, store = null }) {
+function createFreeAllowance(db, { cardsFor, clock = { now: () => Date.now() }, store = null, bonusFor = null }) {
const open = db.prepare(`INSERT INTO free_allowance_usage (subject, provider, metric, period_start, period_end, free_used, updated_at)
const open = db.prepare(`INSERT INTO free_allowance_usage (subject, provider, metric, period_start, period_end, free_used, updated_at)
VALUES (?, ?, ?, ?, ?, 0, ?) ON CONFLICT (subject, provider, metric, period_start) DO NOTHING`);
VALUES (?, ?, ?, ?, ?, 0, ?) ON CONFLICT (subject, provider, metric, period_start) DO NOTHING`);
const lock = db.prepare('SELECT free_used FROM free_allowance_usage WHERE subject = ? AND provider = ? AND metric = ? AND period_start = ? FOR UPDATE');
const lock = db.prepare('SELECT free_used FROM free_allowance_usage WHERE subject = ? AND provider = ? AND metric = ? AND period_start = ? FOR UPDATE');
@@ -83,7 +83,9 @@ function createFreeAllowance(db, { cardsFor, clock = { now: () => Date.now() },
@@ -83,7 +83,9 @@ function createFreeAllowance(db, { cardsFor, clock = { now: () => Date.now() },
await db.tx(async () => {
await db.tx(async () => {
for (const kind of KINDS) { // one fixed order, so two claims lock rows in the same order
for (const kind of KINDS) { // one fixed order, so two claims lock rows in the same order
const c = cards[kind];
const c = cards[kind];
- const cap = Math.floor(Number(c && c.free_allowance) || 0);
+ // A volunteer's kudos buy a bigger output allowance; the bonus is one per claim, never a cash value.
+ const bonus = kind === 'out' && bonusFor ? Math.max(0, Math.floor(Number(await bonusFor(subject)) || 0)) : 0;
+ const cap = Math.floor(Number(c && c.free_allowance) || 0) + bonus;
const q = Math.max(0, Math.floor(Number(usage[kind]) || 0));
const q = Math.max(0, Math.floor(Number(usage[kind]) || 0));
const k = byKind[kind];
const k = byKind[kind];
if (c) k.cost = (q / c.unit_size) * c.unit_price_usd; // the whole quantity's list price, free or not
if (c) k.cost = (q / c.unit_size) * c.unit_price_usd; // the whole quantity's list price, free or not
@@ -32,7 +32,7 @@ const { AiError } = require('./util');
@@ -32,7 +32,7 @@ const { AiError } = require('./util');
const { gracefulStop, telemetry } = require('openvibe-sdk/service');
const { gracefulStop, telemetry } = require('openvibe-sdk/service');
const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk/telemetry');
const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk/telemetry');
-async function start({ config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null, machineModels = null, variantEvidence = null } = {}) {
+async function start({ config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null, machineModels = null, variantEvidence = null, community: givenCommunity = null } = {}) {
config = config || load(env);
config = config || load(env);
schemas.configure(config.schemaCache);
schemas.configure(config.schemaCache);
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.
@@ -56,14 +56,20 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -56,14 +56,20 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
const vault = require('./vault').createVault({ settings, clock, log });
const vault = require('./vault').createVault({ settings, clock, log });
await require('./vault').migrate({ db, config, settings, log }).catch((err) => log.warn(`[Vault] migration failed: ${err.message}`));
await require('./vault').migrate({ db, config, settings, log }).catch((err) => log.warn(`[Vault] migration failed: ${err.message}`));
await vault.load().catch((err) => log.warn(`[Vault] ${err.message}`));
await vault.load().catch((err) => log.warn(`[Vault] ${err.message}`));
+ // A person's own machine as their model (server/machines.js), shared with community offers: both reach a machine
+ // only through OpenVibe.Bot's bot.machine.ai.
+ const machines = machineModels || require('./machines').createMachineModels({ config, env, fetchImpl });
+ // Community AI offers (the design's "Offer to the network"; plan E4): read Network's user-owned offers, verify a
+ // machine before traffic, and keep its seller ledger. Empty without a Network service secret or Bot.
+ const community = givenCommunity || require('./community').createCommunity({ db, config, env, machines, clock, fetchImpl, log });
// BENCH-11: Bench's ratings as routing evidence, and the opted-in tie rule inside placement.
// BENCH-11: Bench's ratings as routing evidence, and the opted-in tie rule inside placement.
const bench = createBenchEvidence({ db, registry, config, clock, fetchImpl, log });
const bench = createBenchEvidence({ db, registry, config, clock, fetchImpl, log });
- const pool = createProviderPool({ db, registry, config, clock, fetchImpl, env, log, credentialFetch, machineModels, bench, variantEvidence });
+ const pool = createProviderPool({ db, registry, config, clock, fetchImpl, env, log, credentialFetch, machineModels: machines, community, bench, variantEvidence });
// T6 step 8: the SaaS tier budgets on openvibe-sdk/govern in Valkey (server/govern.js); off unless AI_GOVERN_TIERS=1.
// T6 step 8: the SaaS tier budgets on openvibe-sdk/govern in Valkey (server/govern.js); off unless AI_GOVERN_TIERS=1.
const tiers = createTiers({ config, valkey, clock, log });
const tiers = createTiers({ config, valkey, clock, log });
// §2.1.8: the provider free allowance per subject, priced off the pool's own rate cards (server/free-allowance.js);
// §2.1.8: the provider free allowance per subject, priced off the pool's own rate cards (server/free-allowance.js);
// with the tiers on, its counter is in their Valkey store.
// with the tiers on, its counter is in their Valkey store.
- const freeAllowance = createFreeAllowance(db, { cardsFor: pool.rateCardsFor, clock, store: tiers.freeStore });
+ const freeAllowance = createFreeAllowance(db, { cardsFor: pool.rateCardsFor, clock, store: tiers.freeStore, bonusFor: (subject) => community.kudosBonus(subject) });
const quotas = createQuotas(db, { clock, registry, freeAllowance, tiers });
const quotas = createQuotas(db, { clock, registry, freeAllowance, tiers });
// The budget a Codes run token carries (Codes C3), and what that run has spent.
// The budget a Codes run token carries (Codes C3), and what that run has spent.
const runBudgets = createRunBudgets(db, { clock });
const runBudgets = createRunBudgets(db, { clock });
@@ -103,7 +109,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -103,7 +109,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
startSettlements() { return credits ? credits.startSettlements() : null; },
startSettlements() { return credits ? credits.startSettlements() : null; },
stopSettlements() { return credits ? credits.stopSettlements() : undefined; },
stopSettlements() { return credits ? credits.stopSettlements() : undefined; },
};
};
- const runs = createRuns({ db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, preferences, tiers, projectSecrets, pool, runBudgets, credits: creditsRef });
+ const runs = createRuns({ db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, preferences, tiers, projectSecrets, pool, runBudgets, credits: creditsRef, community });
await seed({ registry, quotas, config, env, db });
await seed({ registry, quotas, config, env, db });
// The seed creates or moves providers from the environment: publish their origins (the schema and every key's
// The seed creates or moves providers from the environment: publish their origins (the schema and every key's
// bind follow), so a key is bound to the host it is for from the first boot.
// bind follow), so a key is bound to the host it is for from the first boot.
@@ -129,7 +135,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -129,7 +135,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,
clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,
}) : null;
}) : null;
const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });
const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });
- const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory });
+ const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, community });
let jwks = null;
let jwks = null;
let keyLoaded = Promise.resolve(null);
let keyLoaded = Promise.resolve(null);
if (!keys.pinned && config.nodeEnv !== 'test') {
if (!keys.pinned && config.nodeEnv !== 'test') {
@@ -201,7 +207,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -201,7 +207,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
if (!givenDb) await db.close();
if (!givenDb) await db.close();
}
}
- return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, bench, benchEvents, memory, memoryJobs, credits: creditsRef, settings, vault, close };
+ return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, bench, benchEvents, memory, memoryJobs, credits: creditsRef, settings, vault, community, close };
}
}
if (require.main === module) {
if (require.main === module) {
@@ -99,8 +99,12 @@ function createMachineModels({ config, env = process.env, fetchImpl = globalThis
@@ -99,8 +99,12 @@ function createMachineModels({ config, env = process.env, fetchImpl = globalThis
throw new AiError(503, 'machine.none', `none of the person's own machines answered (${tried.map((x) => `${x.machine}: ${x.error}`).join('; ').slice(0, 400)})`);
throw new AiError(503, 'machine.none', `none of the person's own machines answered (${tried.map((x) => `${x.machine}: ${x.error}`).join('; ').slice(0, 400)})`);
}
}
- async function once(machine, { subject, model, messages, maxTokens = null, temperature = null, signal = null, timeoutMs = 120000, tools = null, toolChoice = null }) {
- const value = { model, messages, ...(maxTokens ? { max_tokens: Math.min(32768, Math.max(1, Math.round(maxTokens))) } : {}), ...(temperature != null ? { temperature: Math.min(2, Math.max(0, Number(temperature))) } : {}),
+ async function once(machine, { subject, model, messages, maxTokens = null, temperature = null, signal = null, timeoutMs = 120000, tools = null, toolChoice = null, community = false }) {
+ // The ack wire has no extra field for "this is the machine's offer to the network" (bot.command@1's value is
+ // closed), so the model carries the community/ prefix; the Node ai plugin strips it before its model server
+ // sees it and enforces the offer. The prefix never reaches the local model.
+ const asked = community && !String(model).startsWith('community/') ? `community/${model}` : model;
+ const value = { model: asked, messages, ...(maxTokens ? { max_tokens: Math.min(32768, Math.max(1, Math.round(maxTokens))) } : {}), ...(temperature != null ? { temperature: Math.min(2, Math.max(0, Number(temperature))) } : {}),
...(Array.isArray(tools) && tools.length ? { tools, ...(toolChoice === 'none' || toolChoice === 'auto' ? { tool_choice: toolChoice } : {}) } : {}) };
...(Array.isArray(tools) && tools.length ? { tools, ...(toolChoice === 'none' || toolChoice === 'auto' ? { tool_choice: toolChoice } : {}) } : {}) };
const token = await tokenClient().getToken({ audience: 'openvibe.bot' });
const token = await tokenClient().getToken({ audience: 'openvibe.bot' });
let res;
let res;
@@ -354,6 +354,19 @@ function openapi() {
@@ -354,6 +354,19 @@ function openapi() {
};
};
}
}
+// ── Community AI offers (the design's "Offer to the network") ───────────────────────────────
+route('get', '/community/offers', {
+ operationId: 'communityOffers', summary: 'Live community offers for Exchange', tags: ['community'],
+ description: 'No token. Models, price in credits per million, hours, concurrency, audience, health and open_now; never an owner subject or a machine id (offer_id is a stable hash).',
+ responses: { 200: json('The live community offers'), 503: problem },
+});
+route('get', '/community/sell', {
+ operationId: 'communitySell', summary: 'The seller dashboard for Exchange', tags: ['community'], security: SERVICE,
+ description: 'ai.run.read or ai.usage.read, acting for the owner in X-OV-Subject. Tokens served, credits recorded (1 credit = 1,000,000 micro), uptime, verification (canaries, spot re-runs) and reputation; nothing here moves money.',
+ parameters: [{ name: 'X-OV-Subject', in: 'header', required: true, schema: str({ pattern: '^usr_[0-9A-HJKMNP-TV-Z]{26}$' }), description: 'The owner whose offers and ledger these are' }],
+ responses: { 200: json('The owner\'s offers and totals'), 422: problem, 503: problem, ...errors([401, 403]) },
+});
+
/** The documented routes as "METHOD /api/v1/path": what the app must mount. */
/** The documented routes as "METHOD /api/v1/path": what the app must mount. */
function routes() {
function routes() {
return Object.entries(PATHS).flatMap(([p, item]) => Object.keys(item).map((method) => `${method.toUpperCase()} ${MOUNT}${p}`)).sort();
return Object.entries(PATHS).flatMap(([p, item]) => Object.keys(item).map((method) => `${method.toUpperCase()} ${MOUNT}${p}`)).sort();
@@ -134,7 +134,7 @@ function selfModelFor(cred, operation, req, ctx, prefs) {
@@ -134,7 +134,7 @@ function selfModelFor(cred, operation, req, ctx, prefs) {
return { model, kind, why };
return { model, kind, why };
}
}
-function createProviderPool({ db, registry, config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, credentialFetch = null, machineModels = null, bench = null, variantEvidence = null }) {
+function createProviderPool({ db, registry, config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, credentialFetch = null, machineModels = null, community = null, bench = null, variantEvidence = null }) {
// A person's own machine as their model (credential provider node, server/machines.js).
// A person's own machine as their model (credential provider node, server/machines.js).
const machines = machineModels || require('../machines').createMachineModels({ config, env, fetchImpl });
const machines = machineModels || require('../machines').createMachineModels({ config, env, fetchImpl });
const cache = new Map(); // key -> { stamp, adapter }
const cache = new Map(); // key -> { stamp, adapter }
@@ -883,6 +883,19 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -883,6 +883,19 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
}
}
}
}
+ // Community capacity (design: "Offer to the network"; plan E4): only a request that opted in may run on a
+ // person's machine, never an exact pin, never a BYOK run. The canaries run before the first answer; a failure
+ // stops that machine and the ordinary network answers instead. The answer is labelled in explain.openvibe.
+ if (community && ctx.communityOptIn && !ctx.credential && !ctx.byok && !ctx.exact) {
+ try {
+ const out = await community.execute({ route, operation, req, ctx });
+ if (out) return out;
+ } catch (err) {
+ const why = String((err && (err.detail || err.message)) || err).slice(0, 300);
+ tried.push({ provider: 'community', error: why });
+ log.warn(`[ai] community ${operation} failed, falling back to the network: ${why}`);
+ }
+ }
// CHECKPOINT D3: an exact id runs only its own artifact. The candidates are the catalog's deployments of
// CHECKPOINT D3: an exact id runs only its own artifact. The candidates are the catalog's deployments of
// that id, filtered first by commercial access and what the request needs; no other model is ever added.
// that id, filtered first by commercial access and what the request needs; no other model is ever added.
const exact = ctx.exact || null;
const exact = ctx.exact || null;
@@ -103,7 +103,7 @@ function routePinRequest(value, principal, wf) {
@@ -103,7 +103,7 @@ function routePinRequest(value, principal, wf) {
return { provider, model };
return { provider, model };
}
}
-function createRuns({ db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, preferences: personPreferences = null, tiers = null, projectSecrets = null, pool = null, runBudgets = null, credits = null }) {
+function createRuns({ db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, preferences: personPreferences = null, tiers = null, projectSecrets = null, pool = null, runBudgets = null, credits = null, community = null }) {
const inflight = new Map(); // run id -> { controller, promise, release, caller }
const inflight = new Map(); // run id -> { controller, promise, release, caller }
const queue = []; // run ids waiting for the worker
const queue = []; // run ids waiting for the worker
let active = 0;
let active = 0;
@@ -234,6 +234,9 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -234,6 +234,9 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
}
}
}
}
const options = body.options && typeof body.options === 'object' ? body.options : {};
const options = body.options && typeof body.options === 'object' ? body.options : {};
+ // Community capacity is opt-in per request: a person's machine sees the prompt, so only a caller that says so
+ // may be placed there. The OpenAI and Messages routes set body.community from x-openvibe-community.
+ const communityOptIn = body.community === true || options.community === true;
// A person's own key (WS-O task 2): only the service that stored it, within its daily budget; never cached.
// A person's own key (WS-O task 2): only the service that stored it, within its daily budget; never cached.
// A person's own key: the default one, or the provider the run names (Codes C3: one credential per provider).
// A person's own key: the default one, or the provider the run names (Codes C3: one credential per provider).
const credentialSubject = body.credential && typeof body.credential === 'object' ? String(body.credential.subject || '') : null;
const credentialSubject = body.credential && typeof body.credential === 'object' ? String(body.credential.subject || '') : null;
@@ -279,6 +282,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -279,6 +282,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
// A credential or a project's own provider never reaches managed supply: no Balance hold for that run.
// A credential or a project's own provider never reaches managed supply: no Balance hold for that run.
...(credentialSubject !== null || projectProvider ? { byok: true } : {}),
...(credentialSubject !== null || projectProvider ? { byok: true } : {}),
...(maxCostNano != null ? { maxCostNano: String(maxCostNano) } : {}),
...(maxCostNano != null ? { maxCostNano: String(maxCostNano) } : {}),
+ ...(communityOptIn ? { communityOptIn: true } : {}),
...(pinned ? { routePin: pinned } : {}),
...(pinned ? { routePin: pinned } : {}),
// POST /v1/chat/completions with stream: true: the upstream's deltas, as they arrive (kept in memory only).
// POST /v1/chat/completions with stream: true: the upstream's deltas, as they arrive (kept in memory only).
...(typeof onDelta === 'function' ? { onDelta } : {}),
...(typeof onDelta === 'function' ? { onDelta } : {}),
@@ -298,11 +302,11 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -298,11 +302,11 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
source_service: requester.type === 'service' ? requester.id : null,
source_service: requester.type === 'service' ? requester.id : null,
target: body.target ? JSON.stringify(body.target) : null, target_key: ctx.targetKey,
target: body.target ? JSON.stringify(body.target) : null, target_key: ctx.targetKey,
input: null, input_hash: inputHash, idempotency_key: idem, trace_id: trace, request_id: requestId, retry_of: retryOf,
input: null, input_hash: inputHash, idempotency_key: idem, trace_id: trace, request_id: requestId, retry_of: retryOf,
- options: JSON.stringify({ cache: options.cache !== false && credentialSubject === null && !projectProvider && !pinned && !selfKeys, debug: Boolean(options.debug), input_retained: kept.full,
+ options: JSON.stringify({ cache: options.cache !== false && credentialSubject === null && !projectProvider && !pinned && !selfKeys && !communityOptIn, debug: Boolean(options.debug), input_retained: kept.full,
...(credentialSubject !== null ? { credential_subject: credentialSubject } : {}), ...(credentialProvider !== null ? { credential_provider: credentialProvider } : {}),
...(credentialSubject !== null ? { credential_subject: credentialSubject } : {}), ...(credentialProvider !== null ? { credential_provider: credentialProvider } : {}),
...(selfKeys ? { self_subject: personSubject } : {}),
...(selfKeys ? { self_subject: personSubject } : {}),
...(projectProvider ? { project_provider: projectProvider } : {}), ...(pinned ? { pinned_offer: pinned } : {}),
...(projectProvider ? { project_provider: projectProvider } : {}), ...(pinned ? { pinned_offer: pinned } : {}),
- ...(prefer ? { prefer } : {}) }), created_at: now,
+ ...(communityOptIn ? { community: true } : {}), ...(prefer ? { prefer } : {}) }), created_at: now,
};
};
base.input = stableStringify(kept.value);
base.input = stableStringify(kept.value);
@@ -310,7 +314,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -310,7 +314,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
// A pinned run is never served from (or written to) the shared cache: the caller asked for THIS model,
// A pinned run is never served from (or written to) the shared cache: the caller asked for THIS model,
// and another model's answer under the same prompt would silently break the pin. A run that may use the
// and another model's answer under the same prompt would silently break the pin. A run that may use the
// person's own key is never cached either: their key must be used, and the answer is theirs, not the scope's.
// person's own key is never cached either: their key must be used, and the answer is theirs, not the scope's.
- const scope = options.cache === false || credentialSubject !== null || projectProvider || pinned || selfKeys ? null : cache.scopeFor(wf.cache_mode, ctx);
+ const scope = options.cache === false || credentialSubject !== null || projectProvider || pinned || selfKeys || communityOptIn ? null : cache.scopeFor(wf.cache_mode, ctx);
let cacheKey = null;
let cacheKey = null;
if (scope) {
if (scope) {
// Some workflows key the cache on less than their whole input (hooks.js CACHE_INPUT: a signed URL's signature).
// Some workflows key the cache on less than their whole input (hooks.js CACHE_INPUT: a signed URL's signature).
@@ -343,6 +347,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -343,6 +347,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
// Queue caps, then quota: both before any provider call (a refused run is not counted).
// Queue caps, then quota: both before any provider call (a refused run is not counted).
const caller = `${requester.type}:${requester.id}`;
const caller = `${requester.type}:${requester.id}`;
+ // Kudos bought with volunteer service start this run earlier in the queue (never cash, never a bypass of limits).
+ const priority = community && typeof community.queuePriority === 'function' ? await community.queuePriority(ctx.onBehalfOf).catch(() => 0) : 0;
admit(caller);
admit(caller);
// A run with a person's own key spends their money, not the shared quotas (its own budget was checked above).
// A run with a person's own key spends their money, not the shared quotas (its own budget was checked above).
// The tier budgets (server/govern.js, AI_GOVERN_TIERS) hold first, so a run they refuse is not counted by reserve().
// The tier budgets (server/govern.js, AI_GOVERN_TIERS) hold first, so a run they refuse is not counted by reserve().
@@ -379,7 +385,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -379,7 +385,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
const gate = new Promise((r) => { release = r; });
const gate = new Promise((r) => { release = r; });
// Whatever the run's end, a tier hold it did not settle (accounted) goes back.
// Whatever the run's end, a tier hold it did not settle (accounted) goes back.
const promise = gate.then(async () => { try { await execute(id, { controller, reserved, scope, ctx, input, hold }); } finally { if (hold) await tiers.release(hold); } }).catch((err) => { log.error(`[runs] ${id}: ${err.stack || err}`); });
const promise = gate.then(async () => { try { await execute(id, { controller, reserved, scope, ctx, input, hold }); } finally { if (hold) await tiers.release(hold); } }).catch((err) => { log.error(`[runs] ${id}: ${err.stack || err}`); });
- inflight.set(id, { controller, promise, release, caller });
+ inflight.set(id, { controller, promise, release, caller, priority });
queue.push(id);
queue.push(id);
pump();
pump();
return { run: await get(id), created: true, promise };
return { run: await get(id), created: true, promise };
@@ -397,7 +403,14 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -397,7 +403,14 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
function pump() {
function pump() {
while (active < config.runs.maxConcurrent && queue.length) {
while (active < config.runs.maxConcurrent && queue.length) {
- const id = queue.shift();
+ // The highest kudos priority first; equal priorities keep their arrival order.
+ let at = 0;
+ for (let i = 1; i < queue.length; i++) {
+ const have = inflight.get(queue[at]);
+ const next = inflight.get(queue[i]);
+ if (next && (!have || (next.priority || 0) > (have.priority || 0))) at = i;
+ }
+ const [id] = queue.splice(at, 1);
const h = inflight.get(id);
const h = inflight.get(id);
if (!h) continue;
if (!h) continue;
active++;
active++;
@@ -590,6 +603,9 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -590,6 +603,9 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
const changed = await credentials.markFailed(failure.owner, failure.subject, failure.provider, failure.error);
const changed = await credentials.markFailed(failure.owner, failure.subject, failure.provider, failure.error);
if (changed) await events.credentialFailed({ subject: failure.subject, provider: failure.provider, error: failure.error, owner: failure.owner });
if (changed) await events.credentialFailed({ subject: failure.subject, provider: failure.provider, error: failure.error, owner: failure.owner });
} : undefined;
} : undefined;
+ // With Credits off there is no Balance payer at all, so every run is the free tier for a new community machine;
+ // with it on only a run that could not hold a paid offer is.
+ const communityFree = !(credits && credits.enabled) || Boolean(ctx.creditsRequired);
const r = await engine.execute(wf, row, input, { signal: controller.signal, logRequest, debugRaw: Boolean(config.debugRawLog && opts.debug), inputHash: row.input_hash, preferences,
const r = await engine.execute(wf, row, input, { signal: controller.signal, logRequest, debugRaw: Boolean(config.debugRawLog && opts.debug), inputHash: row.input_hash, preferences,
// A developer app's run (ADR-014): the provider pool excludes a person's byok key, and the run is
// A developer app's run (ADR-014): the provider pool excludes a person's byok key, and the run is
// limited to free and local capacity when it is a sandbox run or when no tier budget governs its
// limited to free and local capacity when it is a sandbox run or when no tier budget governs its
@@ -599,6 +615,9 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -599,6 +615,9 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
requesterType: ctx.requesterType, requesterId: ctx.requesterId,
requesterType: ctx.requesterType, requesterId: ctx.requesterId,
// AI Exchange F: no payer on this run (and not BYOK): the pool skips paid offers with a card.
// AI Exchange F: no payer on this run (and not BYOK): the pool skips paid offers with a card.
creditsRequired: Boolean(ctx.creditsRequired),
creditsRequired: Boolean(ctx.creditsRequired),
+ // Opted in to community capacity: the pool may place this run on a person's machine.
+ communityOptIn: Boolean(ctx.communityOptIn),
+ communityFree,
...(ctx.routePin ? { routePin: ctx.routePin } : {}),
...(ctx.routePin ? { routePin: ctx.routePin } : {}),
// L37: the soft preference the caller made (provider, and a model if it named one).
// L37: the soft preference the caller made (provider, and a model if it named one).
prefer: opts.prefer || null,
prefer: opts.prefer || null,
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -0,0 +1,43 @@
+'use strict';
+// The community AI API Exchange reads (design: "Offer to the network"): /api/v1/community/offers is public and never
+// names an owner; /api/v1/community/sell is the seller's dashboard (tokens, credits, uptime, verification,
+// reputation) for the owner a service names in X-OV-Subject with ai.run.read.
+const assert = require('assert');
+const { boot, request, token, suite } = require('./helpers');
+
+const OWNER = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1';
+
+const t = suite('community-api');
+
+t.test('the seller dashboard is guarded and reads the recorded tokens and credits', async () => {
+ const h = await boot({ env: { AI_COMMUNITY: 'false' } });
+ try {
+ const key = `ai:dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2D1|llama3.1:8b`;
+ await h.db.prepare(`INSERT INTO community_offers (offer_key, offer_id, machine_id, owner_subject, model, models, price_in, price_out, status,
+ canary_passes, last_canary_at, checks, up_checks, reputation, created_at, updated_at)
+ VALUES (?, ?, ?, ?, 'llama3.1:8b', '["llama3.1:8b"]', 10, 30, 'verified', 3, now(), 4, 4, 90, now(), now())`)
+ .run(key, 'ai:dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2D1', 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2D1', OWNER);
+ await h.db.prepare(`INSERT INTO community_usage (id, offer_key, offer_id, machine_id, owner_subject, model, request_id, tokens_in, tokens_out, credits_micro, verified, earning_status, created_at)
+ VALUES ('cu_test', ?, 'ai:dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2D1', 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2D1', ?, 'llama3.1:8b', 'run-1', 1000, 500, 25000, true, 'pending', now())`)
+ .run(key, OWNER);
+ const anon = await request(h.base, 'GET', '/api/v1/community/sell', { headers: { 'X-OV-Subject': OWNER } });
+ assert.strictEqual(anon.status, 401);
+ const tok = token('exchange', ['ai.run.read']);
+ const out = await request(h.base, 'GET', '/api/v1/community/sell', { tok, headers: { 'X-OV-Subject': OWNER } });
+ assert.strictEqual(out.status, 200, out.text);
+ assert.strictEqual(out.body.owner, OWNER);
+ assert.deepStrictEqual([out.body.totals.tokens_in, out.body.totals.tokens_out, out.body.totals.pending_credits_micro], [1000, 500, 25000]);
+ assert.strictEqual(out.body.totals.pending_credits, 0.025);
+ assert.strictEqual(out.body.totals.spendable_credits_micro, 0, 'not spendable until the requester was charged');
+ assert.strictEqual(out.body.offers[0].verification.status, 'verified');
+ assert.strictEqual(out.body.offers[0].uptime_pct, 100);
+ assert.strictEqual(out.body.offers[0].reputation, 90);
+ const bad = await request(h.base, 'GET', '/api/v1/community/sell', { tok, headers: { 'X-OV-Subject': 'not-a-subject' } });
+ assert.strictEqual(bad.status, 422);
+ const pub = await request(h.base, 'GET', '/api/v1/community/offers');
+ assert.strictEqual(pub.status, 200);
+ assert.deepStrictEqual(pub.body.offers, [], 'with community offers off, the public list is empty');
+ } finally { await h.stop(); }
+});
+
+t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,76 @@
@@ -0,0 +1,76 @@
+'use strict';
+// Opting a request in to community capacity (design: "Offer to the network"): x-openvibe-community: 1 is the only
+// thing that lets the router place a run on a person's machine, the answer is labelled in x_openvibe.community and
+// X-OpenVibe-Community, and a request without it never reaches the community layer.
+const assert = require('assert');
+const { boot, request, token, suite, ALL } = require('./helpers');
+
+const t = suite('community-run');
+const OWNER = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1';
+let h;
+let calls = [];
+
+const result = (ctx) => ({
+ result: { completion: { choices: [{ index: 0, message: { role: 'assistant', content: 'from a community box' }, finish_reason: 'stop' }], system_fingerprint: null },
+ text: 'from a community box', json: null, model: 'llama3.1:8b', usage: { input: 10, output: 5 } },
+ provider: 'community:dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2D1', providerKind: 'community', model: 'llama3.1:8b', fallbackUsed: false,
+ usage: { input: 10, output: 5 }, cost: 0, latencyMs: 1, ttftMs: null, synthetic: false, tried: [], startedAt: 0,
+ explain: { objective: 'community', reasons: ['a community machine offered llama3.1:8b'], selected: 'community:dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2D1/llama3.1:8b',
+ paid_by: 'community', candidates: [], attempts: [], community: { machine_id: 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2D1', owner: OWNER, model: 'llama3.1:8b', verification: 'verified', reputation: 50 },
+ openvibe: { resolved_model: 'community/llama3.1:8b', revision: null, deployment: 'community:dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2D1', provider_access: 'COMMUNITY',
+ rate_version: 'community', policy: 'community', community: { machine_id: 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2D1', owner: OWNER, model: 'llama3.1:8b',
+ price: { in_per_million: 10, out_per_million: 30 }, verification: 'verified', reputation: 50, free_tier: true } } },
+});
+
+t.test('boot', async () => {
+ h = await boot({ community: {
+ enabled: true, publicOffers: async () => [], sell: async () => ({ offers: [], totals: {} }),
+ execute: async ({ ctx }) => { calls.push(ctx); return result(ctx); },
+ } });
+});
+
+t.test('a request without the opt-in never reaches a person\'s machine', async () => {
+ calls = [];
+ const r = await request(h.base, 'POST', '/v1/chat/completions', { tok: token('live', ALL), body: { model: 'openvibe/auto', messages: [{ role: 'user', content: 'hi' }] } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(calls.length, 0);
+ assert.strictEqual(r.headers.get('x-openvibe-community'), null);
+});
+
+t.test('x-openvibe-community places it and labels the answer', async () => {
+ calls = [];
+ const r = await request(h.base, 'POST', '/v1/chat/completions', { tok: token('live', ALL), headers: { 'x-openvibe-community': '1' },
+ body: { model: 'openvibe/auto', messages: [{ role: 'user', content: 'hi' }] } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(calls.length, 1);
+ assert.strictEqual(calls[0].communityOptIn, true);
+ assert.strictEqual(r.headers.get('x-openvibe-community'), '1');
+ assert.strictEqual(r.body.x_openvibe.community.owner, OWNER);
+ assert.strictEqual(r.body.x_openvibe.community.verification, 'verified');
+ assert.strictEqual(r.body.choices[0].message.content, 'from a community box');
+});
+
+t.test('a failed community machine falls back to the network and is not labelled', async () => {
+ calls = [];
+ const before = h.community;
+ // The real pool catches the failure and answers from the ordinary route.
+ const saved = before.execute;
+ before.execute = async () => { const err = new Error('community machine failed its canary'); err.code = 'community.verification_failed'; err.status = 502; throw err; };
+ try {
+ const r = await request(h.base, 'POST', '/v1/chat/completions', { tok: token('live', ALL), headers: { 'x-openvibe-community': '1' },
+ body: { model: 'openvibe/auto', messages: [{ role: 'user', content: 'hi' }] } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.headers.get('x-openvibe-community'), null);
+ assert.ok(r.body.x_openvibe.community == null, 'no community label on a network answer');
+ } finally { before.execute = saved; }
+});
+
+t.test('the body flag says the same as the header', async () => {
+ calls = [];
+ const r = await request(h.base, 'POST', '/v1/chat/completions', { tok: token('live', ALL),
+ body: { model: 'openvibe/auto', x_openvibe: { community: true }, messages: [{ role: 'user', content: 'hi' }] } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(calls.length, 1);
+});
+
+t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,234 @@
@@ -0,0 +1,234 @@
+'use strict';
+// Community AI offers (the design's "Offer to the network"; plan E4): only an opted-in request may run on a person's
+// machine; a new machine serves the free tier only; canaries and the model fingerprint verify it before traffic; a
+// failed canary stops it; verified tokens are recorded in credits (never cash); /sell reads them back.
+const assert = require('assert');
+const { boot, suite } = require('./helpers');
+const { createCommunity, withinHours, fingerprintMatches, similar } = require('../server/community');
+
+const OWNER = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1';
+const OTHER = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B2';
+const MACHINE = 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2D1';
+const t = suite('community');
+
+function offerDoc(over = {}) {
+ return {
+ offer_id: `ai:${MACHINE}`, machine_id: MACHINE, owner: OWNER,
+ models: ['llama3.1:8b'], hours: { start: '00:00', end: '23:59', tz: 'UTC' }, concurrency: 1,
+ price: { in_per_million: 10, out_per_million: 30 }, audience: 'everyone', group: null, ready: true,
+ health: { status: 'up' }, ...over,
+ };
+}
+
+/** A fake Node model server: canaries answer correctly, everything else echoes the last user message. */
+function fakeMachines({ canary = true, spotAnswer = null } = {}) {
+ const calls = [];
+ return {
+ enabled: true, calls,
+ async chat({ machine, model, messages, community }) {
+ calls.push({ machine, model, messages, community });
+ const last = messages[messages.length - 1].content;
+ let text;
+ if (last.includes('OPENVIBE-CANARY')) text = 'OPENVIBE-CANARY-7F3A';
+ else if (last.includes('17 + 25')) text = canary ? '42' : '41';
+ else if (last.includes('canary-ok')) text = 'canary-ok';
+ else text = spotAnswer != null ? spotAnswer : `You said: ${last}`;
+ return { text, message: { role: 'assistant', content: text }, model, usage: { input: 10, output: 5 }, finish: 'stop' };
+ },
+ };
+}
+
+async function communityFor(db, machines, { docs = [offerDoc()], followers = [], merges = {}, dailyCapMicro = 100000000 } = {}) {
+ const fetchImpl = async (url) => {
+ if (url.includes('/internal/ai/offers')) return { ok: true, json: async () => ({ offers: docs }) };
+ if (url.includes('/followers')) return { ok: true, json: async () => ({ follows: followers.map((f) => ({ follower: f })), next_cursor: null }) };
+ if (url.includes('/internal/identity/resolve')) {
+ const id = decodeURIComponent(String(url).split('subject_id=')[1] || '');
+ const survivor = merges[id] || id;
+ return { ok: true, json: async () => ({ subject: { id: survivor } }) };
+ }
+ return { ok: false, status: 404, json: async () => null };
+ };
+ let ms = Date.parse('2026-11-06T12:00:00Z');
+ return createCommunity({
+ db, machines, fetchImpl, clock: { now: () => ms }, random: () => 0,
+ config: { networkInternalUrl: 'http://network.test', console: { clientId: 'ai' }, community: { enabled: true, refreshMs: 1000, canaryMaxAgeMs: 21600000, spotRate: 0, canaryTimeoutMs: 5000, trustDays: 7, dailyEarningCapMicro: dailyCapMicro, kudosPerThousandTokens: 1 } },
+ env: {}, tokens: { getToken: async () => 'test-token' }, log: { warn() {}, error() {} },
+ });
+}
+
+t.test('only an opted-in request is placed on a community machine', async () => {
+ const h = await boot({ env: { AI_COMMUNITY: 'false' } });
+ try {
+ const machines = fakeMachines();
+ const c = await communityFor(h.db, machines);
+ assert.strictEqual(await c.execute({ operation: 'chat', req: { messages: [{ role: 'user', content: 'hi' }] }, ctx: { runId: 'x' } }), null);
+ assert.strictEqual(machines.calls.length, 0, 'nothing reaches a person\'s machine unless the request opts in');
+ assert.ok(await c.choose({ ctx: { communityOptIn: true, creditsRequired: true }, operation: 'chat' }));
+ } finally { await h.stop(); }
+});
+
+t.test('a new machine serves the free tier only until verified', async () => {
+ const h = await boot({ env: { AI_COMMUNITY: 'false' } });
+ try {
+ const c = await communityFor(h.db, fakeMachines());
+ assert.strictEqual(await c.choose({ ctx: { communityOptIn: true, creditsRequired: false }, operation: 'chat' }), null, 'not a paid run');
+ const free = await c.choose({ ctx: { communityOptIn: true, creditsRequired: true }, operation: 'chat' });
+ assert.ok(free, 'a free-tier run may use it');
+ const out = await c.execute({ operation: 'chat', req: { messages: [{ role: 'user', content: 'hello' }] }, ctx: { communityOptIn: true, creditsRequired: true, runId: 'r1' } });
+ assert.ok(out.provider.startsWith('community:'), out.provider);
+ assert.strictEqual(out.explain.community.machine_id, MACHINE);
+ assert.strictEqual(out.explain.openvibe.community.owner, OWNER, 'the answer says whose machine it came from');
+ const verified = await c.offers();
+ assert.ok(verified.length);
+ assert.ok(await c.choose({ ctx: { communityOptIn: true, creditsRequired: false }, operation: 'chat' }), 'verified: a paid run may use it');
+ const sell = await c.sell(OWNER);
+ assert.strictEqual(sell.totals.requests, 1, 'the served request is on the seller dashboard');
+ assert.deepStrictEqual([sell.totals.tokens_in, sell.totals.tokens_out], [10, 5]);
+ assert.ok(sell.totals.pending_credits_micro > 0, 'verified tokens are recorded as pending credits');
+ assert.strictEqual(sell.totals.spendable_credits_micro, 0, 'nothing is spendable until the requester was charged');
+ assert.match(sell.note, /pending, not spendable/);
+ assert.strictEqual(sell.offers[0].verification.status, 'verified');
+ assert.strictEqual(sell.offers[0].free_only, false);
+ } finally { await h.stop(); }
+});
+
+t.test('the Anthropic messages operation gets an Anthropic message back', async () => {
+ const h = await boot({ env: { AI_COMMUNITY: 'false' } });
+ try {
+ const c = await communityFor(h.db, fakeMachines());
+ const out = await c.execute({ operation: 'messages', req: { system: 'Be brief.', messages: [{ role: 'user', content: 'hello' }] },
+ ctx: { communityOptIn: true, creditsRequired: true, runId: 'rm1' } });
+ assert.ok(out, 'a community answer');
+ assert.deepStrictEqual(out.result.message.content, [{ type: 'text', text: 'You said: hello' }]);
+ assert.strictEqual(out.result.message.role, 'assistant');
+ } finally { await h.stop(); }
+});
+
+t.test('a wrong canary fails verification and stops that machine', async () => {
+ const h = await boot({ env: { AI_COMMUNITY: 'false' } });
+ try {
+ const machines = fakeMachines({ canary: false });
+ const c = await communityFor(h.db, machines);
+ const ctx = { communityOptIn: true, creditsRequired: true, runId: 'r2' };
+ await assert.rejects(() => c.execute({ operation: 'chat', req: { messages: [{ role: 'user', content: 'hi' }] }, ctx }),
+ (err) => err.code === 'community.verification_failed');
+ assert.strictEqual(await c.choose({ ctx, operation: 'chat' }), null, 'a failed machine is never chosen again');
+ const sell = await c.sell(OWNER);
+ assert.strictEqual(sell.offers[0].verification.status, 'failed');
+ assert.strictEqual(sell.offers[0].reputation, 0);
+ assert.strictEqual(sell.totals.pending_credits_micro, 0, 'a failed canary earns nothing');
+ } finally { await h.stop(); }
+});
+
+t.test('a model fingerprint must be the model the owner declared', async () => {
+ const h = await boot({ env: { AI_COMMUNITY: 'false' } });
+ try {
+ const machines = fakeMachines();
+ machines.chat = async (args) => ({ ...(await fakeMachines().chat(args)), model: 'some-other-model:7b' });
+ const c = await communityFor(h.db, machines);
+ await assert.rejects(() => c.execute({ operation: 'chat', req: { messages: [{ role: 'user', content: 'hi' }] }, ctx: { communityOptIn: true, creditsRequired: true, runId: 'r3' } }),
+ (err) => err.code === 'community.verification_failed');
+ assert.ok(fingerprintMatches('llama3.1:8b-q4', 'llama3.1:8b'));
+ assert.ok(!fingerprintMatches('qwen2.5:7b', 'llama3.1:8b'));
+ assert.ok(similar('A lock one thread holds at a time.', 'a lock one thread holds at a time'));
+ } finally { await h.stop(); }
+});
+
+t.test('the offer hours are checked by the router too, in the owner time zone', async () => {
+ const h = { start: '09:00', end: '17:00', tz: 'UTC' };
+ const at = (iso) => Date.parse(iso);
+ assert.ok(withinHours({ hours: h }, at('2026-11-06T12:00:00Z')));
+ assert.ok(!withinHours({ hours: h }, at('2026-11-06T02:00:00Z')));
+ assert.ok(withinHours({ hours: { start: '22:00', end: '06:00', tz: 'UTC' } }, at('2026-11-06T23:00:00Z')), 'overnight wraps');
+ assert.ok(!withinHours({ hours: { start: '22:00', end: '06:00', tz: 'UTC' } }, at('2026-11-06T12:00:00Z')));
+ assert.ok(withinHours({ hours: { start: '00:00', end: '00:00', tz: 'UTC' } }, at('2026-11-06T12:00:00Z')), 'start == end is all day');
+});
+
+t.test('a run by the offer owner is self-served: zero credits, labelled', async () => {
+ const h = await boot({ env: { AI_COMMUNITY: 'false' } });
+ try {
+ const c = await communityFor(h.db, fakeMachines());
+ const out = await c.execute({ operation: 'chat', req: { messages: [{ role: 'user', content: 'hi' }] },
+ ctx: { communityOptIn: true, creditsRequired: true, onBehalfOf: OWNER, runId: 'self1' } });
+ assert.strictEqual(out.explain.community.self_served, true);
+ assert.strictEqual(out.explain.community.credits_micro, 0);
+ assert.match(out.explain.reasons[0], /self-served/);
+ const sell = await c.sell(OWNER);
+ assert.strictEqual(sell.totals.self_served_requests, 1);
+ assert.strictEqual(sell.totals.pending_credits_micro, 0);
+ } finally { await h.stop(); }
+});
+
+t.test('a requester merged into the owner is self-served too', async () => {
+ const h = await boot({ env: { AI_COMMUNITY: 'false' } });
+ try {
+ const c = await communityFor(h.db, fakeMachines(), { merges: { [OTHER]: OWNER } });
+ const out = await c.execute({ operation: 'chat', req: { messages: [{ role: 'user', content: 'hi' }] },
+ ctx: { communityOptIn: true, creditsRequired: true, onBehalfOf: OTHER, runId: 'merged1' } });
+ assert.strictEqual(out.explain.community.self_served, true);
+ assert.strictEqual(out.explain.community.credits_micro, 0);
+ } finally { await h.stop(); }
+});
+
+t.test('a new machine\'s seller earnings are capped by the day', async () => {
+ const h = await boot({ env: { AI_COMMUNITY: 'false' } });
+ try {
+ const c = await communityFor(h.db, fakeMachines(), { dailyCapMicro: 1 });
+ const out = await c.execute({ operation: 'chat', req: { messages: [{ role: 'user', content: 'hi' }] },
+ ctx: { communityOptIn: true, creditsRequired: true, onBehalfOf: OTHER, runId: 'cap1' } });
+ assert.strictEqual(out.explain.community.earning_status, 'capped');
+ assert.strictEqual(out.explain.community.credits_micro, 1, 'the daily cap is one micro-credit');
+ const sell = await c.sell(OWNER);
+ assert.strictEqual(sell.offers[0].daily_cap.earned_today_micro, 1);
+ assert.ok(sell.offers[0].daily_cap.trust_ends_at);
+ } finally { await h.stop(); }
+});
+
+t.test('a volunteer earns kudos, never cash', async () => {
+ const h = await boot({ env: { AI_COMMUNITY: 'false' } });
+ try {
+ const c = await communityFor(h.db, fakeMachines(), { docs: [offerDoc({ price: { in_per_million: 0, out_per_million: 0 } })] });
+ const out = await c.execute({ operation: 'chat', req: { messages: [{ role: 'user', content: 'hi' }] },
+ ctx: { communityOptIn: true, creditsRequired: true, onBehalfOf: OTHER, runId: 'kudos1' } });
+ assert.ok(out.explain.community.kudos > 0);
+ const sell = await c.sell(OWNER);
+ assert.ok(sell.kudos.balance > 0);
+ assert.strictEqual(sell.kudos.never_cash, true);
+ assert.strictEqual(sell.kudos.never_expires, true);
+ assert.ok(sell.kudos.benefits.queue_priority >= 0);
+ assert.ok(sell.kudos.benefits.free_allowance_bonus_tokens > 0);
+ assert.strictEqual(await c.queuePriority(OWNER), sell.kudos.benefits.queue_priority);
+ assert.strictEqual(await c.kudosBonus(`user:${OWNER}`), sell.kudos.benefits.free_allowance_bonus_tokens);
+ assert.strictEqual(sell.totals.pending_credits_micro, 0, 'a volunteer earns no credits');
+ } finally { await h.stop(); }
+});
+
+t.test('a canary/spot failure rate resets reputation', async () => {
+ const h = await boot({ env: { AI_COMMUNITY: 'false' } });
+ try {
+ const c = await communityFor(h.db, fakeMachines());
+ const key = `${offerDoc().offer_id}|llama3.1:8b`;
+ await h.db.prepare(`INSERT INTO community_offers (offer_key, offer_id, machine_id, owner_subject, model, models, price_in, price_out, status,
+ canary_passes, canary_failures, checks, up_checks, reputation, created_at, updated_at)
+ VALUES (?, ?, ?, ?, 'llama3.1:8b', '["llama3.1:8b"]', 10, 30, 'verified', 9, 2, 4, 4, 90, now(), now())`)
+ .run(key, offerDoc().offer_id, MACHINE, OWNER);
+ await c._test.refreshReputation(key);
+ const row = await h.db.prepare('SELECT reputation FROM community_offers WHERE offer_key = ?').get(key);
+ assert.strictEqual(Number(row.reputation), 0, '2 failures out of 11 attempts resets it');
+ } finally { await h.stop(); }
+});
+
+t.test('followers and groups are enforced, everyone is open', async () => {
+ const h = await boot({ env: { AI_COMMUNITY: 'false' } });
+ try {
+ const c = await communityFor(h.db, fakeMachines(), { docs: [offerDoc({ audience: 'followers' })], followers: [OTHER] });
+ assert.ok(await c.choose({ ctx: { communityOptIn: true, creditsRequired: true, onBehalfOf: OTHER }, operation: 'chat' }), 'a follower may use it');
+ assert.strictEqual(await c.choose({ ctx: { communityOptIn: true, creditsRequired: true, onBehalfOf: OWNER }, operation: 'chat' }), null, 'someone else may not');
+ const g = await communityFor(h.db, fakeMachines(), { docs: [offerDoc({ audience: 'group', group: 'team-a' })] });
+ assert.ok(await g.choose({ ctx: { communityOptIn: true, creditsRequired: true, communityGroup: 'team-a' }, operation: 'chat' }));
+ assert.strictEqual(await g.choose({ ctx: { communityOptIn: true, creditsRequired: true, communityGroup: 'team-b' }, operation: 'chat' }), null);
+ } finally { await h.stop(); }
+});
+
+t.run();
@@ -57,6 +57,13 @@ t.test('free up to the allowance per metric, then fully priced; the real period
@@ -57,6 +57,13 @@ t.test('free up to the allowance per metric, then fully priced; the real period
for (const r of rows) assert.deepStrictEqual([r.period_start, r.period_end], [Date.UTC(2026, 9, 4), Date.UTC(2026, 9, 5)]);
for (const r of rows) assert.deepStrictEqual([r.period_start, r.period_end], [Date.UTC(2026, 9, 4), Date.UTC(2026, 9, 5)]);
});
});
+t.test('a volunteer\'s kudos buy a bigger output allowance', async () => {
+ await clear();
+ const fa = createFreeAllowance(tdb.db, { cardsFor: cardsWith({ free_allowance: 100, reset_period: 'day' }), bonusFor: async () => 40 });
+ const c = await fa.claim('s1', 'p', 'm', { out: 200 }, at(2026, 10, 4));
+ assert.strictEqual(c.byKind.out.tokens, 140, 'the card\'s 100 plus the 40 kudos tokens');
+});
+
t.test('the counter resets at the period boundary: day and month; none never resets', async () => {
t.test('the counter resets at the period boundary: day and month; none never resets', async () => {
await clear();
await clear();
const day = createFreeAllowance(tdb.db, { cardsFor: cardsWith({ free_allowance: 100, reset_period: 'day' }) });
const day = createFreeAllowance(tdb.db, { cardsFor: cardsWith({ free_allowance: 100, reset_period: 'day' }) });
@@ -45,7 +45,7 @@ function tmpDir() {
@@ -45,7 +45,7 @@ function tmpDir() {
// A boot on a dir used before is a restart on the same database: its database stays open.
// A boot on a dir used before is a restart on the same database: its database stays open.
const restartable = new Map();
const restartable = new Map();
-async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research, machineModels, log, variantEvidence } = {}) {
+async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research, machineModels, log, variantEvidence, community } = {}) {
const reuse = dir !== undefined;
const reuse = dir !== undefined;
if (dir === undefined) dir = tmpDir();
if (dir === undefined) dir = tmpDir();
const fullEnv = {
const fullEnv = {
@@ -59,7 +59,7 @@ async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research
@@ -59,7 +59,7 @@ async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research
if (reuse) restartable.set(dir, testdb);
if (reuse) restartable.set(dir, testdb);
// Research reads a canned web (test/fixtures/research-fetch.js) unless a test brings its own: no test reaches the internet.
// Research reads a canned web (test/fixtures/research-fetch.js) unless a test brings its own: no test reaches the internet.
const researcher = research || require('../server/research').createResearch({ config: config.research, env: {}, fetchImpl: require('./fixtures/research-fetch').createResearchFetch().fetchImpl, log: silent });
const researcher = research || require('../server/research').createResearch({ config: config.research, env: {}, fetchImpl: require('./fixtures/research-fetch').createResearchFetch().fetchImpl, log: silent });
- const h = await start({ config, db: testdb.db, env: fullEnv, log: log || silent, clock, fetchImpl, credentialFetch, research: researcher, machineModels, variantEvidence });
+ const h = await start({ config, db: testdb.db, env: fullEnv, log: log || silent, clock, fetchImpl, credentialFetch, research: researcher, machineModels, variantEvidence, community });
const base = `http://127.0.0.1:${h.server.address().port}`;
const base = `http://127.0.0.1:${h.server.address().port}`;
return { ...h, base, dir, env: fullEnv, testdb, stop: async () => { await h.close(); if (!reuse) await testdb.close(); } };
return { ...h, base, dir, env: fullEnv, testdb, stop: async () => { await h.close(); if (!reuse) await testdb.close(); } };
}
}