openvibers/openvibe.ai Commits
Exchange catalog: real upstreams, kinds, DeepSeek and a free-use price ceiling (#314)
Changes
@@@ -147,6 +147,21 @@ AI_MODEL=#AI_INPUT_COST_PER_MTOK=3#AI_OUTPUT_COST_PER_MTOK=15++# ── DeepSeek as its own provider (chat models; the same key research uses) ──++# With DEEPSEEK_API_KEY (or AI_DEEPSEEK_KEY_REF) set, boot registers provider `deepseek` and its two chat++# models (deepseek-flash, deepseek-v4-pro) with prices from DeepSeek's own price page. Without the key it++# stays disabled and its models are not offers.++#AI_DEEPSEEK_ENABLED=true++#AI_DEEPSEEK_BASE_URL=https://api.deepseek.com++#AI_DEEPSEEK_KEY_REF=env:DEEPSEEK_API_KEY++#AI_DEEPSEEK_TIMEOUT_MS=90000++++# ── 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++# Optional local HTTP seam provider (POST {operation, request} -> {text, json, usage})#AI_HTTP_SEAM_URL=http://127.0.0.1:4799/complete# A local model server speaking the OpenAI API (llama.cpp llama-server, Ollama /v1): provider `local`, route@@@ -440,6 +440,7 @@ definition, but never over an admin's version.|---|---|---|| `stub` | Deterministic, no key, realistically shaped output (schema-shaped JSON, sentences, unit-vector embeddings, timed transcript segments), clearly marked synthetic (`(synthetic)` text, `synthetic: true` on the run). Never cached. | always present || `openai` | Any OpenAI-compatible API (OpenAI, OpenRouter, Groq, Together, Ollama, LM Studio, llama.cpp): chat with structured-output step-down, embeddings, Whisper-style transcription, image generation (`POST {base}/images/generations`). Ported from Live's `llm.js` / `ai-provider.js`. | `AI_PROVIDER`, `AI_BASE_URL`, `AI_API_KEY`, `AI_MODEL` — Live's setting names |++| `openai`, key `deepseek` | DeepSeek's own API, registered when its key is configured: `deepseek-flash` and `deepseek-v4-pro`, priced from DeepSeek's price page (peak rates; off-peak is half). A missing key leaves the provider disabled, so none of its models is an offer. | `DEEPSEEK_API_KEY` (or `AI_DEEPSEEK_KEY_REF`), `AI_DEEPSEEK_BASE_URL` || `responses` | The OpenAI Responses API (`POST {base}/responses`) for GPT-family models that only speak it, at any OpenAI-compatible `base_url` (default `https://api.openai.com/v1`): chat and every text operation, structured output through `text.format` with the same step-down, vision; no embeddings or transcription. | the admin API (`kind: "responses"`, `base_url`, `secret_ref`) || `anthropic` | Messages API with cached system blocks and forced-tool JSON. Ported from Live's `llm.js`. | same, with `AI_PROVIDER=anthropic` || `http` | The local HTTP seam: `POST {operation, …}` → `{text, json, usage}` | `AI_HTTP_SEAM_URL` or the admin API |@@@ -530,6 +531,14 @@ 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.++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(and before `reserve()` counts it), it holds an estimate of the units `ai-token` and `ai-usd` against two@@@ -13,7 +13,7 @@*/const express = require('express');const { http } = require('openvibe-contracts');--const { createCatalog } = require('../exchange/catalog');++const { createCatalog, KINDS: catalogKinds } = require('../exchange/catalog');const { createUsage } = require('../exchange/usage');const CATALOG_MS = 30_000;@@@ -28,17 +28,30 @@ function memo(fn, ttlMs, clock) {};}--function exchangeRouter({ db, registry, pool, clock = { now: () => Date.now() } }) {-- const catalog = createCatalog({ db, registry, pool, clock });++function exchangeRouter({ db, registry, pool, env = process.env, clock = { now: () => Date.now() } }) {++ const catalog = createCatalog({ db, registry, pool, env, clock });const usage = createUsage({ db, clock });-- const all = memo(() => catalog.models({ days: 7 }), CATALOG_MS, clock);++ // One cached read per kind (?kind=chat, screenshot, …); the unfiltered read is the full catalog.++ const hits = new Map();++ const allFor = (kind) => {++ const key = kind || '';++ if (!hits.has(key)) hits.set(key, memo(() => catalog.models({ days: 7, kind: kind || null }), CATALOG_MS, clock));++ return hits.get(key)();++ };++ const all = () => allFor(null);const usageByWindow = new Map();const r = express.Router();const publicCache = (seconds) => (req, res, next) => { res.set('Cache-Control', `public, max-age=${seconds}`); next(); };r.get('/api/v1/exchange/catalog', publicCache(30), async (req, res, next) => {-- try { res.json(await all()); } catch (err) { next(err); }++ try {++ const kind = req.query.kind == null || req.query.kind === '' ? null : String(req.query.kind).toLowerCase();++ if (kind && !catalogKinds.includes(kind)) {++ return http.sendProblem(res, 422, 'exchange.kind_invalid', { detail: `kind must be one of ${catalogKinds.join(', ')}`, ctx: req.ov });++ }++ res.json(await allFor(kind));++ } catch (err) { next(err); }});r.get('/api/v1/exchange/models/:slug', publicCache(30), async (req, res, next) => {@@@ -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, clock }));++ app.use(require('./api/exchange').exchangeRouter({ db, registry, pool, env, clock }));// GET /release.json (ADR-016) and POST /release-metrics (open tabs' update reports into /metrics).release.mount(app, { registry: metrics.registry });@@@ -130,6 +130,21 @@ function load(env = process.env) {model: String(env.AI_MODEL || '').trim(),timeoutMs: int(env.AI_PROVIDER_TIMEOUT_MS, 30000),},++ // ── DeepSeek, its own provider when its key is configured ──++ // DEEPSEEK_API_KEY is also the research fallback's key; this makes its chat models managed offers.++ deepseek: {++ enabled: bool(env.AI_DEEPSEEK_ENABLED, true),++ baseUrl: trimUrl(env.AI_DEEPSEEK_BASE_URL || 'https://api.deepseek.com'),++ apiKeyRef: String(env.AI_DEEPSEEK_KEY_REF || 'env:DEEPSEEK_API_KEY').trim(),++ timeoutMs: int(env.AI_DEEPSEEK_TIMEOUT_MS, 90000),++ },++ // ── 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.++ freeUse: {++ maxOutputPricePerMtok: Math.max(0, float(env.AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK, 1)),++ },// ── Placement (T6 provider router): openvibe-sdk/placement orders a route's capability pool ──placement: {// A candidate replaces the route's current placement only when it scores this much better,@@@ -13,6 +13,7 @@* says nothing about training, retention or region.*/const rates = require('../providers/rate-cards');++const upstream = require('../providers/upstream');const DAY_MS = 86400000;const FAMILY_BY_PREFIX = [@@@ -64,6 +65,16 @@ function familyOf(row, provider, canonical) {return familyFrom(canonical) || provider.display_name || provider.key;}++/**++ * The kind a model is offered as. `vision` is a chat model that also sees images, so it is chat here; the++ * catalog has separate kinds for embeddings, speech-to-text and image generation, never mixed into chat.++ */++function kindOf(row) {++ const type = (row && row.type) || 'chat';++ return type === 'vision' ? 'chat' : type;++}++const KINDS = ['chat', 'embedding', 'stt', 'image'];++function policyOf(provider) {const p = provider && provider.metadata && provider.metadata.data_policy;if (!p || typeof p !== 'object') return null;@@@ -90,7 +101,7 @@ function modalitiesOf(row) {return [...out];}--function createCatalog({ db, registry, pool, clock = { now: () => Date.now() } }) {++function createCatalog({ db, registry, pool, env = process.env, clock = { now: () => Date.now() } }) {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,@@@ -120,17 +131,16 @@ function createCatalog({ db, registry, pool, clock = { now: () => Date.now() } }/*** Every live offer, grouped by the model it serves.* days: the window the measured speed, uptime and request counts cover.++ * kind: only models of this kind (chat, embedding, stt, image); null for every kind.*/-- async function models({ days = 7 } = {}) {++ async function models({ days = 7, kind = null } = {}) {const now = clock.now();const today = dayOf(now);const stats = await statsFor(days, today);const providers = await registry.listProviders();const byKey = new Map(providers.map((p) => [p.key, p]));-- const health = new Map();-- for (const p of providers) {-- try { health.set(p.key, await pool.health(p.key)); } catch (e) { health.set(p.key, null); }-- }++ const upstreamByKey = new Map();++ for (const p of providers) upstreamByKey.set(p.key, upstream.upstreamOf(p));const groups = new Map();for (const row of await registry.listModels()) {@@@ -138,11 +148,18 @@ function createCatalog({ db, registry, pool, clock = { now: () => Date.now() } }if (!provider || provider.kind === 'stub' || (provider.metadata && provider.metadata.synthetic)) continue;if (provider.metadata && provider.metadata.hidden === true) continue;if (row.status !== 'active' || provider.status !== 'active') continue;++ // A model is an offer only when the key its provider needs is configured now. A record with no++ // usable upstream (the internal `shared` label with no real provider behind it) is never shown.++ if (!upstream.configured(provider, env)) continue;++ const up = upstreamByKey.get(provider.key);++ if (!up || !upstream.serves(up, provider, row)) continue;// Capacity the network may not resell is never an offer: a person's own key, or a restricted one.if (provider.provider_access === 'CUSTOMER_BYOK' || provider.provider_access === 'RESTRICTED') continue;if (!['chat', 'vision', 'embedding', 'stt', 'image'].includes(row.type)) continue;-- const canonical = canonicalOf(row, provider);++ // The catalog shows the real upstream, never the router's internal key ("shared").++ const shown = { key: up.key, display_name: up.name };++ const canonical = canonicalOf(row, shown);const slug = slugOf(canonical);if (!slug) continue;const cards = await pool.rateCardsFor(provider.key, row.model_key);@@@ -150,15 +167,19 @@ function createCatalog({ db, registry, pool, clock = { now: () => Date.now() } }const st = stats.get(`${provider.key}\u0000${row.model_key}`) || { requests: 0, ok: 0, errors: 0, p50: null, p95: null, today: 0 };const uptime = st.requests > 0 ? st.ok / st.requests : null;const offer = {-- provider: provider.key,-- provider_name: provider.display_name || provider.key,++ provider: up.key,++ provider_name: up.name,model: row.model_key,++ kind: kindOf(row),// The exact artifact id a caller may pin (`org/model[:quant]@revision`); null when nobody recorded one.id: (row.metadata && typeof row.metadata.id === 'string') ? row.metadata.id : null,trust: isCommunity(provider) ? 'community' : 'first-party',access: provider.provider_access || 'UNVERIFIED',free: cards == 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.++ price_source: cards && cards.out.source && !String(cards.out.source).startsWith('urn:openvibe:ai:unverified:') ? cards.out.source : null,++ price_checked_at: cards && cards.out.verified_at && cards.out.verified_at !== rates.UNKNOWN_DATE ? cards.out.verified_at : null,speed: { p50_ms: st.p50, p95_ms: st.p95 },uptime,requests_7d: st.requests,@@@ -170,7 +191,8 @@ function createCatalog({ db, registry, pool, clock = { now: () => Date.now() } }groups.set(slug, {slug, canonical,name: row.display_name || row.model_key,-- family: familyOf(row, provider, canonical),++ family: familyOf(row, shown, canonical),++ kind: kindOf(row),variant: (row.metadata && row.metadata.variant) || null,uncensored: UNCENSORED.test(`${canonical} ${(row.metadata && row.metadata.variant) || ''}`) || Boolean(row.metadata && row.metadata.uncensored === true),type: row.type,@@@ -190,7 +212,8 @@ function createCatalog({ db, registry, pool, clock = { now: () => Date.now() } }if (!g.description && row.metadata && typeof row.metadata.description === 'string') g.description = row.metadata.description;}-- const models = [...groups.values()];++ let models = [...groups.values()];++ if (kind) models = models.filter((m) => m.kind === kind);for (const m of models) {m.offers.sort((a, b) => {const pa = a.price ? a.price.in + a.price.out : Number.POSITIVE_INFINITY;@@@ -206,17 +229,24 @@ function createCatalog({ db, registry, pool, clock = { now: () => Date.now() } }models.sort((a, b) => b.requests_7d - a.requests_7d || b.requests_today - a.requests_today || a.name.localeCompare(b.name));const families = new Map();++ const kinds = new Map();for (const m of models) {const f = families.get(m.family) || { name: m.family, models: 0, offers: 0 };f.models += 1;f.offers += m.offers.length;families.set(m.family, f);++ const k = kinds.get(m.kind) || { kind: m.kind, models: 0, offers: 0 };++ k.models += 1;++ k.offers += m.offers.length;++ kinds.set(m.kind, k);}return {generated_at: new Date(now).toISOString(),window_days: days,++ ...(kind ? { kind } : {}),count: models.length,families: [...families.values()].sort((a, b) => b.models - a.models || a.name.localeCompare(b.name)),++ kinds: [...kinds.values()].sort((a, b) => KINDS.indexOf(a.kind) - KINDS.indexOf(b.kind)),models,};}@@@ -233,4 +263,5 @@ function createCatalog({ db, registry, pool, clock = { now: () => Date.now() } }createCatalog.dayOf = dayOf;createCatalog.slugOf = slugOf;createCatalog.familyOf = familyOf;--module.exports = { createCatalog, slugOf, canonicalOf, dayOf };++createCatalog.kindOf = kindOf;++module.exports = { createCatalog, slugOf, canonicalOf, dayOf, kindOf, KINDS };@@@ -292,14 +292,17 @@ route('get', '/run-budgets/{jti}', {// ── Exchange read API (OpenVibe.Exchange) ───────────────────────────────────────────────────const exchangeOffer = {-- type: 'object', required: ['provider', 'provider_name', 'model', 'trust', 'free', 'price'],++ type: 'object', required: ['provider', 'provider_name', 'model', 'kind', 'trust', 'free', 'price'],properties: {-- provider: str(), 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'] }),id: nullable('string', { description: 'The exact artifact id a request may pin, when the registry records one' }),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).' },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' }),speed: { type: 'object', properties: { p50_ms: nullable('number'), p95_ms: nullable('number') } },uptime: nullable('number', { minimum: 0, maximum: 1 }),requests_7d: integer({ minimum: 0 }), requests_today: integer({ minimum: 0 }),@@@ -308,10 +311,10 @@ const exchangeOffer = {},};const exchangeModel = {-- type: 'object', required: ['slug', 'name', 'family', 'uncensored', 'offers'],++ type: 'object', required: ['slug', 'name', 'family', 'kind', 'uncensored', 'offers'],properties: {slug: str(), canonical: str(), name: str(), family: str(), variant: nullable('string'), uncensored: { type: 'boolean' },-- type: str(), context: nullable('integer'), max_output: nullable('integer'),++ kind: str({ enum: ['chat', 'embedding', 'stt', 'image'] }), type: str(), context: nullable('integer'), max_output: nullable('integer'),capabilities: { type: 'object', properties: { tools: { type: 'boolean' }, json: { type: 'boolean' }, vision: { type: 'boolean' }, stream: { type: 'boolean' } } },modalities: arrayOf(str()), description: nullable('string'),requests_7d: integer({ minimum: 0 }), requests_today: integer({ minimum: 0 }), community: { type: 'boolean' },@@@ -320,16 +323,19 @@ 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.',++ 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.',++ 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(),++ kind: str({ enum: ['chat', 'embedding', 'stt', 'image'] }),families: arrayOf({ type: 'object', properties: { name: str(), models: integer(), offers: integer() } }),++ kinds: arrayOf({ type: 'object', properties: { kind: str(), models: integer(), offers: integer() } }),models: arrayOf(exchangeModel),} }) },});route('get', '/exchange/models/{slug}', {operationId: 'exchangeModel', summary: 'One model and its offers', tags: ['exchange'], security: [],-- description: 'Public. The slug is "<org>/<model>", lowercased, e.g. deepseek/deepseek-chat.',++ description: 'Public. The slug is "<org>/<model>", lowercased, e.g. deepseek/deepseek-flash.',parameters: [path('slug', str())],responses: { 200: json('The model', exchangeModel), 404: problem },});@@@ -826,6 +826,13 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no// L36 Y1: a run on behalf of a person tries the person's OWN keys first, for the providers that can serve the// 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;++ const ceiling = freeCeiling != null ? freeCeiling : prefs.price_ceiling_usd_per_mtok;++ let freeCeilingSkipped = false; // every capable offer was dearer than the free ceilingconst selfAttempts = [];const selfList = Array.isArray(ctx.selfCredentials) ? ctx.selfCredentials : [];if (selfList.length || (ctx.selfSubject && prefs.only_my_keys)) {@@@ -916,18 +923,25 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noif (!p) why.push('unknown_provider');else if (ctx.creditsRequired && !isFreeProvider(p) && card && card.version) { why.push('payer_required'); payerSkipped = true; }else {-- if (!accessOffered(p)) why.push(`provider_access ${modelCatalog.accessOf(p)}`);-- const skip = await skipReason(p, entry && entry.adapter, features);-- if (skip) why.push(skip);++ // The free allowance / anonymous Try it cannot pin a model 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 {++ if (!accessOffered(p)) why.push(`provider_access ${modelCatalog.accessOf(p)}`);++ const skip = await skipReason(p, entry && entry.adapter, features);++ if (skip) why.push(skip);++ }}if (missing.length) why.push(...missing);const reason = why.length ? [...new Set(why)].join(',') : null;candidatesView.push({ provider: offer.provider, model: offer.model, eligible: !reason, excluded_reason: reason, cost: null, latency: null });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 } : {}) };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 (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 });}list = usable;@@@ -959,7 +973,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no: await rankCandidates({list, explain, task, route, warmProvider,preferred: preferredModelFor(prefs, operation, req, ctx),-- ceiling: prefs.price_ceiling_usd_per_mtok,++ ceiling,tierFirst: task.weight === 'heavy' || !freeFirst,policy,// A pinned route keeps its own order: the pin is the admin's decision, not the router's.@@@ -1033,12 +1047,14 @@ 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 };-- // L36 Y3: a price ceiling caps the network's fallback; the person's own keys are never capped (above).-- if (prefs.price_ceiling_usd_per_mtok != null) {++ // 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) {const cards = await rateCardsFor(c.provider, model);const outputPrice = cards ? Number(rates.pricesOf(cards).out) || 0 : 0;-- if (outputPrice > Number(prefs.price_ceiling_usd_per_mtok)) {-- const whyPrice = `over_price_ceiling (${outputPrice} > ${prefs.price_ceiling_usd_per_mtok} USD/Mtok)`;++ if (outputPrice > Number(ceiling)) {++ const whyPrice = `over_price_ceiling (${outputPrice} > ${ceiling} USD/Mtok)`;++ if (freeCeiling != null) freeCeilingSkipped = true;if (preferState && preferMatches(c.provider, model, prefer)) { preferState.honoured = false; preferState.why = 'it is over your price ceiling'; }statFor(c.provider).skipped++;tried.push({ provider: c.provider, skipped: 'over_price_ceiling' });@@@ -1179,6 +1195,13 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noexplain.reasons = [...(explain.reasons || []), 'the media could not be read'];throw new AiError(422, 'media.unreadable', String(unreadable.message || 'the media could not be read').slice(0, 300), { 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 });++ }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 });}@@@ -42,12 +42,19 @@ const ALIASES = [...a,object: 'model',type: 'chat',++ kind: 'chat',dynamic: true,capabilities: ['chat', 'json', 'stream'],modalities: ['text'],availability: 'available',}));++/** The kind a registry row is offered as: vision is chat with image input, and stt/embedding/image stand apart. */++const kindOf = (row) => {++ const type = (row && row.type) || 'chat';++ return type === 'vision' ? 'chat' : type;++};++const accessOf = (provider) => (provider && ACCESS.includes(provider.provider_access) ? provider.provider_access : 'UNVERIFIED');const managedAccessAllowed = (provider) => MANAGED_ACCESS.includes(accessOf(provider));@@@ -117,6 +124,7 @@ function entryOf(row, provider) {model_key: row.model_key,display_name: row.display_name || row.model_key,type: row.type,++ kind: kindOf(row),status: row.status,context_window: row.context_window == null ? null : Number(row.context_window),max_output: row.max_output == null ? null : Number(row.max_output),@@@ -238,5 +246,5 @@ async function disclosureOf({ registry, run }) {module.exports = {ACCESS, MANAGED_ACCESS, ALIASES, EXACT_ID,-- accessOf, managedAccessAllowed, artifactOf, entryOf, entries, exactOffers, select, facts, disclosureOf, retailRate,++ accessOf, managedAccessAllowed, artifactOf, entryOf, entries, exactOffers, select, facts, disclosureOf, retailRate, kindOf,};nnew file mode 100644@@@ -0,0 +1,131 @@++'use strict';++/**++ * The real upstream behind a provider record (`server/providers/upstream.js`).++ *++ * A provider record has an internal key the router uses ("shared" is Live's pooled OpenVibe key), and a++ * display name the owner typed. Neither says who actually serves the model. The Exchange catalog must show++ * the real upstream, and may list a model only when that upstream's configured key can serve it: an OpenAI++ * key cannot serve claude-*, a DeepSeek key cannot serve gpt-*, and a custom proxy whose upstream nobody++ * recorded can serve nothing the catalogue can promise.++ *++ * upstreamOf(provider) → { key, name, families } for a record with a known real upstream,++ * null when `shared` names no upstream and its base URL is not one++ * serves(upstream, provider, row) → could this upstream serve the registered model row?++ * configured(provider, env) → is the key this record needs present right now?++ *++ * For a provider an admin named directly (deepseek, local, …) the real upstream is the record itself; only++ * the seeded `shared` record hides its upstream, and only it is interpreted here. Nothing is invented: an++ * unknown host or provider_name yields null, and the caller leaves those offers out.++ */++const { resolveSecret } = require('../util');++++const NAMES = {++ openai: 'OpenAI',++ anthropic: 'Anthropic',++ deepseek: 'DeepSeek',++ openrouter: 'OpenRouter',++ groq: 'Groq',++ google: 'Google Gemini',++ gemini: 'Google Gemini',++ mistral: 'Mistral AI',++ xai: 'xAI',++ together: 'Together AI',++ fireworks: 'Fireworks AI',++ cerebras: 'Cerebras',++ minimax: 'MiniMax',++ ollama: 'Ollama',++};++++// Hosts that identify the upstream when AI_PROVIDER is blank or says "openai-compatible".++const HOSTS = [++ [/(^|\.)openai\.com$/i, 'openai'],++ [/(^|\.)anthropic\.com$/i, 'anthropic'],++ [/(^|\.)deepseek\.com$/i, 'deepseek'],++ [/(^|\.)openrouter\.ai$/i, 'openrouter'],++ [/(^|\.)groq\.com$/i, 'groq'],++ [/(^|\.)googleapis\.com$/i, 'google'],++ [/(^|\.)mistral\.ai$/i, 'mistral'],++ [/(^|\.)x\.ai$/i, 'xai'],++ [/(^|\.)together\.xyz$/i, 'together'],++ [/(^|\.)fireworks\.ai$/i, 'fireworks'],++ [/(^|\.)cerebras\.ai$/i, 'cerebras'],++ [/(^|\.)minimax\.(ai|io)$/i, 'minimax'],++];++++// Upstreams that route many families: every model row may be theirs. A direct provider (not `shared`) is++// likewise trusted for its own rows: its key is the reason the row exists.++const ROUTES_ALL = new Set(['openrouter', 'ollama', 'together', 'fireworks', 'cerebras']);++// A first-party upstream serves only its own family.++const FAMILIES = {++ openai: ['openai'],++ anthropic: ['anthropic'],++ deepseek: ['deepseek'],++ google: ['google'],++ mistral: ['mistral'],++ xai: ['xai'],++ groq: ['open', 'gpt-oss'],++ minimax: ['minimax'],++};++++const hostOf = (url) => {++ try { return new URL(String(url || '')).hostname.toLowerCase(); } catch (e) { return ''; }++};++++/** The family an id names: what the upstream's key is allowed to serve. */++function familyOfModel(modelKey) {++ const s = String(modelKey || '').toLowerCase().replace(/^models\//, '');++ const leaf = s.includes('/') ? s.slice(s.lastIndexOf('/') + 1) : s;++ if (/^gpt-oss/.test(leaf)) return 'gpt-oss';++ if (/^(gpt-|chatgpt|o[1-9](?:-|$)|text-embedding|whisper|tts-|dall-e|gpt-image|codex|computer-use|omni)/.test(leaf)) return 'openai';++ if (/^claude/.test(leaf)) return 'anthropic';++ if (/^deepseek/.test(leaf)) return 'deepseek';++ if (/^(gemini|gemma|learnlm|imagen|veo)/.test(leaf)) return 'google';++ if (/^(mistral|magistral|codestral|devstral|ministral|pixtral|open-mistral)/.test(leaf)) return 'mistral';++ if (/^grok/.test(leaf)) return 'xai';++ if (/^minimax/.test(leaf)) return 'minimax';++ if (/^(llama|qwen|qwq|kimi|moonshot|glm|zhipu|phi|yi|ernie|hunyuan|nemotron|command|cohere|dbrx|falcon|starcoder|mixtral)/.test(leaf)) return 'open';++ return 'unknown';++}++++/**++ * The real upstream of a provider record. A direct provider is its own upstream; `shared` is resolved from++ * its configured upstream name (AI_PROVIDER in metadata.provider_name) or its base URL host. Unknown → null.++ */++function upstreamOf(provider) {++ if (!provider) return null;++ if (provider.key !== 'shared') {++ return { key: provider.key, name: provider.display_name || provider.key, families: null };++ }++ const meta = provider.metadata || {};++ const declared = String(meta.provider_name || '').trim().toLowerCase();++ let key = NAMES[declared] ? declared : null;++ if (!key) {++ const host = hostOf(provider.base_url);++ for (const [re, name] of HOSTS) if (re.test(host)) { key = name; break; }++ }++ if (!key) return null;++ return { key, name: NAMES[key], families: ROUTES_ALL.has(key) ? null : (FAMILIES[key] || []) };++}++++/** Could this upstream serve this model row? A direct provider's own rows are trusted. */++function serves(upstream, provider, row) {++ if (!upstream || !provider || !row) return false;++ if (provider.key !== 'shared') return true;++ if (!upstream.families) return true;++ return upstream.families.includes(familyOfModel(row.model_key));++}++++/**++ * Is the credential this provider needs present right now? No credential for a keyless record (local,++ * whisper), and none needed for a free or pooled-subscription provider. A missing env/vault reference is++ * not configured: its models must not appear as offers.++ */++function configured(provider, env = process.env) {++ if (!provider || provider.status !== 'active') return false;++ if (provider.auth_mode === 'none') return true;++ if (provider.billing_profile === 'free') return true;++ if (provider.billing_profile === 'subscription' && !provider.secret_ref) return true;++ try { return Boolean(resolveSecret(provider.secret_ref, env)); } catch (e) { return false; }++}++++module.exports = { upstreamOf, serves, configured, familyOfModel, NAMES };@@@ -284,6 +284,14 @@ 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.++ 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'));// The injected run record names the person a user runs for, so preferences and cache scope follow them.if (runPerson && !personSubject) body = { ...body, on_behalf_of: { type: 'user', id: runPerson } };const selfKeys = Boolean(credentials && runPerson && credentialSubject === null && !projectProvider && !pinned && await credentials.hasSelf(runPerson));@@@ -307,7 +315,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowonBehalfOf: runPerson || principal.onBehalfOf || null,// Only the person's own token may hold their Balance; a service naming them draws free capacity only// (or its own run-token budget). The run still records the person, so preferences and cache follow them.-- personSubject: runPerson || null, personPays, serviceBudgeted,++ personSubject: runPerson || null, personPays, serviceBudgeted, freeUse,// A credential or a project's own provider never reaches managed supply: no Balance hold for that run....(credentialSubject !== null || projectProvider ? { byok: true } : {}),...(maxCostNano != null ? { maxCostNano: String(maxCostNano) } : {}),@@@ -647,6 +655,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowrequesterType: ctx.requesterType, requesterId: ctx.requesterId,// AI Exchange F: no payer on this run (and not BYOK): the pool skips paid offers with a card.creditsRequired: Boolean(ctx.creditsRequired),++ // The free allowance / anonymous Try it: the router may use only offers under the output price ceiling.++ freeUse: Boolean(ctx.freeUse),// Opted in to community capacity: the pool may place this run on a person's machine.communityOptIn: Boolean(ctx.communityOptIn),communityFree,@@@ -25,6 +25,38 @@ const { resolveSecret } = require('../util');const ROLE_TIMEOUT = { chat: 20000, vision: 30000, director: 25000, summary: 30000, legacy: 30000 };const HISTORICAL_ROUTES = ['wiki.generate', 'blog.draft', 'news.summarize', 'reviews.summarize', 'deals.enrich', 'coupons.extract', 'trade.summarize', 'codes.generate_docs', 'games.generate_lore', 'moderation.classify', 'tools.describe', 'food.suggest', 'rent.screen'];++// DeepSeek's own list (GET https://api.deepseek.com/models, checked 2026-11-06) and its own price page++// (https://api-docs.deepseek.com/quick_start/pricing, checked 2026-11-06). The card carries the peak rate;++// the page halves it off-peak (all hours outside 01:00-04:00 and 06:00-10:00 UTC Mon-Fri). Never rounded.++const DEEPSEEK_MODELS = [++ {++ model_key: 'deepseek-flash', display_name: 'DeepSeek V4.1 Flash', type: 'chat',++ context_window: 1048576, max_output: 393216,++ supports: { json: true, tools: true, streaming: true, vision: true },++ cost: { in_per_mtok: 0.30, out_per_mtok: 1.20, cached_per_mtok: 0.006, source: 'https://api-docs.deepseek.com/quick_start/pricing', verified_at: '2026-11-06' },++ metadata: { org: 'deepseek', model: 'deepseek-flash', family: 'DeepSeek', tier: 'fast', reasoning: true, configured_by: 'DEEPSEEK_API_KEY', pricing_note: 'Peak rates (01:00-04:00 and 06:00-10:00 UTC, Mon-Fri); off-peak is half: $0.15 in, $0.003 cached, $0.60 out per 1M.' },++ },++ {++ model_key: 'deepseek-v4-pro', display_name: 'DeepSeek V4 Pro', type: 'chat',++ context_window: 1048576, max_output: 393216,++ supports: { json: true, tools: true, streaming: true, vision: false },++ cost: { in_per_mtok: 1.32, out_per_mtok: 3.96, cached_per_mtok: 0.044, source: 'https://api-docs.deepseek.com/quick_start/pricing', verified_at: '2026-11-06' },++ metadata: { org: 'deepseek', model: 'deepseek-v4-pro', family: 'DeepSeek', tier: 'strong', reasoning: true, configured_by: 'DEEPSEEK_API_KEY', pricing_note: 'Peak rates (01:00-04:00 and 06:00-10:00 UTC, Mon-Fri); off-peak is half: $0.66 in, $0.022 cached, $1.98 out per 1M.' },++ },++];++++function deepseekProviderRecord(config, env) {++ const key = resolveSecret(config.deepseek.apiKeyRef, env);++ return {++ key: 'deepseek', display_name: 'DeepSeek', kind: 'openai',++ status: config.deepseek.enabled && key ? 'active' : 'disabled',++ base_url: config.deepseek.baseUrl, auth_mode: 'bearer', secret_ref: config.deepseek.apiKeyRef,++ default_model: DEEPSEEK_MODELS[0].model_key,++ capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'vision'],++ timeout_ms: config.deepseek.timeoutMs, priority: 20, provider_access: 'OWN_DEPLOYMENT',++ metadata: { configured_by: 'DEEPSEEK_API_KEY', provider_name: 'deepseek', billing_profile: 'metered' },++ };++}function sharedProviderRecord(config, env) {const s = config.shared;@@@ -53,6 +85,7 @@ function providerRecords(config, env) {const out = [{ key: 'stub', display_name: 'Deterministic stub (synthetic output)', kind: 'stub', status: 'active', auth_mode: 'none', default_model: 'stub-1', capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed', 'json', 'vision', 'transcribe'], timeout_ms: 10000, priority: 1000, provider_access: 'OWN_DEPLOYMENT', metadata: { synthetic: true, billing_profile: 'free' } },sharedProviderRecord(config, env),++ deepseekProviderRecord(config, env),{ key: 'whisper', display_name: 'whisper.cpp (local speech-to-text)', kind: 'whisper', status: 'active', auth_mode: 'none', default_model: 'whisper.cpp', capabilities: ['transcribe'], timeout_ms: 600000, priority: 20, provider_access: 'OWN_DEPLOYMENT', metadata: { configured_by: 'WHISPER_*', billing_profile: 'free' } },];// A local model server (llama.cpp, Ollama) speaking the OpenAI API: no key, never paid (WS-O task 5).@@@ -105,6 +138,17 @@ async function seed({ registry, quotas, config, env = process.env, db }) {// Modelsconst models = new Set([shared.default_model]);for (const m of models) if (m && !await registry.getModel('shared', m)) await registry.upsertModel({ provider_key: 'shared', model_key: m, type: 'chat', supports: { json: true, vision: true } }, { actor: 'seed' });++ // DeepSeek's own provider and two chat models when its key is configured. A model a person disabled stays++ // disabled; the prices and provenance are refreshed from the constants above on every boot.++ const deepseek = await registry.getProvider('deepseek');++ if (deepseek && deepseek.status === 'active') {++ for (const m of DEEPSEEK_MODELS) {++ const prev = await registry.getModel('deepseek', m.model_key);++ if (!prev || (prev.metadata && prev.metadata.configured_by === 'DEEPSEEK_API_KEY')) {++ await registry.upsertModel({ provider_key: 'deepseek', ...m, status: prev ? prev.status : 'active' }, { actor: 'seed' });++ }++ }++ }if (!await registry.getModel('stub', 'stub-1')) await registry.upsertModel({ provider_key: 'stub', model_key: 'stub-1', type: 'chat', supports: { json: true, vision: true }, metadata: { synthetic: true } }, { actor: 'seed' });if (!await registry.getModel('whisper', 'whisper.cpp')) await registry.upsertModel({ provider_key: 'whisper', model_key: 'whisper.cpp', type: 'stt', supports: { json: false } }, { actor: 'seed' });const embeddingModel = env.AI_EMBEDDING_MODEL || 'text-embedding-3-small';@@@ -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' } });++ h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_OUTPUT_COST_PER_MTOK: '0.5' } });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: [] } });nnew file mode 100644@@@ -0,0 +1,83 @@++'use strict';++// The public Exchange catalog: the real upstream, not the router's internal `shared` label; only models a++// configured key can serve; chat, embeddings, speech-to-text and image as separate kinds; and DeepSeek's own++// offers with the prices its page listed when they were checked (2026-11-06).++const assert = require('assert');++const { boot, request, suite } = require('./helpers');++const upstream = require('../server/providers/upstream');++const fixture = require('./fixtures/exchange-catalog/catalog.json');++++const t = suite('exchange-catalog-kinds');++let h;++++t.test('boot with the configured keys and the catalog fixture', async () => {++ h = await boot({ env: { AI_PROVIDER: 'openai', AI_API_KEY: 'test-openai-key', DEEPSEEK_API_KEY: 'test-deepseek-key', AI_STUB_FALLBACK: 'false' } });++ for (const p of fixture.providers) await h.registry.upsertProvider(p, { actor: 'test', origin: 'admin' });++ for (const m of fixture.models) await h.registry.upsertModel(m, { actor: 'test' });++});++++t.test('the shared key is shown as its real upstream, and only its own models are offers', async () => {++ const r = await request(h.base, 'GET', '/api/v1/exchange/catalog');++ assert.strictEqual(r.status, 200, r.text);++ const gpt = r.body.models.find((m) => m.slug === 'openai/gpt-5');++ assert.ok(gpt, 'the OpenAI model is an offer');++ assert.strictEqual(gpt.offers[0].provider, 'openai');++ assert.strictEqual(gpt.offers[0].provider_name, 'OpenAI');++ assert.ok(!r.body.models.some((m) => m.slug === 'anthropic/claude-haiku'), 'an OpenAI key cannot serve claude-haiku');++ assert.doesNotMatch(r.text, /Shared provider|"provider":"shared"/, 'the internal label never leaves the catalog');++});++++t.test('DeepSeek has its own offers, models and checked prices', async () => {++ const r = await request(h.base, 'GET', '/api/v1/exchange/catalog');++ const flash = r.body.models.find((m) => m.slug === 'deepseek/deepseek-flash');++ const pro = r.body.models.find((m) => m.slug === 'deepseek/deepseek-v4-pro');++ assert.ok(flash && pro, 'both DeepSeek chat models are offers');++ assert.strictEqual(flash.family, 'DeepSeek');++ assert.strictEqual(flash.offers[0].provider, 'deepseek');++ assert.strictEqual(flash.offers[0].price.out, 1.2);++ assert.strictEqual(pro.offers[0].price.out, 3.96);++ assert.match(flash.offers[0].price_source, /api-docs\.deepseek\.com/);++ assert.strictEqual(flash.offers[0].price_checked_at, '2026-11-06');++ assert.strictEqual(flash.context, 1048576);++});++++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'));++ assert.doesNotMatch(r.text, /mystery-chat/);++});++++t.test('chat, embeddings, speech-to-text and image are separate kinds, and ?kind filters', async () => {++ const all = await request(h.base, 'GET', '/api/v1/exchange/catalog');++ assert.strictEqual(all.status, 200, all.text);++ assert.ok(all.body.kinds.some((k) => k.kind === 'chat'));++ assert.ok(all.body.kinds.some((k) => k.kind === 'embedding'));++ assert.ok(all.body.kinds.some((k) => k.kind === 'stt'));++ const embed = all.body.models.find((m) => m.model === 'bge-small-en-v1.5' || m.slug === 'local/bge-small-en-v1.5');++ const stt = all.body.models.find((m) => m.slug === 'whisper/whisper.cpp');++ assert.strictEqual(embed.kind, 'embedding');++ assert.strictEqual(stt.kind, 'stt');++++ const chat = await request(h.base, 'GET', '/api/v1/exchange/catalog?kind=chat');++ assert.strictEqual(chat.status, 200, chat.text);++ assert.ok(chat.body.models.length > 0);++ assert.ok(chat.body.models.every((m) => m.kind === 'chat'));++ assert.ok(!chat.body.models.some((m) => m.kind === 'embedding' || m.kind === 'stt'));++++ const embeddings = await request(h.base, 'GET', '/api/v1/exchange/catalog?kind=embedding');++ assert.strictEqual(embeddings.body.kind, 'embedding');++ assert.ok(embeddings.body.models.every((m) => m.kind === 'embedding'));++++ const bad = await request(h.base, 'GET', '/api/v1/exchange/catalog?kind=screenshot');++ assert.strictEqual(bad.status, 422);++ assert.strictEqual(bad.body.code, 'exchange.kind_invalid');++});++++t.test('an unknown shared upstream is no offer at all', async () => {++ const unknown = upstream.upstreamOf({ key: 'shared', display_name: 'Shared provider', base_url: 'https://proxy.internal/v1', metadata: {} });++ assert.strictEqual(unknown, null);++ assert.strictEqual(upstream.serves(unknown, { key: 'shared' }, { model_key: 'gpt-5' }), false);++ assert.strictEqual(upstream.serves({ families: ['openai'] }, { key: 'shared' }, { model_key: 'gpt-5' }), true);++ assert.strictEqual(upstream.serves({ families: ['openai'] }, { key: 'shared' }, { model_key: 'claude-haiku' }), false);++ assert.strictEqual(upstream.configured({ status: 'active', auth_mode: 'bearer', secret_ref: 'env:NOT_SET_ANYWHERE' }, {}), false);++});@@@ -12,7 +12,9 @@ let h;const day = (n) => new Date(Date.now() - n * 86400000).toISOString().slice(0, 10);t.test('boot', async () => {-- h = await boot({ env: { AI_STUB_FALLBACK: 'false' } });++ // The catalog lists only offers whose key is configured: give the test's providers keys and the shared++ // provider a real OpenAI upstream, as production has.++ h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_PROVIDER: 'openai', AI_API_KEY: 'test-openai-key', DEEPSEEK_KEY: 'test-deepseek-key', DEEPINFRA_KEY: 'test-deepinfra-key' } });});t.test('catalog groups the same model across providers, with prices, speed, uptime and data policy', async () => {nnew file mode 100644@@@ -0,0 +1,114 @@++{++ "providers": [++ {++ "key": "shared",++ "display_name": "Shared provider (Live's shared key)",++ "kind": "openai",++ "status": "active",++ "base_url": "https://api.openai.com/v1",++ "auth_mode": "bearer",++ "secret_ref": "env:AI_API_KEY",++ "default_model": "gpt-5",++ "capabilities": ["chat", "generate", "json", "vision", "embed"],++ "provider_access": "OWN_DEPLOYMENT",++ "metadata": { "provider_name": "openai", "configured_by": "AI_PROVIDER/AI_BASE_URL/AI_API_KEY/AI_MODEL" }++ },++ {++ "key": "deepseek",++ "display_name": "DeepSeek",++ "kind": "openai",++ "status": "active",++ "base_url": "https://api.deepseek.com",++ "auth_mode": "bearer",++ "secret_ref": "env:DEEPSEEK_API_KEY",++ "default_model": "deepseek-flash",++ "capabilities": ["chat", "generate", "json", "vision"],++ "provider_access": "OWN_DEPLOYMENT",++ "metadata": { "provider_name": "deepseek", "configured_by": "DEEPSEEK_API_KEY" }++ },++ {++ "key": "local",++ "display_name": "Local model (OpenAI-compatible server on this host)",++ "kind": "openai",++ "status": "active",++ "base_url": "http://127.0.0.1:8090/v1",++ "auth_mode": "none",++ "default_model": "qwen2.5-1.5b-instruct",++ "capabilities": ["chat", "generate", "embed"],++ "provider_access": "OWN_DEPLOYMENT",++ "metadata": { "local": true, "paid": false, "billing_profile": "free" }++ },++ {++ "key": "unconfigured",++ "display_name": "Unconfigured upstream",++ "kind": "openai",++ "status": "active",++ "base_url": "https://unconfigured.example/v1",++ "auth_mode": "bearer",++ "secret_ref": "env:UNCONFIGURED_API_KEY",++ "default_model": "mystery-chat",++ "capabilities": ["chat", "generate", "json"],++ "metadata": { "provider_name": "unconfigured" }++ }++ ],++ "models": [++ {++ "provider_key": "shared",++ "model_key": "gpt-5",++ "display_name": "gpt-5",++ "type": "chat",++ "supports": { "json": true, "tools": true, "streaming": true, "vision": true },++ "cost": { "in_per_mtok": 1.25, "out_per_mtok": 10, "cached_per_mtok": 0.125, "source": "https://openai.com/api/pricing/", "verified_at": "2026-09-30" }++ },++ {++ "provider_key": "shared",++ "model_key": "claude-haiku",++ "display_name": "claude-haiku",++ "type": "chat",++ "cost": { "in_per_mtok": 0.8, "out_per_mtok": 4, "cached_per_mtok": 0.08 }++ },++ {++ "provider_key": "shared",++ "model_key": "text-embedding-3-small",++ "display_name": "text-embedding-3-small",++ "type": "embedding",++ "cost": { "in_per_mtok": 0.05, "out_per_mtok": 0.4, "cached_per_mtok": 0.005 }++ },++ {++ "provider_key": "deepseek",++ "model_key": "deepseek-flash",++ "display_name": "DeepSeek V4.1 Flash",++ "type": "chat",++ "context_window": 1048576,++ "max_output": 393216,++ "supports": { "json": true, "tools": true, "streaming": true, "vision": true },++ "cost": { "in_per_mtok": 0.3, "out_per_mtok": 1.2, "cached_per_mtok": 0.006, "source": "https://api-docs.deepseek.com/quick_start/pricing", "verified_at": "2026-11-06" },++ "metadata": { "org": "deepseek", "model": "deepseek-flash", "family": "DeepSeek", "tier": "fast" }++ },++ {++ "provider_key": "deepseek",++ "model_key": "deepseek-v4-pro",++ "display_name": "DeepSeek V4 Pro",++ "type": "chat",++ "context_window": 1048576,++ "max_output": 393216,++ "supports": { "json": true, "tools": true, "streaming": true, "vision": false },++ "cost": { "in_per_mtok": 1.32, "out_per_mtok": 3.96, "cached_per_mtok": 0.044, "source": "https://api-docs.deepseek.com/quick_start/pricing", "verified_at": "2026-11-06" },++ "metadata": { "org": "deepseek", "model": "deepseek-v4-pro", "family": "DeepSeek", "tier": "strong" }++ },++ {++ "provider_key": "local",++ "model_key": "bge-small-en-v1.5",++ "display_name": "bge-small-en-v1.5",++ "type": "embedding",++ "supports": { "json": false }++ },++ {++ "provider_key": "unconfigured",++ "model_key": "mystery-chat",++ "display_name": "mystery-chat",++ "type": "chat",++ "cost": { "in_per_mtok": 1, "out_per_mtok": 1 }++ }++ ]++}nnew file mode 100644@@@ -0,0 +1,18 @@++{++ "cheap": {++ "model_key": "cheap-m",++ "display_name": "Cheap chat",++ "type": "chat",++ "supports": { "json": true, "tools": true, "streaming": true },++ "cost": { "in_per_mtok": 0.1, "out_per_mtok": 0.4 },++ "metadata": { "tier": "fast", "listed": true, "synced_at": "2026-11-01T00:00:00.000Z" }++ },++ "dear": {++ "model_key": "dear-m",++ "display_name": "Dear frontier chat",++ "type": "chat",++ "supports": { "json": true, "tools": true, "streaming": true },++ "cost": { "in_per_mtok": 10, "out_per_mtok": 10 },++ "metadata": { "tier": "frontier", "reasoning": true, "listed": true, "synced_at": "2026-11-01T00:00:00.000Z" }++ }++}nnew file mode 100644@@@ -0,0 +1,60 @@++'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.++const assert = require('assert');++const { boot, request, token, suite, seamServer, ALL } = require('./helpers');++const { load } = require('../server/config');++const models = require('./fixtures/free-ceiling/models.json');++++const t = suite('free-ceiling');++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);++ 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' } });++ 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' });++ }++ route = await h.registry.createRouteVersion('test.free-ceiling', { capability: 'chat', constraints: { objective: 'correctness' }, timeout_ms: 3000 }, { actor: 'test' });++});++++t.test('the free run takes the cheap model, though the heavy task would otherwise take the frontier one', async () => {++ const out = await h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'write the whole function' }], reasoning: true, maxTokens: 8000, timeoutMs: 3000 }, { routeKey: route.key, routeVersion: route.version, freeUse: true });++ assert.strictEqual(out.provider, 'cheap', 'the over-ceiling frontier offer was skipped');++ assert.ok(out.explain.attempts.some((a) => a.provider === 'dear' && /over_price_ceiling/.test(a.error || '')));++});++++t.test('when every capable offer is too dear, a free run is refused with 402, never run', async () => {++ const off = await request(h.base, 'POST', '/api/v1/providers/cheap/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),++ );++ assert.strictEqual(srv.calls, before, 'no provider was called');++});++++t.test('the anonymous ai.complete run is capped; the same request on a person\'s own token is not', async () => {++ const anon = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: exchange, body: { workflow: 'ai.complete', input: { messages: [{ role: 'user', content: 'hello' }] } } });++ assert.strictEqual(anon.status, 201, anon.text);++ assert.strictEqual(anon.body.run.status, 'failed');++ assert.strictEqual(anon.body.run.error.code, 'billing.payer_required');++++ const person = token('dana', ALL, { sub: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPY', actorType: 'user' });++ const paid = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: person, body: { workflow: 'ai.complete', input: { messages: [{ role: 'user', content: 'hello' }] } } });++ assert.strictEqual(paid.status, 201, paid.text);++ assert.strictEqual(paid.body.run.status, 'succeeded', paid.text);++ assert.strictEqual(paid.body.run.provenance.provider, 'dear');++});++++t.test('stop', async () => { await h.stop(); await srv.close(); });@@@ -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', 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', 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: [] } });});
@@ -147,6 +147,21 @@ AI_MODEL=
@@ -147,6 +147,21 @@ AI_MODEL=
#AI_INPUT_COST_PER_MTOK=3
#AI_INPUT_COST_PER_MTOK=3
#AI_OUTPUT_COST_PER_MTOK=15
#AI_OUTPUT_COST_PER_MTOK=15
+# ── DeepSeek as its own provider (chat models; the same key research uses) ──
+# With DEEPSEEK_API_KEY (or AI_DEEPSEEK_KEY_REF) set, boot registers provider `deepseek` and its two chat
+# models (deepseek-flash, deepseek-v4-pro) with prices from DeepSeek's own price page. Without the key it
+# stays disabled and its models are not offers.
+#AI_DEEPSEEK_ENABLED=true
+#AI_DEEPSEEK_BASE_URL=https://api.deepseek.com
+#AI_DEEPSEEK_KEY_REF=env:DEEPSEEK_API_KEY
+#AI_DEEPSEEK_TIMEOUT_MS=90000
+
+# ── 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
+
# 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
# A local model server speaking the OpenAI API (llama.cpp llama-server, Ollama /v1): provider `local`, route
# A local model server speaking the OpenAI API (llama.cpp llama-server, Ollama /v1): provider `local`, route
@@ -440,6 +440,7 @@ definition, but never over an admin's version.
@@ -440,6 +440,7 @@ definition, but never over an admin's version.
|---|---|---|
|---|---|---|
| `stub` | Deterministic, no key, realistically shaped output (schema-shaped JSON, sentences, unit-vector embeddings, timed transcript segments), clearly marked synthetic (`(synthetic)` text, `synthetic: true` on the run). Never cached. | always present |
| `stub` | Deterministic, no key, realistically shaped output (schema-shaped JSON, sentences, unit-vector embeddings, timed transcript segments), clearly marked synthetic (`(synthetic)` text, `synthetic: true` on the run). Never cached. | always present |
| `openai` | Any OpenAI-compatible API (OpenAI, OpenRouter, Groq, Together, Ollama, LM Studio, llama.cpp): chat with structured-output step-down, embeddings, Whisper-style transcription, image generation (`POST {base}/images/generations`). Ported from Live's `llm.js` / `ai-provider.js`. | `AI_PROVIDER`, `AI_BASE_URL`, `AI_API_KEY`, `AI_MODEL` — Live's setting names |
| `openai` | Any OpenAI-compatible API (OpenAI, OpenRouter, Groq, Together, Ollama, LM Studio, llama.cpp): chat with structured-output step-down, embeddings, Whisper-style transcription, image generation (`POST {base}/images/generations`). Ported from Live's `llm.js` / `ai-provider.js`. | `AI_PROVIDER`, `AI_BASE_URL`, `AI_API_KEY`, `AI_MODEL` — Live's setting names |
+| `openai`, key `deepseek` | DeepSeek's own API, registered when its key is configured: `deepseek-flash` and `deepseek-v4-pro`, priced from DeepSeek's price page (peak rates; off-peak is half). A missing key leaves the provider disabled, so none of its models is an offer. | `DEEPSEEK_API_KEY` (or `AI_DEEPSEEK_KEY_REF`), `AI_DEEPSEEK_BASE_URL` |
| `responses` | The OpenAI Responses API (`POST {base}/responses`) for GPT-family models that only speak it, at any OpenAI-compatible `base_url` (default `https://api.openai.com/v1`): chat and every text operation, structured output through `text.format` with the same step-down, vision; no embeddings or transcription. | the admin API (`kind: "responses"`, `base_url`, `secret_ref`) |
| `responses` | The OpenAI Responses API (`POST {base}/responses`) for GPT-family models that only speak it, at any OpenAI-compatible `base_url` (default `https://api.openai.com/v1`): chat and every text operation, structured output through `text.format` with the same step-down, vision; no embeddings or transcription. | the admin API (`kind: "responses"`, `base_url`, `secret_ref`) |
| `anthropic` | Messages API with cached system blocks and forced-tool JSON. Ported from Live's `llm.js`. | same, with `AI_PROVIDER=anthropic` |
| `anthropic` | Messages API with cached system blocks and forced-tool JSON. Ported from Live's `llm.js`. | same, with `AI_PROVIDER=anthropic` |
| `http` | The local HTTP seam: `POST {operation, …}` → `{text, json, usage}` | `AI_HTTP_SEAM_URL` or the admin API |
| `http` | The local HTTP seam: `POST {operation, …}` → `{text, json, usage}` | `AI_HTTP_SEAM_URL` or the admin API |
@@ -530,6 +531,14 @@ their price out. It is pricing, never admission: quotas, request counting and th
@@ -530,6 +531,14 @@ 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.
+
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
(and before `reserve()` counts it), it holds an estimate of the units `ai-token` and `ai-usd` against two
(and before `reserve()` counts it), it holds an estimate of the units `ai-token` and `ai-usd` against two
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
*/
*/
const express = require('express');
const express = require('express');
const { http } = require('openvibe-contracts');
const { http } = require('openvibe-contracts');
-const { createCatalog } = require('../exchange/catalog');
+const { createCatalog, KINDS: catalogKinds } = require('../exchange/catalog');
const { createUsage } = require('../exchange/usage');
const { createUsage } = require('../exchange/usage');
const CATALOG_MS = 30_000;
const CATALOG_MS = 30_000;
@@ -28,17 +28,30 @@ function memo(fn, ttlMs, clock) {
@@ -28,17 +28,30 @@ function memo(fn, ttlMs, clock) {
};
};
}
}
-function exchangeRouter({ db, registry, pool, clock = { now: () => Date.now() } }) {
- const catalog = createCatalog({ db, registry, pool, clock });
+function exchangeRouter({ db, registry, pool, env = process.env, clock = { now: () => Date.now() } }) {
+ const catalog = createCatalog({ db, registry, pool, env, clock });
const usage = createUsage({ db, clock });
const usage = createUsage({ db, clock });
- const all = memo(() => catalog.models({ days: 7 }), CATALOG_MS, clock);
+ // One cached read per kind (?kind=chat, screenshot, …); the unfiltered read is the full catalog.
+ const hits = new Map();
+ const allFor = (kind) => {
+ const key = kind || '';
+ if (!hits.has(key)) hits.set(key, memo(() => catalog.models({ days: 7, kind: kind || null }), CATALOG_MS, clock));
+ return hits.get(key)();
+ };
+ const all = () => allFor(null);
const usageByWindow = new Map();
const usageByWindow = new Map();
const r = express.Router();
const r = express.Router();
const publicCache = (seconds) => (req, res, next) => { res.set('Cache-Control', `public, max-age=${seconds}`); next(); };
const publicCache = (seconds) => (req, res, next) => { res.set('Cache-Control', `public, max-age=${seconds}`); next(); };
r.get('/api/v1/exchange/catalog', publicCache(30), async (req, res, next) => {
r.get('/api/v1/exchange/catalog', publicCache(30), async (req, res, next) => {
- try { res.json(await all()); } catch (err) { next(err); }
+ try {
+ const kind = req.query.kind == null || req.query.kind === '' ? null : String(req.query.kind).toLowerCase();
+ if (kind && !catalogKinds.includes(kind)) {
+ return http.sendProblem(res, 422, 'exchange.kind_invalid', { detail: `kind must be one of ${catalogKinds.join(', ')}`, ctx: req.ov });
+ }
+ res.json(await allFor(kind));
+ } catch (err) { next(err); }
});
});
r.get('/api/v1/exchange/models/:slug', publicCache(30), async (req, res, next) => {
r.get('/api/v1/exchange/models/:slug', publicCache(30), async (req, res, next) => {
@@ -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, clock }));
+ app.use(require('./api/exchange').exchangeRouter({ db, registry, pool, env, clock }));
// 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 });
@@ -130,6 +130,21 @@ function load(env = process.env) {
@@ -130,6 +130,21 @@ function load(env = process.env) {
model: String(env.AI_MODEL || '').trim(),
model: String(env.AI_MODEL || '').trim(),
timeoutMs: int(env.AI_PROVIDER_TIMEOUT_MS, 30000),
timeoutMs: int(env.AI_PROVIDER_TIMEOUT_MS, 30000),
},
},
+ // ── DeepSeek, its own provider when its key is configured ──
+ // DEEPSEEK_API_KEY is also the research fallback's key; this makes its chat models managed offers.
+ deepseek: {
+ enabled: bool(env.AI_DEEPSEEK_ENABLED, true),
+ baseUrl: trimUrl(env.AI_DEEPSEEK_BASE_URL || 'https://api.deepseek.com'),
+ apiKeyRef: String(env.AI_DEEPSEEK_KEY_REF || 'env:DEEPSEEK_API_KEY').trim(),
+ timeoutMs: int(env.AI_DEEPSEEK_TIMEOUT_MS, 90000),
+ },
+ // ── 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.
+ freeUse: {
+ maxOutputPricePerMtok: Math.max(0, float(env.AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK, 1)),
+ },
// ── 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: {
// A candidate replaces the route's current placement only when it scores this much better,
// A candidate replaces the route's current placement only when it scores this much better,
@@ -13,6 +13,7 @@
@@ -13,6 +13,7 @@
* says nothing about training, retention or region.
* says nothing about training, retention or region.
*/
*/
const rates = require('../providers/rate-cards');
const rates = require('../providers/rate-cards');
+const upstream = require('../providers/upstream');
const DAY_MS = 86400000;
const DAY_MS = 86400000;
const FAMILY_BY_PREFIX = [
const FAMILY_BY_PREFIX = [
@@ -64,6 +65,16 @@ function familyOf(row, provider, canonical) {
@@ -64,6 +65,16 @@ function familyOf(row, provider, canonical) {
return familyFrom(canonical) || provider.display_name || provider.key;
return familyFrom(canonical) || provider.display_name || provider.key;
}
}
+/**
+ * The kind a model is offered as. `vision` is a chat model that also sees images, so it is chat here; the
+ * catalog has separate kinds for embeddings, speech-to-text and image generation, never mixed into chat.
+ */
+function kindOf(row) {
+ const type = (row && row.type) || 'chat';
+ return type === 'vision' ? 'chat' : type;
+}
+const KINDS = ['chat', 'embedding', 'stt', 'image'];
+
function policyOf(provider) {
function policyOf(provider) {
const p = provider && provider.metadata && provider.metadata.data_policy;
const p = provider && provider.metadata && provider.metadata.data_policy;
if (!p || typeof p !== 'object') return null;
if (!p || typeof p !== 'object') return null;
@@ -90,7 +101,7 @@ function modalitiesOf(row) {
@@ -90,7 +101,7 @@ function modalitiesOf(row) {
return [...out];
return [...out];
}
}
-function createCatalog({ db, registry, pool, clock = { now: () => Date.now() } }) {
+function createCatalog({ db, registry, pool, env = process.env, clock = { now: () => Date.now() } }) {
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,
@@ -120,17 +131,16 @@ function createCatalog({ db, registry, pool, clock = { now: () => Date.now() } }
@@ -120,17 +131,16 @@ function createCatalog({ db, registry, pool, clock = { now: () => Date.now() } }
/**
/**
* Every live offer, grouped by the model it serves.
* Every live offer, grouped by the model it serves.
* days: the window the measured speed, uptime and request counts cover.
* days: the window the measured speed, uptime and request counts cover.
+ * kind: only models of this kind (chat, embedding, stt, image); null for every kind.
*/
*/
- async function models({ days = 7 } = {}) {
+ async function models({ days = 7, kind = null } = {}) {
const now = clock.now();
const now = clock.now();
const today = dayOf(now);
const today = dayOf(now);
const stats = await statsFor(days, today);
const stats = await statsFor(days, today);
const providers = await registry.listProviders();
const providers = await registry.listProviders();
const byKey = new Map(providers.map((p) => [p.key, p]));
const byKey = new Map(providers.map((p) => [p.key, p]));
- const health = new Map();
- for (const p of providers) {
- try { health.set(p.key, await pool.health(p.key)); } catch (e) { health.set(p.key, null); }
- }
+ const upstreamByKey = new Map();
+ for (const p of providers) upstreamByKey.set(p.key, upstream.upstreamOf(p));
const groups = new Map();
const groups = new Map();
for (const row of await registry.listModels()) {
for (const row of await registry.listModels()) {
@@ -138,11 +148,18 @@ function createCatalog({ db, registry, pool, clock = { now: () => Date.now() } }
@@ -138,11 +148,18 @@ function createCatalog({ db, registry, pool, clock = { now: () => Date.now() } }
if (!provider || provider.kind === 'stub' || (provider.metadata && provider.metadata.synthetic)) continue;
if (!provider || provider.kind === 'stub' || (provider.metadata && provider.metadata.synthetic)) continue;
if (provider.metadata && provider.metadata.hidden === true) continue;
if (provider.metadata && provider.metadata.hidden === true) continue;
if (row.status !== 'active' || provider.status !== 'active') continue;
if (row.status !== 'active' || provider.status !== 'active') continue;
+ // A model is an offer only when the key its provider needs is configured now. A record with no
+ // usable upstream (the internal `shared` label with no real provider behind it) is never shown.
+ if (!upstream.configured(provider, env)) continue;
+ const up = upstreamByKey.get(provider.key);
+ if (!up || !upstream.serves(up, provider, row)) continue;
// Capacity the network may not resell is never an offer: a person's own key, or a restricted one.
// Capacity the network may not resell is never an offer: a person's own key, or a restricted one.
if (provider.provider_access === 'CUSTOMER_BYOK' || provider.provider_access === 'RESTRICTED') continue;
if (provider.provider_access === 'CUSTOMER_BYOK' || provider.provider_access === 'RESTRICTED') continue;
if (!['chat', 'vision', 'embedding', 'stt', 'image'].includes(row.type)) continue;
if (!['chat', 'vision', 'embedding', 'stt', 'image'].includes(row.type)) continue;
- const canonical = canonicalOf(row, provider);
+ // The catalog shows the real upstream, never the router's internal key ("shared").
+ const shown = { key: up.key, display_name: up.name };
+ const canonical = canonicalOf(row, shown);
const slug = slugOf(canonical);
const slug = slugOf(canonical);
if (!slug) continue;
if (!slug) continue;
const cards = await pool.rateCardsFor(provider.key, row.model_key);
const cards = await pool.rateCardsFor(provider.key, row.model_key);
@@ -150,15 +167,19 @@ function createCatalog({ db, registry, pool, clock = { now: () => Date.now() } }
@@ -150,15 +167,19 @@ function createCatalog({ db, registry, pool, clock = { now: () => Date.now() } }
const st = stats.get(`${provider.key}\u0000${row.model_key}`) || { requests: 0, ok: 0, errors: 0, p50: null, p95: null, today: 0 };
const st = stats.get(`${provider.key}\u0000${row.model_key}`) || { requests: 0, ok: 0, errors: 0, p50: null, p95: null, today: 0 };
const uptime = st.requests > 0 ? st.ok / st.requests : null;
const uptime = st.requests > 0 ? st.ok / st.requests : null;
const offer = {
const offer = {
- provider: provider.key,
- provider_name: provider.display_name || provider.key,
+ provider: up.key,
+ provider_name: up.name,
model: row.model_key,
model: row.model_key,
+ kind: kindOf(row),
// The exact artifact id a caller may pin (`org/model[:quant]@revision`); null when nobody recorded one.
// The exact artifact id a caller may pin (`org/model[:quant]@revision`); null when nobody recorded one.
id: (row.metadata && typeof row.metadata.id === 'string') ? row.metadata.id : null,
id: (row.metadata && typeof row.metadata.id === 'string') ? row.metadata.id : null,
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,
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.
+ price_source: cards && cards.out.source && !String(cards.out.source).startsWith('urn:openvibe:ai:unverified:') ? cards.out.source : null,
+ price_checked_at: cards && cards.out.verified_at && cards.out.verified_at !== rates.UNKNOWN_DATE ? cards.out.verified_at : null,
speed: { p50_ms: st.p50, p95_ms: st.p95 },
speed: { p50_ms: st.p50, p95_ms: st.p95 },
uptime,
uptime,
requests_7d: st.requests,
requests_7d: st.requests,
@@ -170,7 +191,8 @@ function createCatalog({ db, registry, pool, clock = { now: () => Date.now() } }
@@ -170,7 +191,8 @@ function createCatalog({ db, registry, pool, clock = { now: () => Date.now() } }
groups.set(slug, {
groups.set(slug, {
slug, canonical,
slug, canonical,
name: row.display_name || row.model_key,
name: row.display_name || row.model_key,
- family: familyOf(row, provider, canonical),
+ family: familyOf(row, shown, canonical),
+ kind: kindOf(row),
variant: (row.metadata && row.metadata.variant) || null,
variant: (row.metadata && row.metadata.variant) || null,
uncensored: UNCENSORED.test(`${canonical} ${(row.metadata && row.metadata.variant) || ''}`) || Boolean(row.metadata && row.metadata.uncensored === true),
uncensored: UNCENSORED.test(`${canonical} ${(row.metadata && row.metadata.variant) || ''}`) || Boolean(row.metadata && row.metadata.uncensored === true),
type: row.type,
type: row.type,
@@ -190,7 +212,8 @@ function createCatalog({ db, registry, pool, clock = { now: () => Date.now() } }
@@ -190,7 +212,8 @@ function createCatalog({ db, registry, pool, clock = { now: () => Date.now() } }
if (!g.description && row.metadata && typeof row.metadata.description === 'string') g.description = row.metadata.description;
if (!g.description && row.metadata && typeof row.metadata.description === 'string') g.description = row.metadata.description;
}
}
- const models = [...groups.values()];
+ let models = [...groups.values()];
+ if (kind) models = models.filter((m) => m.kind === kind);
for (const m of models) {
for (const m of models) {
m.offers.sort((a, b) => {
m.offers.sort((a, b) => {
const pa = a.price ? a.price.in + a.price.out : Number.POSITIVE_INFINITY;
const pa = a.price ? a.price.in + a.price.out : Number.POSITIVE_INFINITY;
@@ -206,17 +229,24 @@ function createCatalog({ db, registry, pool, clock = { now: () => Date.now() } }
@@ -206,17 +229,24 @@ function createCatalog({ db, registry, pool, clock = { now: () => Date.now() } }
models.sort((a, b) => b.requests_7d - a.requests_7d || b.requests_today - a.requests_today || a.name.localeCompare(b.name));
models.sort((a, b) => b.requests_7d - a.requests_7d || b.requests_today - a.requests_today || a.name.localeCompare(b.name));
const families = new Map();
const families = new Map();
+ const kinds = new Map();
for (const m of models) {
for (const m of models) {
const f = families.get(m.family) || { name: m.family, models: 0, offers: 0 };
const f = families.get(m.family) || { name: m.family, models: 0, offers: 0 };
f.models += 1;
f.models += 1;
f.offers += m.offers.length;
f.offers += m.offers.length;
families.set(m.family, f);
families.set(m.family, f);
+ const k = kinds.get(m.kind) || { kind: m.kind, models: 0, offers: 0 };
+ k.models += 1;
+ k.offers += m.offers.length;
+ kinds.set(m.kind, k);
}
}
return {
return {
generated_at: new Date(now).toISOString(),
generated_at: new Date(now).toISOString(),
window_days: days,
window_days: days,
+ ...(kind ? { kind } : {}),
count: models.length,
count: models.length,
families: [...families.values()].sort((a, b) => b.models - a.models || a.name.localeCompare(b.name)),
families: [...families.values()].sort((a, b) => b.models - a.models || a.name.localeCompare(b.name)),
+ kinds: [...kinds.values()].sort((a, b) => KINDS.indexOf(a.kind) - KINDS.indexOf(b.kind)),
models,
models,
};
};
}
}
@@ -233,4 +263,5 @@ function createCatalog({ db, registry, pool, clock = { now: () => Date.now() } }
@@ -233,4 +263,5 @@ function createCatalog({ db, registry, pool, clock = { now: () => Date.now() } }
createCatalog.dayOf = dayOf;
createCatalog.dayOf = dayOf;
createCatalog.slugOf = slugOf;
createCatalog.slugOf = slugOf;
createCatalog.familyOf = familyOf;
createCatalog.familyOf = familyOf;
-module.exports = { createCatalog, slugOf, canonicalOf, dayOf };
+createCatalog.kindOf = kindOf;
+module.exports = { createCatalog, slugOf, canonicalOf, dayOf, kindOf, KINDS };
@@ -292,14 +292,17 @@ route('get', '/run-budgets/{jti}', {
@@ -292,14 +292,17 @@ 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', 'trust', 'free', 'price'],
+ type: 'object', required: ['provider', 'provider_name', 'model', 'kind', 'trust', 'free', 'price'],
properties: {
properties: {
- provider: str(), 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'] }),
id: nullable('string', { description: 'The exact artifact id a request may pin, when the registry records one' }),
id: nullable('string', { description: 'The exact artifact id a request may pin, when the registry records one' }),
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).' },
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_checked_at: nullable('string', { description: 'The date someone checked the price, when recorded' }),
speed: { type: 'object', properties: { p50_ms: nullable('number'), p95_ms: nullable('number') } },
speed: { type: 'object', properties: { p50_ms: nullable('number'), p95_ms: nullable('number') } },
uptime: nullable('number', { minimum: 0, maximum: 1 }),
uptime: nullable('number', { minimum: 0, maximum: 1 }),
requests_7d: integer({ minimum: 0 }), requests_today: integer({ minimum: 0 }),
requests_7d: integer({ minimum: 0 }), requests_today: integer({ minimum: 0 }),
@@ -308,10 +311,10 @@ const exchangeOffer = {
@@ -308,10 +311,10 @@ const exchangeOffer = {
},
},
};
};
const exchangeModel = {
const exchangeModel = {
- type: 'object', required: ['slug', 'name', 'family', 'uncensored', 'offers'],
+ type: 'object', required: ['slug', 'name', 'family', 'kind', 'uncensored', 'offers'],
properties: {
properties: {
slug: str(), canonical: str(), name: str(), family: str(), variant: nullable('string'), uncensored: { type: 'boolean' },
slug: str(), canonical: str(), name: str(), family: str(), variant: nullable('string'), uncensored: { type: 'boolean' },
- type: str(), context: nullable('integer'), max_output: nullable('integer'),
+ kind: str({ enum: ['chat', 'embedding', 'stt', 'image'] }), type: str(), context: nullable('integer'), max_output: nullable('integer'),
capabilities: { type: 'object', properties: { tools: { type: 'boolean' }, json: { type: 'boolean' }, vision: { type: 'boolean' }, stream: { type: 'boolean' } } },
capabilities: { type: 'object', properties: { tools: { type: 'boolean' }, json: { type: 'boolean' }, vision: { type: 'boolean' }, stream: { type: 'boolean' } } },
modalities: arrayOf(str()), description: nullable('string'),
modalities: arrayOf(str()), description: nullable('string'),
requests_7d: integer({ minimum: 0 }), requests_today: integer({ minimum: 0 }), community: { type: 'boolean' },
requests_7d: integer({ minimum: 0 }), requests_today: integer({ minimum: 0 }), community: { type: 'boolean' },
@@ -320,16 +323,19 @@ const exchangeModel = {
@@ -320,16 +323,19 @@ 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.',
+ 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.',
+ 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(),
+ kind: str({ enum: ['chat', 'embedding', 'stt', 'image'] }),
families: arrayOf({ type: 'object', properties: { name: str(), models: integer(), offers: integer() } }),
families: arrayOf({ type: 'object', properties: { name: str(), models: integer(), offers: integer() } }),
+ kinds: arrayOf({ type: 'object', properties: { kind: str(), models: integer(), offers: integer() } }),
models: arrayOf(exchangeModel),
models: arrayOf(exchangeModel),
} }) },
} }) },
});
});
route('get', '/exchange/models/{slug}', {
route('get', '/exchange/models/{slug}', {
operationId: 'exchangeModel', summary: 'One model and its offers', tags: ['exchange'], security: [],
operationId: 'exchangeModel', summary: 'One model and its offers', tags: ['exchange'], security: [],
- description: 'Public. The slug is "<org>/<model>", lowercased, e.g. deepseek/deepseek-chat.',
+ description: 'Public. The slug is "<org>/<model>", lowercased, e.g. deepseek/deepseek-flash.',
parameters: [path('slug', str())],
parameters: [path('slug', str())],
responses: { 200: json('The model', exchangeModel), 404: problem },
responses: { 200: json('The model', exchangeModel), 404: problem },
});
});
@@ -826,6 +826,13 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -826,6 +826,13 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
// L36 Y1: a run on behalf of a person tries the person's OWN keys first, for the providers that can serve the
// L36 Y1: a run on behalf of a person tries the person's OWN keys first, for the providers that can serve the
// 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
+ // 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;
+ const ceiling = freeCeiling != null ? freeCeiling : prefs.price_ceiling_usd_per_mtok;
+ let freeCeilingSkipped = false; // every capable offer was dearer than the free ceiling
const selfAttempts = [];
const selfAttempts = [];
const selfList = Array.isArray(ctx.selfCredentials) ? ctx.selfCredentials : [];
const selfList = Array.isArray(ctx.selfCredentials) ? ctx.selfCredentials : [];
if (selfList.length || (ctx.selfSubject && prefs.only_my_keys)) {
if (selfList.length || (ctx.selfSubject && prefs.only_my_keys)) {
@@ -916,18 +923,25 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -916,18 +923,25 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
if (!p) why.push('unknown_provider');
if (!p) why.push('unknown_provider');
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 {
else {
- if (!accessOffered(p)) why.push(`provider_access ${modelCatalog.accessOf(p)}`);
- const skip = await skipReason(p, entry && entry.adapter, features);
- if (skip) why.push(skip);
+ // The free allowance / anonymous Try it cannot pin a model 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 {
+ if (!accessOffered(p)) why.push(`provider_access ${modelCatalog.accessOf(p)}`);
+ const skip = await skipReason(p, entry && entry.adapter, features);
+ if (skip) why.push(skip);
+ }
}
}
if (missing.length) why.push(...missing);
if (missing.length) why.push(...missing);
const reason = why.length ? [...new Set(why)].join(',') : null;
const reason = why.length ? [...new Set(why)].join(',') : null;
candidatesView.push({ provider: offer.provider, model: offer.model, eligible: !reason, excluded_reason: reason, cost: null, latency: null });
candidatesView.push({ provider: offer.provider, model: offer.model, eligible: !reason, excluded_reason: reason, cost: null, latency: null });
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 } : {}) };
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 (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 });
}
}
list = usable;
list = usable;
@@ -959,7 +973,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -959,7 +973,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
: await rankCandidates({
: await rankCandidates({
list, explain, task, route, warmProvider,
list, explain, task, route, warmProvider,
preferred: preferredModelFor(prefs, operation, req, ctx),
preferred: preferredModelFor(prefs, operation, req, ctx),
- ceiling: prefs.price_ceiling_usd_per_mtok,
+ ceiling,
tierFirst: task.weight === 'heavy' || !freeFirst,
tierFirst: task.weight === 'heavy' || !freeFirst,
policy,
policy,
// A pinned route keeps its own order: the pin is the admin's decision, not the router's.
// A pinned route keeps its own order: the pin is the admin's decision, not the router's.
@@ -1033,12 +1047,14 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -1033,12 +1047,14 @@ 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 };
- // L36 Y3: a price ceiling caps the network's fallback; the person's own keys are never capped (above).
- if (prefs.price_ceiling_usd_per_mtok != null) {
+ // 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) {
const cards = await rateCardsFor(c.provider, model);
const cards = await rateCardsFor(c.provider, model);
const outputPrice = cards ? Number(rates.pricesOf(cards).out) || 0 : 0;
const outputPrice = cards ? Number(rates.pricesOf(cards).out) || 0 : 0;
- if (outputPrice > Number(prefs.price_ceiling_usd_per_mtok)) {
- const whyPrice = `over_price_ceiling (${outputPrice} > ${prefs.price_ceiling_usd_per_mtok} USD/Mtok)`;
+ if (outputPrice > Number(ceiling)) {
+ const whyPrice = `over_price_ceiling (${outputPrice} > ${ceiling} USD/Mtok)`;
+ if (freeCeiling != null) freeCeilingSkipped = true;
if (preferState && preferMatches(c.provider, model, prefer)) { preferState.honoured = false; preferState.why = 'it is over your price ceiling'; }
if (preferState && preferMatches(c.provider, model, prefer)) { preferState.honoured = false; preferState.why = 'it is over your price ceiling'; }
statFor(c.provider).skipped++;
statFor(c.provider).skipped++;
tried.push({ provider: c.provider, skipped: 'over_price_ceiling' });
tried.push({ provider: c.provider, skipped: 'over_price_ceiling' });
@@ -1179,6 +1195,13 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -1179,6 +1195,13 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
explain.reasons = [...(explain.reasons || []), 'the media could not be read'];
explain.reasons = [...(explain.reasons || []), 'the media could not be read'];
throw new AiError(422, 'media.unreadable', String(unreadable.message || 'the media could not be read').slice(0, 300), { tried, explain });
throw new AiError(422, 'media.unreadable', String(unreadable.message || 'the media could not be read').slice(0, 300), { 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 });
+ }
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 });
}
}
@@ -42,12 +42,19 @@ const ALIASES = [
@@ -42,12 +42,19 @@ const ALIASES = [
...a,
...a,
object: 'model',
object: 'model',
type: 'chat',
type: 'chat',
+ kind: 'chat',
dynamic: true,
dynamic: true,
capabilities: ['chat', 'json', 'stream'],
capabilities: ['chat', 'json', 'stream'],
modalities: ['text'],
modalities: ['text'],
availability: 'available',
availability: 'available',
}));
}));
+/** The kind a registry row is offered as: vision is chat with image input, and stt/embedding/image stand apart. */
+const kindOf = (row) => {
+ const type = (row && row.type) || 'chat';
+ return type === 'vision' ? 'chat' : type;
+};
+
const accessOf = (provider) => (provider && ACCESS.includes(provider.provider_access) ? provider.provider_access : 'UNVERIFIED');
const accessOf = (provider) => (provider && ACCESS.includes(provider.provider_access) ? provider.provider_access : 'UNVERIFIED');
const managedAccessAllowed = (provider) => MANAGED_ACCESS.includes(accessOf(provider));
const managedAccessAllowed = (provider) => MANAGED_ACCESS.includes(accessOf(provider));
@@ -117,6 +124,7 @@ function entryOf(row, provider) {
@@ -117,6 +124,7 @@ function entryOf(row, provider) {
model_key: row.model_key,
model_key: row.model_key,
display_name: row.display_name || row.model_key,
display_name: row.display_name || row.model_key,
type: row.type,
type: row.type,
+ kind: kindOf(row),
status: row.status,
status: row.status,
context_window: row.context_window == null ? null : Number(row.context_window),
context_window: row.context_window == null ? null : Number(row.context_window),
max_output: row.max_output == null ? null : Number(row.max_output),
max_output: row.max_output == null ? null : Number(row.max_output),
@@ -238,5 +246,5 @@ async function disclosureOf({ registry, run }) {
@@ -238,5 +246,5 @@ async function disclosureOf({ registry, run }) {
module.exports = {
module.exports = {
ACCESS, MANAGED_ACCESS, ALIASES, EXACT_ID,
ACCESS, MANAGED_ACCESS, ALIASES, EXACT_ID,
- accessOf, managedAccessAllowed, artifactOf, entryOf, entries, exactOffers, select, facts, disclosureOf, retailRate,
+ accessOf, managedAccessAllowed, artifactOf, entryOf, entries, exactOffers, select, facts, disclosureOf, retailRate, kindOf,
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,131 @@
@@ -0,0 +1,131 @@
+'use strict';
+/**
+ * The real upstream behind a provider record (`server/providers/upstream.js`).
+ *
+ * A provider record has an internal key the router uses ("shared" is Live's pooled OpenVibe key), and a
+ * display name the owner typed. Neither says who actually serves the model. The Exchange catalog must show
+ * the real upstream, and may list a model only when that upstream's configured key can serve it: an OpenAI
+ * key cannot serve claude-*, a DeepSeek key cannot serve gpt-*, and a custom proxy whose upstream nobody
+ * recorded can serve nothing the catalogue can promise.
+ *
+ * upstreamOf(provider) → { key, name, families } for a record with a known real upstream,
+ * null when `shared` names no upstream and its base URL is not one
+ * serves(upstream, provider, row) → could this upstream serve the registered model row?
+ * configured(provider, env) → is the key this record needs present right now?
+ *
+ * For a provider an admin named directly (deepseek, local, …) the real upstream is the record itself; only
+ * the seeded `shared` record hides its upstream, and only it is interpreted here. Nothing is invented: an
+ * unknown host or provider_name yields null, and the caller leaves those offers out.
+ */
+const { resolveSecret } = require('../util');
+
+const NAMES = {
+ openai: 'OpenAI',
+ anthropic: 'Anthropic',
+ deepseek: 'DeepSeek',
+ openrouter: 'OpenRouter',
+ groq: 'Groq',
+ google: 'Google Gemini',
+ gemini: 'Google Gemini',
+ mistral: 'Mistral AI',
+ xai: 'xAI',
+ together: 'Together AI',
+ fireworks: 'Fireworks AI',
+ cerebras: 'Cerebras',
+ minimax: 'MiniMax',
+ ollama: 'Ollama',
+};
+
+// Hosts that identify the upstream when AI_PROVIDER is blank or says "openai-compatible".
+const HOSTS = [
+ [/(^|\.)openai\.com$/i, 'openai'],
+ [/(^|\.)anthropic\.com$/i, 'anthropic'],
+ [/(^|\.)deepseek\.com$/i, 'deepseek'],
+ [/(^|\.)openrouter\.ai$/i, 'openrouter'],
+ [/(^|\.)groq\.com$/i, 'groq'],
+ [/(^|\.)googleapis\.com$/i, 'google'],
+ [/(^|\.)mistral\.ai$/i, 'mistral'],
+ [/(^|\.)x\.ai$/i, 'xai'],
+ [/(^|\.)together\.xyz$/i, 'together'],
+ [/(^|\.)fireworks\.ai$/i, 'fireworks'],
+ [/(^|\.)cerebras\.ai$/i, 'cerebras'],
+ [/(^|\.)minimax\.(ai|io)$/i, 'minimax'],
+];
+
+// Upstreams that route many families: every model row may be theirs. A direct provider (not `shared`) is
+// likewise trusted for its own rows: its key is the reason the row exists.
+const ROUTES_ALL = new Set(['openrouter', 'ollama', 'together', 'fireworks', 'cerebras']);
+// A first-party upstream serves only its own family.
+const FAMILIES = {
+ openai: ['openai'],
+ anthropic: ['anthropic'],
+ deepseek: ['deepseek'],
+ google: ['google'],
+ mistral: ['mistral'],
+ xai: ['xai'],
+ groq: ['open', 'gpt-oss'],
+ minimax: ['minimax'],
+};
+
+const hostOf = (url) => {
+ try { return new URL(String(url || '')).hostname.toLowerCase(); } catch (e) { return ''; }
+};
+
+/** The family an id names: what the upstream's key is allowed to serve. */
+function familyOfModel(modelKey) {
+ const s = String(modelKey || '').toLowerCase().replace(/^models\//, '');
+ const leaf = s.includes('/') ? s.slice(s.lastIndexOf('/') + 1) : s;
+ if (/^gpt-oss/.test(leaf)) return 'gpt-oss';
+ if (/^(gpt-|chatgpt|o[1-9](?:-|$)|text-embedding|whisper|tts-|dall-e|gpt-image|codex|computer-use|omni)/.test(leaf)) return 'openai';
+ if (/^claude/.test(leaf)) return 'anthropic';
+ if (/^deepseek/.test(leaf)) return 'deepseek';
+ if (/^(gemini|gemma|learnlm|imagen|veo)/.test(leaf)) return 'google';
+ if (/^(mistral|magistral|codestral|devstral|ministral|pixtral|open-mistral)/.test(leaf)) return 'mistral';
+ if (/^grok/.test(leaf)) return 'xai';
+ if (/^minimax/.test(leaf)) return 'minimax';
+ if (/^(llama|qwen|qwq|kimi|moonshot|glm|zhipu|phi|yi|ernie|hunyuan|nemotron|command|cohere|dbrx|falcon|starcoder|mixtral)/.test(leaf)) return 'open';
+ return 'unknown';
+}
+
+/**
+ * The real upstream of a provider record. A direct provider is its own upstream; `shared` is resolved from
+ * its configured upstream name (AI_PROVIDER in metadata.provider_name) or its base URL host. Unknown → null.
+ */
+function upstreamOf(provider) {
+ if (!provider) return null;
+ if (provider.key !== 'shared') {
+ return { key: provider.key, name: provider.display_name || provider.key, families: null };
+ }
+ const meta = provider.metadata || {};
+ const declared = String(meta.provider_name || '').trim().toLowerCase();
+ let key = NAMES[declared] ? declared : null;
+ if (!key) {
+ const host = hostOf(provider.base_url);
+ for (const [re, name] of HOSTS) if (re.test(host)) { key = name; break; }
+ }
+ if (!key) return null;
+ return { key, name: NAMES[key], families: ROUTES_ALL.has(key) ? null : (FAMILIES[key] || []) };
+}
+
+/** Could this upstream serve this model row? A direct provider's own rows are trusted. */
+function serves(upstream, provider, row) {
+ if (!upstream || !provider || !row) return false;
+ if (provider.key !== 'shared') return true;
+ if (!upstream.families) return true;
+ return upstream.families.includes(familyOfModel(row.model_key));
+}
+
+/**
+ * Is the credential this provider needs present right now? No credential for a keyless record (local,
+ * whisper), and none needed for a free or pooled-subscription provider. A missing env/vault reference is
+ * not configured: its models must not appear as offers.
+ */
+function configured(provider, env = process.env) {
+ if (!provider || provider.status !== 'active') return false;
+ if (provider.auth_mode === 'none') return true;
+ if (provider.billing_profile === 'free') return true;
+ if (provider.billing_profile === 'subscription' && !provider.secret_ref) return true;
+ try { return Boolean(resolveSecret(provider.secret_ref, env)); } catch (e) { return false; }
+}
+
+module.exports = { upstreamOf, serves, configured, familyOfModel, NAMES };
@@ -284,6 +284,14 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -284,6 +284,14 @@ 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.
+ 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'));
// The injected run record names the person a user runs for, so preferences and cache scope follow them.
// The injected run record names the person a user runs for, so preferences and cache scope follow them.
if (runPerson && !personSubject) body = { ...body, on_behalf_of: { type: 'user', id: runPerson } };
if (runPerson && !personSubject) body = { ...body, on_behalf_of: { type: 'user', id: runPerson } };
const selfKeys = Boolean(credentials && runPerson && credentialSubject === null && !projectProvider && !pinned && await credentials.hasSelf(runPerson));
const selfKeys = Boolean(credentials && runPerson && credentialSubject === null && !projectProvider && !pinned && await credentials.hasSelf(runPerson));
@@ -307,7 +315,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -307,7 +315,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
onBehalfOf: runPerson || principal.onBehalfOf || null,
onBehalfOf: runPerson || principal.onBehalfOf || null,
// Only the person's own token may hold their Balance; a service naming them draws free capacity only
// Only the person's own token may hold their Balance; a service naming them draws free capacity only
// (or its own run-token budget). The run still records the person, so preferences and cache follow them.
// (or its own run-token budget). The run still records the person, so preferences and cache follow them.
- personSubject: runPerson || null, personPays, serviceBudgeted,
+ personSubject: runPerson || null, personPays, serviceBudgeted, freeUse,
// A credential or a project's own provider never reaches managed supply: no Balance hold for that run.
// A credential or a project's own provider never reaches managed supply: no Balance hold for that run.
...(credentialSubject !== null || projectProvider ? { byok: true } : {}),
...(credentialSubject !== null || projectProvider ? { byok: true } : {}),
...(maxCostNano != null ? { maxCostNano: String(maxCostNano) } : {}),
...(maxCostNano != null ? { maxCostNano: String(maxCostNano) } : {}),
@@ -647,6 +655,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -647,6 +655,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
requesterType: ctx.requesterType, requesterId: ctx.requesterId,
requesterType: ctx.requesterType, requesterId: ctx.requesterId,
// AI Exchange F: no payer on this run (and not BYOK): the pool skips paid offers with a card.
// AI Exchange F: no payer on this run (and not BYOK): the pool skips paid offers with a card.
creditsRequired: Boolean(ctx.creditsRequired),
creditsRequired: Boolean(ctx.creditsRequired),
+ // The free allowance / anonymous Try it: the router may use only offers under the output price ceiling.
+ freeUse: Boolean(ctx.freeUse),
// Opted in to community capacity: the pool may place this run on a person's machine.
// Opted in to community capacity: the pool may place this run on a person's machine.
communityOptIn: Boolean(ctx.communityOptIn),
communityOptIn: Boolean(ctx.communityOptIn),
communityFree,
communityFree,
@@ -25,6 +25,38 @@ const { resolveSecret } = require('../util');
@@ -25,6 +25,38 @@ const { resolveSecret } = require('../util');
const ROLE_TIMEOUT = { chat: 20000, vision: 30000, director: 25000, summary: 30000, legacy: 30000 };
const ROLE_TIMEOUT = { chat: 20000, vision: 30000, director: 25000, summary: 30000, legacy: 30000 };
const HISTORICAL_ROUTES = ['wiki.generate', 'blog.draft', 'news.summarize', 'reviews.summarize', 'deals.enrich', 'coupons.extract', 'trade.summarize', 'codes.generate_docs', 'games.generate_lore', 'moderation.classify', 'tools.describe', 'food.suggest', 'rent.screen'];
const HISTORICAL_ROUTES = ['wiki.generate', 'blog.draft', 'news.summarize', 'reviews.summarize', 'deals.enrich', 'coupons.extract', 'trade.summarize', 'codes.generate_docs', 'games.generate_lore', 'moderation.classify', 'tools.describe', 'food.suggest', 'rent.screen'];
+// DeepSeek's own list (GET https://api.deepseek.com/models, checked 2026-11-06) and its own price page
+// (https://api-docs.deepseek.com/quick_start/pricing, checked 2026-11-06). The card carries the peak rate;
+// the page halves it off-peak (all hours outside 01:00-04:00 and 06:00-10:00 UTC Mon-Fri). Never rounded.
+const DEEPSEEK_MODELS = [
+ {
+ model_key: 'deepseek-flash', display_name: 'DeepSeek V4.1 Flash', type: 'chat',
+ context_window: 1048576, max_output: 393216,
+ supports: { json: true, tools: true, streaming: true, vision: true },
+ cost: { in_per_mtok: 0.30, out_per_mtok: 1.20, cached_per_mtok: 0.006, source: 'https://api-docs.deepseek.com/quick_start/pricing', verified_at: '2026-11-06' },
+ metadata: { org: 'deepseek', model: 'deepseek-flash', family: 'DeepSeek', tier: 'fast', reasoning: true, configured_by: 'DEEPSEEK_API_KEY', pricing_note: 'Peak rates (01:00-04:00 and 06:00-10:00 UTC, Mon-Fri); off-peak is half: $0.15 in, $0.003 cached, $0.60 out per 1M.' },
+ },
+ {
+ model_key: 'deepseek-v4-pro', display_name: 'DeepSeek V4 Pro', type: 'chat',
+ context_window: 1048576, max_output: 393216,
+ supports: { json: true, tools: true, streaming: true, vision: false },
+ cost: { in_per_mtok: 1.32, out_per_mtok: 3.96, cached_per_mtok: 0.044, source: 'https://api-docs.deepseek.com/quick_start/pricing', verified_at: '2026-11-06' },
+ metadata: { org: 'deepseek', model: 'deepseek-v4-pro', family: 'DeepSeek', tier: 'strong', reasoning: true, configured_by: 'DEEPSEEK_API_KEY', pricing_note: 'Peak rates (01:00-04:00 and 06:00-10:00 UTC, Mon-Fri); off-peak is half: $0.66 in, $0.022 cached, $1.98 out per 1M.' },
+ },
+];
+
+function deepseekProviderRecord(config, env) {
+ const key = resolveSecret(config.deepseek.apiKeyRef, env);
+ return {
+ key: 'deepseek', display_name: 'DeepSeek', kind: 'openai',
+ status: config.deepseek.enabled && key ? 'active' : 'disabled',
+ base_url: config.deepseek.baseUrl, auth_mode: 'bearer', secret_ref: config.deepseek.apiKeyRef,
+ default_model: DEEPSEEK_MODELS[0].model_key,
+ capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'vision'],
+ timeout_ms: config.deepseek.timeoutMs, priority: 20, provider_access: 'OWN_DEPLOYMENT',
+ metadata: { configured_by: 'DEEPSEEK_API_KEY', provider_name: 'deepseek', billing_profile: 'metered' },
+ };
+}
function sharedProviderRecord(config, env) {
function sharedProviderRecord(config, env) {
const s = config.shared;
const s = config.shared;
@@ -53,6 +85,7 @@ function providerRecords(config, env) {
@@ -53,6 +85,7 @@ function providerRecords(config, env) {
const out = [
const out = [
{ key: 'stub', display_name: 'Deterministic stub (synthetic output)', kind: 'stub', status: 'active', auth_mode: 'none', default_model: 'stub-1', capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed', 'json', 'vision', 'transcribe'], timeout_ms: 10000, priority: 1000, provider_access: 'OWN_DEPLOYMENT', metadata: { synthetic: true, billing_profile: 'free' } },
{ key: 'stub', display_name: 'Deterministic stub (synthetic output)', kind: 'stub', status: 'active', auth_mode: 'none', default_model: 'stub-1', capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed', 'json', 'vision', 'transcribe'], timeout_ms: 10000, priority: 1000, provider_access: 'OWN_DEPLOYMENT', metadata: { synthetic: true, billing_profile: 'free' } },
sharedProviderRecord(config, env),
sharedProviderRecord(config, env),
+ deepseekProviderRecord(config, env),
{ key: 'whisper', display_name: 'whisper.cpp (local speech-to-text)', kind: 'whisper', status: 'active', auth_mode: 'none', default_model: 'whisper.cpp', capabilities: ['transcribe'], timeout_ms: 600000, priority: 20, provider_access: 'OWN_DEPLOYMENT', metadata: { configured_by: 'WHISPER_*', billing_profile: 'free' } },
{ key: 'whisper', display_name: 'whisper.cpp (local speech-to-text)', kind: 'whisper', status: 'active', auth_mode: 'none', default_model: 'whisper.cpp', capabilities: ['transcribe'], timeout_ms: 600000, priority: 20, provider_access: 'OWN_DEPLOYMENT', metadata: { configured_by: 'WHISPER_*', billing_profile: 'free' } },
];
];
// A local model server (llama.cpp, Ollama) speaking the OpenAI API: no key, never paid (WS-O task 5).
// A local model server (llama.cpp, Ollama) speaking the OpenAI API: no key, never paid (WS-O task 5).
@@ -105,6 +138,17 @@ async function seed({ registry, quotas, config, env = process.env, db }) {
@@ -105,6 +138,17 @@ async function seed({ registry, quotas, config, env = process.env, db }) {
// Models
// Models
const models = new Set([shared.default_model]);
const models = new Set([shared.default_model]);
for (const m of models) if (m && !await registry.getModel('shared', m)) await registry.upsertModel({ provider_key: 'shared', model_key: m, type: 'chat', supports: { json: true, vision: true } }, { actor: 'seed' });
for (const m of models) if (m && !await registry.getModel('shared', m)) await registry.upsertModel({ provider_key: 'shared', model_key: m, type: 'chat', supports: { json: true, vision: true } }, { actor: 'seed' });
+ // DeepSeek's own provider and two chat models when its key is configured. A model a person disabled stays
+ // disabled; the prices and provenance are refreshed from the constants above on every boot.
+ const deepseek = await registry.getProvider('deepseek');
+ if (deepseek && deepseek.status === 'active') {
+ for (const m of DEEPSEEK_MODELS) {
+ const prev = await registry.getModel('deepseek', m.model_key);
+ if (!prev || (prev.metadata && prev.metadata.configured_by === 'DEEPSEEK_API_KEY')) {
+ await registry.upsertModel({ provider_key: 'deepseek', ...m, status: prev ? prev.status : 'active' }, { actor: 'seed' });
+ }
+ }
+ }
if (!await registry.getModel('stub', 'stub-1')) await registry.upsertModel({ provider_key: 'stub', model_key: 'stub-1', type: 'chat', supports: { json: true, vision: true }, metadata: { synthetic: true } }, { actor: 'seed' });
if (!await registry.getModel('stub', 'stub-1')) await registry.upsertModel({ provider_key: 'stub', model_key: 'stub-1', type: 'chat', supports: { json: true, vision: true }, metadata: { synthetic: true } }, { actor: 'seed' });
if (!await registry.getModel('whisper', 'whisper.cpp')) await registry.upsertModel({ provider_key: 'whisper', model_key: 'whisper.cpp', type: 'stt', supports: { json: false } }, { actor: 'seed' });
if (!await registry.getModel('whisper', 'whisper.cpp')) await registry.upsertModel({ provider_key: 'whisper', model_key: 'whisper.cpp', type: 'stt', supports: { json: false } }, { actor: 'seed' });
const embeddingModel = env.AI_EMBEDDING_MODEL || 'text-embedding-3-small';
const embeddingModel = env.AI_EMBEDDING_MODEL || 'text-embedding-3-small';
@@ -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' } });
+ h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_OUTPUT_COST_PER_MTOK: '0.5' } });
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: [] } });
new file mode 100644
new file mode 100644
@@ -0,0 +1,83 @@
@@ -0,0 +1,83 @@
+'use strict';
+// The public Exchange catalog: the real upstream, not the router's internal `shared` label; only models a
+// configured key can serve; chat, embeddings, speech-to-text and image as separate kinds; and DeepSeek's own
+// offers with the prices its page listed when they were checked (2026-11-06).
+const assert = require('assert');
+const { boot, request, suite } = require('./helpers');
+const upstream = require('../server/providers/upstream');
+const fixture = require('./fixtures/exchange-catalog/catalog.json');
+
+const t = suite('exchange-catalog-kinds');
+let h;
+
+t.test('boot with the configured keys and the catalog fixture', async () => {
+ h = await boot({ env: { AI_PROVIDER: 'openai', AI_API_KEY: 'test-openai-key', DEEPSEEK_API_KEY: 'test-deepseek-key', AI_STUB_FALLBACK: 'false' } });
+ for (const p of fixture.providers) await h.registry.upsertProvider(p, { actor: 'test', origin: 'admin' });
+ for (const m of fixture.models) await h.registry.upsertModel(m, { actor: 'test' });
+});
+
+t.test('the shared key is shown as its real upstream, and only its own models are offers', async () => {
+ const r = await request(h.base, 'GET', '/api/v1/exchange/catalog');
+ assert.strictEqual(r.status, 200, r.text);
+ const gpt = r.body.models.find((m) => m.slug === 'openai/gpt-5');
+ assert.ok(gpt, 'the OpenAI model is an offer');
+ assert.strictEqual(gpt.offers[0].provider, 'openai');
+ assert.strictEqual(gpt.offers[0].provider_name, 'OpenAI');
+ assert.ok(!r.body.models.some((m) => m.slug === 'anthropic/claude-haiku'), 'an OpenAI key cannot serve claude-haiku');
+ assert.doesNotMatch(r.text, /Shared provider|"provider":"shared"/, 'the internal label never leaves the catalog');
+});
+
+t.test('DeepSeek has its own offers, models and checked prices', async () => {
+ const r = await request(h.base, 'GET', '/api/v1/exchange/catalog');
+ const flash = r.body.models.find((m) => m.slug === 'deepseek/deepseek-flash');
+ const pro = r.body.models.find((m) => m.slug === 'deepseek/deepseek-v4-pro');
+ assert.ok(flash && pro, 'both DeepSeek chat models are offers');
+ assert.strictEqual(flash.family, 'DeepSeek');
+ assert.strictEqual(flash.offers[0].provider, 'deepseek');
+ assert.strictEqual(flash.offers[0].price.out, 1.2);
+ assert.strictEqual(pro.offers[0].price.out, 3.96);
+ assert.match(flash.offers[0].price_source, /api-docs\.deepseek\.com/);
+ assert.strictEqual(flash.offers[0].price_checked_at, '2026-11-06');
+ assert.strictEqual(flash.context, 1048576);
+});
+
+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'));
+ assert.doesNotMatch(r.text, /mystery-chat/);
+});
+
+t.test('chat, embeddings, speech-to-text and image are separate kinds, and ?kind filters', async () => {
+ const all = await request(h.base, 'GET', '/api/v1/exchange/catalog');
+ assert.strictEqual(all.status, 200, all.text);
+ assert.ok(all.body.kinds.some((k) => k.kind === 'chat'));
+ assert.ok(all.body.kinds.some((k) => k.kind === 'embedding'));
+ assert.ok(all.body.kinds.some((k) => k.kind === 'stt'));
+ const embed = all.body.models.find((m) => m.model === 'bge-small-en-v1.5' || m.slug === 'local/bge-small-en-v1.5');
+ const stt = all.body.models.find((m) => m.slug === 'whisper/whisper.cpp');
+ assert.strictEqual(embed.kind, 'embedding');
+ assert.strictEqual(stt.kind, 'stt');
+
+ const chat = await request(h.base, 'GET', '/api/v1/exchange/catalog?kind=chat');
+ assert.strictEqual(chat.status, 200, chat.text);
+ assert.ok(chat.body.models.length > 0);
+ assert.ok(chat.body.models.every((m) => m.kind === 'chat'));
+ assert.ok(!chat.body.models.some((m) => m.kind === 'embedding' || m.kind === 'stt'));
+
+ const embeddings = await request(h.base, 'GET', '/api/v1/exchange/catalog?kind=embedding');
+ assert.strictEqual(embeddings.body.kind, 'embedding');
+ assert.ok(embeddings.body.models.every((m) => m.kind === 'embedding'));
+
+ const bad = await request(h.base, 'GET', '/api/v1/exchange/catalog?kind=screenshot');
+ assert.strictEqual(bad.status, 422);
+ assert.strictEqual(bad.body.code, 'exchange.kind_invalid');
+});
+
+t.test('an unknown shared upstream is no offer at all', async () => {
+ const unknown = upstream.upstreamOf({ key: 'shared', display_name: 'Shared provider', base_url: 'https://proxy.internal/v1', metadata: {} });
+ assert.strictEqual(unknown, null);
+ assert.strictEqual(upstream.serves(unknown, { key: 'shared' }, { model_key: 'gpt-5' }), false);
+ assert.strictEqual(upstream.serves({ families: ['openai'] }, { key: 'shared' }, { model_key: 'gpt-5' }), true);
+ assert.strictEqual(upstream.serves({ families: ['openai'] }, { key: 'shared' }, { model_key: 'claude-haiku' }), false);
+ assert.strictEqual(upstream.configured({ status: 'active', auth_mode: 'bearer', secret_ref: 'env:NOT_SET_ANYWHERE' }, {}), false);
+});
@@ -12,7 +12,9 @@ let h;
@@ -12,7 +12,9 @@ let h;
const day = (n) => new Date(Date.now() - n * 86400000).toISOString().slice(0, 10);
const day = (n) => new Date(Date.now() - n * 86400000).toISOString().slice(0, 10);
t.test('boot', async () => {
t.test('boot', async () => {
- h = await boot({ env: { AI_STUB_FALLBACK: 'false' } });
+ // The catalog lists only offers whose key is configured: give the test's providers keys and the shared
+ // provider a real OpenAI upstream, as production has.
+ h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_PROVIDER: 'openai', AI_API_KEY: 'test-openai-key', DEEPSEEK_KEY: 'test-deepseek-key', DEEPINFRA_KEY: 'test-deepinfra-key' } });
});
});
t.test('catalog groups the same model across providers, with prices, speed, uptime and data policy', async () => {
t.test('catalog groups the same model across providers, with prices, speed, uptime and data policy', async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,114 @@
@@ -0,0 +1,114 @@
+{
+ "providers": [
+ {
+ "key": "shared",
+ "display_name": "Shared provider (Live's shared key)",
+ "kind": "openai",
+ "status": "active",
+ "base_url": "https://api.openai.com/v1",
+ "auth_mode": "bearer",
+ "secret_ref": "env:AI_API_KEY",
+ "default_model": "gpt-5",
+ "capabilities": ["chat", "generate", "json", "vision", "embed"],
+ "provider_access": "OWN_DEPLOYMENT",
+ "metadata": { "provider_name": "openai", "configured_by": "AI_PROVIDER/AI_BASE_URL/AI_API_KEY/AI_MODEL" }
+ },
+ {
+ "key": "deepseek",
+ "display_name": "DeepSeek",
+ "kind": "openai",
+ "status": "active",
+ "base_url": "https://api.deepseek.com",
+ "auth_mode": "bearer",
+ "secret_ref": "env:DEEPSEEK_API_KEY",
+ "default_model": "deepseek-flash",
+ "capabilities": ["chat", "generate", "json", "vision"],
+ "provider_access": "OWN_DEPLOYMENT",
+ "metadata": { "provider_name": "deepseek", "configured_by": "DEEPSEEK_API_KEY" }
+ },
+ {
+ "key": "local",
+ "display_name": "Local model (OpenAI-compatible server on this host)",
+ "kind": "openai",
+ "status": "active",
+ "base_url": "http://127.0.0.1:8090/v1",
+ "auth_mode": "none",
+ "default_model": "qwen2.5-1.5b-instruct",
+ "capabilities": ["chat", "generate", "embed"],
+ "provider_access": "OWN_DEPLOYMENT",
+ "metadata": { "local": true, "paid": false, "billing_profile": "free" }
+ },
+ {
+ "key": "unconfigured",
+ "display_name": "Unconfigured upstream",
+ "kind": "openai",
+ "status": "active",
+ "base_url": "https://unconfigured.example/v1",
+ "auth_mode": "bearer",
+ "secret_ref": "env:UNCONFIGURED_API_KEY",
+ "default_model": "mystery-chat",
+ "capabilities": ["chat", "generate", "json"],
+ "metadata": { "provider_name": "unconfigured" }
+ }
+ ],
+ "models": [
+ {
+ "provider_key": "shared",
+ "model_key": "gpt-5",
+ "display_name": "gpt-5",
+ "type": "chat",
+ "supports": { "json": true, "tools": true, "streaming": true, "vision": true },
+ "cost": { "in_per_mtok": 1.25, "out_per_mtok": 10, "cached_per_mtok": 0.125, "source": "https://openai.com/api/pricing/", "verified_at": "2026-09-30" }
+ },
+ {
+ "provider_key": "shared",
+ "model_key": "claude-haiku",
+ "display_name": "claude-haiku",
+ "type": "chat",
+ "cost": { "in_per_mtok": 0.8, "out_per_mtok": 4, "cached_per_mtok": 0.08 }
+ },
+ {
+ "provider_key": "shared",
+ "model_key": "text-embedding-3-small",
+ "display_name": "text-embedding-3-small",
+ "type": "embedding",
+ "cost": { "in_per_mtok": 0.05, "out_per_mtok": 0.4, "cached_per_mtok": 0.005 }
+ },
+ {
+ "provider_key": "deepseek",
+ "model_key": "deepseek-flash",
+ "display_name": "DeepSeek V4.1 Flash",
+ "type": "chat",
+ "context_window": 1048576,
+ "max_output": 393216,
+ "supports": { "json": true, "tools": true, "streaming": true, "vision": true },
+ "cost": { "in_per_mtok": 0.3, "out_per_mtok": 1.2, "cached_per_mtok": 0.006, "source": "https://api-docs.deepseek.com/quick_start/pricing", "verified_at": "2026-11-06" },
+ "metadata": { "org": "deepseek", "model": "deepseek-flash", "family": "DeepSeek", "tier": "fast" }
+ },
+ {
+ "provider_key": "deepseek",
+ "model_key": "deepseek-v4-pro",
+ "display_name": "DeepSeek V4 Pro",
+ "type": "chat",
+ "context_window": 1048576,
+ "max_output": 393216,
+ "supports": { "json": true, "tools": true, "streaming": true, "vision": false },
+ "cost": { "in_per_mtok": 1.32, "out_per_mtok": 3.96, "cached_per_mtok": 0.044, "source": "https://api-docs.deepseek.com/quick_start/pricing", "verified_at": "2026-11-06" },
+ "metadata": { "org": "deepseek", "model": "deepseek-v4-pro", "family": "DeepSeek", "tier": "strong" }
+ },
+ {
+ "provider_key": "local",
+ "model_key": "bge-small-en-v1.5",
+ "display_name": "bge-small-en-v1.5",
+ "type": "embedding",
+ "supports": { "json": false }
+ },
+ {
+ "provider_key": "unconfigured",
+ "model_key": "mystery-chat",
+ "display_name": "mystery-chat",
+ "type": "chat",
+ "cost": { "in_per_mtok": 1, "out_per_mtok": 1 }
+ }
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+{
+ "cheap": {
+ "model_key": "cheap-m",
+ "display_name": "Cheap chat",
+ "type": "chat",
+ "supports": { "json": true, "tools": true, "streaming": true },
+ "cost": { "in_per_mtok": 0.1, "out_per_mtok": 0.4 },
+ "metadata": { "tier": "fast", "listed": true, "synced_at": "2026-11-01T00:00:00.000Z" }
+ },
+ "dear": {
+ "model_key": "dear-m",
+ "display_name": "Dear frontier chat",
+ "type": "chat",
+ "supports": { "json": true, "tools": true, "streaming": true },
+ "cost": { "in_per_mtok": 10, "out_per_mtok": 10 },
+ "metadata": { "tier": "frontier", "reasoning": true, "listed": true, "synced_at": "2026-11-01T00:00:00.000Z" }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
+'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.
+const assert = require('assert');
+const { boot, request, token, suite, seamServer, ALL } = require('./helpers');
+const { load } = require('../server/config');
+const models = require('./fixtures/free-ceiling/models.json');
+
+const t = suite('free-ceiling');
+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);
+ 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' } });
+ 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' });
+ }
+ route = await h.registry.createRouteVersion('test.free-ceiling', { capability: 'chat', constraints: { objective: 'correctness' }, timeout_ms: 3000 }, { actor: 'test' });
+});
+
+t.test('the free run takes the cheap model, though the heavy task would otherwise take the frontier one', async () => {
+ const out = await h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'write the whole function' }], reasoning: true, maxTokens: 8000, timeoutMs: 3000 }, { routeKey: route.key, routeVersion: route.version, freeUse: true });
+ assert.strictEqual(out.provider, 'cheap', 'the over-ceiling frontier offer was skipped');
+ assert.ok(out.explain.attempts.some((a) => a.provider === 'dear' && /over_price_ceiling/.test(a.error || '')));
+});
+
+t.test('when every capable offer is too dear, a free run is refused with 402, never run', async () => {
+ const off = await request(h.base, 'POST', '/api/v1/providers/cheap/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),
+ );
+ assert.strictEqual(srv.calls, before, 'no provider was called');
+});
+
+t.test('the anonymous ai.complete run is capped; the same request on a person\'s own token is not', async () => {
+ const anon = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: exchange, body: { workflow: 'ai.complete', input: { messages: [{ role: 'user', content: 'hello' }] } } });
+ assert.strictEqual(anon.status, 201, anon.text);
+ assert.strictEqual(anon.body.run.status, 'failed');
+ assert.strictEqual(anon.body.run.error.code, 'billing.payer_required');
+
+ const person = token('dana', ALL, { sub: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPY', actorType: 'user' });
+ const paid = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: person, body: { workflow: 'ai.complete', input: { messages: [{ role: 'user', content: 'hello' }] } } });
+ assert.strictEqual(paid.status, 201, paid.text);
+ assert.strictEqual(paid.body.run.status, 'succeeded', paid.text);
+ assert.strictEqual(paid.body.run.provenance.provider, 'dear');
+});
+
+t.test('stop', async () => { await h.stop(); await srv.close(); });
@@ -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', 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', 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: [] } });
});
});