openvibers/openvibe.exchange Commits
Price book: waiting prices grouped by provider and model, accepted together (#185)
Changes
@@@ -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}}@@@ -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 {@@@ -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>`;}@@@ -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');
@@ -70,8 +70,34 @@
@@ -70,8 +70,34 @@
.exc-delta.is-down em{color:color-mix(in srgb,var(--ovk-ok) 60%,var(--ovk-text))}
.exc-delta.is-down em{color:color-mix(in srgb,var(--ovk-ok) 60%,var(--ovk-text))}
.exc-decide .ovk-btn{min-height:44px}
.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){
@media (max-width:600px){
.exc-prov-head{flex-wrap:wrap}
.exc-prov-head{flex-wrap:wrap}
.exc-quick{margin-left:0;width:100%}
.exc-quick{margin-left:0;width:100%}
.exc-quick .ovk-btn{flex:1}
.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}
}
}
@@ -278,6 +278,22 @@ function createConsoleRoutes({ config, ai }) {
@@ -278,6 +278,22 @@ 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 decision could not be saved.', 502); }
} 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) => {
r.post('/console/pricebook/run', async (req, res) => {
if (!sameOrigin(req)) return res.sendStatus(403);
if (!sameOrigin(req)) return res.sendStatus(403);
try {
try {
@@ -196,12 +196,57 @@ function pricebookSection(data = {}) {
@@ -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>
<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>
<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>`}
${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>`;
</li>`;
};
};
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>
-<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>` : ''}
${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>`;
</div>`;
}
}
@@ -50,4 +50,20 @@ const book = pricebookSection({ pending: [{ id: 'pc_1', provider_key: 'deepseek'
@@ -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('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');
+
+// 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');
console.log('console-ai: providers, models and the price book passed');