openvibers/openvibe.ai Commits
Expose the full model registry for public directory readers (#467)
Changes
@@@ -183,6 +183,9 @@ function exchangeRouter({ db, registry, pool, quotas = null, env = process.env,} catch (err) { next(err); }});++ r.get('/api/v1/exchange/directory', publicCache(60), wrap(async () =>++ require('../exchange/directory').directoryModels(registry)));++// Your own endpoint: whether it is on and, when it is off, the plain reason. Public: the Exchange page// reads it before anyone signs in, and nothing here is per person.// Public, non-personal, read-only previews. Cache each model/strategy for one minute to avoidnnew file mode 100644@@@ -0,0 +1,52 @@++'use strict';++++/** Public, explicit fields from the full registry. Disabled and retired rows remain discoverable. */++async function directoryModels(registry) {++ const presets = require('../providers/pool-providers').byKey;++ const publicSource = (provider, value) => {++ const preset = presets.get(provider.key);++ if (!preset) return null;++ try {++ const url = new URL(value);++ const origins = [preset.models_endpoint, preset.docs_url, ...(preset.models || []).map(m => m.source)]++ .filter(Boolean).map(source => { try { return new URL(source).origin; } catch { return null; } });++ return url.protocol === 'https:' && !url.username && !url.password && origins.includes(url.origin) ? url.href : null;++ } catch { return null; }++ };++ const providers = new Map((await registry.listProviders()).map(p => [p.key, p]));++ const models = [];++ for (const row of await registry.listModels()) {++ const provider = providers.get(row.provider_key);++ if (!provider || provider.kind === 'stub' || provider.metadata?.synthetic || provider.metadata?.hidden || row.metadata?.hidden) continue;++ const meta = row.metadata || {};++ const retired = Boolean(meta.missing_since);++ const preview = /(?:^|[-_])(?:preview|experimental|exp)(?:$|[-_])/i.test(row.model_key);++ const status = retired ? 'retired' : preview ? 'preview' : 'available';++ const price = row.cost || {};++ models.push({++ id: `${row.provider_key}/${row.model_key}`,++ provider: row.provider_key,++ provider_name: provider.display_name,++ model: row.model_key,++ name: row.display_name || row.model_key,++ family: typeof meta.family === 'string' ? meta.family : null,++ version: typeof meta.version === 'string' ? meta.version : null,++ status,++ routing_enabled: row.status === 'active' && provider.status === 'active' && !retired,++ context_window: row.context_window == null ? null : Number(row.context_window),++ max_output: row.max_output == null ? null : Number(row.max_output),++ type: row.type,++ modalities: [...new Set(['text', ...(row.supports?.vision || row.type === 'image' ? ['image'] : []), ...(['stt', 'speech'].includes(row.type) ? ['audio'] : [])])],++ supports: { tools: Boolean(row.supports?.tools), json: Boolean(row.supports?.json), vision: Boolean(row.supports?.vision) },++ price: { input_per_million: price.in_per_mtok ?? null, output_per_million: price.out_per_mtok ?? null,++ cached_per_million: price.cached_per_mtok ?? null, source: publicSource(provider, price.source), checked_at: price.verified_at || null },++ first_seen_at: row.created_at || null,++ updated_at: row.updated_at || null,++ retired_at: retired ? meta.missing_since : null,++ source: publicSource(provider, meta.listed ? provider.metadata?.models_endpoint : price.source),++ });++ }++ return { generated_at: new Date().toISOString(), count: models.length, models };++}++++module.exports = { directoryModels };@@@ -629,6 +629,14 @@ route('get', '/exchange/catalog', {models: arrayOf(exchangeModel),} }) },});++route('get', '/exchange/directory', {++ operationId: 'exchangeDirectory', summary: 'Listed, preview and retired registry models', tags: ['exchange'], security: [],++ description: 'Public model directory feed. Registry entries include provider listing status and dated price sources; disabled entries are not routable.',++ responses: { 200: json('Complete public model registry', { type: 'object', required: ['generated_at', 'count', 'models'], properties: {++ generated_at: str({ format: 'date-time' }), count: integer(),++ models: arrayOf({ type: 'object', additionalProperties: true }),++ } }) },++});route('get', '/exchange/willingness', {operationId: 'exchangeWillingness', summary: 'The measured 18+ willingness rates, per offer', tags: ['exchange'], security: [],description: 'Public, cached 60 seconds. The refusal and hedge rates of each offer on the published 60-prompt lawful adult set, with n and Wilson intervals. A rate nobody measured is absent, never zero. `plan=1` returns the dry run instead: the offers, prompts and estimated cost from the live rate cards; it never calls a provider and is not cached.',nnew file mode 100644@@@ -0,0 +1,35 @@++'use strict';++const assert = require('node:assert/strict');++const { directoryModels } = require('../server/exchange/directory');++const { boot, request, suite } = require('./helpers');++++const t = suite('exchange-directory');++t.test('public model directory includes listed, preview and retired rows without provider credentials', async () => {++ const provider = { key: 'gemini', display_name: 'Google', kind: 'openai', status: 'active', secret_ref: 'vault:gemini',++ metadata: { models_endpoint: 'https://example.com/models', private_key: 'must-not-appear' } };++ const rows = [++ { provider_key: 'gemini', model_key: 'gemini-live', status: 'disabled', display_name: 'Gemini Live', type: 'chat', context_window: 1000,++ cost: { in_per_mtok: 1, out_per_mtok: 2, source: 'https://generativelanguage.googleapis.com/v1beta/models', verified_at: '2026-11-11' },++ supports: { vision: true, tools: true }, metadata: { listed: true, version: '1' } },++ { provider_key: 'gemini', model_key: 'gemini-preview', status: 'disabled', type: 'chat', supports: {}, metadata: { listed: true } },++ { provider_key: 'gemini', model_key: 'gemini-old', status: 'disabled', type: 'chat', supports: {}, metadata: { listed: false, missing_since: '2026-11-10T00:00:00Z' } },++ ];++ const out = await directoryModels({ listProviders: async () => [provider], listModels: async () => rows });++ assert.deepEqual(out.models.map(m => m.status), ['available', 'preview', 'retired']);++ assert.equal(out.models[0].routing_enabled, false);++ assert.equal(out.models[0].price.source, 'https://generativelanguage.googleapis.com/v1beta/models');++ assert.equal(out.models[2].retired_at, '2026-11-10T00:00:00Z');++ assert.ok(!JSON.stringify(out).includes('must-not-appear'));++ assert.ok(!JSON.stringify(out).includes('secret_ref'));++});++t.test('the complete registry reader is public', async () => {++ const h = await boot({ env: { AI_USER_MODULES: 'off' } });++ try {++ const r = await request(h.base, 'GET', '/api/v1/exchange/directory');++ assert.equal(r.status, 200, r.text);++ assert.ok(Array.isArray(r.body.models));++ assert.ok(!r.text.includes('secret_ref'));++ } finally { await h.close(); }++});++++t.run();
@@ -183,6 +183,9 @@ function exchangeRouter({ db, registry, pool, quotas = null, env = process.env,
@@ -183,6 +183,9 @@ function exchangeRouter({ db, registry, pool, quotas = null, env = process.env,
} catch (err) { next(err); }
} catch (err) { next(err); }
});
});
+ r.get('/api/v1/exchange/directory', publicCache(60), wrap(async () =>
+ require('../exchange/directory').directoryModels(registry)));
+
// Your own endpoint: whether it is on and, when it is off, the plain reason. Public: the Exchange page
// Your own endpoint: whether it is on and, when it is off, the plain reason. Public: the Exchange page
// reads it before anyone signs in, and nothing here is per person.
// reads it before anyone signs in, and nothing here is per person.
// Public, non-personal, read-only previews. Cache each model/strategy for one minute to avoid
// Public, non-personal, read-only previews. Cache each model/strategy for one minute to avoid
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
+'use strict';
+
+/** Public, explicit fields from the full registry. Disabled and retired rows remain discoverable. */
+async function directoryModels(registry) {
+ const presets = require('../providers/pool-providers').byKey;
+ const publicSource = (provider, value) => {
+ const preset = presets.get(provider.key);
+ if (!preset) return null;
+ try {
+ const url = new URL(value);
+ const origins = [preset.models_endpoint, preset.docs_url, ...(preset.models || []).map(m => m.source)]
+ .filter(Boolean).map(source => { try { return new URL(source).origin; } catch { return null; } });
+ return url.protocol === 'https:' && !url.username && !url.password && origins.includes(url.origin) ? url.href : null;
+ } catch { return null; }
+ };
+ const providers = new Map((await registry.listProviders()).map(p => [p.key, p]));
+ const models = [];
+ for (const row of await registry.listModels()) {
+ const provider = providers.get(row.provider_key);
+ if (!provider || provider.kind === 'stub' || provider.metadata?.synthetic || provider.metadata?.hidden || row.metadata?.hidden) continue;
+ const meta = row.metadata || {};
+ const retired = Boolean(meta.missing_since);
+ const preview = /(?:^|[-_])(?:preview|experimental|exp)(?:$|[-_])/i.test(row.model_key);
+ const status = retired ? 'retired' : preview ? 'preview' : 'available';
+ const price = row.cost || {};
+ models.push({
+ id: `${row.provider_key}/${row.model_key}`,
+ provider: row.provider_key,
+ provider_name: provider.display_name,
+ model: row.model_key,
+ name: row.display_name || row.model_key,
+ family: typeof meta.family === 'string' ? meta.family : null,
+ version: typeof meta.version === 'string' ? meta.version : null,
+ status,
+ routing_enabled: row.status === 'active' && provider.status === 'active' && !retired,
+ context_window: row.context_window == null ? null : Number(row.context_window),
+ max_output: row.max_output == null ? null : Number(row.max_output),
+ type: row.type,
+ modalities: [...new Set(['text', ...(row.supports?.vision || row.type === 'image' ? ['image'] : []), ...(['stt', 'speech'].includes(row.type) ? ['audio'] : [])])],
+ supports: { tools: Boolean(row.supports?.tools), json: Boolean(row.supports?.json), vision: Boolean(row.supports?.vision) },
+ price: { input_per_million: price.in_per_mtok ?? null, output_per_million: price.out_per_mtok ?? null,
+ cached_per_million: price.cached_per_mtok ?? null, source: publicSource(provider, price.source), checked_at: price.verified_at || null },
+ first_seen_at: row.created_at || null,
+ updated_at: row.updated_at || null,
+ retired_at: retired ? meta.missing_since : null,
+ source: publicSource(provider, meta.listed ? provider.metadata?.models_endpoint : price.source),
+ });
+ }
+ return { generated_at: new Date().toISOString(), count: models.length, models };
+}
+
+module.exports = { directoryModels };
@@ -629,6 +629,14 @@ route('get', '/exchange/catalog', {
@@ -629,6 +629,14 @@ route('get', '/exchange/catalog', {
models: arrayOf(exchangeModel),
models: arrayOf(exchangeModel),
} }) },
} }) },
});
});
+route('get', '/exchange/directory', {
+ operationId: 'exchangeDirectory', summary: 'Listed, preview and retired registry models', tags: ['exchange'], security: [],
+ description: 'Public model directory feed. Registry entries include provider listing status and dated price sources; disabled entries are not routable.',
+ responses: { 200: json('Complete public model registry', { type: 'object', required: ['generated_at', 'count', 'models'], properties: {
+ generated_at: str({ format: 'date-time' }), count: integer(),
+ models: arrayOf({ type: 'object', additionalProperties: true }),
+ } }) },
+});
route('get', '/exchange/willingness', {
route('get', '/exchange/willingness', {
operationId: 'exchangeWillingness', summary: 'The measured 18+ willingness rates, per offer', tags: ['exchange'], security: [],
operationId: 'exchangeWillingness', summary: 'The measured 18+ willingness rates, per offer', tags: ['exchange'], security: [],
description: 'Public, cached 60 seconds. The refusal and hedge rates of each offer on the published 60-prompt lawful adult set, with n and Wilson intervals. A rate nobody measured is absent, never zero. `plan=1` returns the dry run instead: the offers, prompts and estimated cost from the live rate cards; it never calls a provider and is not cached.',
description: 'Public, cached 60 seconds. The refusal and hedge rates of each offer on the published 60-prompt lawful adult set, with n and Wilson intervals. A rate nobody measured is absent, never zero. `plan=1` returns the dry run instead: the offers, prompts and estimated cost from the live rate cards; it never calls a provider and is not cached.',
new file mode 100644
new file mode 100644
@@ -0,0 +1,35 @@
@@ -0,0 +1,35 @@
+'use strict';
+const assert = require('node:assert/strict');
+const { directoryModels } = require('../server/exchange/directory');
+const { boot, request, suite } = require('./helpers');
+
+const t = suite('exchange-directory');
+t.test('public model directory includes listed, preview and retired rows without provider credentials', async () => {
+ const provider = { key: 'gemini', display_name: 'Google', kind: 'openai', status: 'active', secret_ref: 'vault:gemini',
+ metadata: { models_endpoint: 'https://example.com/models', private_key: 'must-not-appear' } };
+ const rows = [
+ { provider_key: 'gemini', model_key: 'gemini-live', status: 'disabled', display_name: 'Gemini Live', type: 'chat', context_window: 1000,
+ cost: { in_per_mtok: 1, out_per_mtok: 2, source: 'https://generativelanguage.googleapis.com/v1beta/models', verified_at: '2026-11-11' },
+ supports: { vision: true, tools: true }, metadata: { listed: true, version: '1' } },
+ { provider_key: 'gemini', model_key: 'gemini-preview', status: 'disabled', type: 'chat', supports: {}, metadata: { listed: true } },
+ { provider_key: 'gemini', model_key: 'gemini-old', status: 'disabled', type: 'chat', supports: {}, metadata: { listed: false, missing_since: '2026-11-10T00:00:00Z' } },
+ ];
+ const out = await directoryModels({ listProviders: async () => [provider], listModels: async () => rows });
+ assert.deepEqual(out.models.map(m => m.status), ['available', 'preview', 'retired']);
+ assert.equal(out.models[0].routing_enabled, false);
+ assert.equal(out.models[0].price.source, 'https://generativelanguage.googleapis.com/v1beta/models');
+ assert.equal(out.models[2].retired_at, '2026-11-10T00:00:00Z');
+ assert.ok(!JSON.stringify(out).includes('must-not-appear'));
+ assert.ok(!JSON.stringify(out).includes('secret_ref'));
+});
+t.test('the complete registry reader is public', async () => {
+ const h = await boot({ env: { AI_USER_MODULES: 'off' } });
+ try {
+ const r = await request(h.base, 'GET', '/api/v1/exchange/directory');
+ assert.equal(r.status, 200, r.text);
+ assert.ok(Array.isArray(r.body.models));
+ assert.ok(!r.text.includes('secret_ref'));
+ } finally { await h.close(); }
+});
+
+t.run();