openvibers/openvibe.ai Commits

Community AI capacity: opt-in placement, canaries, spot re-runs and seller credits (#303)

OP OpenVibers committed 7 days ago · be33d51910 · 19 files, +1364 −25

* Community AI capacity: opt-in placement, canaries, spot re-runs and seller credits

* Document the community API routes in /openapi.json

* Community offers: close self-dealing, pending credits, caps and kudos

Changes

ddiff --git a/docs/community-offers.md b/docs/community-offers.md
nnew file mode 100644
iindex 0000000..23d4842
---- /dev/null
++++ b/docs/community-offers.md
@@@ -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`.
ddiff --git a/migrations/0029_community_offers.sql b/migrations/0029_community_offers.sql
nnew file mode 100644
iindex 0000000..f644f21
---- /dev/null
++++ b/migrations/0029_community_offers.sql
@@@ -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);
ddiff --git a/server/api/community.js b/server/api/community.js
nnew file mode 100644
iindex 0000000..f176d67
---- /dev/null
++++ b/server/api/community.js
@@@ -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 };
ddiff --git a/server/api/messages.js b/server/api/messages.js
iindex 39b1759..8923b8f 100644
---- a/server/api/messages.js
++++ b/server/api/messages.js
@@@ -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
ddiff --git a/server/api/openai.js b/server/api/openai.js
iindex d8a6b4e..3fea101 100644
---- a/server/api/openai.js
++++ b/server/api/openai.js
@@@ -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 headers
if (live) {
const send = (o) => live.write(o);
ddiff --git a/server/app.js b/server/app.js
iindex 7a5f3bb..94cfcb0 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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, keys
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.
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).
ddiff --git a/server/community/index.js b/server/community/index.js
nnew file mode 100644
iindex 0000000..8a58e26
---- /dev/null
++++ b/server/community/index.js
@@@ -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 };
ddiff --git a/server/config.js b/server/config.js
iindex 0eada9c..6872de8 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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 }),
};
ddiff --git a/server/free-allowance.js b/server/free-allowance.js
iindex 35412eb..060352f 100644
---- a/server/free-allowance.js
++++ b/server/free-allowance.js
@@@ -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 order
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 k = byKind[kind];
if (c) k.cost = (q / c.unit_size) * c.unit_price_usd; // the whole quantity's list price, free or not
ddiff --git a/server/index.js b/server/index.js
iindex ee2b693..a06f647 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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.now
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 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.now
startSettlements() { 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.now
clientSecret: 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.now
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) {
ddiff --git a/server/machines.js b/server/machines.js
iindex e85f7e5..3949934 100644
---- a/server/machines.js
++++ b/server/machines.js
@@@ -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)})`);
}
-- 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;
ddiff --git a/server/openapi.js b/server/openapi.js
iindex 4b249be..1d0d9a8 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -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();
ddiff --git a/server/providers/index.js b/server/providers/index.js
iindex 3dd1468..db4c0f9 100644
---- a/server/providers/index.js
++++ b/server/providers/index.js
@@@ -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;
ddiff --git a/server/runs.js b/server/runs.js
iindex 2b6cc6b..75acdc2 100644
---- a/server/runs.js
++++ b/server/runs.js
@@@ -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 worker
let 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 = { now
source_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 = { now
const 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 = { now
function 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 = { now
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 });
} : 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 = { now
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.
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,
ddiff --git a/test/community-api.test.js b/test/community-api.test.js
nnew file mode 100644
iindex 0000000..44de062
---- /dev/null
++++ b/test/community-api.test.js
@@@ -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();
ddiff --git a/test/community-run.test.js b/test/community-run.test.js
nnew file mode 100644
iindex 0000000..a713442
---- /dev/null
++++ b/test/community-run.test.js
@@@ -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();
ddiff --git a/test/community.test.js b/test/community.test.js
nnew file mode 100644
iindex 0000000..e0578f8
---- /dev/null
++++ b/test/community.test.js
@@@ -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();
ddiff --git a/test/free-allowance.test.js b/test/free-allowance.test.js
iindex f64b05e..35c33ff 100644
---- a/test/free-allowance.test.js
++++ b/test/free-allowance.test.js
@@@ -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)]);
});
++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' }) });
ddiff --git a/test/helpers.js b/test/helpers.js
iindex 12bd8ea..3430c09 100644
---- a/test/helpers.js
++++ b/test/helpers.js
@@@ -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, research
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.
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(); } };
}