openvibers/openvibe.ai Commits
The free tier runs only on its named sources, at or below $1.50 (#316)
Changes
@@@ -158,9 +158,12 @@ AI_MODEL=# ── Spend safety for runs nobody pays for ──# The free allowance (a service naming a person, without their own token) and the anonymous Try it may use only--# models at or below this output price in USD per 1M tokens. Dearer models need the person's own Balance (their--# own token) or their own key; the router answers 402 billing.payer_required instead. Default: 1.--#AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK=1++# offers from these sources AND at or below this output price in USD per 1M tokens. A source is a provider key;++# `local` covers every local model server (local, local2, whisper.cpp) and `community` the community machines.++# Anything else needs the person's own Balance (their own token) or their own key; the router answers 402++# billing.payer_required instead. Default providers: deepseek,local,community. Default ceiling: 1.5.++#AI_FREE_PROVIDERS=deepseek,local,community++#AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK=1.5# Optional local HTTP seam provider (POST {operation, request} -> {text, json, usage})#AI_HTTP_SEAM_URL=http://127.0.0.1:4799/complete@@@ -600,13 +600,18 @@ their price out. It is pricing, never admission: quotas, request counting and thunchanged, and no call is ever refused because the allowance is used up. Every card defaults to 0, sonothing is free and the field is absent until an operator configures an allowance.--Free-use price ceiling (`AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK`, default 1): a run that nobody pays for--— the free allowance (a first-party service naming a person, without that person's own token) or the--Exchange's anonymous Try it — may use only offers at or below this output price per million tokens.--The router skips dearer offers and answers `402 billing.payer_required` when every capable offer is--above it. A person's own token (and so their Balance, AI #311), their own provider key, an app's--project and a service's own run-token budget are not capped this way. The check is admission, not--pricing: it never changes a price or a Balance reading.++Free-use sources and price ceiling (`AI_FREE_PROVIDERS`, default `deepseek,local,community`;++`AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK`, default 1.5): a run that nobody pays for — the free allowance++(a first-party service naming a person, without that person's own token) or the Exchange's anonymous Try++it — may use only offers from those sources and at or below this output price per million tokens. A++source is a provider key; `local` covers every local model server (`local`, `local2`, whisper.cpp) and++`community` the community machines. The design funds free use from the owner's DeepSeek key, local models++and volunteers, never from Live's shared OpenAI key. The router skips the rest and answers++`402 billing.payer_required` when every capable offer is outside those rules. A person's own token (and so++their Balance, AI #311), their own provider key, an app's project and a service's own run-token budget are++not capped this way. The check is admission, not pricing: it never changes a price or a Balance reading.++The Exchange catalog marks each offer `free_tier` by the same two rules (distinct from `free`, which means++prepaid capacity with no per-token price).Tier budgets (T6 step 8, decision D5; `server/govern.js`, off unless `AI_GOVERN_TIERS=1`): the SaaS tiers`free`, `paid` and `staff` on `openvibe-sdk/govern`, counted in Valkey. Before a run touches a provider@@@ -34,8 +34,8 @@ function memo(fn, ttlMs, clock) {};}--function exchangeRouter({ db, registry, pool, env = process.env, clock = { now: () => Date.now() }, presets, receipts, auth, log = console }) {-- const catalog = createCatalog({ db, registry, pool, env, clock });++function exchangeRouter({ db, registry, pool, env = process.env, clock = { now: () => Date.now() }, config = null, presets, receipts, auth, log = console }) {++ const catalog = createCatalog({ db, registry, pool, env, clock, config });const usage = createUsage({ db, clock });// One cached read per kind (?kind=chat, screenshot, …); the unfiltered read is the full catalog.const hits = new Map();@@@ -110,7 +110,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keysopenapi.mount(app);// OpenVibe.Exchange's read API (server/api/exchange.js): the public catalog and aggregate usage. Public on// purpose — the site and anyone comparing prices reads the same numbers the router ranks on.-- app.use(require('./api/exchange').exchangeRouter({ db, registry, pool, env, clock, presets, receipts, auth, log }));++ app.use(require('./api/exchange').exchangeRouter({ config, db, registry, pool, env, clock, presets, receipts, auth, log }));// GET /release.json (ADR-016) and POST /release-metrics (open tabs' update reports into /metrics).release.mount(app, { registry: metrics.registry });@@@ -13,6 +13,7 @@*/const { loadGovern } = require('./govern');++const { DEFAULT_PROVIDERS: DEFAULT_FREE_PROVIDERS } = require('./free-tier');const int = (v, d) => {const n = parseInt(v, 10);@@@ -140,10 +141,12 @@ function load(env = process.env) {},// ── Spend safety for runs nobody pays for (the free allowance, the anonymous Try it) ──// A run with no person's own token, no app project, no service run-token budget and no own key may-- // use only offers at or below this output price (USD per 1M tokens). Dearer models need the person's-- // own Balance or their own key; the router answers 402 billing.payer_required instead.++ // use only offers from these sources (provider keys; `local` is every local model server and++ // `community` the community machines) AND at or below this output price (USD per 1M tokens). Anything++ // else needs the person's own Balance or their own key; the router answers 402 billing.payer_required.freeUse: {-- maxOutputPricePerMtok: Math.max(0, float(env.AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK, 1)),++ providers: list(env.AI_FREE_PROVIDERS, DEFAULT_FREE_PROVIDERS),++ maxOutputPricePerMtok: Math.max(0, float(env.AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK, 1.5)),},// ── Placement (T6 provider router): openvibe-sdk/placement orders a route's capability pool ──placement: {@@@ -14,6 +14,7 @@*/const rates = require('../providers/rate-cards');const upstream = require('../providers/upstream');++const { allowsOffer } = require('../free-tier');const DAY_MS = 86400000;const FAMILY_BY_PREFIX = [@@@ -101,7 +102,11 @@ function modalitiesOf(row) {return [...out];}--function createCatalog({ db, registry, pool, env = process.env, clock = { now: () => Date.now() } }) {++function createCatalog({ db, registry, pool, env = process.env, clock = { now: () => Date.now() }, config = null }) {++ // The free-tier admission rules are the router's (server/free-tier.js): the catalog marks an offer with what++ // the free allowance and the anonymous Try it may actually use. config.freeUse is fixed at boot, like the++ // provider keys and upstreams the rest of this read shows.++ const freeUse = config && config.freeUse ? config.freeUse : undefined;const getStats = db.prepare(`SELECT provider, model,SUM(requests)::bigint AS requests, SUM(ok)::bigint AS ok, SUM(errors)::bigint AS errors,CASE WHEN SUM(requests) > 0 THEN SUM(COALESCE(latency_p50_ms, 0) * requests) / SUM(requests) END AS p50,@@@ -176,6 +181,10 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (trust: isCommunity(provider) ? 'community' : 'first-party',access: provider.provider_access || 'UNVERIFIED',free: cards == null,++ // What the free allowance and the anonymous Try it may use, distinct from `free` above (prepaid++ // capacity with no per-token card): both its provider is a named AI_FREE_PROVIDERS source and its++ // output price is at or below AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK.++ free_tier: allowsOffer({ providerKey: provider.key, providerRecord: provider, outputPrice: price ? price.out : null }, freeUse),price: price ? { in: price.in, out: price.out, cached: price.cached, per: '1M tokens', currency: 'USD' } : null,// Where the number came from and when someone checked it; null when nobody recorded it.price_source: cards && cards.out.source && !String(cards.out.source).startsWith('urn:openvibe:ai:unverified:') ? cards.out.source : null,nnew file mode 100644@@@ -0,0 +1,63 @@++'use strict';++/**++ * Who may serve a run nobody pays for (the free allowance and the Exchange's anonymous Try it).++ *++ * The design funds free use from the owner's DeepSeek key, local models and community volunteers++ * (EXCHANGE-REMAKE-2026-11-06.md, "Money and free use"). AI_FREE_PROVIDERS names those sources; every++ * other provider needs a payer. AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK then caps what a metered source may++ * cost, so a free run never lands on a dearer model through the owner's key.++ *++ * A source id is a provider's own key (deepseek, shared, …) or one of two families:++ * - `local` matches every local model server (the seeded `local` and `local2`, and whisper.cpp);++ * - `community` matches the community machines (server/community/, which has no registry provider row).++ * Matching is case-insensitive. An empty list allows nothing: the free tier then needs a payer.++ *++ * Both rules are admission, never pricing: a skipped offer is not charged, and a person's own key, own++ * token (their Balance), project or service run-token budget never goes through here.++ */++++/** The sources a free run may use when AI_FREE_PROVIDERS is unset (the design's three funding sources). */++const DEFAULT_PROVIDERS = ['deepseek', 'local', 'community'];++++const isLocal = (provider) => Boolean(provider) && (provider.kind === 'whisper' || Boolean(provider.metadata && provider.metadata.local));++++/** The configured source ids, lowercased; `freeUse` is config.freeUse (undefined falls back to the default). */++function sourcesOf(freeUse) {++ const list = freeUse && Array.isArray(freeUse.providers) ? freeUse.providers : DEFAULT_PROVIDERS;++ return new Set(list.map((s) => String(s).trim().toLowerCase()).filter(Boolean));++}++++/** The output price ceiling in USD per 1M tokens, or null when the config names no usable number. */++function ceilingOf(freeUse) {++ const n = Number(freeUse && freeUse.maxOutputPricePerMtok);++ return Number.isFinite(n) ? n : null;++}++++/**++ * May this provider record serve a free run? An exact key always matches; `local` also covers a local++ * server under another key (local2), so adding a second local model never silently leaves the free tier.++ */++function allowsProvider(providerKey, providerRecord, sources) {++ if (!sources) return false;++ const key = String(providerKey || '').trim().toLowerCase();++ if (!key) return false;++ if (sources.has(key)) return true;++ return isLocal(providerRecord) && sources.has('local');++}++++/** May a community machine serve a free run? (community capacity is not a registry provider.) */++const allowsCommunity = (sources) => Boolean(sources) && sources.has('community');++++/**++ * Could this offer serve the free tier? Both rules: its provider is a named source and its output price++ * is at or below the ceiling. An offer with no per-token card (local, prepaid) has no output price to cap.++ */++function allowsOffer({ providerKey, providerRecord = null, outputPrice = null }, freeUse) {++ const sources = sourcesOf(freeUse);++ if (!allowsProvider(providerKey, providerRecord, sources)) return false;++ const ceiling = ceilingOf(freeUse);++ if (ceiling == null) return true;++ return !(Number(outputPrice) > ceiling);++}++++module.exports = { DEFAULT_PROVIDERS, isLocal, sourcesOf, ceilingOf, allowsProvider, allowsCommunity, allowsOffer };@@@ -292,7 +292,7 @@ route('get', '/run-budgets/{jti}', {// ── Exchange read API (OpenVibe.Exchange) ───────────────────────────────────────────────────const exchangeOffer = {-- type: 'object', required: ['provider', 'provider_name', 'model', 'kind', 'trust', 'free', 'price'],++ type: 'object', required: ['provider', 'provider_name', 'model', 'kind', 'trust', 'free', 'free_tier', 'price'],properties: {provider: str({ description: 'The real upstream, never the router\'s internal provider key' }), provider_name: str(), model: str(),kind: str({ enum: ['chat', 'embedding', 'stt', 'image'] }),@@@ -300,6 +300,7 @@ const exchangeOffer = {trust: { type: 'string', enum: ['first-party', 'community'] },access: str({ enum: ['OWN_DEPLOYMENT', 'AUTHORIZED_WHOLESALE', 'UNVERIFIED'] }),free: { type: 'boolean', description: 'Prepaid capacity with no per-token price (a free server, a subscription, a local machine).' },++ free_tier: { type: 'boolean', description: 'The free allowance and the anonymous Try it may use this offer: its provider is one of AI_FREE_PROVIDERS (default deepseek,local,community; local covers local2 and whisper.cpp) and its output price is at or below AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK (default 1.5). Distinct from free: a priced DeepSeek Flash offer is free_tier true while a dearer priced offer is false.' },price: nullable('object', { properties: { in: { type: 'number' }, out: { type: 'number' }, cached: { type: 'number' }, per: str(), currency: str() } }),price_source: nullable('string', { description: 'The price page the card was read from, when recorded' }),price_checked_at: nullable('string', { description: 'The date someone checked the price, when recorded' }),@@@ -323,7 +324,7 @@ const exchangeModel = {};route('get', '/exchange/catalog', {operationId: 'exchangeCatalog', summary: 'Every model and every offer that serves it', tags: ['exchange'], security: [],-- description: 'Public. The live offers: price in, out and cached per million tokens, measured p50/p95, uptime, requests, and the provider data policy. A prepaid offer has no price (free: true). The window is 7 days. Models carry a kind (chat, embedding, stt, image); chat lists must use kind "chat" and never mix embeddings or speech-to-text.',++ description: 'Public. The live offers: price in, out and cached per million tokens, measured p50/p95, uptime, requests, and the provider data policy. A prepaid offer has no price (free: true); free_tier says whether the free allowance and the anonymous Try it may use an offer at all (a named AI_FREE_PROVIDERS source under the output price ceiling). The window is 7 days. Models carry a kind (chat, embedding, stt, image); chat lists must use kind "chat" and never mix embeddings or speech-to-text.',parameters: [query('kind', str({ enum: ['chat', 'embedding', 'stt', 'image'] }), 'Only models of this kind')],responses: { 200: json('The models, grouped by model, with their offers', { type: 'object', required: ['models'], properties: {generated_at: str({ format: 'date-time' }), window_days: integer(), count: integer(),@@@ -28,6 +28,7 @@ const placement = require('openvibe-sdk/placement');const contracts = require('openvibe-contracts');const { AiError, resolveSecret, sha256 } = require('../util');const { retryable, sleep, ProviderError, schemaInstruction, insufficientBalance } = require('./common');++const { sourcesOf, ceilingOf, allowsProvider, allowsCommunity } = require('../free-tier');const { createStubProvider } = require('./stub');const { createOpenAiProvider } = require('./openai');const { createResponsesProvider } = require('./responses');@@@ -995,12 +996,14 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no// request. With only_my_keys it never falls back; otherwise the network is the fallback and the explain says so.const prefs = ctx.selfPreferences || {};// Spend safety: the free allowance and the anonymous Try it (ctx.freeUse, server/runs.js) may use only-- // offers at or below this output price. A person's own token (their Balance) or their own key is not capped-- // this way. A person's own price ceiling still applies to the network fallback as before.-- const configuredFreeCeiling = Number(config.freeUse && config.freeUse.maxOutputPricePerMtok);-- const freeCeiling = ctx.freeUse && Number.isFinite(configuredFreeCeiling) ? configuredFreeCeiling : null;++ // offers from AI_FREE_PROVIDERS (server/free-tier.js) and at or below this output price. A person's own++ // token (their Balance) or their own key is not capped this way. A person's own price ceiling still++ // applies to the network fallback as before.++ const freeCeiling = ctx.freeUse ? ceilingOf(config.freeUse) : null;++ const freeSources = ctx.freeUse ? sourcesOf(config.freeUse) : null;const ceiling = freeCeiling != null ? freeCeiling : prefs.price_ceiling_usd_per_mtok;let freeCeilingSkipped = false; // every capable offer was dearer than the free ceiling++ let freeProviderSkipped = false; // every capable offer came from a source the free tier may not useconst selfAttempts = [];// The adult tier is network supply only: a person's own provider key is not an offer marked uncensored, and// only such an offer may answer an uncensored request. Their key is still used for every standard request.@@@ -1064,20 +1067,24 @@ 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.// Community placement runs before the catalog pool. A preset with price, privacy, region, or quality// rules must use the catalog path so those rules are checked before any offer is called.const communityPresetSafe = !ctx.presetPolicy || Object.keys(ctx.presetPolicy).every((key) => key === 'community');if (community && ctx.communityOptIn && communityPresetSafe && !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}`);++ // Community capacity is a free-tier source only when the allowlist names it: without that, the++ // ordinary network answers and the skip is named in the explain, never silently used.++ if (freeSources && !allowsCommunity(freeSources)) {++ tried.push({ provider: 'community', skipped: 'free_provider_not_allowed' });++ freeProviderSkipped = true;++ } else {++ 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@@@ -1104,8 +1111,12 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noif (!p) why.push('unknown_provider');else if (!policyOk) why.push('content_policy');else if (ctx.creditsRequired && !isFreeProvider(p) && card && card.version) { why.push('payer_required'); payerSkipped = true; }-- else {-- // The free allowance / anonymous Try it cannot pin a model above the output price ceiling.++ else if (freeSources && !allowsProvider(p.key, p, freeSources)) {++ // The free allowance / anonymous Try it may pin only a model from one of its sources...++ why.push('free_provider_not_allowed');++ freeProviderSkipped = true;++ } else {++ // ...and never one above the output price ceiling.const price = freeCeiling != null && !isFreeProvider(p) ? await priceFor(offer.provider, offer.model) : null;if (price && Number(price.out) > freeCeiling) { why.push('over_price_ceiling'); freeCeilingSkipped = true; }else {@@@ -1120,9 +1131,11 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noif (!reason) usable.push({ provider: offer.provider, model: offer.model });}explain = { objective: 'exact', policy: 'exact', reasons: [`exact model ${exact.id}: only its own deployment may answer`], selected: null, candidates: candidatesView,-- ...(freeCeiling != null ? { free_ceiling_usd_per_mtok: freeCeiling } : {}) };++ ...(freeCeiling != null ? { free_ceiling_usd_per_mtok: freeCeiling } : {}),++ ...(freeSources ? { free_providers: [...freeSources] } : {}) };if (!usable.length) {if (payerSkipped) throw new AiError(402, 'billing.payer_required', `the exact model ${exact.id} needs a Balance payer (a person, or an app with its project); it was not run`, { explain });++ if (freeProviderSkipped) throw new AiError(402, 'billing.payer_required', `the exact model ${exact.id} comes from a provider the free allowance may not use (${[...freeSources].join(', ')}); use your own key, or run with your own token so your Balance pays`, { explain });if (freeCeilingSkipped) throw new AiError(402, 'billing.payer_required', `the exact model ${exact.id} is above the free-use ceiling of $${freeCeiling} per 1M output tokens; use your own key, or run with your own token so your Balance pays`, { explain });throw new AiError(400, 'ai.model_unavailable', `the artifact ${exact.id} cannot run this request`, { explain });}@@@ -1259,6 +1272,19 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noconst chosen = ranked.reasons.get(c.provider + ':' + (model || '')) || null;const fallback = i > 0;const base = { operation, provider_key: c.provider, model_key: model, route_key: ctx.routeKey || null, route_version: ctx.routeVersion || null, fallback: fallback ? 1 : 0, prompt_hash: ctx.promptHash || null, input_hash: ctx.inputHash || null };++ // Spend safety: the free allowance / anonymous Try it runs only from its named sources++ // (server/free-tier.js). A provider outside them is skipped even when it is cheaper: who pays++ // matters, not only the price. The person's own keys (above) are always tried first and are never++ // gated here.++ if (freeSources && !allowsProvider(c.provider, p, freeSources)) {++ if (preferState && preferMatches(c.provider, model, prefer)) { preferState.honoured = false; preferState.why = 'it is not a provider the free tier may use'; }++ statFor(c.provider).skipped++;++ tried.push({ provider: c.provider, skipped: 'free_provider_not_allowed' });++ attempts.push({ key: 'openvibe', provider: c.provider, model, why: chosen || 'the router chose it', status: 'skipped', error: 'free_provider_not_allowed' });++ if (ctx.logRequest) ctx.logRequest({ ...base, status: 'skipped', skip_reason: 'free_provider_not_allowed' });++ freeProviderSkipped = true;++ continue;++ }// L36 Y3 / spend safety: a price ceiling caps the network's fallback; the person's own keys are never// capped (above). The free allowance's ceiling is the network's, so an over-ceiling offer is skipped.if (ceiling != null) {@@@ -1464,12 +1490,21 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noexplain.reasons = [...(explain.reasons || []), 'a provider is out of balance or paused; AI is resting'];throw new AiError(503, 'ai.resting', 'AI is resting for a moment', { retry_after_seconds: Math.ceil(exhaustedCooldownMs() / 1000), tried, explain });}-- // The free allowance / anonymous Try it had capable offers, but every one was above its ceiling. That is-- // not an outage: the caller needs their own key, or their own token so their Balance can pay.-- if (freeCeilingSkipped && attemptsMade === 0) {-- explain.free_ceiling_usd_per_mtok = freeCeiling;-- explain.reasons = [...(explain.reasons || []), `every capable offer costs more than the free-use ceiling of $${freeCeiling} per 1M output tokens`];-- throw new AiError(402, 'billing.payer_required', `this run has no payer, so its free allowance may use models up to $${freeCeiling} per 1M output tokens; the model it needs is dearer. Use your own key, or run with your own token so your Balance pays.`, { tried, explain });++ // The free allowance / anonymous Try it had capable offers, but every one was outside its sources or++ // above its ceiling. That is not an outage: the caller needs their own key, or their own token so++ // their Balance can pay.++ if (attemptsMade === 0 && (freeCeilingSkipped || freeProviderSkipped)) {++ const allowed = [...freeSources].join(', ');++ const cap = freeCeiling == null ? '' : ` at up to $${freeCeiling} per 1M output tokens`;++ explain.free_providers = [...freeSources];++ if (freeCeiling != null) explain.free_ceiling_usd_per_mtok = freeCeiling;++ explain.reasons = [...(explain.reasons || []),++ ...(freeProviderSkipped ? [`every capable offer comes from a provider outside the free tier (${allowed})`] : []),++ ...(freeCeilingSkipped ? [`every capable offer costs more than the free-use ceiling of $${freeCeiling} per 1M output tokens`] : [])];++ const detail = freeProviderSkipped++ ? `this run has no payer, so its free allowance may use only ${allowed}${cap}; the model it needs is outside that. Use your own key, or run with your own token so your Balance pays.`++ : `this run has no payer, so its free allowance may use models up to $${freeCeiling} per 1M output tokens; the model it needs is dearer. Use your own key, or run with your own token so your Balance pays.`;++ throw new AiError(402, 'billing.payer_required', detail, { tried, explain });}explain.reasons = [...(explain.reasons || []), 'no provider on this route could answer'];throw new AiError(503, 'provider.unavailable', 'no provider on this route could answer', { tried, explain });@@@ -303,11 +303,12 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now// use paid capacity, counted against that budget; without it, only free capacity serves the run.const tokenBudget = runBudgets && principal.jti ? await runBudgets.spent(principal.jti) : null;const serviceBudgeted = Boolean(tokenBudget && (tokenBudget.budget_usd != null || tokenBudget.budget_tokens != null));-- // Spend safety (server/providers/index.js): a run nobody pays for — the free allowance (a service naming-- // a person, without their own token) or the Exchange's anonymous Try it (its service token, no person and-- // no run-token budget, on ai.complete) — may use only offers under the free-use output price ceiling. A-- // person's own token (their Balance), a project's run, a service's own run-token budget and a person's own-- // key are paid by someone else, so the ceiling never applies to them.++ // Spend safety (server/free-tier.js, server/providers/index.js): a run nobody pays for — the free++ // allowance (a service naming a person, without their own token) or the Exchange's anonymous Try it (its++ // service token, no person and no run-token budget, on ai.complete) — may use only offers from++ // AI_FREE_PROVIDERS and under the free-use output price ceiling. A person's own token (their Balance), a++ // project's run, a service's own run-token budget and a person's own key are paid by someone else, so++ // neither rule applies to them.const anonymousTryIt = principal.kind !== 'app' && requester.type === 'service' && requester.id === 'exchange';const freeUse = !personPays && !serviceBudgeted && !app && credentialSubject === null && !projectProvider&& (Boolean(runPerson) || (anonymousTryIt && wf.key === 'ai.complete'));@@@ -171,7 +171,9 @@ t.test('a booted service seeds the nine off, and the uncensored request is gatedcalls.push(body);return { id: 'chatcmpl-x', object: 'chat.completion', model: body.model, choices: [{ index: 0, message: { role: 'assistant', content: 'an answer from the adult offer' }, finish_reason: 'stop' }], usage: { prompt_tokens: 4, completion_tokens: 5 } };});-- const h = await boot({ env: { OV_OAUTH_CLIENT_SECRET: 'test-secret', AI_STUB_FALLBACK: 'true' }, fetchImpl });++ // The free allowance (a service naming a person) is gated by AI_FREE_PROVIDERS: this test's network is the++ // seeded stub plus the uncensored offer it boots on the seam, so both are named as free-tier sources.++ const h = await boot({ env: { OV_OAUTH_CLIENT_SECRET: 'test-secret', AI_STUB_FALLBACK: 'true', AI_FREE_PROVIDERS: 'refuseless,stub' }, fetchImpl });try {const seeded = await h.registry.listProviders();for (const p of pool.PROVIDERS) {@@@ -19,7 +19,7 @@ const summarize = async (tok, extra = {}) => await request(h.base, 'POST', '/apit.test('boot with a real (non-synthetic) seam provider', async () => {seam = await seamServer((b) => ({ text: `summary #${seam.calls}`, usage: { input: 5, output: 2 } }));-- h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_OUTPUT_COST_PER_MTOK: '0.5' } });++ h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_OUTPUT_COST_PER_MTOK: '0.5', AI_FREE_PROVIDERS: 'seam' } });await request(h.base, 'POST', '/api/v1/providers', { tok: live, body: { key: 'seam', kind: 'http', base_url: seam.url, auth_mode: 'none', capabilities: ['summarize', 'generate', 'chat', 'json'] } });await request(h.base, 'POST', '/api/v1/routes/default.chat/versions', { tok: live, body: { primary: { provider: 'seam' }, fallbacks: [] } });await request(h.base, 'POST', '/api/v1/routes/live.chat/versions', { tok: live, body: { primary: { provider: 'seam' }, fallbacks: [] } });@@@ -41,6 +41,26 @@ t.test('DeepSeek has its own offers, models and checked prices', async () => {assert.strictEqual(flash.context, 1048576);});++t.test('free_tier marks what the free allowance and Try it may use, distinct from free', async () => {++ const r = await request(h.base, 'GET', '/api/v1/exchange/catalog');++ const offerOf = (slug) => {++ const model = r.body.models.find((m) => m.slug === slug);++ assert.ok(model, `the catalog lists ${slug}`);++ return model.offers[0];++ };++ const flash = offerOf('deepseek/deepseek-flash');++ assert.strictEqual(flash.free, false, 'a priced offer, not prepaid capacity');++ assert.strictEqual(flash.free_tier, true, 'DeepSeek Flash is $1.20 out: a named source under the $1.50 ceiling');++ assert.strictEqual(offerOf('deepseek/deepseek-v4-pro').free_tier, false, 'the same source at $3.96 out is above the ceiling');++ assert.strictEqual(offerOf('openai/gpt-5').free_tier, false, 'the shared OpenAI key is not a free-tier source');++ assert.strictEqual(offerOf('cheapco/cheap-chat').free_tier, false, 'cheap is not enough: the source must be named');++ const local = offerOf('local/bge-small-en-v1.5');++ assert.strictEqual(local.free, true);++ assert.strictEqual(local.free_tier, true, 'local capacity is free and named');++ assert.strictEqual(offerOf('whisper/whisper.cpp').free_tier, true, 'whisper.cpp is local capacity');++ assert.ok(r.body.models.every((m) => m.offers.every((o) => typeof o.free_tier === 'boolean')), 'every offer carries the flag');++});++t.test('a provider whose key is not configured is never an offer', async () => {const r = await request(h.base, 'GET', '/api/v1/exchange/catalog');assert.ok(!r.body.models.some((m) => m.slug === 'unconfigured/mystery-chat'));@@@ -38,6 +38,18 @@"provider_access": "OWN_DEPLOYMENT","metadata": { "local": true, "paid": false, "billing_profile": "free" }},++ {++ "key": "cheapco",++ "display_name": "CheapCo",++ "kind": "openai",++ "status": "active",++ "base_url": "https://api.cheapco.test/v1",++ "auth_mode": "none",++ "default_model": "cheap-chat",++ "capabilities": ["chat", "generate"],++ "provider_access": "AUTHORIZED_WHOLESALE",++ "metadata": { "provider_name": "cheapco" }++ },{"key": "unconfigured","display_name": "Unconfigured upstream",@@@ -103,6 +115,15 @@"type": "embedding","supports": { "json": false }},++ {++ "provider_key": "cheapco",++ "model_key": "cheap-chat",++ "display_name": "Cheap chat",++ "type": "chat",++ "supports": { "json": true, "streaming": true },++ "cost": { "in_per_mtok": 0.1, "out_per_mtok": 0.3 },++ "metadata": { "org": "cheapco", "model": "cheap-chat", "family": "CheapCo", "tier": "fast" }++ },{"provider_key": "unconfigured","model_key": "mystery-chat",@@@ -1,7 +1,9 @@'use strict';--// Spend safety: a run nobody pays for (the free allowance, the anonymous Try it) may use only offers at or--// below AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK (default $1 per 1M output tokens). A person's own token may pay--// a dearer model from their Balance; their own key never goes through the router's cap at all.++// Spend safety: a run nobody pays for (the free allowance, the anonymous Try it) may use only offers from++// AI_FREE_PROVIDERS and at or below AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK (default 1.5 per 1M output tokens).++// A person's own token may pay a dearer model from their Balance; their own key never goes through the++// router's caps at all. The provider allowlist has its own test file (free-providers.test.js); this one names++// its two test providers so it can exercise the ceiling alone.const assert = require('assert');const { boot, request, token, suite, seamServer, ALL } = require('./helpers');const { load } = require('../server/config');@@@ -12,14 +14,14 @@ let h; let srv; let route;const tok = token('test', ALL);const exchange = token('exchange', ALL);--t.test('the ceiling is $1 by default and configurable', () => {-- assert.strictEqual(load({}).freeUse.maxOutputPricePerMtok, 1);++t.test('the ceiling is $1.5 by default and configurable', () => {++ assert.strictEqual(load({}).freeUse.maxOutputPricePerMtok, 1.5);assert.strictEqual(load({ AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK: '2.5' }).freeUse.maxOutputPricePerMtok, 2.5);});t.test('boot two offers: cheap, and a frontier model over the ceiling', async () => {srv = await seamServer(() => ({ text: 'an answer', model: 'm', usage: { input: 5, output: 2 } }));-- h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0' } });++ h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0', AI_FREE_PROVIDERS: 'cheap,dear' } });for (const [key, label] of [['cheap', 'Cheap'], ['dear', 'Dear']]) {await h.registry.upsertProvider({ key, display_name: label, kind: 'http', status: 'active', base_url: srv.url, auth_mode: 'none', capabilities: ['chat', 'generate', 'json'], default_model: models[key].model_key, timeout_ms: 3000 }, { actor: 'test', origin: 'admin' });await h.registry.upsertModel({ provider_key: key, ...models[key] }, { actor: 'test' });nnew file mode 100644@@@ -0,0 +1,90 @@++'use strict';++// The free tier's sources: a run nobody pays for (the free allowance, the anonymous Try it) may use only++// offers from AI_FREE_PROVIDERS (default deepseek,local,community) AND at or below the output price ceiling.++// The provider is as much a rule as the price: a cheaper OpenAI offer is skipped for a free run, while a++// person's own token pays for it and a local model stays free capacity.++const assert = require('assert');++const { boot, request, token, suite, seamServer, ALL } = require('./helpers');++const { load } = require('../server/config');++const freeTier = require('../server/free-tier');++++const t = suite('free-providers');++const tok = token('test', ALL);++const person = token('dana', ALL, { sub: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPY', actorType: 'user' });++const models = {++ deepseek: { model_key: 'deepseek-flash', display_name: 'DeepSeek Flash', type: 'chat', supports: { json: true, tools: true, streaming: true }, cost: { in_per_mtok: 0.3, out_per_mtok: 1.2 } },++ openai: { model_key: 'gpt-5-nano', display_name: 'GPT-5 nano', type: 'chat', supports: { json: true, tools: true, streaming: true }, cost: { in_per_mtok: 0.05, out_per_mtok: 0.2 } },++ local2: { model_key: 'qwen3-1.7b', display_name: 'Qwen3 1.7B', type: 'chat', supports: { json: true, streaming: true } },++};++let h; let srv; let route;++++t.test('the default sources, the local family and the ceiling rule', () => {++ const cfg = load({});++ assert.deepStrictEqual(cfg.freeUse.providers, ['deepseek', 'local', 'community']);++ assert.strictEqual(cfg.freeUse.maxOutputPricePerMtok, 1.5);++ assert.deepStrictEqual(load({ AI_FREE_PROVIDERS: 'deepseek, local' }).freeUse.providers, ['deepseek', 'local']);++ assert.deepStrictEqual(load({ AI_FREE_PROVIDERS: '' }).freeUse.providers, ['deepseek', 'local', 'community']);++ const sources = freeTier.sourcesOf(cfg.freeUse);++ assert.ok(freeTier.allowsProvider('deepseek', { key: 'deepseek', kind: 'openai' }, sources));++ assert.ok(freeTier.allowsProvider('local2', { key: 'local2', kind: 'openai', metadata: { local: true } }, sources), 'local covers the second local server');++ assert.ok(freeTier.allowsProvider('whisper', { key: 'whisper', kind: 'whisper' }, sources), 'local covers whisper.cpp');++ assert.ok(!freeTier.allowsProvider('shared', { key: 'shared', kind: 'openai' }, sources), 'the shared OpenAI key is not a free-tier source');++ assert.ok(freeTier.allowsCommunity(sources));++ assert.strictEqual(freeTier.allowsOffer({ providerKey: 'deepseek', outputPrice: 1.2 }, cfg.freeUse), true);++ assert.strictEqual(freeTier.allowsOffer({ providerKey: 'deepseek', outputPrice: 3.96 }, cfg.freeUse), false, 'dearer than the ceiling');++ assert.strictEqual(freeTier.allowsOffer({ providerKey: 'openai', outputPrice: 0.2 }, cfg.freeUse), false, 'cheap is not enough: the source must be named');++ assert.strictEqual(freeTier.allowsOffer({ providerKey: 'local2', providerRecord: { kind: 'openai', metadata: { local: true } }, outputPrice: null }, cfg.freeUse), true);++});++++t.test('boot a named source and a cheaper provider outside the list', async () => {++ srv = await seamServer(() => ({ text: 'an answer', model: 'm', usage: { input: 5, output: 2 } }));++ h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0' } });++ for (const key of ['deepseek', 'openai']) {++ await h.registry.upsertProvider({ key, display_name: key, kind: 'http', status: 'active', base_url: srv.url, auth_mode: 'none', capabilities: ['chat', 'generate', 'json'], default_model: models[key].model_key, timeout_ms: 3000 }, { actor: 'test', origin: 'admin' });++ await h.registry.upsertModel({ provider_key: key, ...models[key] }, { actor: 'test' });++ }++ route = await h.registry.createRouteVersion('test.free-providers', { capability: 'chat', constraints: { objective: 'correctness' }, timeout_ms: 3000 }, { actor: 'test' });++});++++t.test('a free run skips the cheaper provider outside the list and answers from DeepSeek', async () => {++ const out = await h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'hello' }], timeoutMs: 3000 }, { routeKey: route.key, routeVersion: route.version, freeUse: true });++ assert.strictEqual(out.provider, 'deepseek');++ assert.ok(out.explain.attempts.some((a) => a.provider === 'openai' && /free_provider_not_allowed/.test(a.error || '')), JSON.stringify(out.explain.attempts));++});++++t.test('the free allowance (a service naming a person) gets the same rule', async () => {++ const r = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok, body: {++ workflow: 'ai.complete', input: { messages: [{ role: 'user', content: 'hello' }] },++ on_behalf_of: { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPY' } } });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.body.run.status, 'succeeded', r.text);++ assert.strictEqual(r.body.run.provenance.provider, 'deepseek', r.text);++});++++t.test('when every offering provider is outside the list, a free run is refused 402, never run', async () => {++ const off = await request(h.base, 'POST', '/api/v1/providers/deepseek/disable', { tok });++ assert.strictEqual(off.status, 200, off.text);++ const before = srv.calls;++ await assert.rejects(++ () => h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'hello' }], timeoutMs: 3000 }, { routeKey: route.key, routeVersion: route.version, freeUse: true }),++ (err) => err.code === 'billing.payer_required' && /free allowance/.test(err.detail || err.message) && /deepseek, local, community/.test(err.detail || err.message),++ );++ assert.strictEqual(srv.calls, before, 'no provider was called');++});++++t.test("a person's own token may still use the provider outside the list", async () => {++ const r = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: person, body: { workflow: 'ai.complete', input: { messages: [{ role: 'user', content: 'hello' }] } } });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.body.run.status, 'succeeded', r.text);++ assert.strictEqual(r.body.run.provenance.provider, 'openai');++});++++t.test('`local` covers a second local server (local2) for a free run', async () => {++ await h.registry.upsertProvider({ key: 'local2', display_name: 'Local model 2', kind: 'http', status: 'active', base_url: srv.url, auth_mode: 'none', billing_profile: 'free', capabilities: ['chat', 'generate', 'json'], default_model: models.local2.model_key, timeout_ms: 3000, metadata: { local: true } }, { actor: 'test', origin: 'admin' });++ await h.registry.upsertModel({ provider_key: 'local2', ...models.local2 }, { actor: 'test' });++ const out = await h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'hello' }], timeoutMs: 3000 }, { routeKey: route.key, routeVersion: route.version, freeUse: true });++ assert.strictEqual(out.provider, 'local2');++ assert.strictEqual(out.cost, 0);++});++++t.test('stop', async () => { await h.stop(); await srv.close(); });@@@ -50,7 +50,7 @@ const credentialFetch = async (url, init = {}) => {};t.test('boot', async () => {-- h = await boot({ env: { AI_CREDENTIALS_KEY: KEY, AI_CREDENTIAL_TEST_TIMEOUT_MS: '80' }, credentialFetch, log: capture });++ h = await boot({ env: { AI_CREDENTIALS_KEY: KEY, AI_CREDENTIAL_TEST_TIMEOUT_MS: '80', AI_FREE_PROVIDERS: 'stub' }, credentialFetch, log: capture });});t.test('the person routes refuse a service token, an on_behalf_of token, and another site\'s session', async () => {@@@ -17,7 +17,9 @@ const WEATHER = { type: 'function', function: { name: 'get_weather', descriptionlet h;const post = (body, tok = service) => request(h.base, 'POST', '/v1/chat/completions', { tok, body });--t.test('boot', async () => { h = await boot(); });++// The free allowance (a service acting for a person) would be gated by AI_FREE_PROVIDERS; in tests the++// deterministic stub is the network's stand-in, so it is the named free-tier source.++t.test('boot', async () => { h = await boot({ env: { AI_FREE_PROVIDERS: 'stub' } }); });t.test('a plain chat answers the Chat Completions shape, with usage and the run it was', async () => {const r = await post({ model: 'openvibe', messages: [{ role: 'system', content: 'Be brief.' }, { role: 'user', content: 'Say hello' }] });@@@ -39,7 +39,7 @@ t.test('boot with a stub Network and a seam provider', async () => {});await new Promise((r) => network.listen(0, '127.0.0.1', r));seam = await seamServer(() => ({ text: `summary #${seam.calls}`, usage: { input: 30, output: 10 } }));-- h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_OUTPUT_COST_PER_MTOK: '0.5', OV_OAUTH_CLIENT_SECRET: 'x'.repeat(40), OV_NETWORK_INTERNAL_URL: `http://127.0.0.1:${network.address().port}` } });++ h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_OUTPUT_COST_PER_MTOK: '0.5', AI_FREE_PROVIDERS: 'seam', OV_OAUTH_CLIENT_SECRET: 'x'.repeat(40), OV_NETWORK_INTERNAL_URL: `http://127.0.0.1:${network.address().port}` } });await request(h.base, 'POST', '/api/v1/providers', { tok: live, body: { key: 'seam', kind: 'http', base_url: seam.url, auth_mode: 'none', capabilities: ['summarize', 'generate', 'chat', 'json'] } });await request(h.base, 'POST', '/api/v1/routes/default.chat/versions', { tok: live, body: { primary: { provider: 'seam' }, fallbacks: [] } });});
@@ -158,9 +158,12 @@ AI_MODEL=
@@ -158,9 +158,12 @@ AI_MODEL=
# ── Spend safety for runs nobody pays for ──
# ── Spend safety for runs nobody pays for ──
# The free allowance (a service naming a person, without their own token) and the anonymous Try it may use only
# The free allowance (a service naming a person, without their own token) and the anonymous Try it may use only
-# models at or below this output price in USD per 1M tokens. Dearer models need the person's own Balance (their
-# own token) or their own key; the router answers 402 billing.payer_required instead. Default: 1.
-#AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK=1
+# offers from these sources AND at or below this output price in USD per 1M tokens. A source is a provider key;
+# `local` covers every local model server (local, local2, whisper.cpp) and `community` the community machines.
+# Anything else needs the person's own Balance (their own token) or their own key; the router answers 402
+# billing.payer_required instead. Default providers: deepseek,local,community. Default ceiling: 1.5.
+#AI_FREE_PROVIDERS=deepseek,local,community
+#AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK=1.5
# Optional local HTTP seam provider (POST {operation, request} -> {text, json, usage})
# Optional local HTTP seam provider (POST {operation, request} -> {text, json, usage})
#AI_HTTP_SEAM_URL=http://127.0.0.1:4799/complete
#AI_HTTP_SEAM_URL=http://127.0.0.1:4799/complete
@@ -600,13 +600,18 @@ their price out. It is pricing, never admission: quotas, request counting and th
@@ -600,13 +600,18 @@ their price out. It is pricing, never admission: quotas, request counting and th
unchanged, and no call is ever refused because the allowance is used up. Every card defaults to 0, so
unchanged, and no call is ever refused because the allowance is used up. Every card defaults to 0, so
nothing is free and the field is absent until an operator configures an allowance.
nothing is free and the field is absent until an operator configures an allowance.
-Free-use price ceiling (`AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK`, default 1): a run that nobody pays for
-— the free allowance (a first-party service naming a person, without that person's own token) or the
-Exchange's anonymous Try it — may use only offers at or below this output price per million tokens.
-The router skips dearer offers and answers `402 billing.payer_required` when every capable offer is
-above it. A person's own token (and so their Balance, AI #311), their own provider key, an app's
-project and a service's own run-token budget are not capped this way. The check is admission, not
-pricing: it never changes a price or a Balance reading.
+Free-use sources and price ceiling (`AI_FREE_PROVIDERS`, default `deepseek,local,community`;
+`AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK`, default 1.5): a run that nobody pays for — the free allowance
+(a first-party service naming a person, without that person's own token) or the Exchange's anonymous Try
+it — may use only offers from those sources and at or below this output price per million tokens. A
+source is a provider key; `local` covers every local model server (`local`, `local2`, whisper.cpp) and
+`community` the community machines. The design funds free use from the owner's DeepSeek key, local models
+and volunteers, never from Live's shared OpenAI key. The router skips the rest and answers
+`402 billing.payer_required` when every capable offer is outside those rules. A person's own token (and so
+their Balance, AI #311), their own provider key, an app's project and a service's own run-token budget are
+not capped this way. The check is admission, not pricing: it never changes a price or a Balance reading.
+The Exchange catalog marks each offer `free_tier` by the same two rules (distinct from `free`, which means
+prepaid capacity with no per-token price).
Tier budgets (T6 step 8, decision D5; `server/govern.js`, off unless `AI_GOVERN_TIERS=1`): the SaaS tiers
Tier budgets (T6 step 8, decision D5; `server/govern.js`, off unless `AI_GOVERN_TIERS=1`): the SaaS tiers
`free`, `paid` and `staff` on `openvibe-sdk/govern`, counted in Valkey. Before a run touches a provider
`free`, `paid` and `staff` on `openvibe-sdk/govern`, counted in Valkey. Before a run touches a provider
@@ -34,8 +34,8 @@ function memo(fn, ttlMs, clock) {
@@ -34,8 +34,8 @@ function memo(fn, ttlMs, clock) {
};
};
}
}
-function exchangeRouter({ db, registry, pool, env = process.env, clock = { now: () => Date.now() }, presets, receipts, auth, log = console }) {
- const catalog = createCatalog({ db, registry, pool, env, clock });
+function exchangeRouter({ db, registry, pool, env = process.env, clock = { now: () => Date.now() }, config = null, presets, receipts, auth, log = console }) {
+ const catalog = createCatalog({ db, registry, pool, env, clock, config });
const usage = createUsage({ db, clock });
const usage = createUsage({ db, clock });
// One cached read per kind (?kind=chat, screenshot, …); the unfiltered read is the full catalog.
// One cached read per kind (?kind=chat, screenshot, …); the unfiltered read is the full catalog.
const hits = new Map();
const hits = new Map();
@@ -110,7 +110,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
@@ -110,7 +110,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
openapi.mount(app);
openapi.mount(app);
// OpenVibe.Exchange's read API (server/api/exchange.js): the public catalog and aggregate usage. Public on
// OpenVibe.Exchange's read API (server/api/exchange.js): the public catalog and aggregate usage. Public on
// purpose — the site and anyone comparing prices reads the same numbers the router ranks on.
// purpose — the site and anyone comparing prices reads the same numbers the router ranks on.
- app.use(require('./api/exchange').exchangeRouter({ db, registry, pool, env, clock, presets, receipts, auth, log }));
+ app.use(require('./api/exchange').exchangeRouter({ config, db, registry, pool, env, clock, presets, receipts, auth, log }));
// GET /release.json (ADR-016) and POST /release-metrics (open tabs' update reports into /metrics).
// GET /release.json (ADR-016) and POST /release-metrics (open tabs' update reports into /metrics).
release.mount(app, { registry: metrics.registry });
release.mount(app, { registry: metrics.registry });
@@ -13,6 +13,7 @@
@@ -13,6 +13,7 @@
*/
*/
const { loadGovern } = require('./govern');
const { loadGovern } = require('./govern');
+const { DEFAULT_PROVIDERS: DEFAULT_FREE_PROVIDERS } = require('./free-tier');
const int = (v, d) => {
const int = (v, d) => {
const n = parseInt(v, 10);
const n = parseInt(v, 10);
@@ -140,10 +141,12 @@ function load(env = process.env) {
@@ -140,10 +141,12 @@ function load(env = process.env) {
},
},
// ── Spend safety for runs nobody pays for (the free allowance, the anonymous Try it) ──
// ── Spend safety for runs nobody pays for (the free allowance, the anonymous Try it) ──
// A run with no person's own token, no app project, no service run-token budget and no own key may
// A run with no person's own token, no app project, no service run-token budget and no own key may
- // use only offers at or below this output price (USD per 1M tokens). Dearer models need the person's
- // own Balance or their own key; the router answers 402 billing.payer_required instead.
+ // use only offers from these sources (provider keys; `local` is every local model server and
+ // `community` the community machines) AND at or below this output price (USD per 1M tokens). Anything
+ // else needs the person's own Balance or their own key; the router answers 402 billing.payer_required.
freeUse: {
freeUse: {
- maxOutputPricePerMtok: Math.max(0, float(env.AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK, 1)),
+ providers: list(env.AI_FREE_PROVIDERS, DEFAULT_FREE_PROVIDERS),
+ maxOutputPricePerMtok: Math.max(0, float(env.AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK, 1.5)),
},
},
// ── Placement (T6 provider router): openvibe-sdk/placement orders a route's capability pool ──
// ── Placement (T6 provider router): openvibe-sdk/placement orders a route's capability pool ──
placement: {
placement: {
@@ -14,6 +14,7 @@
@@ -14,6 +14,7 @@
*/
*/
const rates = require('../providers/rate-cards');
const rates = require('../providers/rate-cards');
const upstream = require('../providers/upstream');
const upstream = require('../providers/upstream');
+const { allowsOffer } = require('../free-tier');
const DAY_MS = 86400000;
const DAY_MS = 86400000;
const FAMILY_BY_PREFIX = [
const FAMILY_BY_PREFIX = [
@@ -101,7 +102,11 @@ function modalitiesOf(row) {
@@ -101,7 +102,11 @@ function modalitiesOf(row) {
return [...out];
return [...out];
}
}
-function createCatalog({ db, registry, pool, env = process.env, clock = { now: () => Date.now() } }) {
+function createCatalog({ db, registry, pool, env = process.env, clock = { now: () => Date.now() }, config = null }) {
+ // The free-tier admission rules are the router's (server/free-tier.js): the catalog marks an offer with what
+ // the free allowance and the anonymous Try it may actually use. config.freeUse is fixed at boot, like the
+ // provider keys and upstreams the rest of this read shows.
+ const freeUse = config && config.freeUse ? config.freeUse : undefined;
const getStats = db.prepare(`SELECT provider, model,
const getStats = db.prepare(`SELECT provider, model,
SUM(requests)::bigint AS requests, SUM(ok)::bigint AS ok, SUM(errors)::bigint AS errors,
SUM(requests)::bigint AS requests, SUM(ok)::bigint AS ok, SUM(errors)::bigint AS errors,
CASE WHEN SUM(requests) > 0 THEN SUM(COALESCE(latency_p50_ms, 0) * requests) / SUM(requests) END AS p50,
CASE WHEN SUM(requests) > 0 THEN SUM(COALESCE(latency_p50_ms, 0) * requests) / SUM(requests) END AS p50,
@@ -176,6 +181,10 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
@@ -176,6 +181,10 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
trust: isCommunity(provider) ? 'community' : 'first-party',
trust: isCommunity(provider) ? 'community' : 'first-party',
access: provider.provider_access || 'UNVERIFIED',
access: provider.provider_access || 'UNVERIFIED',
free: cards == null,
free: cards == null,
+ // What the free allowance and the anonymous Try it may use, distinct from `free` above (prepaid
+ // capacity with no per-token card): both its provider is a named AI_FREE_PROVIDERS source and its
+ // output price is at or below AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK.
+ free_tier: allowsOffer({ providerKey: provider.key, providerRecord: provider, outputPrice: price ? price.out : null }, freeUse),
price: price ? { in: price.in, out: price.out, cached: price.cached, per: '1M tokens', currency: 'USD' } : null,
price: price ? { in: price.in, out: price.out, cached: price.cached, per: '1M tokens', currency: 'USD' } : null,
// Where the number came from and when someone checked it; null when nobody recorded it.
// Where the number came from and when someone checked it; null when nobody recorded it.
price_source: cards && cards.out.source && !String(cards.out.source).startsWith('urn:openvibe:ai:unverified:') ? cards.out.source : null,
price_source: cards && cards.out.source && !String(cards.out.source).startsWith('urn:openvibe:ai:unverified:') ? cards.out.source : null,
new file mode 100644
new file mode 100644
@@ -0,0 +1,63 @@
@@ -0,0 +1,63 @@
+'use strict';
+/**
+ * Who may serve a run nobody pays for (the free allowance and the Exchange's anonymous Try it).
+ *
+ * The design funds free use from the owner's DeepSeek key, local models and community volunteers
+ * (EXCHANGE-REMAKE-2026-11-06.md, "Money and free use"). AI_FREE_PROVIDERS names those sources; every
+ * other provider needs a payer. AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK then caps what a metered source may
+ * cost, so a free run never lands on a dearer model through the owner's key.
+ *
+ * A source id is a provider's own key (deepseek, shared, …) or one of two families:
+ * - `local` matches every local model server (the seeded `local` and `local2`, and whisper.cpp);
+ * - `community` matches the community machines (server/community/, which has no registry provider row).
+ * Matching is case-insensitive. An empty list allows nothing: the free tier then needs a payer.
+ *
+ * Both rules are admission, never pricing: a skipped offer is not charged, and a person's own key, own
+ * token (their Balance), project or service run-token budget never goes through here.
+ */
+
+/** The sources a free run may use when AI_FREE_PROVIDERS is unset (the design's three funding sources). */
+const DEFAULT_PROVIDERS = ['deepseek', 'local', 'community'];
+
+const isLocal = (provider) => Boolean(provider) && (provider.kind === 'whisper' || Boolean(provider.metadata && provider.metadata.local));
+
+/** The configured source ids, lowercased; `freeUse` is config.freeUse (undefined falls back to the default). */
+function sourcesOf(freeUse) {
+ const list = freeUse && Array.isArray(freeUse.providers) ? freeUse.providers : DEFAULT_PROVIDERS;
+ return new Set(list.map((s) => String(s).trim().toLowerCase()).filter(Boolean));
+}
+
+/** The output price ceiling in USD per 1M tokens, or null when the config names no usable number. */
+function ceilingOf(freeUse) {
+ const n = Number(freeUse && freeUse.maxOutputPricePerMtok);
+ return Number.isFinite(n) ? n : null;
+}
+
+/**
+ * May this provider record serve a free run? An exact key always matches; `local` also covers a local
+ * server under another key (local2), so adding a second local model never silently leaves the free tier.
+ */
+function allowsProvider(providerKey, providerRecord, sources) {
+ if (!sources) return false;
+ const key = String(providerKey || '').trim().toLowerCase();
+ if (!key) return false;
+ if (sources.has(key)) return true;
+ return isLocal(providerRecord) && sources.has('local');
+}
+
+/** May a community machine serve a free run? (community capacity is not a registry provider.) */
+const allowsCommunity = (sources) => Boolean(sources) && sources.has('community');
+
+/**
+ * Could this offer serve the free tier? Both rules: its provider is a named source and its output price
+ * is at or below the ceiling. An offer with no per-token card (local, prepaid) has no output price to cap.
+ */
+function allowsOffer({ providerKey, providerRecord = null, outputPrice = null }, freeUse) {
+ const sources = sourcesOf(freeUse);
+ if (!allowsProvider(providerKey, providerRecord, sources)) return false;
+ const ceiling = ceilingOf(freeUse);
+ if (ceiling == null) return true;
+ return !(Number(outputPrice) > ceiling);
+}
+
+module.exports = { DEFAULT_PROVIDERS, isLocal, sourcesOf, ceilingOf, allowsProvider, allowsCommunity, allowsOffer };
@@ -292,7 +292,7 @@ route('get', '/run-budgets/{jti}', {
@@ -292,7 +292,7 @@ route('get', '/run-budgets/{jti}', {
// ── Exchange read API (OpenVibe.Exchange) ───────────────────────────────────────────────────
// ── Exchange read API (OpenVibe.Exchange) ───────────────────────────────────────────────────
const exchangeOffer = {
const exchangeOffer = {
- type: 'object', required: ['provider', 'provider_name', 'model', 'kind', 'trust', 'free', 'price'],
+ type: 'object', required: ['provider', 'provider_name', 'model', 'kind', 'trust', 'free', 'free_tier', 'price'],
properties: {
properties: {
provider: str({ description: 'The real upstream, never the router\'s internal provider key' }), provider_name: str(), model: str(),
provider: str({ description: 'The real upstream, never the router\'s internal provider key' }), provider_name: str(), model: str(),
kind: str({ enum: ['chat', 'embedding', 'stt', 'image'] }),
kind: str({ enum: ['chat', 'embedding', 'stt', 'image'] }),
@@ -300,6 +300,7 @@ const exchangeOffer = {
@@ -300,6 +300,7 @@ const exchangeOffer = {
trust: { type: 'string', enum: ['first-party', 'community'] },
trust: { type: 'string', enum: ['first-party', 'community'] },
access: str({ enum: ['OWN_DEPLOYMENT', 'AUTHORIZED_WHOLESALE', 'UNVERIFIED'] }),
access: str({ enum: ['OWN_DEPLOYMENT', 'AUTHORIZED_WHOLESALE', 'UNVERIFIED'] }),
free: { type: 'boolean', description: 'Prepaid capacity with no per-token price (a free server, a subscription, a local machine).' },
free: { type: 'boolean', description: 'Prepaid capacity with no per-token price (a free server, a subscription, a local machine).' },
+ free_tier: { type: 'boolean', description: 'The free allowance and the anonymous Try it may use this offer: its provider is one of AI_FREE_PROVIDERS (default deepseek,local,community; local covers local2 and whisper.cpp) and its output price is at or below AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK (default 1.5). Distinct from free: a priced DeepSeek Flash offer is free_tier true while a dearer priced offer is false.' },
price: nullable('object', { properties: { in: { type: 'number' }, out: { type: 'number' }, cached: { type: 'number' }, per: str(), currency: str() } }),
price: nullable('object', { properties: { in: { type: 'number' }, out: { type: 'number' }, cached: { type: 'number' }, per: str(), currency: str() } }),
price_source: nullable('string', { description: 'The price page the card was read from, when recorded' }),
price_source: nullable('string', { description: 'The price page the card was read from, when recorded' }),
price_checked_at: nullable('string', { description: 'The date someone checked the price, when recorded' }),
price_checked_at: nullable('string', { description: 'The date someone checked the price, when recorded' }),
@@ -323,7 +324,7 @@ const exchangeModel = {
@@ -323,7 +324,7 @@ const exchangeModel = {
};
};
route('get', '/exchange/catalog', {
route('get', '/exchange/catalog', {
operationId: 'exchangeCatalog', summary: 'Every model and every offer that serves it', tags: ['exchange'], security: [],
operationId: 'exchangeCatalog', summary: 'Every model and every offer that serves it', tags: ['exchange'], security: [],
- description: 'Public. The live offers: price in, out and cached per million tokens, measured p50/p95, uptime, requests, and the provider data policy. A prepaid offer has no price (free: true). The window is 7 days. Models carry a kind (chat, embedding, stt, image); chat lists must use kind "chat" and never mix embeddings or speech-to-text.',
+ description: 'Public. The live offers: price in, out and cached per million tokens, measured p50/p95, uptime, requests, and the provider data policy. A prepaid offer has no price (free: true); free_tier says whether the free allowance and the anonymous Try it may use an offer at all (a named AI_FREE_PROVIDERS source under the output price ceiling). The window is 7 days. Models carry a kind (chat, embedding, stt, image); chat lists must use kind "chat" and never mix embeddings or speech-to-text.',
parameters: [query('kind', str({ enum: ['chat', 'embedding', 'stt', 'image'] }), 'Only models of this kind')],
parameters: [query('kind', str({ enum: ['chat', 'embedding', 'stt', 'image'] }), 'Only models of this kind')],
responses: { 200: json('The models, grouped by model, with their offers', { type: 'object', required: ['models'], properties: {
responses: { 200: json('The models, grouped by model, with their offers', { type: 'object', required: ['models'], properties: {
generated_at: str({ format: 'date-time' }), window_days: integer(), count: integer(),
generated_at: str({ format: 'date-time' }), window_days: integer(), count: integer(),
@@ -28,6 +28,7 @@ const placement = require('openvibe-sdk/placement');
@@ -28,6 +28,7 @@ const placement = require('openvibe-sdk/placement');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const { AiError, resolveSecret, sha256 } = require('../util');
const { AiError, resolveSecret, sha256 } = require('../util');
const { retryable, sleep, ProviderError, schemaInstruction, insufficientBalance } = require('./common');
const { retryable, sleep, ProviderError, schemaInstruction, insufficientBalance } = require('./common');
+const { sourcesOf, ceilingOf, allowsProvider, allowsCommunity } = require('../free-tier');
const { createStubProvider } = require('./stub');
const { createStubProvider } = require('./stub');
const { createOpenAiProvider } = require('./openai');
const { createOpenAiProvider } = require('./openai');
const { createResponsesProvider } = require('./responses');
const { createResponsesProvider } = require('./responses');
@@ -995,12 +996,14 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -995,12 +996,14 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
// request. With only_my_keys it never falls back; otherwise the network is the fallback and the explain says so.
// request. With only_my_keys it never falls back; otherwise the network is the fallback and the explain says so.
const prefs = ctx.selfPreferences || {};
const prefs = ctx.selfPreferences || {};
// Spend safety: the free allowance and the anonymous Try it (ctx.freeUse, server/runs.js) may use only
// Spend safety: the free allowance and the anonymous Try it (ctx.freeUse, server/runs.js) may use only
- // offers at or below this output price. A person's own token (their Balance) or their own key is not capped
- // this way. A person's own price ceiling still applies to the network fallback as before.
- const configuredFreeCeiling = Number(config.freeUse && config.freeUse.maxOutputPricePerMtok);
- const freeCeiling = ctx.freeUse && Number.isFinite(configuredFreeCeiling) ? configuredFreeCeiling : null;
+ // offers from AI_FREE_PROVIDERS (server/free-tier.js) and at or below this output price. A person's own
+ // token (their Balance) or their own key is not capped this way. A person's own price ceiling still
+ // applies to the network fallback as before.
+ const freeCeiling = ctx.freeUse ? ceilingOf(config.freeUse) : null;
+ const freeSources = ctx.freeUse ? sourcesOf(config.freeUse) : null;
const ceiling = freeCeiling != null ? freeCeiling : prefs.price_ceiling_usd_per_mtok;
const ceiling = freeCeiling != null ? freeCeiling : prefs.price_ceiling_usd_per_mtok;
let freeCeilingSkipped = false; // every capable offer was dearer than the free ceiling
let freeCeilingSkipped = false; // every capable offer was dearer than the free ceiling
+ let freeProviderSkipped = false; // every capable offer came from a source the free tier may not use
const selfAttempts = [];
const selfAttempts = [];
// The adult tier is network supply only: a person's own provider key is not an offer marked uncensored, and
// The adult tier is network supply only: a person's own provider key is not an offer marked uncensored, and
// only such an offer may answer an uncensored request. Their key is still used for every standard request.
// only such an offer may answer an uncensored request. Their key is still used for every standard request.
@@ -1064,20 +1067,24 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -1064,20 +1067,24 @@ 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.
// Community placement runs before the catalog pool. A preset with price, privacy, region, or quality
// Community placement runs before the catalog pool. A preset with price, privacy, region, or quality
// rules must use the catalog path so those rules are checked before any offer is called.
// rules must use the catalog path so those rules are checked before any offer is called.
const communityPresetSafe = !ctx.presetPolicy || Object.keys(ctx.presetPolicy).every((key) => key === 'community');
const communityPresetSafe = !ctx.presetPolicy || Object.keys(ctx.presetPolicy).every((key) => key === 'community');
if (community && ctx.communityOptIn && communityPresetSafe && !ctx.credential && !ctx.byok && !ctx.exact) {
if (community && ctx.communityOptIn && communityPresetSafe && !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}`);
+ // Community capacity is a free-tier source only when the allowlist names it: without that, the
+ // ordinary network answers and the skip is named in the explain, never silently used.
+ if (freeSources && !allowsCommunity(freeSources)) {
+ tried.push({ provider: 'community', skipped: 'free_provider_not_allowed' });
+ freeProviderSkipped = true;
+ } else {
+ try {
+ const out = await community.execute({ route, operation, req, ctx });
+ if (out) return out;
+ } catch (err) {
+ const why = String((err && (err.detail || err.message)) || err).slice(0, 300);
+ tried.push({ provider: 'community', error: why });
+ log.warn(`[ai] community ${operation} failed, falling back to the network: ${why}`);
+ }
}
}
}
}
// CHECKPOINT D3: an exact id runs only its own artifact. The candidates are the catalog's deployments of
// CHECKPOINT D3: an exact id runs only its own artifact. The candidates are the catalog's deployments of
@@ -1104,8 +1111,12 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -1104,8 +1111,12 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
if (!p) why.push('unknown_provider');
if (!p) why.push('unknown_provider');
else if (!policyOk) why.push('content_policy');
else if (!policyOk) why.push('content_policy');
else if (ctx.creditsRequired && !isFreeProvider(p) && card && card.version) { why.push('payer_required'); payerSkipped = true; }
else if (ctx.creditsRequired && !isFreeProvider(p) && card && card.version) { why.push('payer_required'); payerSkipped = true; }
- else {
- // The free allowance / anonymous Try it cannot pin a model above the output price ceiling.
+ else if (freeSources && !allowsProvider(p.key, p, freeSources)) {
+ // The free allowance / anonymous Try it may pin only a model from one of its sources...
+ why.push('free_provider_not_allowed');
+ freeProviderSkipped = true;
+ } else {
+ // ...and never one above the output price ceiling.
const price = freeCeiling != null && !isFreeProvider(p) ? await priceFor(offer.provider, offer.model) : null;
const price = freeCeiling != null && !isFreeProvider(p) ? await priceFor(offer.provider, offer.model) : null;
if (price && Number(price.out) > freeCeiling) { why.push('over_price_ceiling'); freeCeilingSkipped = true; }
if (price && Number(price.out) > freeCeiling) { why.push('over_price_ceiling'); freeCeilingSkipped = true; }
else {
else {
@@ -1120,9 +1131,11 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -1120,9 +1131,11 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
if (!reason) usable.push({ provider: offer.provider, model: offer.model });
if (!reason) usable.push({ provider: offer.provider, model: offer.model });
}
}
explain = { objective: 'exact', policy: 'exact', reasons: [`exact model ${exact.id}: only its own deployment may answer`], selected: null, candidates: candidatesView,
explain = { objective: 'exact', policy: 'exact', reasons: [`exact model ${exact.id}: only its own deployment may answer`], selected: null, candidates: candidatesView,
- ...(freeCeiling != null ? { free_ceiling_usd_per_mtok: freeCeiling } : {}) };
+ ...(freeCeiling != null ? { free_ceiling_usd_per_mtok: freeCeiling } : {}),
+ ...(freeSources ? { free_providers: [...freeSources] } : {}) };
if (!usable.length) {
if (!usable.length) {
if (payerSkipped) throw new AiError(402, 'billing.payer_required', `the exact model ${exact.id} needs a Balance payer (a person, or an app with its project); it was not run`, { explain });
if (payerSkipped) throw new AiError(402, 'billing.payer_required', `the exact model ${exact.id} needs a Balance payer (a person, or an app with its project); it was not run`, { explain });
+ if (freeProviderSkipped) throw new AiError(402, 'billing.payer_required', `the exact model ${exact.id} comes from a provider the free allowance may not use (${[...freeSources].join(', ')}); use your own key, or run with your own token so your Balance pays`, { explain });
if (freeCeilingSkipped) throw new AiError(402, 'billing.payer_required', `the exact model ${exact.id} is above the free-use ceiling of $${freeCeiling} per 1M output tokens; use your own key, or run with your own token so your Balance pays`, { explain });
if (freeCeilingSkipped) throw new AiError(402, 'billing.payer_required', `the exact model ${exact.id} is above the free-use ceiling of $${freeCeiling} per 1M output tokens; use your own key, or run with your own token so your Balance pays`, { explain });
throw new AiError(400, 'ai.model_unavailable', `the artifact ${exact.id} cannot run this request`, { explain });
throw new AiError(400, 'ai.model_unavailable', `the artifact ${exact.id} cannot run this request`, { explain });
}
}
@@ -1259,6 +1272,19 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -1259,6 +1272,19 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const chosen = ranked.reasons.get(c.provider + ':' + (model || '')) || null;
const chosen = ranked.reasons.get(c.provider + ':' + (model || '')) || null;
const fallback = i > 0;
const fallback = i > 0;
const base = { operation, provider_key: c.provider, model_key: model, route_key: ctx.routeKey || null, route_version: ctx.routeVersion || null, fallback: fallback ? 1 : 0, prompt_hash: ctx.promptHash || null, input_hash: ctx.inputHash || null };
const base = { operation, provider_key: c.provider, model_key: model, route_key: ctx.routeKey || null, route_version: ctx.routeVersion || null, fallback: fallback ? 1 : 0, prompt_hash: ctx.promptHash || null, input_hash: ctx.inputHash || null };
+ // Spend safety: the free allowance / anonymous Try it runs only from its named sources
+ // (server/free-tier.js). A provider outside them is skipped even when it is cheaper: who pays
+ // matters, not only the price. The person's own keys (above) are always tried first and are never
+ // gated here.
+ if (freeSources && !allowsProvider(c.provider, p, freeSources)) {
+ if (preferState && preferMatches(c.provider, model, prefer)) { preferState.honoured = false; preferState.why = 'it is not a provider the free tier may use'; }
+ statFor(c.provider).skipped++;
+ tried.push({ provider: c.provider, skipped: 'free_provider_not_allowed' });
+ attempts.push({ key: 'openvibe', provider: c.provider, model, why: chosen || 'the router chose it', status: 'skipped', error: 'free_provider_not_allowed' });
+ if (ctx.logRequest) ctx.logRequest({ ...base, status: 'skipped', skip_reason: 'free_provider_not_allowed' });
+ freeProviderSkipped = true;
+ continue;
+ }
// L36 Y3 / spend safety: a price ceiling caps the network's fallback; the person's own keys are never
// L36 Y3 / spend safety: a price ceiling caps the network's fallback; the person's own keys are never
// capped (above). The free allowance's ceiling is the network's, so an over-ceiling offer is skipped.
// capped (above). The free allowance's ceiling is the network's, so an over-ceiling offer is skipped.
if (ceiling != null) {
if (ceiling != null) {
@@ -1464,12 +1490,21 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -1464,12 +1490,21 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
explain.reasons = [...(explain.reasons || []), 'a provider is out of balance or paused; AI is resting'];
explain.reasons = [...(explain.reasons || []), 'a provider is out of balance or paused; AI is resting'];
throw new AiError(503, 'ai.resting', 'AI is resting for a moment', { retry_after_seconds: Math.ceil(exhaustedCooldownMs() / 1000), tried, explain });
throw new AiError(503, 'ai.resting', 'AI is resting for a moment', { retry_after_seconds: Math.ceil(exhaustedCooldownMs() / 1000), tried, explain });
}
}
- // The free allowance / anonymous Try it had capable offers, but every one was above its ceiling. That is
- // not an outage: the caller needs their own key, or their own token so their Balance can pay.
- if (freeCeilingSkipped && attemptsMade === 0) {
- explain.free_ceiling_usd_per_mtok = freeCeiling;
- explain.reasons = [...(explain.reasons || []), `every capable offer costs more than the free-use ceiling of $${freeCeiling} per 1M output tokens`];
- throw new AiError(402, 'billing.payer_required', `this run has no payer, so its free allowance may use models up to $${freeCeiling} per 1M output tokens; the model it needs is dearer. Use your own key, or run with your own token so your Balance pays.`, { tried, explain });
+ // The free allowance / anonymous Try it had capable offers, but every one was outside its sources or
+ // above its ceiling. That is not an outage: the caller needs their own key, or their own token so
+ // their Balance can pay.
+ if (attemptsMade === 0 && (freeCeilingSkipped || freeProviderSkipped)) {
+ const allowed = [...freeSources].join(', ');
+ const cap = freeCeiling == null ? '' : ` at up to $${freeCeiling} per 1M output tokens`;
+ explain.free_providers = [...freeSources];
+ if (freeCeiling != null) explain.free_ceiling_usd_per_mtok = freeCeiling;
+ explain.reasons = [...(explain.reasons || []),
+ ...(freeProviderSkipped ? [`every capable offer comes from a provider outside the free tier (${allowed})`] : []),
+ ...(freeCeilingSkipped ? [`every capable offer costs more than the free-use ceiling of $${freeCeiling} per 1M output tokens`] : [])];
+ const detail = freeProviderSkipped
+ ? `this run has no payer, so its free allowance may use only ${allowed}${cap}; the model it needs is outside that. Use your own key, or run with your own token so your Balance pays.`
+ : `this run has no payer, so its free allowance may use models up to $${freeCeiling} per 1M output tokens; the model it needs is dearer. Use your own key, or run with your own token so your Balance pays.`;
+ throw new AiError(402, 'billing.payer_required', detail, { tried, explain });
}
}
explain.reasons = [...(explain.reasons || []), 'no provider on this route could answer'];
explain.reasons = [...(explain.reasons || []), 'no provider on this route could answer'];
throw new AiError(503, 'provider.unavailable', 'no provider on this route could answer', { tried, explain });
throw new AiError(503, 'provider.unavailable', 'no provider on this route could answer', { tried, explain });
@@ -303,11 +303,12 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -303,11 +303,12 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
// use paid capacity, counted against that budget; without it, only free capacity serves the run.
// use paid capacity, counted against that budget; without it, only free capacity serves the run.
const tokenBudget = runBudgets && principal.jti ? await runBudgets.spent(principal.jti) : null;
const tokenBudget = runBudgets && principal.jti ? await runBudgets.spent(principal.jti) : null;
const serviceBudgeted = Boolean(tokenBudget && (tokenBudget.budget_usd != null || tokenBudget.budget_tokens != null));
const serviceBudgeted = Boolean(tokenBudget && (tokenBudget.budget_usd != null || tokenBudget.budget_tokens != null));
- // Spend safety (server/providers/index.js): a run nobody pays for — the free allowance (a service naming
- // a person, without their own token) or the Exchange's anonymous Try it (its service token, no person and
- // no run-token budget, on ai.complete) — may use only offers under the free-use output price ceiling. A
- // person's own token (their Balance), a project's run, a service's own run-token budget and a person's own
- // key are paid by someone else, so the ceiling never applies to them.
+ // Spend safety (server/free-tier.js, server/providers/index.js): a run nobody pays for — the free
+ // allowance (a service naming a person, without their own token) or the Exchange's anonymous Try it (its
+ // service token, no person and no run-token budget, on ai.complete) — may use only offers from
+ // AI_FREE_PROVIDERS and under the free-use output price ceiling. A person's own token (their Balance), a
+ // project's run, a service's own run-token budget and a person's own key are paid by someone else, so
+ // neither rule applies to them.
const anonymousTryIt = principal.kind !== 'app' && requester.type === 'service' && requester.id === 'exchange';
const anonymousTryIt = principal.kind !== 'app' && requester.type === 'service' && requester.id === 'exchange';
const freeUse = !personPays && !serviceBudgeted && !app && credentialSubject === null && !projectProvider
const freeUse = !personPays && !serviceBudgeted && !app && credentialSubject === null && !projectProvider
&& (Boolean(runPerson) || (anonymousTryIt && wf.key === 'ai.complete'));
&& (Boolean(runPerson) || (anonymousTryIt && wf.key === 'ai.complete'));
@@ -171,7 +171,9 @@ t.test('a booted service seeds the nine off, and the uncensored request is gated
@@ -171,7 +171,9 @@ t.test('a booted service seeds the nine off, and the uncensored request is gated
calls.push(body);
calls.push(body);
return { id: 'chatcmpl-x', object: 'chat.completion', model: body.model, choices: [{ index: 0, message: { role: 'assistant', content: 'an answer from the adult offer' }, finish_reason: 'stop' }], usage: { prompt_tokens: 4, completion_tokens: 5 } };
return { id: 'chatcmpl-x', object: 'chat.completion', model: body.model, choices: [{ index: 0, message: { role: 'assistant', content: 'an answer from the adult offer' }, finish_reason: 'stop' }], usage: { prompt_tokens: 4, completion_tokens: 5 } };
});
});
- const h = await boot({ env: { OV_OAUTH_CLIENT_SECRET: 'test-secret', AI_STUB_FALLBACK: 'true' }, fetchImpl });
+ // The free allowance (a service naming a person) is gated by AI_FREE_PROVIDERS: this test's network is the
+ // seeded stub plus the uncensored offer it boots on the seam, so both are named as free-tier sources.
+ const h = await boot({ env: { OV_OAUTH_CLIENT_SECRET: 'test-secret', AI_STUB_FALLBACK: 'true', AI_FREE_PROVIDERS: 'refuseless,stub' }, fetchImpl });
try {
try {
const seeded = await h.registry.listProviders();
const seeded = await h.registry.listProviders();
for (const p of pool.PROVIDERS) {
for (const p of pool.PROVIDERS) {
@@ -19,7 +19,7 @@ const summarize = async (tok, extra = {}) => await request(h.base, 'POST', '/api
@@ -19,7 +19,7 @@ const summarize = async (tok, extra = {}) => await request(h.base, 'POST', '/api
t.test('boot with a real (non-synthetic) seam provider', async () => {
t.test('boot with a real (non-synthetic) seam provider', async () => {
seam = await seamServer((b) => ({ text: `summary #${seam.calls}`, usage: { input: 5, output: 2 } }));
seam = await seamServer((b) => ({ text: `summary #${seam.calls}`, usage: { input: 5, output: 2 } }));
- h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_OUTPUT_COST_PER_MTOK: '0.5' } });
+ h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_OUTPUT_COST_PER_MTOK: '0.5', AI_FREE_PROVIDERS: 'seam' } });
await request(h.base, 'POST', '/api/v1/providers', { tok: live, body: { key: 'seam', kind: 'http', base_url: seam.url, auth_mode: 'none', capabilities: ['summarize', 'generate', 'chat', 'json'] } });
await request(h.base, 'POST', '/api/v1/providers', { tok: live, body: { key: 'seam', kind: 'http', base_url: seam.url, auth_mode: 'none', capabilities: ['summarize', 'generate', 'chat', 'json'] } });
await request(h.base, 'POST', '/api/v1/routes/default.chat/versions', { tok: live, body: { primary: { provider: 'seam' }, fallbacks: [] } });
await request(h.base, 'POST', '/api/v1/routes/default.chat/versions', { tok: live, body: { primary: { provider: 'seam' }, fallbacks: [] } });
await request(h.base, 'POST', '/api/v1/routes/live.chat/versions', { tok: live, body: { primary: { provider: 'seam' }, fallbacks: [] } });
await request(h.base, 'POST', '/api/v1/routes/live.chat/versions', { tok: live, body: { primary: { provider: 'seam' }, fallbacks: [] } });
@@ -41,6 +41,26 @@ t.test('DeepSeek has its own offers, models and checked prices', async () => {
@@ -41,6 +41,26 @@ t.test('DeepSeek has its own offers, models and checked prices', async () => {
assert.strictEqual(flash.context, 1048576);
assert.strictEqual(flash.context, 1048576);
});
});
+t.test('free_tier marks what the free allowance and Try it may use, distinct from free', async () => {
+ const r = await request(h.base, 'GET', '/api/v1/exchange/catalog');
+ const offerOf = (slug) => {
+ const model = r.body.models.find((m) => m.slug === slug);
+ assert.ok(model, `the catalog lists ${slug}`);
+ return model.offers[0];
+ };
+ const flash = offerOf('deepseek/deepseek-flash');
+ assert.strictEqual(flash.free, false, 'a priced offer, not prepaid capacity');
+ assert.strictEqual(flash.free_tier, true, 'DeepSeek Flash is $1.20 out: a named source under the $1.50 ceiling');
+ assert.strictEqual(offerOf('deepseek/deepseek-v4-pro').free_tier, false, 'the same source at $3.96 out is above the ceiling');
+ assert.strictEqual(offerOf('openai/gpt-5').free_tier, false, 'the shared OpenAI key is not a free-tier source');
+ assert.strictEqual(offerOf('cheapco/cheap-chat').free_tier, false, 'cheap is not enough: the source must be named');
+ const local = offerOf('local/bge-small-en-v1.5');
+ assert.strictEqual(local.free, true);
+ assert.strictEqual(local.free_tier, true, 'local capacity is free and named');
+ assert.strictEqual(offerOf('whisper/whisper.cpp').free_tier, true, 'whisper.cpp is local capacity');
+ assert.ok(r.body.models.every((m) => m.offers.every((o) => typeof o.free_tier === 'boolean')), 'every offer carries the flag');
+});
+
t.test('a provider whose key is not configured is never an offer', async () => {
t.test('a provider whose key is not configured is never an offer', async () => {
const r = await request(h.base, 'GET', '/api/v1/exchange/catalog');
const r = await request(h.base, 'GET', '/api/v1/exchange/catalog');
assert.ok(!r.body.models.some((m) => m.slug === 'unconfigured/mystery-chat'));
assert.ok(!r.body.models.some((m) => m.slug === 'unconfigured/mystery-chat'));
@@ -38,6 +38,18 @@
@@ -38,6 +38,18 @@
"provider_access": "OWN_DEPLOYMENT",
"provider_access": "OWN_DEPLOYMENT",
"metadata": { "local": true, "paid": false, "billing_profile": "free" }
"metadata": { "local": true, "paid": false, "billing_profile": "free" }
},
},
+ {
+ "key": "cheapco",
+ "display_name": "CheapCo",
+ "kind": "openai",
+ "status": "active",
+ "base_url": "https://api.cheapco.test/v1",
+ "auth_mode": "none",
+ "default_model": "cheap-chat",
+ "capabilities": ["chat", "generate"],
+ "provider_access": "AUTHORIZED_WHOLESALE",
+ "metadata": { "provider_name": "cheapco" }
+ },
{
{
"key": "unconfigured",
"key": "unconfigured",
"display_name": "Unconfigured upstream",
"display_name": "Unconfigured upstream",
@@ -103,6 +115,15 @@
@@ -103,6 +115,15 @@
"type": "embedding",
"type": "embedding",
"supports": { "json": false }
"supports": { "json": false }
},
},
+ {
+ "provider_key": "cheapco",
+ "model_key": "cheap-chat",
+ "display_name": "Cheap chat",
+ "type": "chat",
+ "supports": { "json": true, "streaming": true },
+ "cost": { "in_per_mtok": 0.1, "out_per_mtok": 0.3 },
+ "metadata": { "org": "cheapco", "model": "cheap-chat", "family": "CheapCo", "tier": "fast" }
+ },
{
{
"provider_key": "unconfigured",
"provider_key": "unconfigured",
"model_key": "mystery-chat",
"model_key": "mystery-chat",
@@ -1,7 +1,9 @@
@@ -1,7 +1,9 @@
'use strict';
'use strict';
-// Spend safety: a run nobody pays for (the free allowance, the anonymous Try it) may use only offers at or
-// below AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK (default $1 per 1M output tokens). A person's own token may pay
-// a dearer model from their Balance; their own key never goes through the router's cap at all.
+// Spend safety: a run nobody pays for (the free allowance, the anonymous Try it) may use only offers from
+// AI_FREE_PROVIDERS and at or below AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK (default 1.5 per 1M output tokens).
+// A person's own token may pay a dearer model from their Balance; their own key never goes through the
+// router's caps at all. The provider allowlist has its own test file (free-providers.test.js); this one names
+// its two test providers so it can exercise the ceiling alone.
const assert = require('assert');
const assert = require('assert');
const { boot, request, token, suite, seamServer, ALL } = require('./helpers');
const { boot, request, token, suite, seamServer, ALL } = require('./helpers');
const { load } = require('../server/config');
const { load } = require('../server/config');
@@ -12,14 +14,14 @@ let h; let srv; let route;
@@ -12,14 +14,14 @@ let h; let srv; let route;
const tok = token('test', ALL);
const tok = token('test', ALL);
const exchange = token('exchange', ALL);
const exchange = token('exchange', ALL);
-t.test('the ceiling is $1 by default and configurable', () => {
- assert.strictEqual(load({}).freeUse.maxOutputPricePerMtok, 1);
+t.test('the ceiling is $1.5 by default and configurable', () => {
+ assert.strictEqual(load({}).freeUse.maxOutputPricePerMtok, 1.5);
assert.strictEqual(load({ AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK: '2.5' }).freeUse.maxOutputPricePerMtok, 2.5);
assert.strictEqual(load({ AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK: '2.5' }).freeUse.maxOutputPricePerMtok, 2.5);
});
});
t.test('boot two offers: cheap, and a frontier model over the ceiling', async () => {
t.test('boot two offers: cheap, and a frontier model over the ceiling', async () => {
srv = await seamServer(() => ({ text: 'an answer', model: 'm', usage: { input: 5, output: 2 } }));
srv = await seamServer(() => ({ text: 'an answer', model: 'm', usage: { input: 5, output: 2 } }));
- h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0' } });
+ h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0', AI_FREE_PROVIDERS: 'cheap,dear' } });
for (const [key, label] of [['cheap', 'Cheap'], ['dear', 'Dear']]) {
for (const [key, label] of [['cheap', 'Cheap'], ['dear', 'Dear']]) {
await h.registry.upsertProvider({ key, display_name: label, kind: 'http', status: 'active', base_url: srv.url, auth_mode: 'none', capabilities: ['chat', 'generate', 'json'], default_model: models[key].model_key, timeout_ms: 3000 }, { actor: 'test', origin: 'admin' });
await h.registry.upsertProvider({ key, display_name: label, kind: 'http', status: 'active', base_url: srv.url, auth_mode: 'none', capabilities: ['chat', 'generate', 'json'], default_model: models[key].model_key, timeout_ms: 3000 }, { actor: 'test', origin: 'admin' });
await h.registry.upsertModel({ provider_key: key, ...models[key] }, { actor: 'test' });
await h.registry.upsertModel({ provider_key: key, ...models[key] }, { actor: 'test' });
new file mode 100644
new file mode 100644
@@ -0,0 +1,90 @@
@@ -0,0 +1,90 @@
+'use strict';
+// The free tier's sources: a run nobody pays for (the free allowance, the anonymous Try it) may use only
+// offers from AI_FREE_PROVIDERS (default deepseek,local,community) AND at or below the output price ceiling.
+// The provider is as much a rule as the price: a cheaper OpenAI offer is skipped for a free run, while a
+// person's own token pays for it and a local model stays free capacity.
+const assert = require('assert');
+const { boot, request, token, suite, seamServer, ALL } = require('./helpers');
+const { load } = require('../server/config');
+const freeTier = require('../server/free-tier');
+
+const t = suite('free-providers');
+const tok = token('test', ALL);
+const person = token('dana', ALL, { sub: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPY', actorType: 'user' });
+const models = {
+ deepseek: { model_key: 'deepseek-flash', display_name: 'DeepSeek Flash', type: 'chat', supports: { json: true, tools: true, streaming: true }, cost: { in_per_mtok: 0.3, out_per_mtok: 1.2 } },
+ openai: { model_key: 'gpt-5-nano', display_name: 'GPT-5 nano', type: 'chat', supports: { json: true, tools: true, streaming: true }, cost: { in_per_mtok: 0.05, out_per_mtok: 0.2 } },
+ local2: { model_key: 'qwen3-1.7b', display_name: 'Qwen3 1.7B', type: 'chat', supports: { json: true, streaming: true } },
+};
+let h; let srv; let route;
+
+t.test('the default sources, the local family and the ceiling rule', () => {
+ const cfg = load({});
+ assert.deepStrictEqual(cfg.freeUse.providers, ['deepseek', 'local', 'community']);
+ assert.strictEqual(cfg.freeUse.maxOutputPricePerMtok, 1.5);
+ assert.deepStrictEqual(load({ AI_FREE_PROVIDERS: 'deepseek, local' }).freeUse.providers, ['deepseek', 'local']);
+ assert.deepStrictEqual(load({ AI_FREE_PROVIDERS: '' }).freeUse.providers, ['deepseek', 'local', 'community']);
+ const sources = freeTier.sourcesOf(cfg.freeUse);
+ assert.ok(freeTier.allowsProvider('deepseek', { key: 'deepseek', kind: 'openai' }, sources));
+ assert.ok(freeTier.allowsProvider('local2', { key: 'local2', kind: 'openai', metadata: { local: true } }, sources), 'local covers the second local server');
+ assert.ok(freeTier.allowsProvider('whisper', { key: 'whisper', kind: 'whisper' }, sources), 'local covers whisper.cpp');
+ assert.ok(!freeTier.allowsProvider('shared', { key: 'shared', kind: 'openai' }, sources), 'the shared OpenAI key is not a free-tier source');
+ assert.ok(freeTier.allowsCommunity(sources));
+ assert.strictEqual(freeTier.allowsOffer({ providerKey: 'deepseek', outputPrice: 1.2 }, cfg.freeUse), true);
+ assert.strictEqual(freeTier.allowsOffer({ providerKey: 'deepseek', outputPrice: 3.96 }, cfg.freeUse), false, 'dearer than the ceiling');
+ assert.strictEqual(freeTier.allowsOffer({ providerKey: 'openai', outputPrice: 0.2 }, cfg.freeUse), false, 'cheap is not enough: the source must be named');
+ assert.strictEqual(freeTier.allowsOffer({ providerKey: 'local2', providerRecord: { kind: 'openai', metadata: { local: true } }, outputPrice: null }, cfg.freeUse), true);
+});
+
+t.test('boot a named source and a cheaper provider outside the list', async () => {
+ srv = await seamServer(() => ({ text: 'an answer', model: 'm', usage: { input: 5, output: 2 } }));
+ h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0' } });
+ for (const key of ['deepseek', 'openai']) {
+ await h.registry.upsertProvider({ key, display_name: key, kind: 'http', status: 'active', base_url: srv.url, auth_mode: 'none', capabilities: ['chat', 'generate', 'json'], default_model: models[key].model_key, timeout_ms: 3000 }, { actor: 'test', origin: 'admin' });
+ await h.registry.upsertModel({ provider_key: key, ...models[key] }, { actor: 'test' });
+ }
+ route = await h.registry.createRouteVersion('test.free-providers', { capability: 'chat', constraints: { objective: 'correctness' }, timeout_ms: 3000 }, { actor: 'test' });
+});
+
+t.test('a free run skips the cheaper provider outside the list and answers from DeepSeek', async () => {
+ const out = await h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'hello' }], timeoutMs: 3000 }, { routeKey: route.key, routeVersion: route.version, freeUse: true });
+ assert.strictEqual(out.provider, 'deepseek');
+ assert.ok(out.explain.attempts.some((a) => a.provider === 'openai' && /free_provider_not_allowed/.test(a.error || '')), JSON.stringify(out.explain.attempts));
+});
+
+t.test('the free allowance (a service naming a person) gets the same rule', async () => {
+ const r = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok, body: {
+ workflow: 'ai.complete', input: { messages: [{ role: 'user', content: 'hello' }] },
+ on_behalf_of: { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPY' } } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.body.run.status, 'succeeded', r.text);
+ assert.strictEqual(r.body.run.provenance.provider, 'deepseek', r.text);
+});
+
+t.test('when every offering provider is outside the list, a free run is refused 402, never run', async () => {
+ const off = await request(h.base, 'POST', '/api/v1/providers/deepseek/disable', { tok });
+ assert.strictEqual(off.status, 200, off.text);
+ const before = srv.calls;
+ await assert.rejects(
+ () => h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'hello' }], timeoutMs: 3000 }, { routeKey: route.key, routeVersion: route.version, freeUse: true }),
+ (err) => err.code === 'billing.payer_required' && /free allowance/.test(err.detail || err.message) && /deepseek, local, community/.test(err.detail || err.message),
+ );
+ assert.strictEqual(srv.calls, before, 'no provider was called');
+});
+
+t.test("a person's own token may still use the provider outside the list", async () => {
+ const r = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: person, body: { workflow: 'ai.complete', input: { messages: [{ role: 'user', content: 'hello' }] } } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.body.run.status, 'succeeded', r.text);
+ assert.strictEqual(r.body.run.provenance.provider, 'openai');
+});
+
+t.test('`local` covers a second local server (local2) for a free run', async () => {
+ await h.registry.upsertProvider({ key: 'local2', display_name: 'Local model 2', kind: 'http', status: 'active', base_url: srv.url, auth_mode: 'none', billing_profile: 'free', capabilities: ['chat', 'generate', 'json'], default_model: models.local2.model_key, timeout_ms: 3000, metadata: { local: true } }, { actor: 'test', origin: 'admin' });
+ await h.registry.upsertModel({ provider_key: 'local2', ...models.local2 }, { actor: 'test' });
+ const out = await h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'hello' }], timeoutMs: 3000 }, { routeKey: route.key, routeVersion: route.version, freeUse: true });
+ assert.strictEqual(out.provider, 'local2');
+ assert.strictEqual(out.cost, 0);
+});
+
+t.test('stop', async () => { await h.stop(); await srv.close(); });
@@ -50,7 +50,7 @@ const credentialFetch = async (url, init = {}) => {
@@ -50,7 +50,7 @@ const credentialFetch = async (url, init = {}) => {
};
};
t.test('boot', async () => {
t.test('boot', async () => {
- h = await boot({ env: { AI_CREDENTIALS_KEY: KEY, AI_CREDENTIAL_TEST_TIMEOUT_MS: '80' }, credentialFetch, log: capture });
+ h = await boot({ env: { AI_CREDENTIALS_KEY: KEY, AI_CREDENTIAL_TEST_TIMEOUT_MS: '80', AI_FREE_PROVIDERS: 'stub' }, credentialFetch, log: capture });
});
});
t.test('the person routes refuse a service token, an on_behalf_of token, and another site\'s session', async () => {
t.test('the person routes refuse a service token, an on_behalf_of token, and another site\'s session', async () => {
@@ -17,7 +17,9 @@ const WEATHER = { type: 'function', function: { name: 'get_weather', description
@@ -17,7 +17,9 @@ const WEATHER = { type: 'function', function: { name: 'get_weather', description
let h;
let h;
const post = (body, tok = service) => request(h.base, 'POST', '/v1/chat/completions', { tok, body });
const post = (body, tok = service) => request(h.base, 'POST', '/v1/chat/completions', { tok, body });
-t.test('boot', async () => { h = await boot(); });
+// The free allowance (a service acting for a person) would be gated by AI_FREE_PROVIDERS; in tests the
+// deterministic stub is the network's stand-in, so it is the named free-tier source.
+t.test('boot', async () => { h = await boot({ env: { AI_FREE_PROVIDERS: 'stub' } }); });
t.test('a plain chat answers the Chat Completions shape, with usage and the run it was', async () => {
t.test('a plain chat answers the Chat Completions shape, with usage and the run it was', async () => {
const r = await post({ model: 'openvibe', messages: [{ role: 'system', content: 'Be brief.' }, { role: 'user', content: 'Say hello' }] });
const r = await post({ model: 'openvibe', messages: [{ role: 'system', content: 'Be brief.' }, { role: 'user', content: 'Say hello' }] });
@@ -39,7 +39,7 @@ t.test('boot with a stub Network and a seam provider', async () => {
@@ -39,7 +39,7 @@ t.test('boot with a stub Network and a seam provider', async () => {
});
});
await new Promise((r) => network.listen(0, '127.0.0.1', r));
await new Promise((r) => network.listen(0, '127.0.0.1', r));
seam = await seamServer(() => ({ text: `summary #${seam.calls}`, usage: { input: 30, output: 10 } }));
seam = await seamServer(() => ({ text: `summary #${seam.calls}`, usage: { input: 30, output: 10 } }));
- h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_OUTPUT_COST_PER_MTOK: '0.5', OV_OAUTH_CLIENT_SECRET: 'x'.repeat(40), OV_NETWORK_INTERNAL_URL: `http://127.0.0.1:${network.address().port}` } });
+ h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_OUTPUT_COST_PER_MTOK: '0.5', AI_FREE_PROVIDERS: 'seam', OV_OAUTH_CLIENT_SECRET: 'x'.repeat(40), OV_NETWORK_INTERNAL_URL: `http://127.0.0.1:${network.address().port}` } });
await request(h.base, 'POST', '/api/v1/providers', { tok: live, body: { key: 'seam', kind: 'http', base_url: seam.url, auth_mode: 'none', capabilities: ['summarize', 'generate', 'chat', 'json'] } });
await request(h.base, 'POST', '/api/v1/providers', { tok: live, body: { key: 'seam', kind: 'http', base_url: seam.url, auth_mode: 'none', capabilities: ['summarize', 'generate', 'chat', 'json'] } });
await request(h.base, 'POST', '/api/v1/routes/default.chat/versions', { tok: live, body: { primary: { provider: 'seam' }, fallbacks: [] } });
await request(h.base, 'POST', '/api/v1/routes/default.chat/versions', { tok: live, body: { primary: { provider: 'seam' }, fallbacks: [] } });
});
});