openvibers/openvibe.exchange Commits

Price book: waiting prices grouped by provider and model, accepted together (#185)

OP OpenVibers committed 3 days ago · e6ab71fb02 · 4 files, +104 −1

The 43 readings of 2026-11-10 put 114 prices in the console's waiting list, one card and one
Accept each. Now:
- a card per provider (its models, how many are new, when it was read, its price page once);
- a row per model with its input, cached and output prices on one line (a changed price shows
  old, new and the change), Accept and Keep beside it, deciding that model's prices together;
- Accept all for a provider, and Accept all for everything waiting (the one primary button);
- the summary says how many models from how many providers, how many are new, and that a new
  model stays switched off until it is turned on in Models (approval sets the price only).

POST /console/pricebook/decide takes the change ids and decides them one by one through AI
(never in parallel: each approval rewrites its model's cost row from a fresh read); a partial
failure says how many and why.

Changes

ddiff --git a/public/css/console-ai.css b/public/css/console-ai.css
iindex 30adc2e..bbaf706 100644
---- a/public/css/console-ai.css
++++ b/public/css/console-ai.css
@@@ -70,8 +70,34 @@
.exc-delta.is-down em{color:color-mix(in srgb,var(--ovk-ok) 60%,var(--ovk-text))}
.exc-decide .ovk-btn{min-height:44px}
++/* Waiting prices, grouped: a card per provider, a row per model (its prices on one line, Accept / Keep beside). */
++.exc-pb-all{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px 16px}
++.exc-pb-all .exc-note{flex:1 1 20rem}
++.exc-pb-prov{display:grid;gap:6px;min-width:0;padding:14px;border:1px solid var(--ovk-line);border-radius:16px;background:var(--ovk-panel)}
++.exc-pb-prov>.exc-prov-head .exc-decide{margin-left:auto}
++.exc-prov-name small a{color:inherit}
++.exc-pb-models{list-style:none;margin:0;padding:0;display:grid}
++.exc-pb-model{display:grid;grid-template-columns:minmax(0,1fr) auto;grid-template-areas:"name act" "prices act";gap:4px 12px;align-items:center;padding:10px 0;border-top:1px solid var(--ovk-line)}
++.exc-pb-name{grid-area:name;display:flex;flex-wrap:wrap;align-items:center;gap:6px 8px;min-width:0}
++.exc-pb-name b{overflow-wrap:anywhere}
++.exc-pb-name a{color:var(--ovk-muted);font-size:.78rem}
++.exc-pb-prices{grid-area:prices;display:flex;flex-wrap:wrap;gap:4px 14px;margin:0;font-size:.86rem;font-variant-numeric:tabular-nums}
++.exc-pb-price{display:inline-flex;align-items:baseline;gap:5px}
++.exc-pb-price em{font-style:normal;font-size:.68rem;font-weight:800;letter-spacing:.05em;text-transform:uppercase;color:var(--ovk-muted)}
++.exc-pb-price s{color:var(--ovk-muted)}
++.exc-pb-price i{font-style:normal;font-weight:700;font-size:.78rem}
++.exc-pb-price.is-up i{color:color-mix(in srgb,var(--ovk-bad) 60%,var(--ovk-text))}
++.exc-pb-price.is-down i{color:color-mix(in srgb,var(--ovk-ok) 60%,var(--ovk-text))}
++.exc-pb-model .exc-decide{grid-area:act;flex-wrap:nowrap;gap:8px}
++.exc-pb-yes{color:var(--ovk-accent);border-color:color-mix(in srgb,var(--ovk-accent) 45%,var(--ovk-line))}
++.exc-pb-yes:hover{background:color-mix(in srgb,var(--ovk-accent) 12%,transparent)}
++
@media (max-width:600px){
.exc-prov-head{flex-wrap:wrap}
.exc-quick{margin-left:0;width:100%}
.exc-quick .ovk-btn{flex:1}
++ .exc-pb-model{grid-template-columns:minmax(0,1fr);grid-template-areas:"name" "prices" "act"}
++ .exc-pb-prov>.exc-prov-head{flex-wrap:wrap}
++ .exc-pb-prov>.exc-prov-head .exc-decide{flex-basis:100%;margin-left:0}
++ .exc-pb-all .exc-decide,.exc-pb-all .exc-decide .ovk-btn{flex:1}
}
ddiff --git a/server/http/console.js b/server/http/console.js
iindex 632fa1d..55c8f26 100644
---- a/server/http/console.js
++++ b/server/http/console.js
@@@ -278,6 +278,22 @@ function createConsoleRoutes({ config, ai }) {
return res.redirect(303, '/console/pricebook');
} catch (err) { return page(req, res, 'pricebook', {}, err.message || 'The price decision could not be saved.', 502); }
});
++ // Several changes at once (a model's three prices, a provider's, everything waiting): one by one, never in
++ // parallel, because each approval rewrites its model's cost row from a fresh read.
++ r.post('/console/pricebook/decide', async (req, res) => {
++ if (!sameOrigin(req)) return res.sendStatus(403);
++ const decision = String(req.body.decision) === 'approve' ? 'approve' : 'reject';
++ const ids = [...new Set(String(req.body.ids || '').split(',').map((s) => s.trim().slice(0, 60)).filter(Boolean))].slice(0, 500);
++ if (!ids.length) return res.redirect(303, '/console/pricebook');
++ const failed = [];
++ for (const id of ids) {
++ try {
++ await ai.adminRequest(`/api/v1/admin/console/pricebook/changes/${encodeURIComponent(id)}`, { method: 'POST', subject: req.viewer.subject, body: { decision } });
++ } catch (err) { failed.push(err.message || 'refused'); }
++ }
++ if (!failed.length) return res.redirect(303, '/console/pricebook');
++ return page(req, res, 'pricebook', {}, `${failed.length} of ${ids.length} could not be saved: ${failed[0]}`, 502);
++ });
r.post('/console/pricebook/run', 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 af972d5..d6fa093 100644
---- a/server/views/console-ai.js
++++ b/server/views/console-ai.js
@@@ -196,12 +196,57 @@ function pricebookSection(data = {}) {
<div class="exc-model-head">${mark(c.provider_key, 24)}<b>${esc(c.model_key || '')}</b><small>${esc(c.provider_key || '')} · ${esc(FIELD[c.field] || c.field || '')} price per 1M${c.checked_at ? ` · read ${esc(day(c.checked_at))}` : ''}${c.source ? ` · <a href="${esc(c.source)}" rel="noopener noreferrer" target="_blank">their page</a>` : ''}</small></div>
<p class="exc-delta is-${dir}"><span>${esc(money(c.old_price) || 'No price')}</span><span aria-hidden="true">→</span><b>${esc(money(c.new_price) || '–')}</b>${pct != null ? `<em>${pct > 0 ? '+' : ''}${esc(pct.toFixed(Math.abs(pct) < 10 ? 1 : 0))}%</em>` : ''}</p>
${open ? `<form method="post" action="/console/pricebook/changes/${encodeURIComponent(c.id)}" class="exc-inline exc-decide"><button class="ovk-btn ovk-btn--primary" name="decision" value="approve">Accept</button><button class="ovk-btn ovk-btn--quiet" name="decision" value="reject">Keep the current price</button></form>` : `<p class="exc-note">${esc(c.status === 'applied' ? 'Applied' : c.status || '')}${c.decided_at ? ` ${esc(day(c.decided_at))}` : ''}</p>`}
++</li>`;
++ };
++ // Waiting changes, grouped the way the owner decides them: by provider, then one row per model with its input,
++ // cached and output prices together. Accept for a model, for a provider, or for everything waiting (one form each,
++ // carrying the change ids; /console/pricebook/decide applies them one by one through AI).
++ const ids = (list) => list.map((c) => c.id).join(',');
++ const decide = (list, accept, keep, kind = 'primary') => `<form method="post" action="/console/pricebook/decide" class="exc-inline exc-decide"><input type="hidden" name="ids" value="${esc(ids(list))}">${accept ? `<button class="ovk-btn ovk-btn--${kind}${kind === 'primary' ? '' : ' exc-pb-yes'}" name="decision" value="approve">${esc(accept)}</button>` : ''}${keep ? `<button class="ovk-btn ovk-btn--quiet" name="decision" value="reject">${esc(keep)}</button>` : ''}</form>`;
++ const ORDER = ['in_per_mtok', 'cached_per_mtok', 'out_per_mtok'];
++ const LABEL = { in_per_mtok: 'Input', cached_per_mtok: 'Cached', out_per_mtok: 'Output' };
++ const providers = new Map();
++ for (const c of pending) {
++ const p = providers.get(c.provider_key) || new Map();
++ const m = p.get(c.model_key) || [];
++ m.push(c); p.set(c.model_key, m); providers.set(c.provider_key, p);
++ }
++ const isNew = (list) => list.every((c) => c.old_price == null);
++ const fieldLine = (c) => {
++ const pct = c.change_pct == null || !Number.isFinite(Number(c.change_pct)) ? null : Number(c.change_pct) * (Math.abs(Number(c.change_pct)) <= 5 ? 100 : 1);
++ const dir = c.old_price == null ? 'new' : Number(c.new_price) > Number(c.old_price) ? 'up' : 'down';
++ return `<span class="exc-pb-price is-${dir}"><em>${esc(LABEL[c.field] || FIELD[c.field] || c.field || '')}</em>${c.old_price != null ? `<s>${esc(money(c.old_price) || '')}</s>` : ''}<b>${esc(money(c.new_price) || '–')}</b>${pct != null && c.old_price != null ? `<i>${pct > 0 ? '+' : ''}${esc(pct.toFixed(Math.abs(pct) < 10 ? 1 : 0))}%</i>` : ''}</span>`;
++ };
++ const sourceOf = (list) => (list.find((c) => c.source) || {}).source || '';
++ const modelRow = (model, list, { common, mixed }) => {
++ const sorted = [...list].sort((a, b) => ORDER.indexOf(a.field) - ORDER.indexOf(b.field));
++ const src = sourceOf(sorted);
++ return `<li class="exc-pb-model">
++ <div class="exc-pb-name"><b>${esc(model || '')}</b>${mixed && isNew(sorted) ? tag('New model', 'ok') : ''}${src && src !== common ? `<a href="${esc(src)}" rel="noopener noreferrer" target="_blank">${esc(host(src) || 'their page')}</a>` : ''}</div>
++ <p class="exc-pb-prices">${sorted.map(fieldLine).join('')}</p>
++ ${decide(sorted, 'Accept', 'Keep', 'quiet')}
++</li>`;
++ };
++ const groups = [...providers.entries()].sort((a, b) => b[1].size - a[1].size || String(a[0]).localeCompare(String(b[0])));
++ const fresh = [...providers.values()].reduce((n, models) => n + [...models.values()].filter(isNew).length, 0);
++ const modelCount = [...providers.values()].reduce((n, models) => n + models.size, 0);
++ const providerCard = ([provider, models]) => {
++ const all = [...models.values()].flat();
++ const read = all.map((c) => c.checked_at).filter(Boolean).sort().pop();
++ const sources = [...models.values()].map(sourceOf).filter(Boolean);
++ const common = sources.length && sources.every((x) => x === sources[0]) ? sources[0] : '';
++ const newCount = [...models.values()].filter(isNew).length;
++ const newWords = !newCount ? '' : newCount === models.size ? (models.size === 1 ? ', new' : ', all new') : `, ${newCount} new`;
++ return `<li class="exc-pb-prov">
++ <div class="exc-prov-head">${mark(provider, 32)}<span class="exc-prov-name"><b>${esc(provider || '')}</b><small>${models.size === 1 ? '1 model' : `${models.size} models`}${newWords} · ${all.length === 1 ? '1 price' : `${all.length} prices`}${read ? ` · read ${esc(day(read))}` : ''}${common ? ` · <a href="${esc(common)}" rel="noopener noreferrer" target="_blank">${esc(host(common) || 'their page')}</a>` : ''}</small></span>${models.size > 1 ? decide(all, `Accept all ${models.size}`, '', 'quiet') : ''}</div>
++ <ul class="exc-pb-models">${[...models.entries()].map(([model, list]) => modelRow(model, list, { common, mixed: newCount > 0 && newCount < models.size })).join('')}</ul>
</li>`;
};
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>
--<section class="exc-sec"><h3>Waiting for you <span class="exc-n">${pending.length}</span></h3>${pending.length ? `<ul class="exc-list">${pending.map((c) => change(c, true)).join('')}</ul>` : '<p class="exc-note">Nothing waiting: every price matches its provider\'s page.</p>'}</section>
++<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>` : ''}
</div>`;
}
ddiff --git a/test/console-ai.test.js b/test/console-ai.test.js
iindex c129eb4..da5ed9e 100644
---- a/test/console-ai.test.js
++++ b/test/console-ai.test.js
@@@ -50,4 +50,20 @@ 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');
++
++// Grouped: a card per provider, one row per model with its prices together, Accept per model, provider and all.
++const many = pricebookSection({ pending: [
++ { id: 'pc_a', provider_key: 'deepinfra', model_key: 'qwen3-14b', field: 'in_per_mtok', old_price: null, new_price: 0.06, source: 'https://deepinfra.com/pricing', checked_at: '2026-11-10' },
++ { id: 'pc_b', provider_key: 'deepinfra', model_key: 'qwen3-14b', field: 'out_per_mtok', old_price: null, new_price: 0.24, source: 'https://deepinfra.com/pricing', checked_at: '2026-11-10' },
++ { id: 'pc_c', provider_key: 'deepinfra', model_key: 'gpt-oss-120b', field: 'out_per_mtok', old_price: null, new_price: 0.17, checked_at: '2026-11-10' },
++ { id: 'pc_d', provider_key: 'sambanova', model_key: 'gpt-oss-120b', field: 'in_per_mtok', old_price: 0.3, new_price: 0.22, change_pct: -0.2667, checked_at: '2026-11-10' },
++], recent: [] });
++assert.equal((many.match(/class="exc-pb-prov"/g) || []).length, 2, 'one card per provider');
++assert.equal((many.match(/class="exc-pb-model"/g) || []).length, 3, 'one row per model');
++assert.ok(many.includes('name="ids" value="pc_a,pc_b"'), 'a model row decides its prices together');
++assert.ok(many.includes('Accept all 2</button>'), 'a provider with several models can be accepted at once');
++assert.ok(many.includes('Accept all 4 prices') && many.includes('name="ids" value="pc_a,pc_b,pc_c,pc_d"'), 'everything waiting can be accepted at once');
++assert.ok(many.includes('3 models from 2 providers: 2 new to OpenVibe'), 'the summary counts models and the new ones');
++assert.ok(many.includes('<s>$0.30</s><b>$0.22</b><i>-27%</i>'), 'a changed price shows old, new and the change');
++assert.ok(!/action="\/console\/pricebook\/changes\//.test(many), 'the waiting list decides through the grouped route');
console.log('console-ai: providers, models and the price book passed');