openvibers/openvibe.exchange Commits
Load the reviewed prices into the price book for the owner to decide (#216)
Changes
@@@ -325,6 +325,18 @@ function createConsoleRoutes({ config, ai }) {return res.redirect(303, '/console/pricebook');} catch (err) { return page(req, res, 'pricebook', {}, err.message || 'The price book could not run.', 502); }});++ // The reviewed readings bundled with AI's release go into the waiting list; the owner still decides each price,++ // and loading creates a model row disabled when one does not exist. Nothing is approved by this route.++ r.post('/console/pricebook/confirmed', async (req, res) => {++ if (!sameOrigin(req)) return res.sendStatus(403);++ try {++ const data = await ai.adminRequest('/api/v1/admin/console/pricebook');++ const proposals = Array.isArray(data.confirmed) ? data.confirmed : [];++ if (!proposals.length) return res.redirect(303, '/console/pricebook');++ await ai.adminRequest('/api/v1/admin/console/pricebook/proposals', { method: 'POST', subject: req.viewer.subject, body: { proposals } });++ return res.redirect(303, '/console/pricebook');++ } catch (err) { return page(req, res, 'pricebook', {}, err.message || 'The reviewed readings could not be loaded.', err.status === 422 ? 422 : 502); }++ });r.post('/console/exchange/credits/:id/resolve', async (req, res) => {if (!sameOrigin(req)) return res.sendStatus(403);try {@@@ -212,6 +212,7 @@ function syncSection(data = {}) {function pricebookSection(data = {}) {const pending = Array.isArray(data.pending) ? data.pending : [];const recent = Array.isArray(data.recent) ? data.recent : [];++ const confirmed = Array.isArray(data.confirmed) ? data.confirmed : [];const rep = data.report || null;// One change is one price of one model (price_changes: provider_key, model_key, field, old_price, new_price,// change_pct, source, checked_at).@@@ -269,9 +270,15 @@ function pricebookSection(data = {}) {<ul class="exc-pb-models">${[...models.entries()].map(([model, list]) => modelRow(model, list, { common, mixed: newCount > 0 && newCount < models.size })).join('')}</ul></li>`;};++ const reviewed = confirmed.length ? `<section class="exc-sec"><h3>Reviewed readings <span class="exc-n">${confirmed.length}</span></h3>++<p class="exc-note">Read from the providers' own pages and reviewed when they were added. Load them to put each price in the waiting list below; loading writes no price and switches no model on.</p>++<ul class="exc-list">${confirmed.map((c) => `<li class="exc-pb-model"><div class="exc-pb-name"><b>${esc(c.display_name || c.model_key || '')}</b><small>${esc(c.provider_key || '')} · ${esc(c.model_key || '')}${c.checked_at ? ` · read ${esc(day(c.checked_at))}` : ''}${c.source ? ` · <a href="${esc(c.source)}" rel="noopener noreferrer" target="_blank">${esc(host(c.source) || 'their page')}</a>` : ''}</small></div>++<p class="exc-pb-prices">${[['in_per_mtok', 'Input'], ['cached_per_mtok', 'Cached'], ['out_per_mtok', 'Output']].map(([field, label]) => `<span class="exc-pb-price"><em>${esc(label)}</em><b>${esc(money(c[field]) || '–')}</b></span>`).join('')}</p></li>`).join('')}</ul>++<form method="post" action="/console/pricebook/confirmed" class="exc-inline">${btn(`Load ${confirmed.length} reading${confirmed.length === 1 ? '' : 's'} into the waiting list`, 'primary')}</form></section>` : '';return `<div class="exc"><header class="exc-head"><h2>Price book</h2><p>OpenVibe reads every provider's own price page on a schedule. A change waits here for your yes; nothing a provider publishes reaches people's bills unseen.</p><form method="post" action="/console/pricebook/run" class="exc-inline">${btn('Read their pages now')}${rep && rep.finished_at ? `<span class="exc-note">Last read ${esc(day(rep.finished_at))}${Array.isArray(rep.sources) ? ` · ${rep.sources.length} pages` : ''}</span>` : ''}</form></header>++${reviewed}<section class="exc-sec"><h3>Waiting for you <span class="exc-n">${pending.length}</span></h3>${pending.length ? `<div class="exc-pb-all"><p class="exc-note">${modelCount === 1 ? '1 model' : `${modelCount} models`} from ${groups.length === 1 ? '1 provider' : `${groups.length} providers`}${fresh ? `: ${fresh === modelCount ? (fresh === 1 ? 'new to OpenVibe' : 'all new to OpenVibe') : `${fresh} new to OpenVibe`}` : ''}. A new model stays switched off until you turn it on in Models.</p>${groups.length > 1 ? decide(pending, `Accept all ${pending.length} prices`, '') : ''}</div><ul class="exc-list">${groups.map(providerCard).join('')}</ul>` : '<p class="exc-note">Nothing waiting: every price matches its provider\'s page.</p>'}</section>${recent.length ? `<section class="exc-sec"><h3>Applied lately <span class="exc-n">${recent.length}</span></h3><ul class="exc-list">${recent.slice(0, 20).map((c) => change(c, false)).join('')}</ul></section>` : ''}@@@ -81,6 +81,12 @@ const book = pricebookSection({ pending: [{ id: 'pc_1', provider_key: 'deepseek'assert.ok(book.includes('value="approve"'), 'Accept sends approve, the word AI takes');assert.ok(book.includes('$1.20') && book.includes('$0.90') && book.includes('-25%'), 'old, new and the change');assert.ok(pricebookSection({ pending: [], recent: [] }).includes('Nothing waiting'), 'an empty price book says so');++const reviewed = pricebookSection({ pending: [], recent: [], confirmed: [++ { provider_key: 'sable', model_key: 'deepseek-v4.1-flash-1017-uncensored', display_name: 'DeepSeek V4.1 Flash (uncensored)',++ in_per_mtok: 0.15, cached_per_mtok: 0.01, out_per_mtok: 0.6, source: 'https://sableapi.com/models', checked_at: '2026-11-10' }] });++assert.ok(reviewed.includes('Reviewed readings') && reviewed.includes('DeepSeek V4.1 Flash (uncensored)'), 'a reviewed reading is named');++assert.ok(reviewed.includes('action="/console/pricebook/confirmed"') && reviewed.includes('Load 1 reading into the waiting list'), 'one action loads it into the waiting list');++assert.ok(reviewed.includes('$0.15') && reviewed.includes('$0.60') && reviewed.includes('sableapi.com'), 'its own prices and page');// Grouped: a card per provider, one row per model with its prices together, Accept per model, provider and all.const many = pricebookSection({ pending: [@@@ -16,7 +16,7 @@ app.use((req, _res, next) => {});app.use(createConsoleRoutes({config: { baseUrl: 'https://openvibe.exchange' },-- ai: { async adminRequest(path, options = {}) { calls.push(path); if (options.method) writes.push({ path, ...options }); return { providers: [{ key: 'openai', status: 'active', api_key: fakeSecret }] }; } },++ ai: { async adminRequest(path, options = {}) { calls.push(path); if (options.method) writes.push({ path, ...options }); if (path === '/api/v1/admin/console/pricebook') return { confirmed: [{ provider_key: 'sable', model_key: 'deepseek-v4.1-flash-1017-uncensored', in_per_mtok: 0.15, out_per_mtok: 0.6, source: 'https://sableapi.com/models', checked_at: '2026-11-10' }] }; return { providers: [{ key: 'openai', status: 'active', api_key: fakeSecret }] }; } },}));(async () => {@@@ -125,6 +125,13 @@ app.use(createConsoleRoutes({});assert.strictEqual(providerSettings.status, 303);assert.ok(writes.some((write) => write.path === '/api/v1/providers/groq' && write.body.markup_percent === 12.5 && write.body.uncensored === true));++ const loadConfirmed = await fetch(`${base}/console/pricebook/confirmed`, {++ method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange' }, redirect: 'manual',++ });++ assert.strictEqual(loadConfirmed.status, 303);++ assert.strictEqual(writes.at(-1).path, '/api/v1/admin/console/pricebook/proposals');++ assert.strictEqual(writes.at(-1).body.proposals.length, 1);++ assert.strictEqual(writes.at(-1).body.proposals[0].provider_key, 'sable');const modelOpen = await fetch(`${base}/console/models/groq/openai%2Fgpt-oss-120b/open`, {method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange' }, redirect: 'manual',});
@@ -325,6 +325,18 @@ function createConsoleRoutes({ config, ai }) {
@@ -325,6 +325,18 @@ function createConsoleRoutes({ config, ai }) {
return res.redirect(303, '/console/pricebook');
return res.redirect(303, '/console/pricebook');
} catch (err) { return page(req, res, 'pricebook', {}, err.message || 'The price book could not run.', 502); }
} catch (err) { return page(req, res, 'pricebook', {}, err.message || 'The price book could not run.', 502); }
});
});
+ // The reviewed readings bundled with AI's release go into the waiting list; the owner still decides each price,
+ // and loading creates a model row disabled when one does not exist. Nothing is approved by this route.
+ r.post('/console/pricebook/confirmed', async (req, res) => {
+ if (!sameOrigin(req)) return res.sendStatus(403);
+ try {
+ const data = await ai.adminRequest('/api/v1/admin/console/pricebook');
+ const proposals = Array.isArray(data.confirmed) ? data.confirmed : [];
+ if (!proposals.length) return res.redirect(303, '/console/pricebook');
+ await ai.adminRequest('/api/v1/admin/console/pricebook/proposals', { method: 'POST', subject: req.viewer.subject, body: { proposals } });
+ return res.redirect(303, '/console/pricebook');
+ } catch (err) { return page(req, res, 'pricebook', {}, err.message || 'The reviewed readings could not be loaded.', err.status === 422 ? 422 : 502); }
+ });
r.post('/console/exchange/credits/:id/resolve', async (req, res) => {
r.post('/console/exchange/credits/:id/resolve', async (req, res) => {
if (!sameOrigin(req)) return res.sendStatus(403);
if (!sameOrigin(req)) return res.sendStatus(403);
try {
try {
@@ -212,6 +212,7 @@ function syncSection(data = {}) {
@@ -212,6 +212,7 @@ function syncSection(data = {}) {
function pricebookSection(data = {}) {
function pricebookSection(data = {}) {
const pending = Array.isArray(data.pending) ? data.pending : [];
const pending = Array.isArray(data.pending) ? data.pending : [];
const recent = Array.isArray(data.recent) ? data.recent : [];
const recent = Array.isArray(data.recent) ? data.recent : [];
+ const confirmed = Array.isArray(data.confirmed) ? data.confirmed : [];
const rep = data.report || null;
const rep = data.report || null;
// One change is one price of one model (price_changes: provider_key, model_key, field, old_price, new_price,
// One change is one price of one model (price_changes: provider_key, model_key, field, old_price, new_price,
// change_pct, source, checked_at).
// change_pct, source, checked_at).
@@ -269,9 +270,15 @@ function pricebookSection(data = {}) {
@@ -269,9 +270,15 @@ function pricebookSection(data = {}) {
<ul class="exc-pb-models">${[...models.entries()].map(([model, list]) => modelRow(model, list, { common, mixed: newCount > 0 && newCount < models.size })).join('')}</ul>
<ul class="exc-pb-models">${[...models.entries()].map(([model, list]) => modelRow(model, list, { common, mixed: newCount > 0 && newCount < models.size })).join('')}</ul>
</li>`;
</li>`;
};
};
+ const reviewed = confirmed.length ? `<section class="exc-sec"><h3>Reviewed readings <span class="exc-n">${confirmed.length}</span></h3>
+<p class="exc-note">Read from the providers' own pages and reviewed when they were added. Load them to put each price in the waiting list below; loading writes no price and switches no model on.</p>
+<ul class="exc-list">${confirmed.map((c) => `<li class="exc-pb-model"><div class="exc-pb-name"><b>${esc(c.display_name || c.model_key || '')}</b><small>${esc(c.provider_key || '')} · ${esc(c.model_key || '')}${c.checked_at ? ` · read ${esc(day(c.checked_at))}` : ''}${c.source ? ` · <a href="${esc(c.source)}" rel="noopener noreferrer" target="_blank">${esc(host(c.source) || 'their page')}</a>` : ''}</small></div>
+<p class="exc-pb-prices">${[['in_per_mtok', 'Input'], ['cached_per_mtok', 'Cached'], ['out_per_mtok', 'Output']].map(([field, label]) => `<span class="exc-pb-price"><em>${esc(label)}</em><b>${esc(money(c[field]) || '–')}</b></span>`).join('')}</p></li>`).join('')}</ul>
+<form method="post" action="/console/pricebook/confirmed" class="exc-inline">${btn(`Load ${confirmed.length} reading${confirmed.length === 1 ? '' : 's'} into the waiting list`, 'primary')}</form></section>` : '';
return `<div class="exc">
return `<div class="exc">
<header class="exc-head"><h2>Price book</h2><p>OpenVibe reads every provider's own price page on a schedule. A change waits here for your yes; nothing a provider publishes reaches people's bills unseen.</p>
<header class="exc-head"><h2>Price book</h2><p>OpenVibe reads every provider's own price page on a schedule. A change waits here for your yes; nothing a provider publishes reaches people's bills unseen.</p>
<form method="post" action="/console/pricebook/run" class="exc-inline">${btn('Read their pages now')}${rep && rep.finished_at ? `<span class="exc-note">Last read ${esc(day(rep.finished_at))}${Array.isArray(rep.sources) ? ` · ${rep.sources.length} pages` : ''}</span>` : ''}</form></header>
<form method="post" action="/console/pricebook/run" class="exc-inline">${btn('Read their pages now')}${rep && rep.finished_at ? `<span class="exc-note">Last read ${esc(day(rep.finished_at))}${Array.isArray(rep.sources) ? ` · ${rep.sources.length} pages` : ''}</span>` : ''}</form></header>
+${reviewed}
<section class="exc-sec"><h3>Waiting for you <span class="exc-n">${pending.length}</span></h3>${pending.length ? `<div class="exc-pb-all"><p class="exc-note">${modelCount === 1 ? '1 model' : `${modelCount} models`} from ${groups.length === 1 ? '1 provider' : `${groups.length} providers`}${fresh ? `: ${fresh === modelCount ? (fresh === 1 ? 'new to OpenVibe' : 'all new to OpenVibe') : `${fresh} new to OpenVibe`}` : ''}. A new model stays switched off until you turn it on in Models.</p>${groups.length > 1 ? decide(pending, `Accept all ${pending.length} prices`, '') : ''}</div>
<section class="exc-sec"><h3>Waiting for you <span class="exc-n">${pending.length}</span></h3>${pending.length ? `<div class="exc-pb-all"><p class="exc-note">${modelCount === 1 ? '1 model' : `${modelCount} models`} from ${groups.length === 1 ? '1 provider' : `${groups.length} providers`}${fresh ? `: ${fresh === modelCount ? (fresh === 1 ? 'new to OpenVibe' : 'all new to OpenVibe') : `${fresh} new to OpenVibe`}` : ''}. A new model stays switched off until you turn it on in Models.</p>${groups.length > 1 ? decide(pending, `Accept all ${pending.length} prices`, '') : ''}</div>
<ul class="exc-list">${groups.map(providerCard).join('')}</ul>` : '<p class="exc-note">Nothing waiting: every price matches its provider\'s page.</p>'}</section>
<ul class="exc-list">${groups.map(providerCard).join('')}</ul>` : '<p class="exc-note">Nothing waiting: every price matches its provider\'s page.</p>'}</section>
${recent.length ? `<section class="exc-sec"><h3>Applied lately <span class="exc-n">${recent.length}</span></h3><ul class="exc-list">${recent.slice(0, 20).map((c) => change(c, false)).join('')}</ul></section>` : ''}
${recent.length ? `<section class="exc-sec"><h3>Applied lately <span class="exc-n">${recent.length}</span></h3><ul class="exc-list">${recent.slice(0, 20).map((c) => change(c, false)).join('')}</ul></section>` : ''}
@@ -81,6 +81,12 @@ const book = pricebookSection({ pending: [{ id: 'pc_1', provider_key: 'deepseek'
@@ -81,6 +81,12 @@ const book = pricebookSection({ pending: [{ id: 'pc_1', provider_key: 'deepseek'
assert.ok(book.includes('value="approve"'), 'Accept sends approve, the word AI takes');
assert.ok(book.includes('value="approve"'), 'Accept sends approve, the word AI takes');
assert.ok(book.includes('$1.20') && book.includes('$0.90') && book.includes('-25%'), 'old, new and the change');
assert.ok(book.includes('$1.20') && book.includes('$0.90') && book.includes('-25%'), 'old, new and the change');
assert.ok(pricebookSection({ pending: [], recent: [] }).includes('Nothing waiting'), 'an empty price book says so');
assert.ok(pricebookSection({ pending: [], recent: [] }).includes('Nothing waiting'), 'an empty price book says so');
+const reviewed = pricebookSection({ pending: [], recent: [], confirmed: [
+ { provider_key: 'sable', model_key: 'deepseek-v4.1-flash-1017-uncensored', display_name: 'DeepSeek V4.1 Flash (uncensored)',
+ in_per_mtok: 0.15, cached_per_mtok: 0.01, out_per_mtok: 0.6, source: 'https://sableapi.com/models', checked_at: '2026-11-10' }] });
+assert.ok(reviewed.includes('Reviewed readings') && reviewed.includes('DeepSeek V4.1 Flash (uncensored)'), 'a reviewed reading is named');
+assert.ok(reviewed.includes('action="/console/pricebook/confirmed"') && reviewed.includes('Load 1 reading into the waiting list'), 'one action loads it into the waiting list');
+assert.ok(reviewed.includes('$0.15') && reviewed.includes('$0.60') && reviewed.includes('sableapi.com'), 'its own prices and page');
// Grouped: a card per provider, one row per model with its prices together, Accept per model, provider and all.
// Grouped: a card per provider, one row per model with its prices together, Accept per model, provider and all.
const many = pricebookSection({ pending: [
const many = pricebookSection({ pending: [
@@ -16,7 +16,7 @@ app.use((req, _res, next) => {
@@ -16,7 +16,7 @@ app.use((req, _res, next) => {
});
});
app.use(createConsoleRoutes({
app.use(createConsoleRoutes({
config: { baseUrl: 'https://openvibe.exchange' },
config: { baseUrl: 'https://openvibe.exchange' },
- ai: { async adminRequest(path, options = {}) { calls.push(path); if (options.method) writes.push({ path, ...options }); return { providers: [{ key: 'openai', status: 'active', api_key: fakeSecret }] }; } },
+ ai: { async adminRequest(path, options = {}) { calls.push(path); if (options.method) writes.push({ path, ...options }); if (path === '/api/v1/admin/console/pricebook') return { confirmed: [{ provider_key: 'sable', model_key: 'deepseek-v4.1-flash-1017-uncensored', in_per_mtok: 0.15, out_per_mtok: 0.6, source: 'https://sableapi.com/models', checked_at: '2026-11-10' }] }; return { providers: [{ key: 'openai', status: 'active', api_key: fakeSecret }] }; } },
}));
}));
(async () => {
(async () => {
@@ -125,6 +125,13 @@ app.use(createConsoleRoutes({
@@ -125,6 +125,13 @@ app.use(createConsoleRoutes({
});
});
assert.strictEqual(providerSettings.status, 303);
assert.strictEqual(providerSettings.status, 303);
assert.ok(writes.some((write) => write.path === '/api/v1/providers/groq' && write.body.markup_percent === 12.5 && write.body.uncensored === true));
assert.ok(writes.some((write) => write.path === '/api/v1/providers/groq' && write.body.markup_percent === 12.5 && write.body.uncensored === true));
+ const loadConfirmed = await fetch(`${base}/console/pricebook/confirmed`, {
+ method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange' }, redirect: 'manual',
+ });
+ assert.strictEqual(loadConfirmed.status, 303);
+ assert.strictEqual(writes.at(-1).path, '/api/v1/admin/console/pricebook/proposals');
+ assert.strictEqual(writes.at(-1).body.proposals.length, 1);
+ assert.strictEqual(writes.at(-1).body.proposals[0].provider_key, 'sable');
const modelOpen = await fetch(`${base}/console/models/groq/openai%2Fgpt-oss-120b/open`, {
const modelOpen = await fetch(`${base}/console/models/groq/openai%2Fgpt-oss-120b/open`, {
method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange' }, redirect: 'manual',
method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange' }, redirect: 'manual',
});
});