openvibers/openvibe.exchange Commits

Exchange: the Decisions page (#243)

OP OpenVibers committed 14 h ago · 3923b55137 · 7 files, +154 −1

* Exchange: the Decisions page, linked from the nav

A public page for OpenVibe.AI's POST /api/v1/decisions: the three closed-set question types (noul, choice, score) with their JSON, one full request and answer, and who answers — Google's gemini-decision-1 when a Gemini key is set, otherwise the router's cheapest structured model with calibrated: false. Added to the nav under More and to the sitemap; one pages test covers the page, its types, the endpoint and the nav link.

* Exchange: list Decisions in llms.txt

The page's sitemap row is there; the agent-facing start list and the API detail line now name /decisions and its endpoint too.

Changes

ddiff --git a/README.md b/README.md
iindex b35afca..9094986 100644
---- a/README.md
++++ b/README.md
@@@ -31,6 +31,7 @@ for a person — and keeps only two things of its own: a person's chats and thei
| `/ai` | Which OpenVibe products run which models through the same router, and what each costs a day, with the product's own mark. |
| `/status` | Uptime per offer, the model changelog and incidents. |
| `/code` | GitHub repositories through OpenVibe.Sources: a deep repository search, Trending today / This week / Hot / New / Top, twelve language chips and one card per repository (stars, the week's gain, forks, language, topics, updated). `/api/v1/code/repos` and `/api/v1/code/search` are its public JSON. |
++| `/decisions` | OpenVibe.AI's Decisions API: yes/no (`noul`), one-of-a-list (`choice`) and rubric (`score`) questions about one input, a full request and answer, and who answers — Google's `gemini-decision-1` when a Gemini key is set, otherwise the router's cheapest structured model with `calibrated: false`. |
The old chat rows remain for a person's one-time import into Chat. `GET /internal/exchange/chat/export` requires Chat's service token with `exchange.chat.export` and the authenticated person's `x-ov-subject`; it returns only that person's unsealed chats.
ddiff --git a/public/css/platform.css b/public/css/platform.css
iindex be7c276..e9abadc 100644
---- a/public/css/platform.css
++++ b/public/css/platform.css
@@@ -480,3 +480,16 @@
/* The chevron points down, and up when open. */
.xp-q-chev { transform: rotate(90deg); }
.xp-q[open] .xp-q-chev { transform: rotate(-90deg); }
++
++/* /decisions: three question types, then one request beside one answer; one column on a phone. */
++.xd-types { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin: 12px 14px 14px; }
++.xd-type { display: grid; align-content: start; gap: 8px; min-width: 0; padding: 14px; border: 1px solid var(--ovk-line); border-radius: 14px; background: var(--ovk-panel); }
++.xd-type h3 { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px; margin: 0; font-size: 1rem; }
++.xd-type h3 code { color: var(--ovk-accent); font-size: .82em; font-weight: 700; }
++.xd-type p { margin: 0; color: var(--ovk-muted); font-size: .9rem; line-height: 1.5; }
++.xd-type .ovk-snip, .xd-pair .ovk-snip { margin: 0; }
++.xd-type .ovk-snip > pre, .xd-pair .ovk-snip > pre { margin-bottom: 0; font-size: .78rem; }
++.xd-pair { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 14px; margin: 12px 14px 14px; }
++.xd-pair > div { display: grid; align-content: start; gap: 6px; min-width: 0; }
++.xd-pair h3 { margin: 0; font-size: .95rem; }
++@media (max-width: 900px) { .xd-types, .xd-pair { grid-template-columns: minmax(0, 1fr); } }
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 2700d80..d0985e5 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -28,6 +28,7 @@ const PUBLIC_PAGES = [
{ path: '/uncensored', changefreq: 'weekly', priority: 0.6, title: 'Uncensored', text: 'Abliterated models for adults, with the floor: no minors, no non-consensual content, no mass harm.' },
{ path: '/rankings', changefreq: 'daily', priority: 0.8, title: 'Rankings', text: 'Model rankings by task category from blind Arena votes, beside each model’s price per million, measured speed and uptime.' },
{ path: '/code', changefreq: 'daily', priority: 0.8, title: 'Code', text: 'GitHub repositories trending today, new and hot by stars gained, plus a deep repository search: read through OpenVibe.Sources.' },
++ { path: '/decisions', changefreq: 'weekly', priority: 0.7, title: 'Decisions', text: 'Yes/no, one-of-a-list and rubric questions about one input — text, pictures, audio, video or a PDF by link — answered in one call with calibrated probabilities, or the router’s cheapest structured model with calibrated: false.' },
{ path: '/ai', changefreq: 'daily', priority: 0.6, title: 'AI in OpenVibe', text: 'Which OpenVibe products run which models through the same router, and what each costs a day.' },
{ path: '/status', changefreq: 'hourly', priority: 0.5, title: 'Status', text: 'Uptime per offer, the model changelog, and incidents.' },
];
@@@ -107,7 +108,7 @@ function createDiscoveryRoutes(ctx) {
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({
name: SITE_NAME,
summary: DESCRIPTION,
-- details: 'Prices are read live from OpenVibe.AI and are the offer\'s own, per million tokens. The API is OpenAI-compatible at https://api.openvibe.exchange/v1/chat/completions and Anthropic-compatible at /v1/messages; model ids are deepseek/deepseek-chat and their families, or openvibe/auto to let the router pick by floor, ceiling and policy. A small free daily allowance comes with an account. GitHub repositories are read through OpenVibe.Sources and served at /api/v1/code/repos and /api/v1/code/search; nothing is invented when the source is behind.',
++ details: 'Prices are read live from OpenVibe.AI and are the offer\'s own, per million tokens. The API is OpenAI-compatible at https://api.openvibe.exchange/v1/chat/completions and Anthropic-compatible at /v1/messages; model ids are deepseek/deepseek-chat and their families, or openvibe/auto to let the router pick by floor, ceiling and policy. A small free daily allowance comes with an account. GitHub repositories are read through OpenVibe.Sources and served at /api/v1/code/repos and /api/v1/code/search; nothing is invented when the source is behind. Decisions answers closed-set questions about one input at https://ai.openvibe.services/api/v1/decisions.',
markdown: true,
base: site,
sections: [
@@@ -116,6 +117,7 @@ function createDiscoveryRoutes(ctx) {
{ title: 'Models', url: abs('/models'), note: 'prices and measured latency' },
{ title: 'Rankings', url: abs('/rankings'), note: 'blind Arena votes by task, beside price and speed' },
{ title: 'Code', url: abs('/code'), note: 'GitHub trending, hot and new repositories, and a deep search' },
++ { title: 'Decisions', url: abs('/decisions'), note: 'yes/no, choice and rubric answers about one input' },
{ title: 'The API', url: abs('/api') },
{ title: 'Compute prices', url: abs('/compute'), note: 'GPUs and computers per hour; JSON at /api/compute/prices' },
{ title: 'Code repositories API', url: abs('/api/v1/code/repos?list=week'), note: 'cached GitHub rankings from Sources; search at /api/v1/code/search?q=stars:>500' },
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 3f6433f..48dca6d 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -28,6 +28,7 @@ const marks = require('../views/marks');
const { send } = require('../render/layout');
const views = require('../views/exchange');
const platform = require('../views/platform');
++const decisionsView = require('../views/decisions');
const credentialViews = require('../views/credentials');
const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 60 });
@@@ -101,6 +102,12 @@ function createPageRoutes(ctx) {
});
r.get('/pricing', (req, res) => page(req, res, { title: 'Pricing', description: 'What a call to OpenVibe.Exchange costs: your own key at 0%, managed models at their listed price, your own endpoint by the hour, search per 1,000.', body: raw(platform.pricingPage()) }));
++ r.get('/decisions', (req, res) => page(req, res, {
++ title: 'Decisions',
++ description: "Yes/no, one-of-a-list and rubric questions about one input in one call, with calibrated probabilities from Google's Decisions API or the router's cheapest structured model.",
++ cache: PUBLIC_CACHE,
++ body: raw(decisionsView.decisionsPage()),
++ }));
r.get('/docs', (req,res) => res.redirect(301,'/api'));
r.get('/docs/migrate', (req,res) => page(req,res,{title:'Migrate to Exchange', description:'Move an OpenAI or Anthropic client to Exchange, validate routing and inspect API receipts.', jsonLd:[{'@context':'https://schema.org','@type':'TechArticle',headline:'Move your application to Exchange',url:'https://openvibe.exchange/docs/migrate'}], body:raw(require('../views/migration').migrationPage())}));
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex e3d0117..cd4a103 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -29,6 +29,7 @@ const NAV = [
{ label: 'Status', href: '/status', icon: 'fa-gauge' },
{ label: 'Rankings', href: '/rankings', icon: 'fa-chart-line', more: true },
{ label: 'Code', href: '/code', icon: 'fa-code', more: true },
++ { label: 'Decisions', href: '/decisions', icon: 'fa-list-check', more: true },
];
const hashes = new Map();
ddiff --git a/server/views/decisions.js b/server/views/decisions.js
nnew file mode 100644
iindex 0000000..b08d268
---- /dev/null
++++ b/server/views/decisions.js
@@@ -0,0 +1,117 @@
++'use strict';
++/**
++ * /decisions (2026-11-12): OpenVibe.AI's Decisions API as a public page. Closed-set questions about one input
++ * (text, or content parts: text, image, audio, video, document by https link), answered in one call with
++ * probabilities and a confidence: noul (yes/no), choice (one of 2-255 options) and score (a rubric level from 0).
++ *
++ * Google's Decisions API (gemini-decision-1) answers when OpenVibe.AI has a Gemini key: calibrated, $0.10 per 1M
++ * input tokens, nothing for output. Without it the router's cheapest structured model answers text with estimated
++ * probabilities, and says calibrated: false. The page copies the shapes from OpenVibe.AI's server/api/decisions.js.
++ */
++const kit = require('openvibe-shared/kit');
++const { esc } = require('../render/html');
++
++const ENDPOINT = 'https://ai.openvibe.services/api/v1/decisions';
++
++/** The page's card: one section in the page's column, its body on the heading's line. */
++const panel = ({ title, note = '', id, body, cls = '' }) =>
++ kit.section({ title, note, id, cls: `ex-card${cls ? ` ${cls}` : ''}`, body: `<div class="ex-sec-body">${body}</div>` });
++
++const TYPES = [
++ {
++ key: 'noul',
++ name: 'Yes or no',
++ text: 'The probability that the answer is yes, from 0 to 1.',
++ code: `{ "is_spam": {
++ "type": "noul", "instructions": "Is this spam?" } }`,
++ },
++ {
++ key: 'choice',
++ name: 'One of a list',
++ text: 'The winning option, and a probability for each option.',
++ code: `{ "kind": {
++ "type": "choice", "instructions": "What is it?",
++ "criteria": { "bug": "Broken.", "idea": "A suggestion." } } }`,
++ },
++ {
++ key: 'score',
++ name: 'A rubric level',
++ text: 'A level from 0, and a probability for each level.',
++ code: `{ "severity": {
++ "type": "score", "instructions": "How bad?",
++ "criteria": ["fine", "minor", "major"] } }`,
++ },
++];
++
++const REQUEST = `POST ${ENDPOINT}
++Authorization: Bearer $OPENVIBE_TOKEN
++Content-Type: application/json
++
++{
++ "input": "The app crashes when I open the settings page.",
++ "questions": {
++ "is_spam": { "type": "noul", "instructions": "Is this spam?" },
++ "kind": { "type": "choice", "instructions": "What is it?", "criteria": { "bug": "Something is broken.", "idea": "A suggestion." } },
++ "severity": { "type": "score", "instructions": "How bad is it?", "criteria": ["fine", "minor", "major"] }
++ },
++ "prefer": "calibrated"
++}`;
++
++const ANSWER = `{
++ "answers": {
++ "is_spam": { "type": "noul", "noul": 0.03, "confidence": 0.97 },
++ "kind": { "type": "choice", "choice": "bug", "probabilities": { "bug": 0.94, "idea": 0.06 }, "confidence": 0.94 },
++ "severity": { "type": "score", "score": 1.8, "probabilities": { "0": 0.02, "1": 0.16, "2": 0.82 }, "legend": { "0": "fine", "1": "minor", "2": "major" }, "confidence": 0.82 }
++ },
++ "calibrated": true,
++ "receipt": { "provider": "gemini", "model": "gemini-decision-1", "input_tokens": 120, "cost_usd": 0.000012 }
++}`;
++
++const INPUT_PARTS = `{
++ "input": [
++ { "type": "text", "text": "What is wrong in this screenshot?" },
++ { "type": "image", "uri": "https://example.com/error.png", "mime_type": "image/png" }
++ ]
++}`;
++
++function decisionsPage() {
++ const types = TYPES.map((t) => `<article class="xd-type" id="xd-${esc(t.key)}">
++<h3>${esc(t.name)} <code>${esc(t.key)}</code></h3>
++<p>${esc(t.text)}</p>
++${kit.snippet({ code: t.code, id: `xd-${t.key}-snippet` })}
++</article>`).join('');
++
++ const rows = [
++ {
++ when: 'A Gemini key is set',
++ who: "Google's Decisions API (gemini-decision-1)",
++ what: 'Calibrated. Text, pictures, audio, video and PDFs by link.',
++ price: '$0.10 per 1M input tokens. Output is free.',
++ },
++ {
++ when: 'No Gemini key, or Google is away',
++ who: "The router's cheapest structured model",
++ what: 'Text only, up to 100 questions. Estimated, and marked calibrated: false.',
++ price: 'Metered like any request.',
++ },
++ ];
++
++ return `<div class="ex-wrap ex-page xd">
++${kit.head({ title: 'Decisions', sub: 'Yes/no, choice and rubric answers about one input, in one call, with how sure.', actions: [kit.button({ href: '/api', label: 'The API', kind: 'quiet', icon: 'code' })] })}
++${panel({ title: 'What it is', id: 'what', body: `<p class="ex-prose">Ask yes/no, one-of-a-list and rubric questions about one input: text, a picture, audio, video or a PDF by link. One call answers every question and says how sure it is.</p>
++<p class="ex-prose">Use it for routing, moderation checks, grading and tool choice when no model needs to write an answer.</p>` })}
++${panel({ title: 'One input', id: 'input', body: `<p class="ex-prose"><code>input</code> is a string, or up to 50 content parts. A part is <code>text</code>, <code>image</code>, <code>audio</code>, <code>video</code> or <code>document</code>. A picture, a recording, a video or a PDF is a public <code>https</code> link; nothing is uploaded.</p>
++${kit.snippet({ code: INPUT_PARTS, id: 'xd-input-snippet' })}` })}
++${panel({ title: 'The three question types', id: 'types', body: `<p class="ex-prose">Name each question, say what type it is, and give its instructions. A <code>choice</code> names its options in <code>criteria</code>; a <code>score</code> lists its levels from 0. Every answer carries a confidence from 0 to 1.</p>
++<div class="xd-types">${types}</div>` })}
++${panel({ title: 'One request, one answer', id: 'request', body: `<p class="ex-prose">Send a POST to <code>${esc(ENDPOINT)}</code> with an OpenVibe token that can make a run, or a person's own token. Up to 1,000 questions and 50 input parts; a choice has 2 to 255 options and a score has 2 to 20 levels. Question names are lowercase letters, digits and <code>_</code>.</p>
++<p class="ex-prose"><code>prefer</code> is <code>"calibrated"</code> by default. <code>"cheap"</code> asks for the router's cheap structured model first when the input is text.</p>
++<div class="xd-pair"><div><h3>Request</h3>${kit.snippet({ code: REQUEST, id: 'xd-request-snippet' })}</div><div><h3>Answer</h3>${kit.snippet({ code: ANSWER, id: 'xd-answer-snippet' })}</div></div>` })}
++${panel({ title: 'Who answers', id: 'who', body: `<p class="ex-prose">Google's Decisions API answers when OpenVibe.AI has a Gemini key. Otherwise the router's cheapest structured model answers text, and the answer says <code>calibrated: false</code>.</p>
++${kit.table({ columns: [{ key: 'when', label: 'When', primary: true }, { key: 'who', label: 'Who answers' }, { key: 'what', label: 'What you get' }, { key: 'price', label: 'Price' }], rows, label: 'Who answers Decisions', stack: true })}
++${kit.notice({ kind: 'info', text: "Google's free tier may use what it reads to improve its models. Send public or synthetic data only." })}
++<p class="ex-prose">Without a Gemini key, pictures, audio, video and PDFs cannot be answered. Every call is metered like any request, and the <code>receipt</code> says which provider and model ran and what it cost.</p>` })}
++</div>`;
++}
++
++module.exports = { decisionsPage, ENDPOINT };
ddiff --git a/test/pages.test.js b/test/pages.test.js
iindex fafe95d..11c2f95 100644
---- a/test/pages.test.js
++++ b/test/pages.test.js
@@@ -147,6 +147,18 @@ const contracts = require('openvibe-contracts');
assert.ok(r.text.includes('2026-11-05') && r.text.includes('Requests'), 'usage by day');
});
++ await check('decisions: the page, the three question types, the endpoint and its link from the nav', async () => {
++ const r = await t.get('/decisions');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('<h1>Decisions</h1>'), 'the title');
++ assert.ok(r.text.includes('https://ai.openvibe.services/api/v1/decisions'), 'the endpoint');
++ for (const type of ['noul', 'choice', 'score']) assert.ok(r.text.includes(`&quot;type&quot;: &quot;${type}&quot;`), `${type} has its JSON example`);
++ assert.ok(r.text.includes('gemini-decision-1') && r.text.includes('$0.10 per 1M input tokens'), 'who answers and its price');
++ assert.ok(r.text.includes('calibrated: false'), 'the fallback is marked');
++ const home = await t.get('/');
++ assert.ok(home.text.includes('"href":"/decisions"'), 'the nav links the page beside Search and Code');
++ });
++
await check('seller entry moves permanently to Link', async () => {
const r = await t.get('/sell'); assert.strictEqual(r.status,301);
assert.strictEqual(r.headers.get('location'),'https://openvibe.link');