openvibers/openvibe.report Commits

Add public AI comparison API (#140)

OP OpenVibers committed 4 days ago · bdbdc96659 · 9 files, +176 −3

* Add public AI comparison API

* Explain unavailable AI picks

Changes

ddiff --git a/.env.example b/.env.example
iindex 1ffd2ca..e47e2b4 100644
---- a/.env.example
++++ b/.env.example
@@@ -26,6 +26,9 @@ OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000
# Bench's public read API; set to an internal origin if Report and Bench share one.
OV_BENCH_INTERNAL_URL=https://bench.openvibe.services
++# OpenVibe.AI public Exchange catalogue and measured provider offers.
++OV_AI_INTERNAL_URL=https://ai.openvibe.services
++
# OpenVibe.Billing's read API. Report quotes Billing and never computes money. The service needs the grants
# billing.stats.read (GET /api/v1/stats) and billing.balance.read (GET /api/v1/transactions?subject=…).
OV_BILLING_INTERNAL_URL=http://127.0.0.1:4600
ddiff --git a/README.md b/README.md
iindex b4c1516..7522a4d 100644
---- a/README.md
++++ b/README.md
@@@ -37,6 +37,7 @@ OpenVibe.Live in both modes.
| **OpenVibe.Live** — The Rundown | `GET /api/rundown/`, `/api/rundown/topics`, `/api/rundown/t/:slug`, `/api/rundown/e/:streamId`, `/api/rundown/s/:username` | `/rundown`, `/rundown/t/:slug`, `/rundown/e/:streamId`, `/rundown/s/:username` |
| **OpenVibe.Live** — after-show reports | `GET /api/recap/:streamId`, `GET /api/recap/channel/:username` | `/after-show/:streamId`, `/c/:username` |
| **OpenVibe.Bench** | `GET /api/v1/kinds`, `/api/v1/boards/:kind?type=elo\|pareto\|cost`, `/api/v1/benchmarks`, `/api/v1/rankings`, `/api/v1/runs`, `/api/v1/overview`, `/api/v1/models/:model`, `/api/v1/models/:model/summary` | `/ai`, `/ai/:kind`, `/ai/<benchmark>`, `/ai/models/:model`, `/ai/compare`, `/ai/method`, `/ai/feed.xml`, `GET /api/v1/ai/results` |
++| **OpenVibe.AI** | `GET /api/v1/exchange/catalog` | `GET /api/v1/ai/leaderboard`, `/api/v1/ai/models/:id`, `/api/v1/ai/providers`, `/api/v1/ai/compare?models=`, `/api/v1/ai/picks` |
| **OpenVibe.Billing** | `GET /api/v1/stats`, `GET /api/v1/transactions?subject=…` | the revenue read for a channel; network money counts |
| **OpenVibe.Billing** — settlements, through Events | `billing.credit.settled@1` on `POST /internal/events` (only with `REPORT_AI_EXCHANGE=1`) | the AI Exchange aggregates and the staff page/API |
@@@ -173,6 +174,7 @@ Every value comes from the environment (production: `/etc/openvibe/report.env`).
| `OV_NETWORK_URL` | `https://openvibe.network` | the OAuth token endpoint's origin |
| `OV_LIVE_INTERNAL_URL` | `http://127.0.0.1:3000` | Live's loopback origin |
| `OV_BENCH_INTERNAL_URL` | `https://bench.openvibe.services` | Bench's public read origin, optionally an internal origin |
++| `OV_AI_INTERNAL_URL` | `https://ai.openvibe.services` | AI's public Exchange catalogue and measured provider offers |
| `OV_BILLING_INTERNAL_URL` | `http://127.0.0.1:4600` | Billing's loopback origin |
| `OV_OAUTH_CLIENT_ID` / `OV_OAUTH_CLIENT_SECRET` | `report` / unset | Report's client-credentials token for `openvibe.billing` |
| `REPORT_BILLING_TOKEN` | unset | an explicit Billing bearer token for an operator run |
ddiff --git a/server/app.js b/server/app.js
iindex f708971..2864006 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -31,6 +31,7 @@ const { createNetworkClient } = require('./data/network');
const { createLiveClient } = require('./data/live');
const { createBillingClient } = require('./data/billing');
const { createBenchClient } = require('./data/bench');
++const { createAiCatalogue } = require('./data/ai-catalogue');
const { createAnalytics } = require('./analytics');
const { createAiUsage } = require('./analytics/ai-usage');
const { createAiExchange } = require('./analytics/ai-exchange');
@@@ -88,6 +89,7 @@ async function createApp(opts = {}) {
const live = opts.live || createLiveClient({ config, fetchImpl, log, now });
const billing = opts.billing || createBillingClient({ config, fetchImpl, log, now });
const bench = opts.bench || createBenchClient({ config, fetchImpl, log, now });
++ const aiCatalogue = opts.aiCatalogue || createAiCatalogue({ config, fetchImpl, log, now });
// D11 owner settings (report.ai_exchange): the switch is read through the store, env pins winning. Report can
// run without a database (a network-only front): then there is no store and the env value is all there is.
const exchangeConfig = store ? await createAiExchangeConfig({ db: store.db, config, env: opts.env || process.env, now, log, onActivate: opts.onConfigActivate }) : null;
@@@ -116,7 +118,7 @@ async function createApp(opts = {}) {
secureCookies: /^https:/.test(config.baseUrl), fetch: fetchImpl, log,
});
-- const ctx = { config, log, fetchImpl, now, store, network, live, billing, bench, analytics, shadow, sso, aiUsage, aiExchange, searchIndex, exchangeConfig, sessionReports };
++ const ctx = { config, log, fetchImpl, now, store, network, live, billing, bench, aiCatalogue, analytics, shadow, sso, aiUsage, aiExchange, searchIndex, exchangeConfig, sessionReports };
const app = express();
if (exchangeConfig) exchangeConfig.mount(app);
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
@@@ -287,7 +289,7 @@ async function createApp(opts = {}) {
legacyHeaders: false,
handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }),
}));
-- app.use('/api/v1', createApi({ network, live, bench, analytics, aiUsage, aiExchange, sessionReports, config, now, log }));
++ app.use('/api/v1', createApi({ network, live, bench, aiCatalogue, analytics, aiUsage, aiExchange, sessionReports, config, now, log }));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
// ── Crawl artifacts, then the pages ─────────────────────
ddiff --git a/server/config.js b/server/config.js
iindex fef5595..17c9a67 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -50,6 +50,7 @@ function load(env = process.env) {
// OpenVibe.Bench's public reads (kinds, boards, models, datasets) — the AI boards under /ai. Public HTML
// boards render here; Bench stays the benchmark authority that owns the numbers.
benchUrl: trim(env.OV_BENCH_INTERNAL_URL || 'https://bench.openvibe.services'),
++ aiUrl: trim(env.OV_AI_INTERNAL_URL || 'https://ai.openvibe.services'),
// Report's own database (ADR-035). DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner
// role); development without DATABASE_URL uses an embedded PGlite database under REPORT_PGLITE_DIR.
ddiff --git a/server/data/ai-catalogue.js b/server/data/ai-catalogue.js
nnew file mode 100644
iindex 0000000..02c1ae3
---- /dev/null
++++ b/server/data/ai-catalogue.js
@@@ -0,0 +1,23 @@
++'use strict';
++
++const { requestJson, trim } = require('./source');
++
++const TTL_MS = 120_000;
++function createAiCatalogue({ config = {}, fetchImpl = globalThis.fetch, log = console, now = () => Date.now(), timeoutMs } = {}) {
++ const baseUrl = trim(config.aiUrl || 'https://ai.openvibe.services');
++ let hit = null;
++ let pending = null;
++ async function catalog() {
++ if (hit && hit.expires > now()) return hit.value;
++ if (!pending) pending = (async () => {
++ const data = await requestJson(`${baseUrl}/api/v1/exchange/catalog`, { fetchImpl, log, label: 'ai exchange catalog', timeoutMs });
++ if (data && Array.isArray(data.models)) hit = { value: data, expires: now() + TTL_MS };
++ pending = null;
++ return data && Array.isArray(data.models) ? data : null;
++ })();
++ return pending;
++ }
++ return { catalog, baseUrl, ttlMs: TTL_MS };
++}
++
++module.exports = { createAiCatalogue, TTL_MS };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 8945094..96912b5 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -30,7 +30,7 @@ const sharing = require('../sharing');
const CACHE = 'public, max-age=120';
--function createApi({ network, live, bench = null, analytics = null, aiUsage = null, aiExchange = null, sessionReports = null, config = {}, now = () => Date.now(), log = console }) {
++function createApi({ network, live, bench = null, aiCatalogue = null, analytics = null, aiUsage = null, aiExchange = null, sessionReports = null, config = {}, now = () => Date.now(), log = console }) {
const r = express.Router();
const serviceAuth = createServiceAuth({ config, now });
@@@ -82,6 +82,92 @@ function createApi({ network, live, bench = null, analytics = null, aiUsage = nu
// Every site's design health (plan L45 step 6): the snapshot scripts/design-health.js wrote from each site's code.
r.get('/design-health', (req, res) => res.json(designHealth.all()));
++
++ const aiEnvelope = (schema, data) => ({ '$schema': schema, generated_at: new Date(now()).toISOString(), ...data });
++ const aiRows = async () => {
++ const [kindsResult, catalogueResult] = await Promise.all([bench && bench.kinds(), aiCatalogue && aiCatalogue.catalog()]);
++ if (!kindsResult) return null;
++ const kinds = kindsResult.data && kindsResult.data.kinds || [];
++ const catalogModels = catalogueResult && catalogueResult.models || [];
++ const boards = await Promise.all(kinds.map((kind) => bench.board(kind.id, 'elo')));
++ const rows = [];
++ for (let i = 0; i < boards.length; i += 1) {
++ const board = boards[i];
++ const list = board && board.data && board.data.board && board.data.board.rows || [];
++ for (const entry of list) {
++ const id = entry.subject || entry.model || entry.name;
++ const catalogue = catalogModels.find((m) => m.slug === id || m.canonical === id || m.id === id) || null;
++ const elo = entry.elo != null && Number.isFinite(Number(entry.elo)) ? Number(entry.elo) : null;
++ rows.push({ id, kind: kinds[i].id, maker: catalogue && (catalogue.maker || (String(catalogue.slug || '').includes('/') ? String(catalogue.slug).split('/')[0] : null)) || null, weights: catalogue && (catalogue.weights || (catalogue.open_weights === true ? 'open' : null)) || null,
++ status: catalogue && catalogue.status || 'current', uncensored: catalogue && catalogue.uncensored === true,
++ community: Boolean(catalogue && catalogue.community), rating: elo == null ? { value: null, reason: 'Bench has no rating.' } : { value: elo, interval: [entry.ci_lower ?? null, entry.ci_upper ?? null], source: 'OpenVibe.Bench', date: board.at, sample_size: entry.games ?? null },
++ benchmark: entry.score == null ? { value: null, reason: 'No benchmark score.' } : { value: entry.score, source: 'OpenVibe.Bench', date: board.at, sample_size: entry.runs ?? null },
++ provider_offers: catalogue ? catalogue.offers.map((offer) => ({ ...offer, source: { name: 'OpenVibe.AI', date: catalogueResult.generated_at || null, sample_size: offer.requests_7d ?? null } })) : [], source: { name: 'OpenVibe.Bench', date: board.at, sample_size: entry.games ?? null } });
++ }
++ }
++ rows.sort((a, b) => (b.rating.value ?? -Infinity) - (a.rating.value ?? -Infinity) || String(a.id).localeCompare(String(b.id)));
++ rows.forEach((row, index) => {
++ const lo = row.rating.interval && row.rating.interval[0]; const hi = row.rating.interval && row.rating.interval[1];
++ if (lo == null || hi == null) row.rank = { value: null, reason: 'Not enough data to establish a rank band.' };
++ else { const overlapping = rows.filter((other) => other.rating.interval && other.rating.interval[0] != null && other.rating.interval[1] != null && other.rating.interval[0] <= hi && other.rating.interval[1] >= lo); row.rank = { min: Math.min(...overlapping.map((x) => rows.indexOf(x) + 1)), max: Math.max(...overlapping.map((x) => rows.indexOf(x) + 1)), position: index + 1, source: 'OpenVibe.Bench', date: row.source.date, sample_size: row.source.sample_size }; }
++ });
++ return rows;
++ };
++
++ r.get('/ai/leaderboard', async (req, res) => {
++ const all = await aiRows();
++ if (!all) return problem(req, res, 502, 'report.ai_source_unavailable', 'OpenVibe.Bench did not answer.');
++ let rows = all;
++ const q = req.query;
++ if (q.kind) rows = rows.filter((x) => x.kind === q.kind);
++ if (q.maker) rows = rows.filter((x) => String(x.maker || '').toLowerCase() === String(q.maker).toLowerCase());
++ if (q.weights === 'open') rows = rows.filter((x) => x.weights === 'open' || x.weights === 'open_weights');
++ if (q.weights === 'closed') rows = rows.filter((x) => x.weights === 'closed');
++ if (q.status === 'current') rows = rows.filter((x) => x.status === 'current');
++ if (q.uncensored === 'true') rows = rows.filter((x) => x.uncensored);
++ if (q.community === 'true') rows = rows.filter((x) => x.community);
++ if (q.price) rows = rows.filter((x) => x.provider_offers.some((o) => o.price && (q.price === 'free' ? o.free_tier : q.price === 'low' ? Number(o.price.out) <= 1 : ['mid', 'medium'].includes(q.price) ? Number(o.price.out) > 1 && Number(o.price.out) <= 10 : q.price === 'high' ? Number(o.price.out) > 10 : false)));
++ if (q.sort === 'name') rows.sort((a, b) => String(a.id).localeCompare(String(b.id)));
++ if (q.sort === 'price') rows.sort((a, b) => Math.min(...a.provider_offers.map((o) => Number(o.price && o.price.out)).filter(Number.isFinite), Infinity) - Math.min(...b.provider_offers.map((o) => Number(o.price && o.price.out)).filter(Number.isFinite), Infinity));
++ if (q.sort === 'speed') rows.sort((a, b) => Math.min(...a.provider_offers.map((o) => Number(o.speed && o.speed.p50_ms)).filter(Number.isFinite), Infinity) - Math.min(...b.provider_offers.map((o) => Number(o.speed && o.speed.p50_ms)).filter(Number.isFinite), Infinity));
++ const limit = Math.max(1, Math.min(100, Number(q.limit) || 25)); const offset = Math.max(0, Number(q.cursor) || 0);
++ return res.json(aiEnvelope('report.ai-leaderboard@1', { count: rows.length, rows: rows.slice(offset, offset + limit), next_cursor: offset + limit < rows.length ? String(offset + limit) : null }));
++ });
++ r.get('/ai/models/:id', async (req, res) => {
++ const result = bench && await bench.modelDetail(req.params.id);
++ if (!result) return notFound(req, res, 'report.ai_model_not_found', 'No public Bench model by that id.');
++ const rows = await aiRows(); const ranking = rows && rows.filter((x) => x.id === req.params.id);
++ return res.json(aiEnvelope('report.ai-model@1', { ...result.data, ratings: ranking || [], source: { name: 'OpenVibe.Bench', date: result.at, sample_size: result.data.runs ?? null } }));
++ });
++ r.get('/ai/providers', async (_req, res) => {
++ const data = aiCatalogue && await aiCatalogue.catalog();
++ if (!data) return problem(_req, res, 502, 'report.ai_source_unavailable', 'OpenVibe.AI did not answer with provider metrics.');
++ const providers = data.models.flatMap((model) => (model.offers || []).map((offer) => {
++ const evidence = { name: 'OpenVibe.AI', date: offer.observed_at || data.generated_at, sample_size: offer.requests_7d ?? null };
++ return { model: model.slug, ...offer, error_rate: offer.uptime == null ? { value: null, reason: 'Not enough provider attempts to calculate an error rate.' } : { value: Math.max(0, 1 - Number(offer.uptime)), source: evidence, derivation: '1 - uptime' },
++ tokens_per_second: { value: null, reason: 'OpenVibe.AI public catalogue does not report generated tokens per second.' },
++ ttft_ms: { value: null, reason: 'OpenVibe.AI public catalogue p50 latency may be total response latency; it does not expose TTFT separately.' }, source: evidence };
++ }));
++ return res.json(aiEnvelope('report.ai-providers@1', { generated_at: data.generated_at, providers }));
++ });
++ r.get('/ai/compare', async (req, res) => {
++ const ids = String(req.query.models || '').split(',').filter(Boolean).slice(0, 4);
++ const models = await Promise.all(ids.map(async (id) => { const value = bench && await bench.modelDetail(id); return value ? { ...value.data, source: { name: 'OpenVibe.Bench', date: value.at, sample_size: value.data.runs ?? null } } : { id, model: null, reason: 'No public Bench data for this model.' }; }));
++ return res.json(aiEnvelope('report.ai-compare@1', { models }));
++ });
++ r.get('/ai/picks', async (_req, res) => {
++ const rows = await aiRows(); if (!rows) return problem(_req, res, 502, 'report.ai_source_unavailable', 'OpenVibe.Bench did not answer.');
++ const best = rows.find((x) => x.rating.value != null) || null;
++ const cheapest = rows.filter((x) => x.provider_offers.some((o) => o.price)).sort((a, b) => Number(a.provider_offers[0].price.out) - Number(b.provider_offers[0].price.out))[0] || null;
++ const value = rows.filter((x) => x.rating.value != null && x.provider_offers.some((o) => o.price && Number(o.price.out) > 0)).sort((a, b) => Number(a.provider_offers.find((o) => o.price && Number(o.price.out) > 0).price.out) / a.rating.value - Number(b.provider_offers.find((o) => o.price && Number(o.price.out) > 0).price.out) / b.rating.value)[0] || null;
++ const fastest = rows.filter((x) => x.provider_offers.some((o) => Number.isFinite(Number(o.speed && o.speed.p50_ms)))).sort((a, b) => Math.min(...a.provider_offers.map((o) => Number(o.speed && o.speed.p50_ms)).filter(Number.isFinite)) - Math.min(...b.provider_offers.map((o) => Number(o.speed && o.speed.p50_ms)).filter(Number.isFinite)))[0] || null;
++ const picks = [{ id: 'overall', model: best, why: best ? 'Highest Bench rating.' : 'Not enough data.' }, { id: 'value', model: value, why: value ? 'Bench rating relative to listed provider output price.' : 'Not enough paired rating and price data.' }, { id: 'fastest', model: fastest, why: fastest ? 'Lowest measured p50 response time.' : 'Not enough measured speed data.' }];
++ for (const id of ['open', 'code', 'writing', 'uncensored', 'free']) {
++ const model = rows.find((x) => id === 'open' ? x.weights === 'open' : id === 'uncensored' ? x.uncensored : id === 'free' ? x.provider_offers.some((o) => o.free_tier) : x.kind === id) || null;
++ picks.push({ id, model, why: model ? 'Based on currently available public source data.' : `Not enough source data to establish a ${id} pick.` });
++ }
++ return res.json(aiEnvelope('report.ai-picks@1', { picks }));
++ });
r.get('/design-health/:site', (req, res) => {
const site = designHealth.site(req.params.site);
if (!site) return notFound(req, res, 'report.design_site_not_found', 'No site by that name in the design health snapshot.');
ddiff --git a/test/ai-api.test.js b/test/ai-api.test.js
nnew file mode 100644
iindex 0000000..0033d97
---- /dev/null
++++ b/test/ai-api.test.js
@@@ -0,0 +1,36 @@
++'use strict';
++const assert = require('assert');
++const { boot, check } = require('./helpers/boot');
++
++(async () => {
++ const bench = {
++ kinds: async () => ({ at: '2026-11-08T00:00:00.000Z', data: { kinds: [{ id: 'chat.arena' }] } }),
++ board: async () => ({ at: '2026-11-08T00:00:00.000Z', data: { board: { kind_id: 'chat.arena', rows: [{ subject: 'm/a', elo: 1200, ci_lower: 1180, ci_upper: 1220, games: 40 }, { subject: 'vendor/new', elo: null, games: 0 }] } } }),
++ modelDetail: async (id) => id === 'm/a' ? ({ at: '2026-11-08T00:00:00.000Z', data: { model: id, runs: 40 } }) : null,
++ };
++ const aiCatalogue = { catalog: async () => ({ generated_at: '2026-11-08T00:00:00.000Z', models: [{ slug: 'm/a', uncensored: true, offers: [{ provider: 'p', free_tier: true, price: { out: 0 }, requests_7d: 40, speed: { p50_ms: 90 }, uptime: 1 }] }] }) };
++ const t = await boot({ bench, aiCatalogue });
++ try {
++ await check('AI comparison endpoints use public source stand-ins and CORS without credentials', async () => {
++ for (const path of ['/api/v1/ai/leaderboard?kind=chat.arena', '/api/v1/ai/models/m%2Fa', '/api/v1/ai/providers', '/api/v1/ai/compare?models=m%2Fa', '/api/v1/ai/picks']) {
++ const response = await t.get(path);
++ assert.strictEqual(response.status, 200, path);
++ assert.match(response.headers.get('access-control-allow-origin') || '', /^\*$/);
++ assert.strictEqual(response.headers.get('access-control-allow-credentials'), null);
++ assert.match(response.headers.get('cache-control') || '', /max-age=120/);
++ assert.match(response.headers.get('content-type') || '', /^application\/json/);
++ }
++ const result = await t.get('/api/v1/ai/leaderboard?uncensored=true');
++ assert.strictEqual(result.json().rows.length, 1);
++ assert.deepStrictEqual(result.json().rows[0].rank, { min: 1, max: 1, position: 1, source: 'OpenVibe.Bench', date: '2026-11-08T00:00:00.000Z', sample_size: 40 });
++ const filtered = await t.get('/api/v1/ai/leaderboard?maker=m&price=free&kind=chat.arena');
++ assert.strictEqual(filtered.json().rows.length, 1);
++ const notEnough = await t.get('/api/v1/ai/leaderboard?maker=vendor');
++ assert.strictEqual(notEnough.json().rows[0].rating.value, null);
++ assert.match(notEnough.json().rows[0].rating.reason, /no rating/i);
++ const picks = await t.get('/api/v1/ai/picks');
++ assert.match(picks.json().picks.find((x) => x.id === 'open').why, /not enough source data/i);
++ assert.strictEqual((await t.get('/api/v1/ai/leaderboard?limit=1&cursor=1')).json().rows.length, 1);
++ });
++ } finally { await t.close(); }
++})().catch((error) => { console.error(error); process.exitCode = 1; });
ddiff --git a/test/ai-catalogue.test.js b/test/ai-catalogue.test.js
nnew file mode 100644
iindex 0000000..ccc0cc1
---- /dev/null
++++ b/test/ai-catalogue.test.js
@@@ -0,0 +1,18 @@
++'use strict';
++const assert = require('assert');
++const { createAiCatalogue } = require('../server/data/ai-catalogue');
++(async () => {
++ let now = 1000; let calls = 0;
++ const client = createAiCatalogue({ config: { aiUrl: 'https://ai.test/' }, now: () => now, fetchImpl: async (url) => {
++ calls += 1; assert.strictEqual(url, 'https://ai.test/api/v1/exchange/catalog');
++ return new Response(JSON.stringify({ models: [{ slug: 'vendor/model', offers: [] }] }), { status: 200, headers: { 'content-type': 'application/json' } });
++ } });
++ const first = await client.catalog();
++ await client.catalog();
++ assert.strictEqual(first.models[0].slug, 'vendor/model');
++ assert.strictEqual(calls, 1, 'second read uses the 2-minute cache');
++ now += 120_001;
++ await client.catalog();
++ assert.strictEqual(calls, 2, 'expired catalogue refreshes');
++ console.log('AI catalogue source tests passed');
++})().catch((error) => { console.error(error); process.exitCode = 1; });
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 9a2013d..3d80c0f 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -178,6 +178,8 @@ async function boot(opts = {}) {
sso: opts.sso,
shadow: opts.shadow,
...(opts.aiUsage ? { aiUsage: opts.aiUsage } : {}),
++ ...(opts.bench ? { bench: opts.bench } : {}),
++ ...(opts.aiCatalogue ? { aiCatalogue: opts.aiCatalogue } : {}),
...(opts.verifyServiceToken ? { verifyServiceToken: opts.verifyServiceToken } : {}),
...(opts.shareNetwork ? { shareNetwork: opts.shareNetwork } : {}),
releaseEnv: { ...process.env, RELEASE_COMMIT: '0000000' },