openvibers/openvibe.ai Commits
Read Google AI Studio models in owner settings and daily sync (#465)
Changes
@@@ -49,6 +49,7 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,const hints = vault && vault.hints ? await vault.hints() : new Map();const rows = (await Promise.all((await registry.listProviders()).map(ops.providerView))).map((p) => ({...p, key_present: p.credentials === 'configured', preference_order: p.priority,++ keys_url: require('../providers/pool-providers').byKey.get(p.key)?.keys_url || null,key_state: p.auth_mode === 'none' ? { source: 'not_required', hint: null, set_at: null }: { source: hints.has(p.key) ? 'stored' : p.credentials === 'configured' ? 'environment' : 'missing',hint: hints.get(p.key)?.hint || (providerSecret(p, env) ? `…${providerSecret(p, env).slice(-4)}` : null),@@@ -168,15 +169,18 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,await registry.audit(who.actor, 'provider.key_removed', 'provider', provider.key, { trace: who.trace });return { ok: true, provider: provider.key };}));-- r.post('/api/v1/admin/console/providers/:key/check', write, wrap(async (req) => {++ r.post('/api/v1/admin/console/providers/:key/check', json, write, wrap(async (req) => {const provider = await registry.getProvider(req.params.key);if (!provider) throw new AiError(404, 'ai.not_found', 'no such provider');++ const keyOverride = (req.body || {}).api_key;++ if (keyOverride != null && (typeof keyOverride !== 'string' || keyOverride.length < 8 || keyOverride.length > 400))++ throw new AiError(422, 'ai.invalid', 'the provider key must be 8-400 characters');const { checkProvider } = require('../providers/check');-- const result = await checkProvider(provider, { env, now: clock.now,++ const result = await checkProvider(provider, { env, now: clock.now, keyOverride,fetchImpl: fetchImpl === globalThis.fetch ? require('../providers/guarded-fetch').guardedFetch : fetchImpl });// A check does not enable a provider or overwrite settings changed while its request was in flight.const current = await registry.getProvider(provider.key);-- if (current.updated_at === provider.updated_at) await registry.upsertProvider({ key: provider.key, metadata: { ...current.metadata, last_check: result } }, { actor: actor(req), origin: current.origin });++ if (!keyOverride && current.updated_at === provider.updated_at) await registry.upsertProvider({ key: provider.key, metadata: { ...current.metadata, last_check: result } }, { actor: actor(req), origin: current.origin });return { provider: provider.key, ...result };}));r.post('/api/v1/admin/console/providers/:key/sync', write, wrap(async (req) => {@@@ -111,13 +111,13 @@ async function testProviderKey(provider, key, { fetchImpl }) {if (provider.kind === 'anthropic') {headers = /anthropic\.com/i.test(base) ? { 'x-api-key': key, 'anthropic-version': '2023-06-01' } : { 'x-api-key': key, Authorization: `Bearer ${key}`, 'anthropic-version': '2023-06-01' };if (!/\/v1$/.test(base) && /anthropic\.com/i.test(base)) url = `${base}/v1/models`;-- } else if (provider.kind === 'gemini') {-- url = `${base}/models?pageSize=1`;++ } else if (provider.key === 'gemini' || provider.kind === 'gemini' || provider.metadata?.api_family === 'google') {++ url = 'https://generativelanguage.googleapis.com/v1beta/models';headers = { 'x-goog-api-key': key };}if (provider.metadata && provider.metadata.models_endpoint) url = provider.metadata.models_endpoint;if (provider.auth_mode === 'none') headers = {};-- else if (provider.kind !== 'anthropic' && provider.kind !== 'gemini' && provider.auth_mode === 'x-api-key') headers = { 'x-api-key': key };++ else if (provider.kind !== 'anthropic' && provider.key !== 'gemini' && provider.kind !== 'gemini' && provider.metadata?.api_family !== 'google' && provider.auth_mode === 'x-api-key') headers = { 'x-api-key': key };// A configured models endpoint receives a key only on the origin the owner entered it for.try { if (new URL(url).origin !== new URL(base).origin) return { ok: false, message: `${name} has a model-list address on a different origin.` }; }catch { return { ok: false, message: `${name} has an invalid model-list address.` }; }@@@ -125,7 +125,7 @@ async function testProviderKey(provider, key, { fetchImpl }) {if (r.status >= 200 && r.status < 300) {const list = r.json && (Array.isArray(r.json.data) ? r.json.data : Array.isArray(r.json.models) ? r.json.models : null);if (!list) return { ok: false, message: `${name} answered without a model list.` };-- return { ok: true, message: `${name} accepted the key${list ? ` and lists ${list.length} model${list.length === 1 ? '' : 's'}` : ''}.` };++ return { ok: true, message: `${name} accepted the key and lists ${list.length} model${list.length === 1 ? '' : 's'}.`, models: list.map(m => m.id || m.name).filter(x => typeof x === 'string') };}return { ok: false, message: refused(name, r) };}@@@ -95,6 +95,22 @@ async function listRemote(provider, apiKey, { fetchImpl = globalThis.fetch } = {if (!base) throw new ProviderError('the provider has no base URL', { code: 'provider.config' });const endpoint = provider.metadata?.models_endpoint || `${base}/models`;if (new URL(endpoint).origin !== new URL(base).origin) throw new ProviderError('model list must use the provider origin', { code: 'provider.config' });++ if (provider.key === 'gemini' || provider.metadata?.api_family === 'google') {++ const out = [];++ const tokens = new Set();++ const url = new URL(endpoint);++ for (let page = 0; page < 100; page++) {++ const j = await getJson(url.href, { 'x-goog-api-key': apiKey }, { fetchImpl });++ if (!Array.isArray(j.models) || j.models.some(m => !m || typeof m.name !== 'string' || !m.name.startsWith('models/')))++ throw new ProviderError('invalid model list', { code: 'provider.catalog_invalid' });++ out.push(...j.models.map(remoteModel));++ if (!j.nextPageToken) return out;++ if (typeof j.nextPageToken !== 'string' || tokens.has(j.nextPageToken) || page === 99)++ throw new ProviderError('incomplete model list', { code: 'provider.catalog_invalid' });++ tokens.add(j.nextPageToken);++ url.searchParams.set('pageToken', j.nextPageToken);++ }++ }if (provider.kind === 'anthropic') {// Anthropic's Models API pages by after_id; its Anthropic-format look-alikes (DeepSeek, MiniMax) have no list.if (!/anthropic\.com/i.test(base) && !provider.metadata?.models_endpoint) return [];@@@ -143,10 +159,14 @@ function remoteModel(m) {const params = Array.isArray(m.supported_parameters) ? m.supported_parameters : null;const type = m.type === 'language' ? 'chat' : m.type;return {-- id, name: m.name && m.name !== m.id ? String(m.name).replace(/^models\//, '') : (m.display_name || id),++ id, name: m.displayName || m.display_name || (m.name && m.name !== m.id ? String(m.name).replace(/^models\//, '') : id),++ ...(typeof m.version === 'string' ? { version: m.version } : {}),++ ...(Array.isArray(m.supportedGenerationMethods) ? { generationMethods: m.supportedGenerationMethods.filter(x => typeof x === 'string') } : {}),hints: {contextWindow: m.context_length || m.context_window || m.inputTokenLimit || (m.top_provider && m.top_provider.context_length),maxOutput: m.outputTokenLimit || (m.top_provider && m.top_provider.max_completion_tokens),++ ...(Array.isArray(m.supportedGenerationMethods) ? { type: m.supportedGenerationMethods.includes('embedContent') && !m.supportedGenerationMethods.includes('generateContent') ? 'embedding' : 'chat' } : {}),++ ...(m.thinking != null ? { reasoning: Boolean(m.thinking) } : {}),vision: Array.isArray(arch.input_modalities) ? arch.input_modalities.includes('image') : null,tools: params ? params.includes('tools') : null,reasoning: params ? params.includes('reasoning') || params.includes('include_reasoning') : null,@@@ -178,6 +198,8 @@ async function sync({ registry, provider, apiKey, fetchImpl = globalThis.fetch,const uncensored = require('./pool-providers').isUncensored(provider, { model_key: m.id, metadata: prev?.metadata || {} });const contributor = provider.key === 'meta' && /-contributor$/i.test(m.id);const metadata = { tier: d.tier, reasoning: d.reasoning, note: d.note, ...prev?.metadata, synced_at: at, listed: true,++ ...(m.version ? { version: m.version } : {}),++ ...(m.generationMethods ? { generation_methods: m.generationMethods } : {}),...(contributor ? { routing_excluded: true, routing_excluded_reason: 'Meta Contributor mode permits training on prompts and is not allowed in OpenVibe routing.' } : {}),...(uncensored ? { uncensored: true, min_age: 18 } : {}),...(extra.source ? { catalogue_enrichment: { source: extra.source, checked_at: extra.checked_at } } : {}) };@@@ -5,15 +5,17 @@ const { testProviderKey } = require('../owner-inputs');const { guardedFetch } = require('./guarded-fetch');/** One explicit owner check, with no inference, retries, redirects or fallback. Never retain upstream text. */--async function checkProvider(provider, { env = process.env, fetchImpl = guardedFetch, now = Date.now } = {}) {++async function checkProvider(provider, { env = process.env, fetchImpl = guardedFetch, now = Date.now, keyOverride = null } = {}) {const started = now();let calls = 0;let httpStatus = null;++ let models = [];const finish = (ok, error = null, method = null) => ({ok, error, method, http_status: httpStatus, requests: calls,++ models_count: models.length, models: models.filter(id => !String(id).includes(key)),latency_ms: Math.max(0, now() - started), checked_at: new Date(started).toISOString(),});-- const key = providerSecret(provider, env);++ const key = keyOverride || providerSecret(provider, env);if (provider.auth_mode !== 'none' && !key) return finish(false, 'key_not_set');if (!provider.base_url) return finish(false, 'base_url_not_set');let origin;@@@ -58,7 +60,25 @@ async function checkProvider(provider, { env = process.env, fetchImpl = guardedF});return finish(true, null, method);}++ if (provider.key === 'gemini' || provider.metadata?.api_family === 'google') {++ const { listRemote } = require('./catalog');++ // Google pages its native list; every page uses the bound origin and key header.++ const remote = await listRemote(provider, key, { fetchImpl: async (url, init) => {++ const target = new URL(String(url));++ if (target.origin !== origin || target.protocol !== 'https:' || target.username || target.password || calls >= 100) {++ failure = 'endpoint_origin_mismatch'; throw new Error(failure);++ }++ calls++;++ const response = await fetchImpl(url, { ...init, redirect: 'error', signal: AbortSignal.timeout(10000) });++ httpStatus = response.status;++ if (!response.ok) failure = `http_${response.status}`;++ return response;++ } });++ models = remote.map(m => m.id);++ return finish(true, null, method);++ }const result = await testProviderKey(provider, key, { fetchImpl: once });++ models = result.models || [];return finish(result.ok, result.ok ? null : httpStatus >= 200 && httpStatus < 300 ? 'invalid_response' : failure, method);} catch {return finish(false, failure, method);@@@ -742,7 +742,8 @@ const PROVIDERS = [docs_url: 'https://platform.claude.com/docs/en/api/overview', capabilities: ['chat', 'messages', 'vision', 'tools'] },{ key: 'openai', keys_url: 'https://platform.openai.com/api-keys', display_name: 'OpenAI', base_url: 'https://api.openai.com/v1', default_model: 'gpt-5-mini',docs_url: 'https://platform.openai.com/docs/api-reference/models/list', capabilities: ['chat', 'vision', 'tools', 'image', 'video', 'speech', 'transcribe', 'embed'] },-- { key: 'gemini', models_dev_id: 'google', keys_url: 'https://aistudio.google.com/apikey', display_name: 'Google Gemini', base_url: 'https://generativelanguage.googleapis.com/v1beta/openai',++ { key: 'gemini', models_dev_id: 'google', keys_url: 'https://aistudio.google.com/apikey', display_name: 'Google AI Studio (Gemini API)', base_url: 'https://generativelanguage.googleapis.com/v1beta/openai',++ models_endpoint: 'https://generativelanguage.googleapis.com/v1beta/models',api_family: 'google', docs_url: 'https://ai.google.dev/gemini-api/docs/openai', capabilities: ['chat', 'vision', 'tools', 'embed'] },{ key: 'xai', keys_url: 'https://console.x.ai/', display_name: 'xAI', base_url: 'https://api.x.ai/v1',docs_url: 'https://docs.x.ai/docs/api-reference', capabilities: ['chat', 'vision', 'tools', 'image', 'video'] },@@@ -29,6 +29,7 @@ function recordOf(p) {metadata: {catalog: 'pool-providers',api_family: p.api_family, models_endpoint: p.models_endpoint,++ model_sync_interval_minutes: p.models_endpoint ? 1440 : 0,check_endpoint: p.check_endpoint || null,models_public: p.models_public === true, models_dev_id: p.models_dev_id || null,auth_header: p.auth_header || null, docs_url: p.docs_url || null,@@@ -111,6 +112,8 @@ async function seedPoolProviders({ registry, actor = 'system', env = process.envdefaults.metadata.check_endpoint = null;}const metadata = { ...defaults.metadata, ...prev.metadata, search_adapters: [...new Set([...(prev.metadata.search_adapters || []), ...(defaults.metadata.search_adapters || [])])] };++ if (p.key === 'gemini' && prev.base_url === p.base_url && prev.metadata.models_endpoint === `${p.base_url}/models`)++ metadata.models_endpoint = defaults.metadata.models_endpoint;if (!configured && prev.base_url !== p.base_url) {metadata.models_endpoint = defaults.metadata.models_endpoint;metadata.api_family = defaults.metadata.api_family;@@@ -132,7 +135,9 @@ async function seedPoolProviders({ registry, actor = 'system', env = process.envprotocol.models_endpoint = prev.base_url ? `${prev.base_url.replace(/\/+$/, '')}/models` : null;protocol.check_endpoint = null;}-- const additions = Object.fromEntries(Object.entries(protocol).filter(([k]) => ['api_family', 'models_endpoint', 'check_endpoint', 'models_public', 'models_dev_id', 'auth_header', 'docs_url', 'search_adapters'].includes(k) && prev.metadata[k] === undefined));++ const additions = Object.fromEntries(Object.entries(protocol).filter(([k]) => ['api_family', 'models_endpoint', 'model_sync_interval_minutes', 'check_endpoint', 'models_public', 'models_dev_id', 'auth_header', 'docs_url', 'search_adapters'].includes(k) && prev.metadata[k] === undefined));++ if (p.key === 'gemini' && prev.base_url === p.base_url && prev.metadata.models_endpoint === `${p.base_url}/models`)++ additions.models_endpoint = protocol.models_endpoint;const engines = [...new Set([...(prev.metadata.search_adapters || []), ...(protocol.search_adapters || [])])];if (engines.length !== (prev.metadata.search_adapters || []).length) additions.search_adapters = engines;if (Object.keys(additions).length) await registry.upsertProvider({ key: p.key, metadata: { ...prev.metadata, ...additions } }, { actor, origin: prev.origin });@@@ -32,6 +32,30 @@ t.test('a model-list check is one free read at the registered endpoint with lateassert.strictEqual(result.latency_ms, 25);});++t.test('AI Studio check and sync read every native Gemini page with the Google header', async () => {++ const { listRemote } = require('../server/providers/catalog');++ const gemini = { key: 'gemini', kind: 'openai', auth_mode: 'bearer', base_url: 'https://generativelanguage.googleapis.com/v1beta/openai',++ metadata: { api_family: 'google', models_endpoint: 'https://generativelanguage.googleapis.com/v1beta/models' }, secret_ref: 'env:CHECK_KEY' };++ const seen = [];++ const fetchImpl = async (url, init) => {++ seen.push(String(url));++ assert.strictEqual(init.headers['x-goog-api-key'], key);++ assert.strictEqual(init.headers.Authorization, undefined);++ return response(200, seen.length % 2 ? { models: [{ name: 'models/gemini-a', displayName: 'Gemini A', version: 'a-1', inputTokenLimit: 1000, outputTokenLimit: 100, supportedGenerationMethods: ['generateContent'] }], nextPageToken: 'next' }++ : { models: [{ name: 'models/gemini-b', displayName: 'Gemini B', supportedGenerationMethods: ['generateContent'] }] });++ };++ const checked = await checkProvider(gemini, { env, fetchImpl });++ assert.strictEqual(checked.ok, true);++ assert.strictEqual(checked.models_count, 2);++ assert.deepStrictEqual(checked.models, ['gemini-a', 'gemini-b']);++ assert.strictEqual(checked.requests, 2);++ seen.length = 0;++ const remote = await listRemote(gemini, key, { fetchImpl });++ assert.strictEqual(remote[0].name, 'Gemini A');++ assert.strictEqual(remote[0].hints.contextWindow, 1000);++ assert.deepStrictEqual(remote.map(m => m.id), checked.models);++});++t.test('upstream refusals and thrown errors cannot echo the key; no retries', async () => {for (const failure of [401, 429, 500, 'throw']) {let calls = 0;@@@ -125,6 +149,16 @@ t.test('the check route makes one request and records only its safe result', asyassert.ok(!JSON.stringify(stored.metadata).includes(replacement));});++t.test('a pasted key can be checked before save without changing the stored credential', async () => {++ const result = await api('POST', `${path}/check`, { api_key: key });++ assert.strictEqual(result.status, 200, result.text);++ assert.strictEqual(result.body.ok, true);++ assert.strictEqual(authUsed, `Bearer ${key}`);++ assert.strictEqual(providerSecret(await h.registry.getProvider('keycheck'), h.env), replacement);++ assert.ok(!result.text.includes(key));++ assert.ok(!JSON.stringify((await h.registry.getProvider('keycheck')).metadata).includes(key));++});++t.test('a service without owner capabilities gets 403 for every key operation', async () => {const before = requests;for (const [method, suffix] of [['POST', 'key'], ['DELETE', 'key'], ['POST', 'check']]) {
@@ -49,6 +49,7 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
@@ -49,6 +49,7 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
const hints = vault && vault.hints ? await vault.hints() : new Map();
const hints = vault && vault.hints ? await vault.hints() : new Map();
const rows = (await Promise.all((await registry.listProviders()).map(ops.providerView))).map((p) => ({
const rows = (await Promise.all((await registry.listProviders()).map(ops.providerView))).map((p) => ({
...p, key_present: p.credentials === 'configured', preference_order: p.priority,
...p, key_present: p.credentials === 'configured', preference_order: p.priority,
+ keys_url: require('../providers/pool-providers').byKey.get(p.key)?.keys_url || null,
key_state: p.auth_mode === 'none' ? { source: 'not_required', hint: null, set_at: null }
key_state: p.auth_mode === 'none' ? { source: 'not_required', hint: null, set_at: null }
: { source: hints.has(p.key) ? 'stored' : p.credentials === 'configured' ? 'environment' : 'missing',
: { source: hints.has(p.key) ? 'stored' : p.credentials === 'configured' ? 'environment' : 'missing',
hint: hints.get(p.key)?.hint || (providerSecret(p, env) ? `…${providerSecret(p, env).slice(-4)}` : null),
hint: hints.get(p.key)?.hint || (providerSecret(p, env) ? `…${providerSecret(p, env).slice(-4)}` : null),
@@ -168,15 +169,18 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
@@ -168,15 +169,18 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
await registry.audit(who.actor, 'provider.key_removed', 'provider', provider.key, { trace: who.trace });
await registry.audit(who.actor, 'provider.key_removed', 'provider', provider.key, { trace: who.trace });
return { ok: true, provider: provider.key };
return { ok: true, provider: provider.key };
}));
}));
- r.post('/api/v1/admin/console/providers/:key/check', write, wrap(async (req) => {
+ r.post('/api/v1/admin/console/providers/:key/check', json, write, wrap(async (req) => {
const provider = await registry.getProvider(req.params.key);
const provider = await registry.getProvider(req.params.key);
if (!provider) throw new AiError(404, 'ai.not_found', 'no such provider');
if (!provider) throw new AiError(404, 'ai.not_found', 'no such provider');
+ const keyOverride = (req.body || {}).api_key;
+ if (keyOverride != null && (typeof keyOverride !== 'string' || keyOverride.length < 8 || keyOverride.length > 400))
+ throw new AiError(422, 'ai.invalid', 'the provider key must be 8-400 characters');
const { checkProvider } = require('../providers/check');
const { checkProvider } = require('../providers/check');
- const result = await checkProvider(provider, { env, now: clock.now,
+ const result = await checkProvider(provider, { env, now: clock.now, keyOverride,
fetchImpl: fetchImpl === globalThis.fetch ? require('../providers/guarded-fetch').guardedFetch : fetchImpl });
fetchImpl: fetchImpl === globalThis.fetch ? require('../providers/guarded-fetch').guardedFetch : fetchImpl });
// A check does not enable a provider or overwrite settings changed while its request was in flight.
// A check does not enable a provider or overwrite settings changed while its request was in flight.
const current = await registry.getProvider(provider.key);
const current = await registry.getProvider(provider.key);
- if (current.updated_at === provider.updated_at) await registry.upsertProvider({ key: provider.key, metadata: { ...current.metadata, last_check: result } }, { actor: actor(req), origin: current.origin });
+ if (!keyOverride && current.updated_at === provider.updated_at) await registry.upsertProvider({ key: provider.key, metadata: { ...current.metadata, last_check: result } }, { actor: actor(req), origin: current.origin });
return { provider: provider.key, ...result };
return { provider: provider.key, ...result };
}));
}));
r.post('/api/v1/admin/console/providers/:key/sync', write, wrap(async (req) => {
r.post('/api/v1/admin/console/providers/:key/sync', write, wrap(async (req) => {
@@ -111,13 +111,13 @@ async function testProviderKey(provider, key, { fetchImpl }) {
@@ -111,13 +111,13 @@ async function testProviderKey(provider, key, { fetchImpl }) {
if (provider.kind === 'anthropic') {
if (provider.kind === 'anthropic') {
headers = /anthropic\.com/i.test(base) ? { 'x-api-key': key, 'anthropic-version': '2023-06-01' } : { 'x-api-key': key, Authorization: `Bearer ${key}`, 'anthropic-version': '2023-06-01' };
headers = /anthropic\.com/i.test(base) ? { 'x-api-key': key, 'anthropic-version': '2023-06-01' } : { 'x-api-key': key, Authorization: `Bearer ${key}`, 'anthropic-version': '2023-06-01' };
if (!/\/v1$/.test(base) && /anthropic\.com/i.test(base)) url = `${base}/v1/models`;
if (!/\/v1$/.test(base) && /anthropic\.com/i.test(base)) url = `${base}/v1/models`;
- } else if (provider.kind === 'gemini') {
- url = `${base}/models?pageSize=1`;
+ } else if (provider.key === 'gemini' || provider.kind === 'gemini' || provider.metadata?.api_family === 'google') {
+ url = 'https://generativelanguage.googleapis.com/v1beta/models';
headers = { 'x-goog-api-key': key };
headers = { 'x-goog-api-key': key };
}
}
if (provider.metadata && provider.metadata.models_endpoint) url = provider.metadata.models_endpoint;
if (provider.metadata && provider.metadata.models_endpoint) url = provider.metadata.models_endpoint;
if (provider.auth_mode === 'none') headers = {};
if (provider.auth_mode === 'none') headers = {};
- else if (provider.kind !== 'anthropic' && provider.kind !== 'gemini' && provider.auth_mode === 'x-api-key') headers = { 'x-api-key': key };
+ else if (provider.kind !== 'anthropic' && provider.key !== 'gemini' && provider.kind !== 'gemini' && provider.metadata?.api_family !== 'google' && provider.auth_mode === 'x-api-key') headers = { 'x-api-key': key };
// A configured models endpoint receives a key only on the origin the owner entered it for.
// A configured models endpoint receives a key only on the origin the owner entered it for.
try { if (new URL(url).origin !== new URL(base).origin) return { ok: false, message: `${name} has a model-list address on a different origin.` }; }
try { if (new URL(url).origin !== new URL(base).origin) return { ok: false, message: `${name} has a model-list address on a different origin.` }; }
catch { return { ok: false, message: `${name} has an invalid model-list address.` }; }
catch { return { ok: false, message: `${name} has an invalid model-list address.` }; }
@@ -125,7 +125,7 @@ async function testProviderKey(provider, key, { fetchImpl }) {
@@ -125,7 +125,7 @@ async function testProviderKey(provider, key, { fetchImpl }) {
if (r.status >= 200 && r.status < 300) {
if (r.status >= 200 && r.status < 300) {
const list = r.json && (Array.isArray(r.json.data) ? r.json.data : Array.isArray(r.json.models) ? r.json.models : null);
const list = r.json && (Array.isArray(r.json.data) ? r.json.data : Array.isArray(r.json.models) ? r.json.models : null);
if (!list) return { ok: false, message: `${name} answered without a model list.` };
if (!list) return { ok: false, message: `${name} answered without a model list.` };
- return { ok: true, message: `${name} accepted the key${list ? ` and lists ${list.length} model${list.length === 1 ? '' : 's'}` : ''}.` };
+ return { ok: true, message: `${name} accepted the key and lists ${list.length} model${list.length === 1 ? '' : 's'}.`, models: list.map(m => m.id || m.name).filter(x => typeof x === 'string') };
}
}
return { ok: false, message: refused(name, r) };
return { ok: false, message: refused(name, r) };
}
}
@@ -95,6 +95,22 @@ async function listRemote(provider, apiKey, { fetchImpl = globalThis.fetch } = {
@@ -95,6 +95,22 @@ async function listRemote(provider, apiKey, { fetchImpl = globalThis.fetch } = {
if (!base) throw new ProviderError('the provider has no base URL', { code: 'provider.config' });
if (!base) throw new ProviderError('the provider has no base URL', { code: 'provider.config' });
const endpoint = provider.metadata?.models_endpoint || `${base}/models`;
const endpoint = provider.metadata?.models_endpoint || `${base}/models`;
if (new URL(endpoint).origin !== new URL(base).origin) throw new ProviderError('model list must use the provider origin', { code: 'provider.config' });
if (new URL(endpoint).origin !== new URL(base).origin) throw new ProviderError('model list must use the provider origin', { code: 'provider.config' });
+ if (provider.key === 'gemini' || provider.metadata?.api_family === 'google') {
+ const out = [];
+ const tokens = new Set();
+ const url = new URL(endpoint);
+ for (let page = 0; page < 100; page++) {
+ const j = await getJson(url.href, { 'x-goog-api-key': apiKey }, { fetchImpl });
+ if (!Array.isArray(j.models) || j.models.some(m => !m || typeof m.name !== 'string' || !m.name.startsWith('models/')))
+ throw new ProviderError('invalid model list', { code: 'provider.catalog_invalid' });
+ out.push(...j.models.map(remoteModel));
+ if (!j.nextPageToken) return out;
+ if (typeof j.nextPageToken !== 'string' || tokens.has(j.nextPageToken) || page === 99)
+ throw new ProviderError('incomplete model list', { code: 'provider.catalog_invalid' });
+ tokens.add(j.nextPageToken);
+ url.searchParams.set('pageToken', j.nextPageToken);
+ }
+ }
if (provider.kind === 'anthropic') {
if (provider.kind === 'anthropic') {
// Anthropic's Models API pages by after_id; its Anthropic-format look-alikes (DeepSeek, MiniMax) have no list.
// Anthropic's Models API pages by after_id; its Anthropic-format look-alikes (DeepSeek, MiniMax) have no list.
if (!/anthropic\.com/i.test(base) && !provider.metadata?.models_endpoint) return [];
if (!/anthropic\.com/i.test(base) && !provider.metadata?.models_endpoint) return [];
@@ -143,10 +159,14 @@ function remoteModel(m) {
@@ -143,10 +159,14 @@ function remoteModel(m) {
const params = Array.isArray(m.supported_parameters) ? m.supported_parameters : null;
const params = Array.isArray(m.supported_parameters) ? m.supported_parameters : null;
const type = m.type === 'language' ? 'chat' : m.type;
const type = m.type === 'language' ? 'chat' : m.type;
return {
return {
- id, name: m.name && m.name !== m.id ? String(m.name).replace(/^models\//, '') : (m.display_name || id),
+ id, name: m.displayName || m.display_name || (m.name && m.name !== m.id ? String(m.name).replace(/^models\//, '') : id),
+ ...(typeof m.version === 'string' ? { version: m.version } : {}),
+ ...(Array.isArray(m.supportedGenerationMethods) ? { generationMethods: m.supportedGenerationMethods.filter(x => typeof x === 'string') } : {}),
hints: {
hints: {
contextWindow: m.context_length || m.context_window || m.inputTokenLimit || (m.top_provider && m.top_provider.context_length),
contextWindow: m.context_length || m.context_window || m.inputTokenLimit || (m.top_provider && m.top_provider.context_length),
maxOutput: m.outputTokenLimit || (m.top_provider && m.top_provider.max_completion_tokens),
maxOutput: m.outputTokenLimit || (m.top_provider && m.top_provider.max_completion_tokens),
+ ...(Array.isArray(m.supportedGenerationMethods) ? { type: m.supportedGenerationMethods.includes('embedContent') && !m.supportedGenerationMethods.includes('generateContent') ? 'embedding' : 'chat' } : {}),
+ ...(m.thinking != null ? { reasoning: Boolean(m.thinking) } : {}),
vision: Array.isArray(arch.input_modalities) ? arch.input_modalities.includes('image') : null,
vision: Array.isArray(arch.input_modalities) ? arch.input_modalities.includes('image') : null,
tools: params ? params.includes('tools') : null,
tools: params ? params.includes('tools') : null,
reasoning: params ? params.includes('reasoning') || params.includes('include_reasoning') : null,
reasoning: params ? params.includes('reasoning') || params.includes('include_reasoning') : null,
@@ -178,6 +198,8 @@ async function sync({ registry, provider, apiKey, fetchImpl = globalThis.fetch,
@@ -178,6 +198,8 @@ async function sync({ registry, provider, apiKey, fetchImpl = globalThis.fetch,
const uncensored = require('./pool-providers').isUncensored(provider, { model_key: m.id, metadata: prev?.metadata || {} });
const uncensored = require('./pool-providers').isUncensored(provider, { model_key: m.id, metadata: prev?.metadata || {} });
const contributor = provider.key === 'meta' && /-contributor$/i.test(m.id);
const contributor = provider.key === 'meta' && /-contributor$/i.test(m.id);
const metadata = { tier: d.tier, reasoning: d.reasoning, note: d.note, ...prev?.metadata, synced_at: at, listed: true,
const metadata = { tier: d.tier, reasoning: d.reasoning, note: d.note, ...prev?.metadata, synced_at: at, listed: true,
+ ...(m.version ? { version: m.version } : {}),
+ ...(m.generationMethods ? { generation_methods: m.generationMethods } : {}),
...(contributor ? { routing_excluded: true, routing_excluded_reason: 'Meta Contributor mode permits training on prompts and is not allowed in OpenVibe routing.' } : {}),
...(contributor ? { routing_excluded: true, routing_excluded_reason: 'Meta Contributor mode permits training on prompts and is not allowed in OpenVibe routing.' } : {}),
...(uncensored ? { uncensored: true, min_age: 18 } : {}),
...(uncensored ? { uncensored: true, min_age: 18 } : {}),
...(extra.source ? { catalogue_enrichment: { source: extra.source, checked_at: extra.checked_at } } : {}) };
...(extra.source ? { catalogue_enrichment: { source: extra.source, checked_at: extra.checked_at } } : {}) };
@@ -5,15 +5,17 @@ const { testProviderKey } = require('../owner-inputs');
@@ -5,15 +5,17 @@ const { testProviderKey } = require('../owner-inputs');
const { guardedFetch } = require('./guarded-fetch');
const { guardedFetch } = require('./guarded-fetch');
/** One explicit owner check, with no inference, retries, redirects or fallback. Never retain upstream text. */
/** One explicit owner check, with no inference, retries, redirects or fallback. Never retain upstream text. */
-async function checkProvider(provider, { env = process.env, fetchImpl = guardedFetch, now = Date.now } = {}) {
+async function checkProvider(provider, { env = process.env, fetchImpl = guardedFetch, now = Date.now, keyOverride = null } = {}) {
const started = now();
const started = now();
let calls = 0;
let calls = 0;
let httpStatus = null;
let httpStatus = null;
+ let models = [];
const finish = (ok, error = null, method = null) => ({
const finish = (ok, error = null, method = null) => ({
ok, error, method, http_status: httpStatus, requests: calls,
ok, error, method, http_status: httpStatus, requests: calls,
+ models_count: models.length, models: models.filter(id => !String(id).includes(key)),
latency_ms: Math.max(0, now() - started), checked_at: new Date(started).toISOString(),
latency_ms: Math.max(0, now() - started), checked_at: new Date(started).toISOString(),
});
});
- const key = providerSecret(provider, env);
+ const key = keyOverride || providerSecret(provider, env);
if (provider.auth_mode !== 'none' && !key) return finish(false, 'key_not_set');
if (provider.auth_mode !== 'none' && !key) return finish(false, 'key_not_set');
if (!provider.base_url) return finish(false, 'base_url_not_set');
if (!provider.base_url) return finish(false, 'base_url_not_set');
let origin;
let origin;
@@ -58,7 +60,25 @@ async function checkProvider(provider, { env = process.env, fetchImpl = guardedF
@@ -58,7 +60,25 @@ async function checkProvider(provider, { env = process.env, fetchImpl = guardedF
});
});
return finish(true, null, method);
return finish(true, null, method);
}
}
+ if (provider.key === 'gemini' || provider.metadata?.api_family === 'google') {
+ const { listRemote } = require('./catalog');
+ // Google pages its native list; every page uses the bound origin and key header.
+ const remote = await listRemote(provider, key, { fetchImpl: async (url, init) => {
+ const target = new URL(String(url));
+ if (target.origin !== origin || target.protocol !== 'https:' || target.username || target.password || calls >= 100) {
+ failure = 'endpoint_origin_mismatch'; throw new Error(failure);
+ }
+ calls++;
+ const response = await fetchImpl(url, { ...init, redirect: 'error', signal: AbortSignal.timeout(10000) });
+ httpStatus = response.status;
+ if (!response.ok) failure = `http_${response.status}`;
+ return response;
+ } });
+ models = remote.map(m => m.id);
+ return finish(true, null, method);
+ }
const result = await testProviderKey(provider, key, { fetchImpl: once });
const result = await testProviderKey(provider, key, { fetchImpl: once });
+ models = result.models || [];
return finish(result.ok, result.ok ? null : httpStatus >= 200 && httpStatus < 300 ? 'invalid_response' : failure, method);
return finish(result.ok, result.ok ? null : httpStatus >= 200 && httpStatus < 300 ? 'invalid_response' : failure, method);
} catch {
} catch {
return finish(false, failure, method);
return finish(false, failure, method);
@@ -742,7 +742,8 @@ const PROVIDERS = [
@@ -742,7 +742,8 @@ const PROVIDERS = [
docs_url: 'https://platform.claude.com/docs/en/api/overview', capabilities: ['chat', 'messages', 'vision', 'tools'] },
docs_url: 'https://platform.claude.com/docs/en/api/overview', capabilities: ['chat', 'messages', 'vision', 'tools'] },
{ key: 'openai', keys_url: 'https://platform.openai.com/api-keys', display_name: 'OpenAI', base_url: 'https://api.openai.com/v1', default_model: 'gpt-5-mini',
{ key: 'openai', keys_url: 'https://platform.openai.com/api-keys', display_name: 'OpenAI', base_url: 'https://api.openai.com/v1', default_model: 'gpt-5-mini',
docs_url: 'https://platform.openai.com/docs/api-reference/models/list', capabilities: ['chat', 'vision', 'tools', 'image', 'video', 'speech', 'transcribe', 'embed'] },
docs_url: 'https://platform.openai.com/docs/api-reference/models/list', capabilities: ['chat', 'vision', 'tools', 'image', 'video', 'speech', 'transcribe', 'embed'] },
- { key: 'gemini', models_dev_id: 'google', keys_url: 'https://aistudio.google.com/apikey', display_name: 'Google Gemini', base_url: 'https://generativelanguage.googleapis.com/v1beta/openai',
+ { key: 'gemini', models_dev_id: 'google', keys_url: 'https://aistudio.google.com/apikey', display_name: 'Google AI Studio (Gemini API)', base_url: 'https://generativelanguage.googleapis.com/v1beta/openai',
+ models_endpoint: 'https://generativelanguage.googleapis.com/v1beta/models',
api_family: 'google', docs_url: 'https://ai.google.dev/gemini-api/docs/openai', capabilities: ['chat', 'vision', 'tools', 'embed'] },
api_family: 'google', docs_url: 'https://ai.google.dev/gemini-api/docs/openai', capabilities: ['chat', 'vision', 'tools', 'embed'] },
{ key: 'xai', keys_url: 'https://console.x.ai/', display_name: 'xAI', base_url: 'https://api.x.ai/v1',
{ key: 'xai', keys_url: 'https://console.x.ai/', display_name: 'xAI', base_url: 'https://api.x.ai/v1',
docs_url: 'https://docs.x.ai/docs/api-reference', capabilities: ['chat', 'vision', 'tools', 'image', 'video'] },
docs_url: 'https://docs.x.ai/docs/api-reference', capabilities: ['chat', 'vision', 'tools', 'image', 'video'] },
@@ -29,6 +29,7 @@ function recordOf(p) {
@@ -29,6 +29,7 @@ function recordOf(p) {
metadata: {
metadata: {
catalog: 'pool-providers',
catalog: 'pool-providers',
api_family: p.api_family, models_endpoint: p.models_endpoint,
api_family: p.api_family, models_endpoint: p.models_endpoint,
+ model_sync_interval_minutes: p.models_endpoint ? 1440 : 0,
check_endpoint: p.check_endpoint || null,
check_endpoint: p.check_endpoint || null,
models_public: p.models_public === true, models_dev_id: p.models_dev_id || null,
models_public: p.models_public === true, models_dev_id: p.models_dev_id || null,
auth_header: p.auth_header || null, docs_url: p.docs_url || null,
auth_header: p.auth_header || null, docs_url: p.docs_url || null,
@@ -111,6 +112,8 @@ async function seedPoolProviders({ registry, actor = 'system', env = process.env
@@ -111,6 +112,8 @@ async function seedPoolProviders({ registry, actor = 'system', env = process.env
defaults.metadata.check_endpoint = null;
defaults.metadata.check_endpoint = null;
}
}
const metadata = { ...defaults.metadata, ...prev.metadata, search_adapters: [...new Set([...(prev.metadata.search_adapters || []), ...(defaults.metadata.search_adapters || [])])] };
const metadata = { ...defaults.metadata, ...prev.metadata, search_adapters: [...new Set([...(prev.metadata.search_adapters || []), ...(defaults.metadata.search_adapters || [])])] };
+ if (p.key === 'gemini' && prev.base_url === p.base_url && prev.metadata.models_endpoint === `${p.base_url}/models`)
+ metadata.models_endpoint = defaults.metadata.models_endpoint;
if (!configured && prev.base_url !== p.base_url) {
if (!configured && prev.base_url !== p.base_url) {
metadata.models_endpoint = defaults.metadata.models_endpoint;
metadata.models_endpoint = defaults.metadata.models_endpoint;
metadata.api_family = defaults.metadata.api_family;
metadata.api_family = defaults.metadata.api_family;
@@ -132,7 +135,9 @@ async function seedPoolProviders({ registry, actor = 'system', env = process.env
@@ -132,7 +135,9 @@ async function seedPoolProviders({ registry, actor = 'system', env = process.env
protocol.models_endpoint = prev.base_url ? `${prev.base_url.replace(/\/+$/, '')}/models` : null;
protocol.models_endpoint = prev.base_url ? `${prev.base_url.replace(/\/+$/, '')}/models` : null;
protocol.check_endpoint = null;
protocol.check_endpoint = null;
}
}
- const additions = Object.fromEntries(Object.entries(protocol).filter(([k]) => ['api_family', 'models_endpoint', 'check_endpoint', 'models_public', 'models_dev_id', 'auth_header', 'docs_url', 'search_adapters'].includes(k) && prev.metadata[k] === undefined));
+ const additions = Object.fromEntries(Object.entries(protocol).filter(([k]) => ['api_family', 'models_endpoint', 'model_sync_interval_minutes', 'check_endpoint', 'models_public', 'models_dev_id', 'auth_header', 'docs_url', 'search_adapters'].includes(k) && prev.metadata[k] === undefined));
+ if (p.key === 'gemini' && prev.base_url === p.base_url && prev.metadata.models_endpoint === `${p.base_url}/models`)
+ additions.models_endpoint = protocol.models_endpoint;
const engines = [...new Set([...(prev.metadata.search_adapters || []), ...(protocol.search_adapters || [])])];
const engines = [...new Set([...(prev.metadata.search_adapters || []), ...(protocol.search_adapters || [])])];
if (engines.length !== (prev.metadata.search_adapters || []).length) additions.search_adapters = engines;
if (engines.length !== (prev.metadata.search_adapters || []).length) additions.search_adapters = engines;
if (Object.keys(additions).length) await registry.upsertProvider({ key: p.key, metadata: { ...prev.metadata, ...additions } }, { actor, origin: prev.origin });
if (Object.keys(additions).length) await registry.upsertProvider({ key: p.key, metadata: { ...prev.metadata, ...additions } }, { actor, origin: prev.origin });
@@ -32,6 +32,30 @@ t.test('a model-list check is one free read at the registered endpoint with late
@@ -32,6 +32,30 @@ t.test('a model-list check is one free read at the registered endpoint with late
assert.strictEqual(result.latency_ms, 25);
assert.strictEqual(result.latency_ms, 25);
});
});
+t.test('AI Studio check and sync read every native Gemini page with the Google header', async () => {
+ const { listRemote } = require('../server/providers/catalog');
+ const gemini = { key: 'gemini', kind: 'openai', auth_mode: 'bearer', base_url: 'https://generativelanguage.googleapis.com/v1beta/openai',
+ metadata: { api_family: 'google', models_endpoint: 'https://generativelanguage.googleapis.com/v1beta/models' }, secret_ref: 'env:CHECK_KEY' };
+ const seen = [];
+ const fetchImpl = async (url, init) => {
+ seen.push(String(url));
+ assert.strictEqual(init.headers['x-goog-api-key'], key);
+ assert.strictEqual(init.headers.Authorization, undefined);
+ return response(200, seen.length % 2 ? { models: [{ name: 'models/gemini-a', displayName: 'Gemini A', version: 'a-1', inputTokenLimit: 1000, outputTokenLimit: 100, supportedGenerationMethods: ['generateContent'] }], nextPageToken: 'next' }
+ : { models: [{ name: 'models/gemini-b', displayName: 'Gemini B', supportedGenerationMethods: ['generateContent'] }] });
+ };
+ const checked = await checkProvider(gemini, { env, fetchImpl });
+ assert.strictEqual(checked.ok, true);
+ assert.strictEqual(checked.models_count, 2);
+ assert.deepStrictEqual(checked.models, ['gemini-a', 'gemini-b']);
+ assert.strictEqual(checked.requests, 2);
+ seen.length = 0;
+ const remote = await listRemote(gemini, key, { fetchImpl });
+ assert.strictEqual(remote[0].name, 'Gemini A');
+ assert.strictEqual(remote[0].hints.contextWindow, 1000);
+ assert.deepStrictEqual(remote.map(m => m.id), checked.models);
+});
+
t.test('upstream refusals and thrown errors cannot echo the key; no retries', async () => {
t.test('upstream refusals and thrown errors cannot echo the key; no retries', async () => {
for (const failure of [401, 429, 500, 'throw']) {
for (const failure of [401, 429, 500, 'throw']) {
let calls = 0;
let calls = 0;
@@ -125,6 +149,16 @@ t.test('the check route makes one request and records only its safe result', asy
@@ -125,6 +149,16 @@ t.test('the check route makes one request and records only its safe result', asy
assert.ok(!JSON.stringify(stored.metadata).includes(replacement));
assert.ok(!JSON.stringify(stored.metadata).includes(replacement));
});
});
+t.test('a pasted key can be checked before save without changing the stored credential', async () => {
+ const result = await api('POST', `${path}/check`, { api_key: key });
+ assert.strictEqual(result.status, 200, result.text);
+ assert.strictEqual(result.body.ok, true);
+ assert.strictEqual(authUsed, `Bearer ${key}`);
+ assert.strictEqual(providerSecret(await h.registry.getProvider('keycheck'), h.env), replacement);
+ assert.ok(!result.text.includes(key));
+ assert.ok(!JSON.stringify((await h.registry.getProvider('keycheck')).metadata).includes(key));
+});
+
t.test('a service without owner capabilities gets 403 for every key operation', async () => {
t.test('a service without owner capabilities gets 403 for every key operation', async () => {
const before = requests;
const before = requests;
for (const [method, suffix] of [['POST', 'key'], ['DELETE', 'key'], ['POST', 'check']]) {
for (const [method, suffix] of [['POST', 'key'], ['DELETE', 'key'], ['POST', 'check']]) {