openvibers/openvibe.exchange Commits

Price one run of an agent blueprint at POST /api/v1/estimate (#245)

OP OpenVibers committed 10 h ago · 1bda8fe2fa · 8 files, +393 −16

Changes

ddiff --git a/README.md b/README.md
iindex 0d9b44d..b35afca 100644
---- a/README.md
++++ b/README.md
@@@ -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 -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 0f1118a..95b3678 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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/.",
ddiff --git a/package-lock.json b/package-lock.json
iindex 94d4f5d..4fa12a0 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex ed543a6..fdea9be 100644
---- a/package.json
++++ b/package.json
@@@ -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"
},
ddiff --git a/server/app.js b/server/app.js
iindex a4815c1..46047a2 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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) => {
ddiff --git a/server/http/estimate.js b/server/http/estimate.js
nnew file mode 100644
iindex 0000000..f2b1f8a
---- /dev/null
++++ b/server/http/estimate.js
@@@ -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 };
ddiff --git a/server/views/exchange.js b/server/views/exchange.js
iindex 5be66b4..26857b2 100644
---- a/server/views/exchange.js
++++ b/server/views/exchange.js
@@@ -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>
ddiff --git a/test/estimate.test.js b/test/estimate.test.js
nnew file mode 100644
iindex 0000000..9e23d90
---- /dev/null
++++ b/test/estimate.test.js
@@@ -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; });