openvibers/openvibe.exchange Commits
Exchange: the Decisions page (#243)
Changes
@@@ -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.@@@ -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); } }@@@ -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' },@@@ -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())}));@@@ -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();nnew file mode 100644@@@ -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 };@@@ -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(`"type": "${type}"`), `${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');
@@ -31,6 +31,7 @@ for a person — and keeps only two things of its own: a person's chats and thei
@@ -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. |
| `/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. |
| `/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. |
| `/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.
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.
@@ -480,3 +480,16 @@
@@ -480,3 +480,16 @@
/* The chevron points down, and up when open. */
/* The chevron points down, and up when open. */
.xp-q-chev { transform: rotate(90deg); }
.xp-q-chev { transform: rotate(90deg); }
.xp-q[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); } }
@@ -28,6 +28,7 @@ const PUBLIC_PAGES = [
@@ -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: '/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: '/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: '/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: '/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.' },
{ 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) {
@@ -107,7 +108,7 @@ function createDiscoveryRoutes(ctx) {
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({
name: SITE_NAME,
name: SITE_NAME,
summary: DESCRIPTION,
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,
markdown: true,
base: site,
base: site,
sections: [
sections: [
@@ -116,6 +117,7 @@ function createDiscoveryRoutes(ctx) {
@@ -116,6 +117,7 @@ function createDiscoveryRoutes(ctx) {
{ title: 'Models', url: abs('/models'), note: 'prices and measured latency' },
{ 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: '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: '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: 'The API', url: abs('/api') },
{ title: 'Compute prices', url: abs('/compute'), note: 'GPUs and computers per hour; JSON at /api/compute/prices' },
{ 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' },
{ 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' },
@@ -28,6 +28,7 @@ const marks = require('../views/marks');
@@ -28,6 +28,7 @@ const marks = require('../views/marks');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const views = require('../views/exchange');
const views = require('../views/exchange');
const platform = require('../views/platform');
const platform = require('../views/platform');
+const decisionsView = require('../views/decisions');
const credentialViews = require('../views/credentials');
const credentialViews = require('../views/credentials');
const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 60 });
const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 60 });
@@ -101,6 +102,12 @@ function createPageRoutes(ctx) {
@@ -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('/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', (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())}));
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())}));
@@ -29,6 +29,7 @@ const NAV = [
@@ -29,6 +29,7 @@ const NAV = [
{ label: 'Status', href: '/status', icon: 'fa-gauge' },
{ label: 'Status', href: '/status', icon: 'fa-gauge' },
{ label: 'Rankings', href: '/rankings', icon: 'fa-chart-line', more: true },
{ label: 'Rankings', href: '/rankings', icon: 'fa-chart-line', more: true },
{ label: 'Code', href: '/code', icon: 'fa-code', 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();
const hashes = new Map();
new file mode 100644
new file mode 100644
@@ -0,0 +1,117 @@
@@ -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 };
@@ -147,6 +147,18 @@ const contracts = require('openvibe-contracts');
@@ -147,6 +147,18 @@ const contracts = require('openvibe-contracts');
assert.ok(r.text.includes('2026-11-05') && r.text.includes('Requests'), 'usage by day');
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(`"type": "${type}"`), `${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 () => {
await check('seller entry moves permanently to Link', async () => {
const r = await t.get('/sell'); assert.strictEqual(r.status,301);
const r = await t.get('/sell'); assert.strictEqual(r.status,301);
assert.strictEqual(r.headers.get('location'),'https://openvibe.link');
assert.strictEqual(r.headers.get('location'),'https://openvibe.link');