openvibers/openvibe.exchange Commits
Price one run of an agent blueprint at POST /api/v1/estimate (#245)
Changes
@@@ -90,8 +90,8 @@ client-credentials token work. See [.env.example](.env.example).AGPL-3.0-only. See [LICENSE](LICENSE).<!-- versions:start -->--- openvibe-contracts: v0.417.0--- openvibe-sdk: v0.161.0++- openvibe-contracts: v0.419.0++- openvibe-sdk: v0.162.0- openvibe-shared: v4.229.0- openvibe-publishing: v1.7.0<!-- versions:end -->@@@ -8,12 +8,12 @@"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9","port": "5150","domain": "openvibe.exchange",-- "contracts": "openvibe-contracts v0.417.0",-- "sdk": "openvibe-sdk v0.161.0",++ "contracts": "openvibe-contracts v0.419.0",++ "sdk": "openvibe-sdk v0.162.0","shared": "openvibe-shared v4.229.0","packages": [-- "openvibe-contracts v0.417.0",-- "openvibe-sdk v0.161.0",++ "openvibe-contracts v0.419.0",++ "openvibe-sdk v0.162.0","openvibe-shared v4.229.0"],"summary": "OpenVibe.Exchange is the network's AI front. It reads OpenVibe.AI's public catalog and aggregate usage and renders: the home (popular now, cheapest per million, uncensored, community machines, live numbers), /models and /m/<org>/<model> (every offer with price in/out, measured p50/p95, uptime and data policy, a Try it box, curl and the SDK), /chat (one to three models side by side, system prompt, temperature, history in the account, on openvibe-shared/thread), /api, /sell (Link's offer to the network; what is not sold, said plainly), /uncensored (an 18+ confirmation on the account and the hard floor) and /ai (which products run which models, and the daily cost). Chat and Try it are ai.complete runs on the person's behalf (on_behalf_of) with the service's ai.run.create token. Exchange v1 (free/swap listings, passports, sandboxes, installs, offers) is retired: every v1 page answers 410 with a pointer, its JSON API answers 410 exchange.retired, and its tables stay read-only; its tests live in test/retired/.",@@@ -16,9 +16,9 @@"helmet": "^8.1.0","iovalkey": "^0.4.0","multer": "^2.5.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.417.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.419.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.161.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.162.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.229.0","pg": "^8.23.0"},@@@ -1268,9 +1268,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.417.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.417.0",-- "integrity": "sha512-Kq4D/PZN9Nakf9iVBCBFwUTy/1q/8mRnLmaf2lygTkXWT9fjl/WmedJ6m/CX9fSnzOenUvYKzDl1Nvsvqhrrcw==",++ "version": "0.419.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.419.0",++ "integrity": "sha512-VxrTdeHchT8gvl2wWC9ASaId92en7ZKfJ8F/0hKzCAD0f2Y00rPrqgBWF9BNPoLGARoyQvsMbhBEhSkhxGNnjA==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -1302,9 +1302,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.161.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.161.0",-- "integrity": "sha512-qReF7K9ct7LCLDJKuguwUQ30nc0kmrp+jlRZcFm4adpEvKL8r5G6i6Osbs0ROAMLmXKwnbI4GiZAgAyQ7oN7Ig==",++ "version": "0.162.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.162.0",++ "integrity": "sha512-LQC+6X21smbLzoYIpk0K7f1fjewi9KRk7x/qNoX/89cGg5cHGNLW9gd/VcQWZ1O8KuzDG4IOwWctSZ5CStMttQ==","license": "MIT","bin": {"openvibe-download": "bin/openvibe-download.js"@@@ -25,9 +25,9 @@"helmet": "^8.1.0","iovalkey": "^0.4.0","multer": "^2.5.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.417.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.419.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.161.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.162.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.229.0","pg": "^8.23.0"},@@@ -35,6 +35,7 @@ const { createCompare } = require('./compare');const { createOpenApi } = require('./openapi');const { createCode } = require('./code');const { createCodeRoutes } = require('./http/code');++const { createEstimateRoutes } = require('./http/estimate');const { createArenaStore } = require('./arena/store');const { createCatalogHistory } = require('./catalog-history');const { createChatStore, createAdultStore } = require('./chat/store');@@@ -337,6 +338,11 @@ async function createApp(opts = {}) {// Exchange's own pages, so one process serves both names without either site's routes leaking into the other.app.use(createCompareSite(ctx));++ // ── What one run of a blueprint costs (agents.blueprint@1) ──++ // Mounted before Exchange v1's catch-all. The body is a blueprint (at most 200 KB); the answer is the price++ // through openvibe-sdk/agents/blueprint, from the person's choices or OpenVibe's public offers.++ app.use('/api/v1/estimate', rateLimit({ windowMs: 60000, limit: Number(process.env.EXCHANGE_API_RATE_LIMIT_PER_MIN) || 120, standardHeaders: true, legacyHeaders: false }), createEstimateRoutes(ctx));++// ── Exchange v1's JSON API: gone ────────────────────────app.use(createCodeRoutes(ctx));app.use('/api/v1', (req, res) => {nnew file mode 100644@@@ -0,0 +1,255 @@++'use strict';++++/**++ * POST /api/v1/estimate — what one run of an agent blueprint costs.++ *++ * Body: { blueprint: agents.blueprint@1, turns?: 1..50 } (the blueprint at most 200 KB). The answer:++ * { usd_per_run, cheapest: { id, name, rung }, free_on, why, turns, tokens: { in, out } }++ *++ * A signed-in person's choices come from OpenVibe.AI's GET /api/v1/choices?task=chat — their own machine, a++ * signed-in plan, their key, the free allowance, then Exchange's credits. Signed out, only OpenVibe's public++ * offers are used, on the allowance and credits rungs. The pricing itself is openvibe-sdk/agents/blueprint's++ * estimate(): this file maps each choice to the Dispatcher's shape and never keeps or logs the blueprint text.++ */++const express = require('express');++const contracts = require('openvibe-contracts');++const blueprint = require('openvibe-sdk/agents/blueprint');++const dispatch = require('openvibe-sdk/agents/dispatch');++++const BODY_LIMIT = '200kb';++const DEFAULT_TURNS = 4;++const MAX_TURNS = 50;++const TOKENS = Object.freeze({ in: 2000, out: 800 });++const NOT_CHAT = new Set(['embedding', 'embeddings', 'stt', 'asr', 'tts', 'speech', 'image', 'images', 'rerank', 'moderation']);++const isObject = (v) => v != null && typeof v === 'object' && !Array.isArray(v);++const num = (v) => (v == null || v === '' || !Number.isFinite(Number(v)) ? null : Number(v));++const lower = (v) => String(v == null ? '' : v).toLowerCase();++++/** SDK tier names, from whatever size class AI gives (a missing one is medium, never invented). */++function tierOf(option, model) {++ const raw = lower((option && (option.tier || option.size || option.size_class || option.model_size)) || (model && (model.tier || (model.metadata && model.metadata.tier))));++ if (dispatch.TIERS.includes(raw)) return raw;++ if (['large', 'xl', 'frontier', 'strong', 'big'].includes(raw)) return 'strong';++ if (['small', 'mini', 'nano', 'tiny', 'fast', 'light'].includes(raw)) return 'small';++ return 'medium';++}++++/** The capabilities AI says the model has, in dispatch's array form. */++function capsOf(option, model) {++ const out = new Set();++ const add = (v) => {++ if (Array.isArray(v)) for (const x of v) { if (typeof x === 'string') out.add(x); }++ else if (isObject(v)) for (const [k, on] of Object.entries(v)) { if (on === true) out.add(k); }++ };++ if (option) { add(option.caps); add(option.capabilities); }++ if (model) add(model.capabilities);++ return [...out];++}++++const usableOffer = (o) => o && o.status !== 'down' && o.status !== 'retired';++++/** A model's cheapest priced offer: the public per-million price, in USD. */++function cheapestOffer(model) {++ const offers = (model && Array.isArray(model.offers) ? model.offers : []).filter((o) => usableOffer(o) && o.price++ && num(o.price.in) != null && num(o.price.out) != null);++ offers.sort((a, b) => (num(a.price.in) + num(a.price.out)) - (num(b.price.in) + num(b.price.out)));++ return offers[0] || null;++}++++/**++ * The price of a choice in USD per million tokens. A rung that is flat or free has none (0): the person's++ * machine, a signed-in plan and the free allowance. A key and credits use the model's catalogue price; when++ * AI sent a per-million price on the choice itself, that is used first.++ */++function priceOf(rung, option, model) {++ if (['machine', 'plan', 'allowance'].includes(rung)) return { in: 0, out: 0 };++ if (option && isObject(option.price) && num(option.price.in) != null && num(option.price.out) != null) {++ return { in: num(option.price.in), out: num(option.price.out) };++ }++ if (option && num(option.price_in) != null && num(option.price_out) != null) return { in: num(option.price_in), out: num(option.price_out) };++ const offer = cheapestOffer(model);++ if (offer) return { in: num(offer.price.in), out: num(offer.price.out) };++ // AI's choice view prices one run of 1,000 in + 1,000 out when it has no per-million field. Use it only as++ // the last resort, split evenly across the two sides, so an unpriced choice is not silently called free.++ const estimated = option ? num(option.price_estimate_usd) : null;++ if (estimated != null) return { in: estimated * 500, out: estimated * 500 };++ return null;++}++++/** The rung a choice is on, from its source (and any rung AI states explicitly). */++function rungOf(option, group) {++ if (option && dispatch.RUNGS.includes(option.rung)) return option.rung;++ const source = lower((option && option.source) || group);++ if (['machine', 'machines', 'own-machine', 'own_machine'].includes(source)) return 'machine';++ if (['plan', 'plans'].includes(source)) return 'plan';++ if (['key', 'keys', 'own-key', 'own_key'].includes(source)) return 'key';++ if (source === 'allowance') return 'allowance';++ if (option && (option.free === true || option.free_tier === true || option.allowance === true)) return 'allowance';++ return 'credits';++}++++function catalogIndex(catalog) {++ const by = new Map();++ for (const model of (catalog && Array.isArray(catalog.models) ? catalog.models : [])) {++ for (const key of [model.slug, model.canonical, model.id]) {++ if (key && !by.has(lower(key))) by.set(lower(key), model);++ }++ }++ return by;++}++++function modelOf(option, index) {++ const id = option && (option.model || option.id);++ return id ? index.get(lower(id)) || null : null;++}++++function choice(option, group, index, rung = rungOf(option, group)) {++ const model = modelOf(option, index);++ const id = String((option && (option.model || option.id)) || (model && (model.slug || model.canonical)) || 'unknown');++ const name = String((option && option.name) || (model && model.name) || id);++ const price = priceOf(rung, option, model);++ // A credit choice without a price is not free: it just cannot be estimated, so the Dispatcher passes it over.++ const priced = rung !== 'credits' || price != null;++ return {++ id,++ name,++ rung,++ family: String((option && (option.family || option.maker)) || (model && model.family) || id.split('/')[0] || ''),++ tier: tierOf(option, model),++ caps: capsOf(option, model),++ context: num(option && (option.context || option.context_window)) || num(model && model.context) || 0,++ speed: num(option && (option.speed || option.tokens_per_second || option.speed_tps)),++ price,++ available: priced && !(option && (option.available === false || option.status === 'down' || option.status === 'retired')),++ };++}++++const hasFreeTier = (model) => Boolean(model && Array.isArray(model.offers) && model.offers.some((o) => usableOffer(o) && (o.free_tier === true || o.free === true)));++++/**++ * The choices in AI's GET /api/v1/choices answer, mapped to the Dispatcher's shape. An OpenVibe model that the++ * free allowance may serve is offered on the allowance rung too, because the same model can run on either.++ */++function choicesFromView(view, catalog) {++ const index = catalogIndex(catalog);++ const options = (view && view.options) || {};++ const out = [];++ for (const group of ['openvibe', 'keys', 'machines', 'plans', 'allowance']) {++ for (const option of (Array.isArray(options[group]) ? options[group] : [])) {++ if (!isObject(option)) continue;++ const rung = rungOf(option, group);++ out.push(choice(option, group, index, rung));++ const model = modelOf(option, index);++ if (group === 'openvibe' && rung === 'credits' && hasFreeTier(model)) {++ out.push(choice(option, group, index, 'allowance'));++ }++ }++ }++ // The resolved choice is a person's pick and may not be in the options list: include it when it is concrete.++ const resolved = view && view.choice;++ if (isObject(resolved) && resolved.model && !String(resolved.model).startsWith('openvibe/')) {++ if (!out.some((c) => c.id === resolved.model)) out.push(choice(resolved, resolved.source, index));++ }++ return out;++}++++/** Signed out: only OpenVibe's public offers, on the allowance and credits rungs. */++function publicChoices(catalog) {++ const out = [];++ for (const model of (catalog && Array.isArray(catalog.models) ? catalog.models : [])) {++ const kind = lower(model.kind || model.type || 'chat');++ if (NOT_CHAT.has(kind) || model.uncensored === true) continue;++ const id = String(model.slug || model.canonical || model.id || '');++ if (!id) continue;++ const base = {++ id, name: String(model.name || id), family: String(model.family || id.split('/')[0] || ''),++ tier: tierOf(null, model), caps: capsOf(null, model), context: num(model.context) || 0,++ speed: null, available: true,++ };++ const offer = cheapestOffer(model);++ if (offer) out.push({ ...base, rung: 'credits', price: { in: num(offer.price.in), out: num(offer.price.out) } });++ if (hasFreeTier(model)) out.push({ ...base, rung: 'allowance', price: { in: 0, out: 0 } });++ }++ return out;++}++++function createEstimateRoutes({ ai }) {++ const r = express.Router();++ r.use(express.json({ limit: BODY_LIMIT }));++++ r.post('/', async (req, res) => {++ const body = isObject(req.body) ? req.body : {};++ const doc = body.blueprint;++ if (!isObject(doc)) {++ return contracts.http.sendProblem(res, 422, 'blueprint.invalid', {++ detail: 'Send the blueprint as { "blueprint": { ... } }.',++ errors: [{ at: '/blueprint', why: 'a blueprint is an object' }], ctx: req.ov,++ });++ }++++ let turns = DEFAULT_TURNS;++ if (body.turns !== undefined) {++ turns = Number(body.turns);++ if (!Number.isInteger(turns) || turns < 1 || turns > MAX_TURNS) {++ return contracts.http.sendProblem(res, 422, 'turns.invalid', {++ detail: `turns is a whole number from 1 to ${MAX_TURNS}.`,++ errors: [{ at: '/turns', why: `a whole number from 1 to ${MAX_TURNS}` }], ctx: req.ov,++ });++ }++ }++++ const shape = contracts.validate('agents.blueprint@1', doc);++ const rules = blueprint.validate(doc);++ if (!shape.valid || !rules.ok) {++ const errors = [++ ...((shape.errors || []).map((e) => ({ at: e.path || '/', why: e.message }))),++ ...((rules.errors || []).map((e) => ({ at: e.at || '/', why: e.why }))),++ ];++ return contracts.http.sendProblem(res, 422, 'blueprint.invalid', {++ detail: 'The blueprint is not a valid agents.blueprint@1 document.',++ errors, ctx: req.ov,++ });++ }++++ const person = req.viewer && req.viewer.kind === 'user' && req.viewer.token ? req.viewer : null;++ let catalog;++ let choices;++ try {++ if (person) {++ // The choices mapping joins AI's choice view to the public catalogue's per-million prices.++ const [cat, view] = await Promise.all([ai.catalog(), ai.meRequest(person.token, '/api/v1/choices?task=chat')]);++ catalog = cat;++ choices = choicesFromView(view, catalog);++ } else {++ catalog = await ai.catalog();++ if (catalog && catalog.unavailable) {++ return contracts.http.sendProblem(res, 503, 'catalog.unavailable', {++ detail: 'The public model catalogue is not available right now; no price was estimated.',++ ctx: req.ov,++ });++ }++ choices = publicChoices(catalog);++ }++ } catch (err) {++ return contracts.http.sendProblem(res, 503, 'choices.unavailable', {++ detail: err && err.status === 401 ? 'Sign in again to use your own choices.' : 'Your model choices are not available right now; no price was estimated.',++ ctx: req.ov,++ });++ }++++ const result = blueprint.estimate(doc, choices, { turns, tokens: { tokensIn: TOKENS.in, tokensOut: TOKENS.out } });++ res.set('Cache-Control', person ? 'private, no-store' : 'public, max-age=60');++ return res.status(200).json({++ usd_per_run: result.usd_per_run,++ cheapest: result.choice ? { id: result.choice.id, name: result.choice.name, rung: result.choice.rung } : null,++ free_on: Array.isArray(result.free_on) ? result.free_on : [],++ why: result.why,++ turns,++ tokens: { in: TOKENS.in, out: TOKENS.out },++ });++ });++++ return r;++}++++module.exports = { createEstimateRoutes, choicesFromView, publicChoices, tierOf, priceOf };@@@ -530,6 +530,11 @@ ${panel({ title: 'Code repositories', id: 'code', body: `${kit.snippet({ code: `curl 'https://openvibe.exchange/api/v1/code/repos?list=week&limit=3' \\\n -H 'Accept: application/json'`, id: 'code-list-curl' })}${kit.snippet({ code: `curl 'https://openvibe.exchange/api/v1/code/search?q=stars:%3E500%20topic:ai' \\\n -H 'Accept: application/json'`, id: 'code-search-curl' })}` })}++${panel({ title: 'What a blueprint run costs', id: 'estimate', body: `++<p class="ex-prose"><code>POST /api/v1/estimate</code> prices one run of an agent blueprint (<code>agents.blueprint@1</code>, at most 200 KB) before anything runs. Send <code>{ "blueprint": { ... }, "turns": 4 }</code>; <code>turns</code> is 1–50 and defaults to 4, and each turn is counted as 2,000 input and 800 output tokens. Signed in, the answer uses the choices you actually have — your machine, a signed-in plan, your own key, the free allowance, then credits. Signed out, it uses OpenVibe's public offers only, on the allowance and credits rungs.</p>++<p class="ex-prose">The answer is <code>{ usd_per_run, cheapest: { id, name, rung }, free_on, why, turns, tokens: { in, out } }</code>. <code>free_on</code> lists the rungs where a run costs nothing. A blueprint nothing can run answers 200 with <code>usd_per_run: null</code> and the reason in <code>why</code>; an invalid blueprint answers 422 with its <code>errors</code>. The route is rate-limited like Exchange's other public API reads and never records the blueprint's text.</p>++${kit.snippet({ code: `curl -X POST 'https://openvibe.exchange/api/v1/estimate' \\\n -H 'Content-Type: application/json' \\\n -d '{"turns":4,"blueprint":{"id":"bp_vibe","version":1,"state":"published","published_at":"2026-11-12T00:00:00Z","name":"Vibe","summary":"A general assistant.","author":{"type":"service","id":"openvibe"},"visibility":"public","brain":{"pick":"auto","rungs":["allowance","credits"],"max_usd_per_m":1},"knows":{"instructions":"Help with the question."}}}'`, id: 'estimate-curl' })}++` })}${panel({ title: 'Compute prices', id: 'compute', body: `<p class="ex-prose"><code>GET /api/compute/prices</code> is the reviewed compute tariff behind <a href="/compute">Compute prices</a>, as JSON. It needs no key and no session: the answer is public and carries <code>Access-Control-Allow-Origin: *</code>, so another origin can read it in a browser. It is cached for 60 seconds (<code>Cache-Control: public, max-age=60</code>). The prices are provider tariffs, not quotes: each row keeps the date it was checked (<code>checked_at</code>) and the date it must be reviewed again (<code>expires_at</code>).</p><p class="ex-prose">Shape: <code>{ checked_at, prices: [{ id, provider, region?, kind: "cpu" | "gpu", cpu?, ram_mb?, gpu?, gpu_mb?, currency, hourly, per_second, billing_seconds, promotion?, promotion_expires?, interruptible?, expires_at, source }] }</code>. <code>hourly</code> and <code>per_second</code> are USD; <code>expires_at</code> is epoch milliseconds; <code>source</code> is the provider's own price page. Storage, network and plan fees are not included.</p>nnew file mode 100644@@@ -0,0 +1,111 @@++'use strict';++// POST /api/v1/estimate: what one run of an agents.blueprint@1 costs, from the person's choices or the public offers.++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { createFakeAi } = require('./helpers/ai');++++const doc = (brain = {}) => ({++ id: 'bp_estimate_test', version: 1, state: 'draft', name: 'Estimate test',++ summary: 'One run of an agent, priced before it runs.',++ author: { type: 'user', id: 'usr_01JD8C4QJ7Z3K9W2X5V6N1M0PB' }, visibility: 'private',++ brain: { pick: 'auto', rungs: ['credits'], ...brain },++ knows: { instructions: 'Answer the question in one paragraph.' },++});++++(async () => {++ const ai = createFakeAi();++ const calls = [];++ ai.meRequest = async (token, path) => {++ calls.push({ token, path });++ return { options: {++ openvibe: [++ // No per-million price here: the route joins AI's choice view to the public catalogue's prices.++ { model: 'deepseek/deepseek-chat', source: 'openvibe', name: 'DeepSeek Chat', maker: 'DeepSeek', tier: 'small', caps: ['tools'] },++ { model: 'qwen/qwen3-32b', source: 'openvibe', name: 'Qwen3 32B', maker: 'Qwen' },++ { model: 'dear/model', source: 'openvibe', name: 'Dear Model', maker: 'dear', price: { in: 2, out: 2 }, tier: 'strong' },++ ],++ keys: [{ model: 'key/model', source: 'keys', name: 'Key Model' }],++ machines: [{ model: 'machine/model', source: 'machines', name: 'Machine Model', machine: 'dev_01JD8C4QJ7Z3K9W2X5V6N1M0PB', machine_name: 'desk' }],++ plans: [{ model: 'plan/model', source: 'plans', name: 'Plan Model' }],++ } };++ };++++ const t = await boot({ ai });++ const user = t.network.addUser('estimate-user');++ try {++ await check('a credits-only blueprint picks the cheapest credit model and multiplies by turns', async () => {++ const res = await t.get('/api/v1/estimate', { as: user, json: { blueprint: doc(), turns: 4 } });++ assert.strictEqual(res.status, 200, res.text);++ const data = res.json();++ // DeepSeek's cheapest catalogue offer is $0.20 in / $0.30 out per million: 2,000 in + 800 out,++ // four turns.++ assert.strictEqual(data.usd_per_run, 0.00256);++ assert.deepStrictEqual(data.cheapest, { id: 'deepseek/deepseek-chat', name: 'DeepSeek Chat', rung: 'credits' });++ assert.strictEqual(data.turns, 4);++ assert.deepStrictEqual(data.tokens, { in: 2000, out: 800 });++ assert(calls.some((c) => c.path === '/api/v1/choices?task=chat' && c.token), 'the person’s choices are read for the chat task');++ assert.strictEqual(res.headers.get('cache-control'), 'private, no-store');++ });++++ await check('max_usd_per_m excludes a dearer model', async () => {++ const pinned = doc({ pick: 'exact', model: 'dear/model', rungs: ['credits'], max_usd_per_m: 0.5 });++ const res = await t.get('/api/v1/estimate', { as: user, json: { blueprint: pinned, turns: 1 } });++ assert.strictEqual(res.status, 200, res.text);++ const data = res.json();++ assert.strictEqual(data.usd_per_run, 0.00064, 'the cheap model at one turn');++ assert.strictEqual(data.cheapest.id, 'deepseek/deepseek-chat', 'the dear model is over the blueprint’s ceiling');++ });++++ await check('signed in, a machine, a plan and the free allowance are the person’s own rungs', async () => {++ const res = await t.get('/api/v1/estimate', { as: user, json: { blueprint: doc({ rungs: ['machine', 'plan', 'allowance'] }) } });++ assert.strictEqual(res.status, 200, res.text);++ const data = res.json();++ assert.strictEqual(data.usd_per_run, 0);++ assert.deepStrictEqual(data.cheapest, { id: 'machine/model', name: 'Machine Model', rung: 'machine' });++ assert.deepStrictEqual(data.free_on, ['machine', 'plan', 'allowance']);++ });++++ await check('an invalid blueprint is 422 with the errors', async () => {++ const res = await t.get('/api/v1/estimate', { as: user, json: { blueprint: { id: 'nope', knows: {} } } });++ assert.strictEqual(res.status, 422, res.text);++ assert.strictEqual(res.headers.get('content-type').startsWith('application/problem+json'), true);++ const data = res.json();++ assert.strictEqual(data.code, 'blueprint.invalid');++ assert(Array.isArray(data.errors) && data.errors.length > 0);++ assert(data.errors.some((e) => e.at === '/id'), 'the invalid id is named');++ });++++ await check('turns must be 1 to 50', async () => {++ for (const turns of [0, 51, 2.5, 'many']) {++ const res = await t.get('/api/v1/estimate', { as: user, json: { blueprint: doc(), turns } });++ assert.strictEqual(res.status, 422, `${turns}: ${res.text}`);++ assert.strictEqual(res.json().code, 'turns.invalid');++ }++ });++++ await check('signed out uses only public offers on the allowance and credits rungs', async () => {++ calls.length = 0;++ const res = await t.get('/api/v1/estimate', { json: { blueprint: doc({ rungs: ['allowance', 'credits'] }) } });++ assert.strictEqual(res.status, 200, res.text);++ const data = res.json();++ // AI's catalogue has DeepSeek at $0.20/$0.30 and Qwen at $0.40/$0.90; the free tier puts DeepSeek++ // on the allowance first, and the response still names only a public rung.++ assert.strictEqual(data.cheapest.id, 'deepseek/deepseek-chat');++ assert(['allowance', 'credits'].includes(data.cheapest.rung));++ assert.deepStrictEqual(data.free_on, ['allowance']);++ assert.strictEqual(calls.length, 0, 'signed out never asks for a person’s choices');++ assert.strictEqual(res.headers.get('cache-control'), 'public, max-age=60');++ });++++ await check('signed out cannot run on a machine, plan or key', async () => {++ const res = await t.get('/api/v1/estimate', { json: { blueprint: doc({ rungs: ['machine', 'plan', 'key'] }) } });++ assert.strictEqual(res.status, 200, res.text);++ const data = res.json();++ assert.strictEqual(data.usd_per_run, null);++ assert.strictEqual(data.cheapest, null);++ assert.deepStrictEqual(data.free_on, []);++ assert(data.why, 'the answer says why nothing can run it');++ });++ } finally { await t.close(); }++ done();++})().catch((err) => { console.error(err); process.exitCode = 1; });
@@ -90,8 +90,8 @@ client-credentials token work. See [.env.example](.env.example).
@@ -90,8 +90,8 @@ client-credentials token work. See [.env.example](.env.example).
AGPL-3.0-only. See [LICENSE](LICENSE).
AGPL-3.0-only. See [LICENSE](LICENSE).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.417.0
-- openvibe-sdk: v0.161.0
+- openvibe-contracts: v0.419.0
+- openvibe-sdk: v0.162.0
- openvibe-shared: v4.229.0
- openvibe-shared: v4.229.0
- openvibe-publishing: v1.7.0
- openvibe-publishing: v1.7.0
<!-- versions:end -->
<!-- versions:end -->
@@ -8,12 +8,12 @@
@@ -8,12 +8,12 @@
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": "5150",
"port": "5150",
"domain": "openvibe.exchange",
"domain": "openvibe.exchange",
- "contracts": "openvibe-contracts v0.417.0",
- "sdk": "openvibe-sdk v0.161.0",
+ "contracts": "openvibe-contracts v0.419.0",
+ "sdk": "openvibe-sdk v0.162.0",
"shared": "openvibe-shared v4.229.0",
"shared": "openvibe-shared v4.229.0",
"packages": [
"packages": [
- "openvibe-contracts v0.417.0",
- "openvibe-sdk v0.161.0",
+ "openvibe-contracts v0.419.0",
+ "openvibe-sdk v0.162.0",
"openvibe-shared v4.229.0"
"openvibe-shared v4.229.0"
],
],
"summary": "OpenVibe.Exchange is the network's AI front. It reads OpenVibe.AI's public catalog and aggregate usage and renders: the home (popular now, cheapest per million, uncensored, community machines, live numbers), /models and /m/<org>/<model> (every offer with price in/out, measured p50/p95, uptime and data policy, a Try it box, curl and the SDK), /chat (one to three models side by side, system prompt, temperature, history in the account, on openvibe-shared/thread), /api, /sell (Link's offer to the network; what is not sold, said plainly), /uncensored (an 18+ confirmation on the account and the hard floor) and /ai (which products run which models, and the daily cost). Chat and Try it are ai.complete runs on the person's behalf (on_behalf_of) with the service's ai.run.create token. Exchange v1 (free/swap listings, passports, sandboxes, installs, offers) is retired: every v1 page answers 410 with a pointer, its JSON API answers 410 exchange.retired, and its tables stay read-only; its tests live in test/retired/.",
"summary": "OpenVibe.Exchange is the network's AI front. It reads OpenVibe.AI's public catalog and aggregate usage and renders: the home (popular now, cheapest per million, uncensored, community machines, live numbers), /models and /m/<org>/<model> (every offer with price in/out, measured p50/p95, uptime and data policy, a Try it box, curl and the SDK), /chat (one to three models side by side, system prompt, temperature, history in the account, on openvibe-shared/thread), /api, /sell (Link's offer to the network; what is not sold, said plainly), /uncensored (an 18+ confirmation on the account and the hard floor) and /ai (which products run which models, and the daily cost). Chat and Try it are ai.complete runs on the person's behalf (on_behalf_of) with the service's ai.run.create token. Exchange v1 (free/swap listings, passports, sandboxes, installs, offers) is retired: every v1 page answers 410 with a pointer, its JSON API answers 410 exchange.retired, and its tables stay read-only; its tests live in test/retired/.",
@@ -16,9 +16,9 @@
@@ -16,9 +16,9 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.5.0",
"multer": "^2.5.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.417.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.419.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.161.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.162.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.229.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.229.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -1268,9 +1268,9 @@
@@ -1268,9 +1268,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.417.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.417.0",
- "integrity": "sha512-Kq4D/PZN9Nakf9iVBCBFwUTy/1q/8mRnLmaf2lygTkXWT9fjl/WmedJ6m/CX9fSnzOenUvYKzDl1Nvsvqhrrcw==",
+ "version": "0.419.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.419.0",
+ "integrity": "sha512-VxrTdeHchT8gvl2wWC9ASaId92en7ZKfJ8F/0hKzCAD0f2Y00rPrqgBWF9BNPoLGARoyQvsMbhBEhSkhxGNnjA==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1302,9 +1302,9 @@
@@ -1302,9 +1302,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.161.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.161.0",
- "integrity": "sha512-qReF7K9ct7LCLDJKuguwUQ30nc0kmrp+jlRZcFm4adpEvKL8r5G6i6Osbs0ROAMLmXKwnbI4GiZAgAyQ7oN7Ig==",
+ "version": "0.162.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.162.0",
+ "integrity": "sha512-LQC+6X21smbLzoYIpk0K7f1fjewi9KRk7x/qNoX/89cGg5cHGNLW9gd/VcQWZ1O8KuzDG4IOwWctSZ5CStMttQ==",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -25,9 +25,9 @@
@@ -25,9 +25,9 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.5.0",
"multer": "^2.5.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.417.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.419.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.161.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.162.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.229.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.229.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -35,6 +35,7 @@ const { createCompare } = require('./compare');
@@ -35,6 +35,7 @@ const { createCompare } = require('./compare');
const { createOpenApi } = require('./openapi');
const { createOpenApi } = require('./openapi');
const { createCode } = require('./code');
const { createCode } = require('./code');
const { createCodeRoutes } = require('./http/code');
const { createCodeRoutes } = require('./http/code');
+const { createEstimateRoutes } = require('./http/estimate');
const { createArenaStore } = require('./arena/store');
const { createArenaStore } = require('./arena/store');
const { createCatalogHistory } = require('./catalog-history');
const { createCatalogHistory } = require('./catalog-history');
const { createChatStore, createAdultStore } = require('./chat/store');
const { createChatStore, createAdultStore } = require('./chat/store');
@@ -337,6 +338,11 @@ async function createApp(opts = {}) {
@@ -337,6 +338,11 @@ async function createApp(opts = {}) {
// Exchange's own pages, so one process serves both names without either site's routes leaking into the other.
// Exchange's own pages, so one process serves both names without either site's routes leaking into the other.
app.use(createCompareSite(ctx));
app.use(createCompareSite(ctx));
+ // ── What one run of a blueprint costs (agents.blueprint@1) ──
+ // Mounted before Exchange v1's catch-all. The body is a blueprint (at most 200 KB); the answer is the price
+ // through openvibe-sdk/agents/blueprint, from the person's choices or OpenVibe's public offers.
+ app.use('/api/v1/estimate', rateLimit({ windowMs: 60000, limit: Number(process.env.EXCHANGE_API_RATE_LIMIT_PER_MIN) || 120, standardHeaders: true, legacyHeaders: false }), createEstimateRoutes(ctx));
+
// ── Exchange v1's JSON API: gone ────────────────────────
// ── Exchange v1's JSON API: gone ────────────────────────
app.use(createCodeRoutes(ctx));
app.use(createCodeRoutes(ctx));
app.use('/api/v1', (req, res) => {
app.use('/api/v1', (req, res) => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,255 @@
@@ -0,0 +1,255 @@
+'use strict';
+
+/**
+ * POST /api/v1/estimate — what one run of an agent blueprint costs.
+ *
+ * Body: { blueprint: agents.blueprint@1, turns?: 1..50 } (the blueprint at most 200 KB). The answer:
+ * { usd_per_run, cheapest: { id, name, rung }, free_on, why, turns, tokens: { in, out } }
+ *
+ * A signed-in person's choices come from OpenVibe.AI's GET /api/v1/choices?task=chat — their own machine, a
+ * signed-in plan, their key, the free allowance, then Exchange's credits. Signed out, only OpenVibe's public
+ * offers are used, on the allowance and credits rungs. The pricing itself is openvibe-sdk/agents/blueprint's
+ * estimate(): this file maps each choice to the Dispatcher's shape and never keeps or logs the blueprint text.
+ */
+const express = require('express');
+const contracts = require('openvibe-contracts');
+const blueprint = require('openvibe-sdk/agents/blueprint');
+const dispatch = require('openvibe-sdk/agents/dispatch');
+
+const BODY_LIMIT = '200kb';
+const DEFAULT_TURNS = 4;
+const MAX_TURNS = 50;
+const TOKENS = Object.freeze({ in: 2000, out: 800 });
+const NOT_CHAT = new Set(['embedding', 'embeddings', 'stt', 'asr', 'tts', 'speech', 'image', 'images', 'rerank', 'moderation']);
+const isObject = (v) => v != null && typeof v === 'object' && !Array.isArray(v);
+const num = (v) => (v == null || v === '' || !Number.isFinite(Number(v)) ? null : Number(v));
+const lower = (v) => String(v == null ? '' : v).toLowerCase();
+
+/** SDK tier names, from whatever size class AI gives (a missing one is medium, never invented). */
+function tierOf(option, model) {
+ const raw = lower((option && (option.tier || option.size || option.size_class || option.model_size)) || (model && (model.tier || (model.metadata && model.metadata.tier))));
+ if (dispatch.TIERS.includes(raw)) return raw;
+ if (['large', 'xl', 'frontier', 'strong', 'big'].includes(raw)) return 'strong';
+ if (['small', 'mini', 'nano', 'tiny', 'fast', 'light'].includes(raw)) return 'small';
+ return 'medium';
+}
+
+/** The capabilities AI says the model has, in dispatch's array form. */
+function capsOf(option, model) {
+ const out = new Set();
+ const add = (v) => {
+ if (Array.isArray(v)) for (const x of v) { if (typeof x === 'string') out.add(x); }
+ else if (isObject(v)) for (const [k, on] of Object.entries(v)) { if (on === true) out.add(k); }
+ };
+ if (option) { add(option.caps); add(option.capabilities); }
+ if (model) add(model.capabilities);
+ return [...out];
+}
+
+const usableOffer = (o) => o && o.status !== 'down' && o.status !== 'retired';
+
+/** A model's cheapest priced offer: the public per-million price, in USD. */
+function cheapestOffer(model) {
+ const offers = (model && Array.isArray(model.offers) ? model.offers : []).filter((o) => usableOffer(o) && o.price
+ && num(o.price.in) != null && num(o.price.out) != null);
+ offers.sort((a, b) => (num(a.price.in) + num(a.price.out)) - (num(b.price.in) + num(b.price.out)));
+ return offers[0] || null;
+}
+
+/**
+ * The price of a choice in USD per million tokens. A rung that is flat or free has none (0): the person's
+ * machine, a signed-in plan and the free allowance. A key and credits use the model's catalogue price; when
+ * AI sent a per-million price on the choice itself, that is used first.
+ */
+function priceOf(rung, option, model) {
+ if (['machine', 'plan', 'allowance'].includes(rung)) return { in: 0, out: 0 };
+ if (option && isObject(option.price) && num(option.price.in) != null && num(option.price.out) != null) {
+ return { in: num(option.price.in), out: num(option.price.out) };
+ }
+ if (option && num(option.price_in) != null && num(option.price_out) != null) return { in: num(option.price_in), out: num(option.price_out) };
+ const offer = cheapestOffer(model);
+ if (offer) return { in: num(offer.price.in), out: num(offer.price.out) };
+ // AI's choice view prices one run of 1,000 in + 1,000 out when it has no per-million field. Use it only as
+ // the last resort, split evenly across the two sides, so an unpriced choice is not silently called free.
+ const estimated = option ? num(option.price_estimate_usd) : null;
+ if (estimated != null) return { in: estimated * 500, out: estimated * 500 };
+ return null;
+}
+
+/** The rung a choice is on, from its source (and any rung AI states explicitly). */
+function rungOf(option, group) {
+ if (option && dispatch.RUNGS.includes(option.rung)) return option.rung;
+ const source = lower((option && option.source) || group);
+ if (['machine', 'machines', 'own-machine', 'own_machine'].includes(source)) return 'machine';
+ if (['plan', 'plans'].includes(source)) return 'plan';
+ if (['key', 'keys', 'own-key', 'own_key'].includes(source)) return 'key';
+ if (source === 'allowance') return 'allowance';
+ if (option && (option.free === true || option.free_tier === true || option.allowance === true)) return 'allowance';
+ return 'credits';
+}
+
+function catalogIndex(catalog) {
+ const by = new Map();
+ for (const model of (catalog && Array.isArray(catalog.models) ? catalog.models : [])) {
+ for (const key of [model.slug, model.canonical, model.id]) {
+ if (key && !by.has(lower(key))) by.set(lower(key), model);
+ }
+ }
+ return by;
+}
+
+function modelOf(option, index) {
+ const id = option && (option.model || option.id);
+ return id ? index.get(lower(id)) || null : null;
+}
+
+function choice(option, group, index, rung = rungOf(option, group)) {
+ const model = modelOf(option, index);
+ const id = String((option && (option.model || option.id)) || (model && (model.slug || model.canonical)) || 'unknown');
+ const name = String((option && option.name) || (model && model.name) || id);
+ const price = priceOf(rung, option, model);
+ // A credit choice without a price is not free: it just cannot be estimated, so the Dispatcher passes it over.
+ const priced = rung !== 'credits' || price != null;
+ return {
+ id,
+ name,
+ rung,
+ family: String((option && (option.family || option.maker)) || (model && model.family) || id.split('/')[0] || ''),
+ tier: tierOf(option, model),
+ caps: capsOf(option, model),
+ context: num(option && (option.context || option.context_window)) || num(model && model.context) || 0,
+ speed: num(option && (option.speed || option.tokens_per_second || option.speed_tps)),
+ price,
+ available: priced && !(option && (option.available === false || option.status === 'down' || option.status === 'retired')),
+ };
+}
+
+const hasFreeTier = (model) => Boolean(model && Array.isArray(model.offers) && model.offers.some((o) => usableOffer(o) && (o.free_tier === true || o.free === true)));
+
+/**
+ * The choices in AI's GET /api/v1/choices answer, mapped to the Dispatcher's shape. An OpenVibe model that the
+ * free allowance may serve is offered on the allowance rung too, because the same model can run on either.
+ */
+function choicesFromView(view, catalog) {
+ const index = catalogIndex(catalog);
+ const options = (view && view.options) || {};
+ const out = [];
+ for (const group of ['openvibe', 'keys', 'machines', 'plans', 'allowance']) {
+ for (const option of (Array.isArray(options[group]) ? options[group] : [])) {
+ if (!isObject(option)) continue;
+ const rung = rungOf(option, group);
+ out.push(choice(option, group, index, rung));
+ const model = modelOf(option, index);
+ if (group === 'openvibe' && rung === 'credits' && hasFreeTier(model)) {
+ out.push(choice(option, group, index, 'allowance'));
+ }
+ }
+ }
+ // The resolved choice is a person's pick and may not be in the options list: include it when it is concrete.
+ const resolved = view && view.choice;
+ if (isObject(resolved) && resolved.model && !String(resolved.model).startsWith('openvibe/')) {
+ if (!out.some((c) => c.id === resolved.model)) out.push(choice(resolved, resolved.source, index));
+ }
+ return out;
+}
+
+/** Signed out: only OpenVibe's public offers, on the allowance and credits rungs. */
+function publicChoices(catalog) {
+ const out = [];
+ for (const model of (catalog && Array.isArray(catalog.models) ? catalog.models : [])) {
+ const kind = lower(model.kind || model.type || 'chat');
+ if (NOT_CHAT.has(kind) || model.uncensored === true) continue;
+ const id = String(model.slug || model.canonical || model.id || '');
+ if (!id) continue;
+ const base = {
+ id, name: String(model.name || id), family: String(model.family || id.split('/')[0] || ''),
+ tier: tierOf(null, model), caps: capsOf(null, model), context: num(model.context) || 0,
+ speed: null, available: true,
+ };
+ const offer = cheapestOffer(model);
+ if (offer) out.push({ ...base, rung: 'credits', price: { in: num(offer.price.in), out: num(offer.price.out) } });
+ if (hasFreeTier(model)) out.push({ ...base, rung: 'allowance', price: { in: 0, out: 0 } });
+ }
+ return out;
+}
+
+function createEstimateRoutes({ ai }) {
+ const r = express.Router();
+ r.use(express.json({ limit: BODY_LIMIT }));
+
+ r.post('/', async (req, res) => {
+ const body = isObject(req.body) ? req.body : {};
+ const doc = body.blueprint;
+ if (!isObject(doc)) {
+ return contracts.http.sendProblem(res, 422, 'blueprint.invalid', {
+ detail: 'Send the blueprint as { "blueprint": { ... } }.',
+ errors: [{ at: '/blueprint', why: 'a blueprint is an object' }], ctx: req.ov,
+ });
+ }
+
+ let turns = DEFAULT_TURNS;
+ if (body.turns !== undefined) {
+ turns = Number(body.turns);
+ if (!Number.isInteger(turns) || turns < 1 || turns > MAX_TURNS) {
+ return contracts.http.sendProblem(res, 422, 'turns.invalid', {
+ detail: `turns is a whole number from 1 to ${MAX_TURNS}.`,
+ errors: [{ at: '/turns', why: `a whole number from 1 to ${MAX_TURNS}` }], ctx: req.ov,
+ });
+ }
+ }
+
+ const shape = contracts.validate('agents.blueprint@1', doc);
+ const rules = blueprint.validate(doc);
+ if (!shape.valid || !rules.ok) {
+ const errors = [
+ ...((shape.errors || []).map((e) => ({ at: e.path || '/', why: e.message }))),
+ ...((rules.errors || []).map((e) => ({ at: e.at || '/', why: e.why }))),
+ ];
+ return contracts.http.sendProblem(res, 422, 'blueprint.invalid', {
+ detail: 'The blueprint is not a valid agents.blueprint@1 document.',
+ errors, ctx: req.ov,
+ });
+ }
+
+ const person = req.viewer && req.viewer.kind === 'user' && req.viewer.token ? req.viewer : null;
+ let catalog;
+ let choices;
+ try {
+ if (person) {
+ // The choices mapping joins AI's choice view to the public catalogue's per-million prices.
+ const [cat, view] = await Promise.all([ai.catalog(), ai.meRequest(person.token, '/api/v1/choices?task=chat')]);
+ catalog = cat;
+ choices = choicesFromView(view, catalog);
+ } else {
+ catalog = await ai.catalog();
+ if (catalog && catalog.unavailable) {
+ return contracts.http.sendProblem(res, 503, 'catalog.unavailable', {
+ detail: 'The public model catalogue is not available right now; no price was estimated.',
+ ctx: req.ov,
+ });
+ }
+ choices = publicChoices(catalog);
+ }
+ } catch (err) {
+ return contracts.http.sendProblem(res, 503, 'choices.unavailable', {
+ detail: err && err.status === 401 ? 'Sign in again to use your own choices.' : 'Your model choices are not available right now; no price was estimated.',
+ ctx: req.ov,
+ });
+ }
+
+ const result = blueprint.estimate(doc, choices, { turns, tokens: { tokensIn: TOKENS.in, tokensOut: TOKENS.out } });
+ res.set('Cache-Control', person ? 'private, no-store' : 'public, max-age=60');
+ return res.status(200).json({
+ usd_per_run: result.usd_per_run,
+ cheapest: result.choice ? { id: result.choice.id, name: result.choice.name, rung: result.choice.rung } : null,
+ free_on: Array.isArray(result.free_on) ? result.free_on : [],
+ why: result.why,
+ turns,
+ tokens: { in: TOKENS.in, out: TOKENS.out },
+ });
+ });
+
+ return r;
+}
+
+module.exports = { createEstimateRoutes, choicesFromView, publicChoices, tierOf, priceOf };
@@ -530,6 +530,11 @@ ${panel({ title: 'Code repositories', id: 'code', body: `
@@ -530,6 +530,11 @@ ${panel({ title: 'Code repositories', id: 'code', body: `
${kit.snippet({ code: `curl 'https://openvibe.exchange/api/v1/code/repos?list=week&limit=3' \\\n -H 'Accept: application/json'`, id: 'code-list-curl' })}
${kit.snippet({ code: `curl 'https://openvibe.exchange/api/v1/code/repos?list=week&limit=3' \\\n -H 'Accept: application/json'`, id: 'code-list-curl' })}
${kit.snippet({ code: `curl 'https://openvibe.exchange/api/v1/code/search?q=stars:%3E500%20topic:ai' \\\n -H 'Accept: application/json'`, id: 'code-search-curl' })}
${kit.snippet({ code: `curl 'https://openvibe.exchange/api/v1/code/search?q=stars:%3E500%20topic:ai' \\\n -H 'Accept: application/json'`, id: 'code-search-curl' })}
` })}
` })}
+${panel({ title: 'What a blueprint run costs', id: 'estimate', body: `
+<p class="ex-prose"><code>POST /api/v1/estimate</code> prices one run of an agent blueprint (<code>agents.blueprint@1</code>, at most 200 KB) before anything runs. Send <code>{ "blueprint": { ... }, "turns": 4 }</code>; <code>turns</code> is 1–50 and defaults to 4, and each turn is counted as 2,000 input and 800 output tokens. Signed in, the answer uses the choices you actually have — your machine, a signed-in plan, your own key, the free allowance, then credits. Signed out, it uses OpenVibe's public offers only, on the allowance and credits rungs.</p>
+<p class="ex-prose">The answer is <code>{ usd_per_run, cheapest: { id, name, rung }, free_on, why, turns, tokens: { in, out } }</code>. <code>free_on</code> lists the rungs where a run costs nothing. A blueprint nothing can run answers 200 with <code>usd_per_run: null</code> and the reason in <code>why</code>; an invalid blueprint answers 422 with its <code>errors</code>. The route is rate-limited like Exchange's other public API reads and never records the blueprint's text.</p>
+${kit.snippet({ code: `curl -X POST 'https://openvibe.exchange/api/v1/estimate' \\\n -H 'Content-Type: application/json' \\\n -d '{"turns":4,"blueprint":{"id":"bp_vibe","version":1,"state":"published","published_at":"2026-11-12T00:00:00Z","name":"Vibe","summary":"A general assistant.","author":{"type":"service","id":"openvibe"},"visibility":"public","brain":{"pick":"auto","rungs":["allowance","credits"],"max_usd_per_m":1},"knows":{"instructions":"Help with the question."}}}'`, id: 'estimate-curl' })}
+` })}
${panel({ title: 'Compute prices', id: 'compute', body: `
${panel({ title: 'Compute prices', id: 'compute', body: `
<p class="ex-prose"><code>GET /api/compute/prices</code> is the reviewed compute tariff behind <a href="/compute">Compute prices</a>, as JSON. It needs no key and no session: the answer is public and carries <code>Access-Control-Allow-Origin: *</code>, so another origin can read it in a browser. It is cached for 60 seconds (<code>Cache-Control: public, max-age=60</code>). The prices are provider tariffs, not quotes: each row keeps the date it was checked (<code>checked_at</code>) and the date it must be reviewed again (<code>expires_at</code>).</p>
<p class="ex-prose"><code>GET /api/compute/prices</code> is the reviewed compute tariff behind <a href="/compute">Compute prices</a>, as JSON. It needs no key and no session: the answer is public and carries <code>Access-Control-Allow-Origin: *</code>, so another origin can read it in a browser. It is cached for 60 seconds (<code>Cache-Control: public, max-age=60</code>). The prices are provider tariffs, not quotes: each row keeps the date it was checked (<code>checked_at</code>) and the date it must be reviewed again (<code>expires_at</code>).</p>
<p class="ex-prose">Shape: <code>{ checked_at, prices: [{ id, provider, region?, kind: "cpu" | "gpu", cpu?, ram_mb?, gpu?, gpu_mb?, currency, hourly, per_second, billing_seconds, promotion?, promotion_expires?, interruptible?, expires_at, source }] }</code>. <code>hourly</code> and <code>per_second</code> are USD; <code>expires_at</code> is epoch milliseconds; <code>source</code> is the provider's own price page. Storage, network and plan fees are not included.</p>
<p class="ex-prose">Shape: <code>{ checked_at, prices: [{ id, provider, region?, kind: "cpu" | "gpu", cpu?, ram_mb?, gpu?, gpu_mb?, currency, hourly, per_second, billing_seconds, promotion?, promotion_expires?, interruptible?, expires_at, source }] }</code>. <code>hourly</code> and <code>per_second</code> are USD; <code>expires_at</code> is epoch milliseconds; <code>source</code> is the provider's own price page. Storage, network and plan fees are not included.</p>
new file mode 100644
new file mode 100644
@@ -0,0 +1,111 @@
@@ -0,0 +1,111 @@
+'use strict';
+// POST /api/v1/estimate: what one run of an agents.blueprint@1 costs, from the person's choices or the public offers.
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { createFakeAi } = require('./helpers/ai');
+
+const doc = (brain = {}) => ({
+ id: 'bp_estimate_test', version: 1, state: 'draft', name: 'Estimate test',
+ summary: 'One run of an agent, priced before it runs.',
+ author: { type: 'user', id: 'usr_01JD8C4QJ7Z3K9W2X5V6N1M0PB' }, visibility: 'private',
+ brain: { pick: 'auto', rungs: ['credits'], ...brain },
+ knows: { instructions: 'Answer the question in one paragraph.' },
+});
+
+(async () => {
+ const ai = createFakeAi();
+ const calls = [];
+ ai.meRequest = async (token, path) => {
+ calls.push({ token, path });
+ return { options: {
+ openvibe: [
+ // No per-million price here: the route joins AI's choice view to the public catalogue's prices.
+ { model: 'deepseek/deepseek-chat', source: 'openvibe', name: 'DeepSeek Chat', maker: 'DeepSeek', tier: 'small', caps: ['tools'] },
+ { model: 'qwen/qwen3-32b', source: 'openvibe', name: 'Qwen3 32B', maker: 'Qwen' },
+ { model: 'dear/model', source: 'openvibe', name: 'Dear Model', maker: 'dear', price: { in: 2, out: 2 }, tier: 'strong' },
+ ],
+ keys: [{ model: 'key/model', source: 'keys', name: 'Key Model' }],
+ machines: [{ model: 'machine/model', source: 'machines', name: 'Machine Model', machine: 'dev_01JD8C4QJ7Z3K9W2X5V6N1M0PB', machine_name: 'desk' }],
+ plans: [{ model: 'plan/model', source: 'plans', name: 'Plan Model' }],
+ } };
+ };
+
+ const t = await boot({ ai });
+ const user = t.network.addUser('estimate-user');
+ try {
+ await check('a credits-only blueprint picks the cheapest credit model and multiplies by turns', async () => {
+ const res = await t.get('/api/v1/estimate', { as: user, json: { blueprint: doc(), turns: 4 } });
+ assert.strictEqual(res.status, 200, res.text);
+ const data = res.json();
+ // DeepSeek's cheapest catalogue offer is $0.20 in / $0.30 out per million: 2,000 in + 800 out,
+ // four turns.
+ assert.strictEqual(data.usd_per_run, 0.00256);
+ assert.deepStrictEqual(data.cheapest, { id: 'deepseek/deepseek-chat', name: 'DeepSeek Chat', rung: 'credits' });
+ assert.strictEqual(data.turns, 4);
+ assert.deepStrictEqual(data.tokens, { in: 2000, out: 800 });
+ assert(calls.some((c) => c.path === '/api/v1/choices?task=chat' && c.token), 'the person’s choices are read for the chat task');
+ assert.strictEqual(res.headers.get('cache-control'), 'private, no-store');
+ });
+
+ await check('max_usd_per_m excludes a dearer model', async () => {
+ const pinned = doc({ pick: 'exact', model: 'dear/model', rungs: ['credits'], max_usd_per_m: 0.5 });
+ const res = await t.get('/api/v1/estimate', { as: user, json: { blueprint: pinned, turns: 1 } });
+ assert.strictEqual(res.status, 200, res.text);
+ const data = res.json();
+ assert.strictEqual(data.usd_per_run, 0.00064, 'the cheap model at one turn');
+ assert.strictEqual(data.cheapest.id, 'deepseek/deepseek-chat', 'the dear model is over the blueprint’s ceiling');
+ });
+
+ await check('signed in, a machine, a plan and the free allowance are the person’s own rungs', async () => {
+ const res = await t.get('/api/v1/estimate', { as: user, json: { blueprint: doc({ rungs: ['machine', 'plan', 'allowance'] }) } });
+ assert.strictEqual(res.status, 200, res.text);
+ const data = res.json();
+ assert.strictEqual(data.usd_per_run, 0);
+ assert.deepStrictEqual(data.cheapest, { id: 'machine/model', name: 'Machine Model', rung: 'machine' });
+ assert.deepStrictEqual(data.free_on, ['machine', 'plan', 'allowance']);
+ });
+
+ await check('an invalid blueprint is 422 with the errors', async () => {
+ const res = await t.get('/api/v1/estimate', { as: user, json: { blueprint: { id: 'nope', knows: {} } } });
+ assert.strictEqual(res.status, 422, res.text);
+ assert.strictEqual(res.headers.get('content-type').startsWith('application/problem+json'), true);
+ const data = res.json();
+ assert.strictEqual(data.code, 'blueprint.invalid');
+ assert(Array.isArray(data.errors) && data.errors.length > 0);
+ assert(data.errors.some((e) => e.at === '/id'), 'the invalid id is named');
+ });
+
+ await check('turns must be 1 to 50', async () => {
+ for (const turns of [0, 51, 2.5, 'many']) {
+ const res = await t.get('/api/v1/estimate', { as: user, json: { blueprint: doc(), turns } });
+ assert.strictEqual(res.status, 422, `${turns}: ${res.text}`);
+ assert.strictEqual(res.json().code, 'turns.invalid');
+ }
+ });
+
+ await check('signed out uses only public offers on the allowance and credits rungs', async () => {
+ calls.length = 0;
+ const res = await t.get('/api/v1/estimate', { json: { blueprint: doc({ rungs: ['allowance', 'credits'] }) } });
+ assert.strictEqual(res.status, 200, res.text);
+ const data = res.json();
+ // AI's catalogue has DeepSeek at $0.20/$0.30 and Qwen at $0.40/$0.90; the free tier puts DeepSeek
+ // on the allowance first, and the response still names only a public rung.
+ assert.strictEqual(data.cheapest.id, 'deepseek/deepseek-chat');
+ assert(['allowance', 'credits'].includes(data.cheapest.rung));
+ assert.deepStrictEqual(data.free_on, ['allowance']);
+ assert.strictEqual(calls.length, 0, 'signed out never asks for a person’s choices');
+ assert.strictEqual(res.headers.get('cache-control'), 'public, max-age=60');
+ });
+
+ await check('signed out cannot run on a machine, plan or key', async () => {
+ const res = await t.get('/api/v1/estimate', { json: { blueprint: doc({ rungs: ['machine', 'plan', 'key'] }) } });
+ assert.strictEqual(res.status, 200, res.text);
+ const data = res.json();
+ assert.strictEqual(data.usd_per_run, null);
+ assert.strictEqual(data.cheapest, null);
+ assert.deepStrictEqual(data.free_on, []);
+ assert(data.why, 'the answer says why nothing can run it');
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((err) => { console.error(err); process.exitCode = 1; });