openvibers/openvibe.ai Commits
Exchange usage: services named, developer apps as one anonymous row (#325)
Changes
@@@ -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 runsWHERE ${REAL} AND created_at::timestamptz >= ?::timestamptz AND created_at::timestamptz < ?::timestamptzGROUP 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 runsWHERE ${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),};}@@@ -149,7 +149,13 @@ t.test('usage: day, model and product aggregates from runs, and never an attribuassert.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));nnew file mode 100644@@@ -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); });
@@ -5,9 +5,11 @@
@@ -5,9 +5,11 @@
* It reads the finished runs themselves — the request its requester row names, and the provider and
* 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
* 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
* 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 DAY_MS = 86400000;
const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10);
const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10);
@@ -33,12 +35,12 @@ function createUsage({ db, clock = { now: () => Date.now() } }) {
@@ -33,12 +35,12 @@ function createUsage({ db, clock = { now: () => Date.now() } }) {
const byModel = db.prepare(`SELECT provider_key, model_key, ${NUM_COLUMNS} FROM runs
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
WHERE ${REAL} AND created_at::timestamptz >= ?::timestamptz AND created_at::timestamptz < ?::timestamptz
GROUP BY provider_key, model_key ORDER BY COUNT(*) DESC LIMIT 100`);
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 today = db.prepare(`SELECT ${NUM_COLUMNS} FROM runs WHERE ${REAL} AND ${DAY} = ?`);
const totals = db.prepare(`SELECT ${NUM_COLUMNS} FROM runs
const totals = db.prepare(`SELECT ${NUM_COLUMNS} FROM runs
WHERE ${REAL} AND created_at::timestamptz >= ?::timestamptz AND created_at::timestamptz < ?::timestamptz`);
WHERE ${REAL} AND created_at::timestamptz >= ?::timestamptz AND created_at::timestamptz < ?::timestamptz`);
@@ -56,16 +58,33 @@ function createUsage({ db, clock = { now: () => Date.now() } }) {
@@ -56,16 +58,33 @@ function createUsage({ db, clock = { now: () => Date.now() } }) {
productModels.all(fromAt, toAt), today.all(todayKey), totals.all(fromAt, toAt),
productModels.all(fromAt, toAt), today.all(todayKey), totals.all(fromAt, toAt),
]);
]);
const products = new Map();
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) {
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) {
for (const r of productModelRows) {
- const product = products.get(String(r.requester_id));
+ const product = products.get(keyOf(r));
if (!product) continue;
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 {
return {
generated_at: new Date(now).toISOString(),
generated_at: new Date(now).toISOString(),
days: span,
days: span,
@@ -75,7 +94,7 @@ function createUsage({ db, clock = { now: () => Date.now() } }) {
@@ -75,7 +94,7 @@ function createUsage({ db, clock = { now: () => Date.now() } }) {
totals: shape(totalRow[0] || {}),
totals: shape(totalRow[0] || {}),
by_day: dayRows.map((r) => ({ day: String(r.day).slice(0, 10), ...shape(r) })),
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_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),
};
};
}
}
@@ -149,7 +149,13 @@ t.test('usage: day, model and product aggregates from runs, and never an attribu
@@ -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].model, 'gpt-5-nano-2025-08-07', 'the model that actually answered');
assert.strictEqual(live.models[0].requests, 5);
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(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.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 === 'gpt-5-nano-2025-08-07' && m.requests === 5));
assert.ok(r.body.by_model.some((m) => m.model === 'deepseek-chat' && m.requests === 6));
assert.ok(r.body.by_model.some((m) => m.model === 'deepseek-chat' && m.requests === 6));
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -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); });