openvibers/openvibe.ai Commits

Expose the full model registry for public directory readers (#467)

OP OpenVibers committed 2 days ago · e29ef7626e · 4 files, +98 −0

* Expose complete public model registry for directory readers

* Merge Gemini model list support from main

* Document public model directory and run its tests

Changes

ddiff --git a/server/api/exchange.js b/server/api/exchange.js
iindex 54c9a24..6b9723b 100644
---- a/server/api/exchange.js
++++ b/server/api/exchange.js
@@@ -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 avoid
ddiff --git a/server/exchange/directory.js b/server/exchange/directory.js
nnew file mode 100644
iindex 0000000..d036386
---- /dev/null
++++ b/server/exchange/directory.js
@@@ -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 };
ddiff --git a/server/openapi.js b/server/openapi.js
iindex 1d98d05..da3ced8 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -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.',
ddiff --git a/test/exchange-directory.test.js b/test/exchange-directory.test.js
nnew file mode 100644
iindex 0000000..0a83e1a
---- /dev/null
++++ b/test/exchange-directory.test.js
@@@ -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();