openvibers/openvibe.exchange Commits

Load the reviewed prices into the price book for the owner to decide (#216)

OP OpenVibers committed 2 days ago · cfc986427d · 4 files, +33 −1

Changes

ddiff --git a/server/http/console.js b/server/http/console.js
iindex 513ab5f..bed1138 100644
---- a/server/http/console.js
++++ b/server/http/console.js
@@@ -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 {
ddiff --git a/server/views/console-ai.js b/server/views/console-ai.js
iindex 1e83a78..1830a52 100644
---- a/server/views/console-ai.js
++++ b/server/views/console-ai.js
@@@ -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>` : ''}
ddiff --git a/test/console-ai.test.js b/test/console-ai.test.js
iindex b4bb2b5..5f223cd 100644
---- a/test/console-ai.test.js
++++ b/test/console-ai.test.js
@@@ -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: [
ddiff --git a/test/console-merge.test.js b/test/console-merge.test.js
iindex 22f1e3c..012d554 100644
---- a/test/console-merge.test.js
++++ b/test/console-merge.test.js
@@@ -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',
});