openvibers/openvibe.ai Commits

Exchange usage: services named, developer apps as one anonymous row (#325)

OP OpenVibers committed 7 days ago · 532a20d54e · 3 files, +80 −16

* Exchange usage: name public apps beside services

Aggregate usage for OpenVibe.Exchange now includes app requesters, so the
rankings can say which OpenVibe products and public apps use which models.
Each row carries its requester type, and a service and an app with the same
id never merge. No prompt, no answer and no person is named.

Checked: npm test -- usage (3 files), npm test -- exchange-read.

* Public usage never names a developer's app: every app's runs are one "apps" row with how many apps it covers

The read is public (Exchange's rankings and AI in OpenVibe). A person's own app usage is theirs to publish, so apps are
aggregated and their models summed; first-party services stay named.

* exchange-read stays as main has it (it never ran; the queued vacuous-suites job repairs its drifted tests); the new exchange-usage-apps test covers this change

Changes

ddiff --git a/server/exchange/usage.js b/server/exchange/usage.js
iindex bf0f068..86dc3bb 100644
---- a/server/exchange/usage.js
++++ b/server/exchange/usage.js
@@@ -5,9 +5,11 @@
* It reads the finished runs themselves — the request its requester row names, and the provider and
* model that actually answered it — never a second roll-up that could carry the route's model instead
* of the answer's. Cached answers are not usage: no provider was called and nothing was spent, so they
-- * are not counted here (the catalog counts them apart). Only services are named as products; a run
-- * that is not a service is counted in the totals but never attributed. No prompt, no answer, and no
-- * person's id is selected.
++ * are not counted here (the catalog counts them apart). A first-party service is named, so Exchange can
++ * show which products use which models. A developer's app is never named on this public read: every app's
++ * runs are one row, "apps", with how many apps it covers (a person's own usage is theirs to publish, not
++ * ours). A run that is neither is counted in the totals but never attributed. No prompt, no answer and no
++ * person's or app's id leaves this function.
*/
const DAY_MS = 86400000;
const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10);
@@@ -33,12 +35,12 @@ function createUsage({ db, clock = { now: () => Date.now() } }) {
const byModel = db.prepare(`SELECT provider_key, model_key, ${NUM_COLUMNS} FROM runs
WHERE ${REAL} AND created_at::timestamptz >= ?::timestamptz AND created_at::timestamptz < ?::timestamptz
GROUP BY provider_key, model_key ORDER BY COUNT(*) DESC LIMIT 100`);
-- const byProduct = db.prepare(`SELECT requester_id, ${NUM_COLUMNS} FROM runs
-- WHERE ${REAL} AND requester_type = 'service' AND created_at::timestamptz >= ?::timestamptz AND created_at::timestamptz < ?::timestamptz
-- GROUP BY requester_id ORDER BY COUNT(*) DESC LIMIT 100`);
-- const productModels = db.prepare(`SELECT requester_id, provider_key, model_key, ${NUM_COLUMNS} FROM runs
-- WHERE ${REAL} AND requester_type = 'service' AND created_at::timestamptz >= ?::timestamptz AND created_at::timestamptz < ?::timestamptz
-- GROUP BY requester_id, provider_key, model_key ORDER BY COUNT(*) DESC`);
++ const byProduct = db.prepare(`SELECT requester_id, requester_type, ${NUM_COLUMNS} FROM runs
++ WHERE ${REAL} AND requester_type IN ('service', 'app') AND created_at::timestamptz >= ?::timestamptz AND created_at::timestamptz < ?::timestamptz
++ GROUP BY requester_id, requester_type ORDER BY COUNT(*) DESC LIMIT 100`);
++ const productModels = db.prepare(`SELECT requester_id, requester_type, provider_key, model_key, ${NUM_COLUMNS} FROM runs
++ WHERE ${REAL} AND requester_type IN ('service', 'app') AND created_at::timestamptz >= ?::timestamptz AND created_at::timestamptz < ?::timestamptz
++ GROUP BY requester_id, requester_type, provider_key, model_key ORDER BY COUNT(*) DESC`);
const today = db.prepare(`SELECT ${NUM_COLUMNS} FROM runs WHERE ${REAL} AND ${DAY} = ?`);
const totals = db.prepare(`SELECT ${NUM_COLUMNS} FROM runs
WHERE ${REAL} AND created_at::timestamptz >= ?::timestamptz AND created_at::timestamptz < ?::timestamptz`);
@@@ -56,16 +58,33 @@ function createUsage({ db, clock = { now: () => Date.now() } }) {
productModels.all(fromAt, toAt), today.all(todayKey), totals.all(fromAt, toAt),
]);
const products = new Map();
++ const APPS = 'apps';
++ const keyOf = (r) => (String(r.requester_type) === 'app' ? APPS : `service:${String(r.requester_id)}`);
++ const add = (into, r) => {
++ const n = shape(r);
++ into.requests += n.requests; into.tokens_in += n.tokens_in; into.tokens_out += n.tokens_out;
++ into.cost_usd = Math.round((into.cost_usd + n.cost_usd) * 1e6) / 1e6;
++ return into;
++ };
for (const r of productRows) {
-- const product = String(r.requester_id);
-- products.set(product, { product, ...shape(r), models: [] });
++ const key = keyOf(r);
++ if (key === APPS) {
++ const all = products.get(APPS) || { product: APPS, type: APPS, apps: 0, requests: 0, tokens_in: 0, tokens_out: 0, cost_usd: 0, models: [] };
++ all.apps += 1;
++ products.set(APPS, add(all, r));
++ } else {
++ products.set(key, { product: String(r.requester_id), type: 'service', ...shape(r), models: [] });
++ }
}
for (const r of productModelRows) {
-- const product = products.get(String(r.requester_id));
++ const product = products.get(keyOf(r));
if (!product) continue;
-- product.models.push({ provider: r.provider_key, model: r.model_key || null, ...shape(r) });
++ const model = r.model_key || null;
++ const same = product.models.find((m) => m.provider === r.provider_key && m.model === model);
++ if (same) add(same, r);
++ else product.models.push({ provider: r.provider_key, model, ...shape(r) });
}
-- for (const p of products.values()) p.models = p.models.slice(0, 8);
++ for (const p of products.values()) p.models = p.models.sort((a, b) => b.requests - a.requests).slice(0, 8);
return {
generated_at: new Date(now).toISOString(),
days: span,
@@@ -75,7 +94,7 @@ function createUsage({ db, clock = { now: () => Date.now() } }) {
totals: shape(totalRow[0] || {}),
by_day: dayRows.map((r) => ({ day: String(r.day).slice(0, 10), ...shape(r) })),
by_model: modelRows.map((r) => ({ provider: r.provider_key, model: r.model_key || null, ...shape(r) })),
-- by_product: [...products.values()],
++ by_product: [...products.values()].sort((a, b) => b.requests - a.requests),
};
}
ddiff --git a/test/exchange-read.test.js b/test/exchange-read.test.js
iindex 07cd1f8..2ac6821 100644
---- a/test/exchange-read.test.js
++++ b/test/exchange-read.test.js
@@@ -149,7 +149,13 @@ t.test('usage: day, model and product aggregates from runs, and never an attribu
assert.strictEqual(live.models[0].model, 'gpt-5-nano-2025-08-07', 'the model that actually answered');
assert.strictEqual(live.models[0].requests, 5);
assert.ok(live.models.every((m) => m.model), 'a product names only models that actually answered');
-- assert.ok(!r.body.by_product.some((p) => p.product.includes('app_')), 'only services are products');
++ const apps = r.body.by_product.find((p) => p.type === 'apps');
++ assert.ok(apps, 'developer apps are one row beside the products');
++ assert.strictEqual(apps.apps, 1, 'with how many apps it covers');
++ assert.strictEqual(apps.requests, 1);
++ assert.strictEqual(apps.models[0].model, 'deepseek-chat');
++ assert.ok(!r.text.includes('app_ABC'), 'a developer\'s app is never named on the public read');
++ assert.ok(r.body.by_product.filter((p) => p.type === 'service').every((p) => !String(p.product).startsWith('app_')), 'a service and an app never merge');
assert.ok(!r.text.includes('SECRETPERSON') && !r.text.includes('usr_'), 'no person in the aggregate');
assert.ok(r.body.by_model.some((m) => m.model === 'gpt-5-nano-2025-08-07' && m.requests === 5));
assert.ok(r.body.by_model.some((m) => m.model === 'deepseek-chat' && m.requests === 6));
ddiff --git a/test/exchange-usage-apps.test.js b/test/exchange-usage-apps.test.js
nnew file mode 100644
iindex 0000000..0b07e07
---- /dev/null
++++ b/test/exchange-usage-apps.test.js
@@@ -0,0 +1,39 @@
++'use strict';
++// Exchange's public usage read (server/exchange/usage.js) names first-party services, never a developer's app: every
++// app's runs fold into one "apps" row with how many apps it covers, and the models used are summed across them.
++const assert = require('assert');
++const { createUsage } = require('../server/exchange/usage');
++
++const product = [
++ { requester_id: 'live', requester_type: 'service', requests: 5, tokens_in: 500, tokens_out: 50, cost_usd: 0.001 },
++ { requester_id: 'app_ABC', requester_type: 'app', requests: 3, tokens_in: 300, tokens_out: 30, cost_usd: 0.0006 },
++ { requester_id: 'app_XYZ', requester_type: 'app', requests: 4, tokens_in: 400, tokens_out: 40, cost_usd: 0.0008 },
++];
++const productModels = [
++ { requester_id: 'live', requester_type: 'service', provider_key: 'shared', model_key: 'gpt-5-nano', requests: 5, tokens_in: 500, tokens_out: 50, cost_usd: 0.001 },
++ { requester_id: 'app_ABC', requester_type: 'app', provider_key: 'deepseek', model_key: 'deepseek-chat', requests: 3, tokens_in: 300, tokens_out: 30, cost_usd: 0.0006 },
++ { requester_id: 'app_XYZ', requester_type: 'app', provider_key: 'deepseek', model_key: 'deepseek-chat', requests: 4, tokens_in: 400, tokens_out: 40, cost_usd: 0.0008 },
++];
++const db = {
++ prepare(sql) {
++ const rows = /GROUP BY requester_id, requester_type, provider_key/.test(sql) ? productModels
++ : /GROUP BY requester_id, requester_type/.test(sql) ? product : [];
++ return { all: async () => rows };
++ },
++};
++
++(async () => {
++ const out = await createUsage({ db, clock: { now: () => Date.parse('2026-11-06T12:00:00Z') } }).read({ days: 7 });
++ const text = JSON.stringify(out);
++ assert.ok(!/app_ABC|app_XYZ/.test(text), 'no developer app is named on the public read');
++ const apps = out.by_product.find((p) => p.type === 'apps');
++ assert.ok(apps, 'the apps are one row');
++ assert.strictEqual(apps.apps, 2, 'covering two apps');
++ assert.strictEqual(apps.requests, 7);
++ assert.strictEqual(apps.models.length, 1, 'one model, summed across the apps');
++ assert.strictEqual(apps.models[0].requests, 7);
++ const live = out.by_product.find((p) => p.product === 'live');
++ assert.strictEqual(live.type, 'service');
++ assert.strictEqual(out.by_product[0].type, 'apps', 'ordered by requests');
++ console.log('exchange usage: apps fold into one row and are never named');
++})().catch((e) => { console.error(e); process.exit(1); });