openvibers/openvibe.ai Commits

The free tier runs only on its named sources, at or below $1.50 (#316)

OP OpenVibers committed 7 days ago · 15ac0dd618 · 19 files, +315 −58

* The free tier runs only on its named sources, at or below $1.50

The free allowance and the Exchange's anonymous Try it now use only offers from AI_FREE_PROVIDERS
(default deepseek,local,community: the owner's DeepSeek key, local model servers and community
machines) and at or below AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK (default 1.5, so DeepSeek Flash at
$1.20 out qualifies while gpt-5-nano through Live's shared OpenAI key does not). A dearer or unnamed
provider is skipped with free_provider_not_allowed / over_price_ceiling, and the run answers 402
billing.payer_required when nothing is left; a person's own key and own token are never gated.

The public Exchange catalog marks every offer free_tier true only when both rules pass (a named source
under the ceiling), distinct from free (prepaid capacity with no per-token card), so the site's
"Free to try" badge matches what a Try it run can actually reach.

* Merge remote-tracking branch 'origin/main' into ai-free-tier

# Conflicts:
#	server/api/exchange.js
#	server/app.js
#	server/providers/index.js

* Name the free-tier sources in the tests that stand in the network

The free allowance now runs only on AI_FREE_PROVIDERS. The tests that act for a person through a
seam or the deterministic stub name that provider, so they still exercise what they were written
for (caching, preferences, the uncensored tier) instead of the allowlist.

* Merge remote-tracking branch 'origin/main' into ai-free-tier

# Conflicts:
#	server/providers/index.js

Changes

ddiff --git a/.env.example b/.env.example
iindex 2bc050c..ac3d7ed 100644
---- a/.env.example
++++ b/.env.example
@@@ -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
ddiff --git a/README.md b/README.md
iindex 680a721..9e59190 100644
---- a/README.md
++++ b/README.md
@@@ -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
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
`free`, `paid` and `staff` on `openvibe-sdk/govern`, counted in Valkey. Before a run touches a provider
ddiff --git a/server/api/exchange.js b/server/api/exchange.js
iindex d0148eb..4e9bd56 100644
---- a/server/api/exchange.js
++++ b/server/api/exchange.js
@@@ -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();
ddiff --git a/server/app.js b/server/app.js
iindex 5b729c0..3be3a7a 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -110,7 +110,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
openapi.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 });
ddiff --git a/server/config.js b/server/config.js
iindex 9e4721b..b61bdd0 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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: {
ddiff --git a/server/exchange/catalog.js b/server/exchange/catalog.js
iindex a24e641..4912c0e 100644
---- a/server/exchange/catalog.js
++++ b/server/exchange/catalog.js
@@@ -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,
ddiff --git a/server/free-tier.js b/server/free-tier.js
nnew file mode 100644
iindex 0000000..b1f87a2
---- /dev/null
++++ b/server/free-tier.js
@@@ -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 };
ddiff --git a/server/openapi.js b/server/openapi.js
iindex cf026e4..a27bfe3 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -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(),
ddiff --git a/server/providers/index.js b/server/providers/index.js
iindex 917d556..f31d892 100644
---- a/server/providers/index.js
++++ b/server/providers/index.js
@@@ -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 use
const 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.no
if (!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.no
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,
-- ...(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.no
const 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.no
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 });
}
-- // 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 });
ddiff --git a/server/runs.js b/server/runs.js
iindex 2e0feb6..640c4c0 100644
---- a/server/runs.js
++++ b/server/runs.js
@@@ -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'));
ddiff --git a/test/ai-exchange-providers.test.js b/test/ai-exchange-providers.test.js
iindex b4a57bb..898366f 100644
---- a/test/ai-exchange-providers.test.js
++++ b/test/ai-exchange-providers.test.js
@@@ -171,7 +171,9 @@ t.test('a booted service seeds the nine off, and the uncensored request is gated
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 } };
});
-- 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) {
ddiff --git a/test/cache.test.js b/test/cache.test.js
iindex f8ce2b3..0b290a2 100644
---- a/test/cache.test.js
++++ b/test/cache.test.js
@@@ -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 () => {
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: [] } });
ddiff --git a/test/exchange-catalog-kinds.test.js b/test/exchange-catalog-kinds.test.js
iindex 3db212a..5770943 100644
---- a/test/exchange-catalog-kinds.test.js
++++ b/test/exchange-catalog-kinds.test.js
@@@ -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'));
ddiff --git a/test/fixtures/exchange-catalog/catalog.json b/test/fixtures/exchange-catalog/catalog.json
iindex ab7a366..6e6eb93 100644
---- a/test/fixtures/exchange-catalog/catalog.json
++++ b/test/fixtures/exchange-catalog/catalog.json
@@@ -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",
ddiff --git a/test/free-ceiling.test.js b/test/free-ceiling.test.js
iindex f5c3e76..c3855bb 100644
---- a/test/free-ceiling.test.js
++++ b/test/free-ceiling.test.js
@@@ -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' });
ddiff --git a/test/free-providers.test.js b/test/free-providers.test.js
nnew file mode 100644
iindex 0000000..4799aae
---- /dev/null
++++ b/test/free-providers.test.js
@@@ -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(); });
ddiff --git a/test/me-credentials.test.js b/test/me-credentials.test.js
iindex 09bd57f..649c818 100644
---- a/test/me-credentials.test.js
++++ b/test/me-credentials.test.js
@@@ -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 () => {
ddiff --git a/test/openai-compat.test.js b/test/openai-compat.test.js
iindex fdfbb95..ab6da8a 100644
---- a/test/openai-compat.test.js
++++ b/test/openai-compat.test.js
@@@ -17,7 +17,9 @@ const WEATHER = { type: 'function', function: { name: 'get_weather', description
let 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' }] });
ddiff --git a/test/user-modules.test.js b/test/user-modules.test.js
iindex edd41f6..2c56649 100644
---- a/test/user-modules.test.js
++++ b/test/user-modules.test.js
@@@ -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: [] } });
});