Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
251 lines
11 KiB
JavaScript
251 lines
11 KiB
JavaScript
/**
|
|
* Providers & Models Controller
|
|
* GET/PUT /providers, GET /providers/:providerId/models
|
|
* POST /providers/:providerId/refresh-models, GET /models
|
|
*/
|
|
|
|
import { Router } from 'express';
|
|
import { dbGet, dbRun, dbAll, isPostgres } from '../../../database/connection.js';
|
|
import { apiLogger } from '../../../utils/logger.js';
|
|
import { success, notFound, badRequest, error } from '../../../utils/response.js';
|
|
import { safeParseJSON } from './shared.js';
|
|
|
|
const router = Router();
|
|
|
|
/**
|
|
* POST /api/v3/ai/providers/:providerId/refresh-models
|
|
*/
|
|
router.post('/providers/:providerId/refresh-models', async (req, res) => {
|
|
try {
|
|
const { providerId } = req.params;
|
|
const userId = req.user?.id;
|
|
|
|
const providerRow = await dbGet(`
|
|
SELECT tr.id, tr.data, tr.table_id FROM table_rows tr
|
|
JOIN universal_tables ut ON tr.table_id = ut.id
|
|
WHERE tr.id = ? AND ut.name LIKE '%Providers%'
|
|
`, [providerId]);
|
|
|
|
if (!providerRow) return notFound(res, 'Provider not found');
|
|
|
|
const provider = safeParseJSON(providerRow.data, {});
|
|
|
|
let apiKey = null;
|
|
if (provider.api_key_id) {
|
|
const keyRow = await dbGet(`SELECT data FROM table_rows WHERE id = ?`, [provider.api_key_id]);
|
|
if (keyRow) { apiKey = safeParseJSON(keyRow.data, {}).api_key; }
|
|
}
|
|
if (!apiKey) return badRequest(res, 'No API key configured for this provider');
|
|
|
|
const modelsTable = await dbGet(`
|
|
SELECT ut.id FROM universal_tables ut
|
|
JOIN projects p ON ut.project_id = p.id
|
|
WHERE ut.name LIKE '%Models%' AND p.space_id = 10 LIMIT 1
|
|
`);
|
|
if (!modelsTable) return notFound(res, 'Models table not found');
|
|
|
|
let models = [];
|
|
|
|
if (provider.api_id === 'openai') {
|
|
const response = await fetch(`${provider.base_url}/models`, { headers: { 'Authorization': `Bearer ${apiKey}` } });
|
|
if (!response.ok) {
|
|
const err = await response.text();
|
|
return error(res, 'OPENAI_API_ERROR', `OpenAI API error: ${err}`, 500);
|
|
}
|
|
const data = await response.json();
|
|
models = data.data
|
|
.filter(m => m.id.includes('gpt') || m.id.includes('o1') || m.id.includes('chatgpt'))
|
|
.map(m => ({
|
|
model_id: m.id, name: m.id.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase()),
|
|
provider_id: providerId, context_window: m.id.includes('gpt-4') ? 128000 : 16385,
|
|
is_active: true, deprecated: false
|
|
}));
|
|
} else if (provider.api_id === 'anthropic') {
|
|
models = [
|
|
{ model_id: 'claude-3-5-sonnet-20241022', name: 'Claude 3.5 Sonnet', provider_id: providerId, context_window: 200000, is_active: true },
|
|
{ model_id: 'claude-3-opus-20240229', name: 'Claude 3 Opus', provider_id: providerId, context_window: 200000, is_active: true },
|
|
{ model_id: 'claude-3-sonnet-20240229', name: 'Claude 3 Sonnet', provider_id: providerId, context_window: 200000, is_active: true },
|
|
{ model_id: 'claude-3-haiku-20240307', name: 'Claude 3 Haiku', provider_id: providerId, context_window: 200000, is_active: true }
|
|
];
|
|
} else if (provider.api_id === 'ollama') {
|
|
const response = await fetch(`${provider.base_url}/api/tags`);
|
|
if (!response.ok) return error(res, 'OLLAMA_ERROR', 'Failed to connect to Ollama', 500);
|
|
const data = await response.json();
|
|
models = (data.models || []).map(m => ({
|
|
model_id: m.name, name: m.name, provider_id: providerId, context_window: 8192, is_active: true
|
|
}));
|
|
}
|
|
|
|
await dbRun(`DELETE FROM table_rows WHERE table_id = ? AND json_extract(data, '$.provider_id') = ?`, [modelsTable.id, providerId]);
|
|
|
|
let addedCount = 0;
|
|
for (const model of models) {
|
|
const baseId = `model-${provider.api_id}-${model.model_id.replace(/[^a-z0-9]/gi, '-')}`;
|
|
await dbRun(`INSERT INTO table_rows (table_id, base_id, data, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))`,
|
|
[modelsTable.id, baseId, JSON.stringify(model), userId]);
|
|
addedCount++;
|
|
}
|
|
|
|
await dbRun(`UPDATE table_rows SET data = json_set(json_set(data, '$.models_count', ?), '$.last_refresh', ?) WHERE id = ?`,
|
|
[addedCount, new Date().toISOString(), providerId]);
|
|
|
|
return success(res, { data: { providerId, providerName: provider.name, modelsFound: models.length, modelsAdded: addedCount } });
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'Error refreshing models');
|
|
return error(res, 'REFRESH_MODELS_ERROR', 'Failed to refresh models: ' + err.message, 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/v3/ai/providers
|
|
*/
|
|
router.get('/providers', async (req, res) => {
|
|
try {
|
|
const spaceId = req.query.spaceId || req.headers['x-space-id'];
|
|
apiLogger.debug({ context: 'AI Providers', spaceId }, 'Request - spaceId');
|
|
|
|
let table;
|
|
if (spaceId) {
|
|
table = await dbGet(`
|
|
SELECT ut.id FROM universal_tables ut
|
|
JOIN projects p ON ut.project_id = p.id
|
|
WHERE (ut.name = 'AI Operators' OR ut.name = 'ai_operators') AND p.space_id = ?
|
|
LIMIT 1
|
|
`, [spaceId]);
|
|
}
|
|
|
|
if (!table) {
|
|
table = await dbGet(`SELECT ut.id FROM universal_tables ut WHERE ut.name = 'AI Operators' OR ut.name = 'ai_operators' ORDER BY id ASC LIMIT 1`);
|
|
}
|
|
|
|
if (!table) return success(res, { providers: [] });
|
|
|
|
const rows = await dbAll(`SELECT id, data FROM table_rows WHERE table_id = ?`, [table.id]);
|
|
const providers = rows.map(r => ({ id: r.id, ...safeParseJSON(r.data, {}) })).filter(p => p.id);
|
|
|
|
return success(res, { providers });
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'Error fetching providers');
|
|
return error(res, 'FETCH_PROVIDERS_ERROR', 'Failed to fetch providers', 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* PUT /api/v3/ai/providers/:providerId
|
|
*/
|
|
router.put('/providers/:providerId', async (req, res) => {
|
|
try {
|
|
const { providerId } = req.params;
|
|
const { api_key } = req.body;
|
|
if (!api_key) return badRequest(res, 'api_key is required');
|
|
|
|
const row = await dbGet(`SELECT id, data FROM table_rows WHERE id = ?`, [providerId]);
|
|
if (!row) return notFound(res, 'Provider not found');
|
|
|
|
const data = safeParseJSON(row.data, {});
|
|
data.api_key = api_key;
|
|
if (!data.status) data.status = 'active';
|
|
|
|
await dbRun(`UPDATE table_rows SET data = ?, updated_at = datetime('now') WHERE id = ?`, [JSON.stringify(data), providerId]);
|
|
|
|
return success(res, null, 'Provider updated');
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'Error updating provider');
|
|
return error(res, 'UPDATE_PROVIDER_ERROR', 'Failed to update provider', 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/v3/ai/providers/:providerId/models
|
|
*/
|
|
router.get('/providers/:providerId/models', async (req, res) => {
|
|
try {
|
|
const { providerId } = req.params;
|
|
const spaceId = req.query.spaceId || req.headers['x-space-id'];
|
|
|
|
let table;
|
|
if (spaceId) {
|
|
table = await dbGet(`SELECT ut.id FROM universal_tables ut JOIN projects p ON ut.project_id = p.id WHERE (ut.name = 'AI Models' OR ut.name = 'ai_models') AND p.space_id = ? LIMIT 1`, [spaceId]);
|
|
}
|
|
if (!table) {
|
|
table = await dbGet(`SELECT ut.id FROM universal_tables ut WHERE ut.name = 'AI Models' OR ut.name = 'ai_models' LIMIT 1`);
|
|
}
|
|
if (!table) return success(res, { models: [] });
|
|
|
|
const providerIds = [String(providerId)];
|
|
const operatorRow = await dbGet('SELECT data FROM table_rows WHERE id = ?', [providerId]);
|
|
if (operatorRow) {
|
|
const opData = safeParseJSON(operatorRow.data, {});
|
|
if (opData.provider === 'claude-code' || opData.provider === 'anthropic') {
|
|
const anthropicOp = await dbGet(isPostgres()
|
|
? `SELECT tr.id FROM table_rows tr JOIN universal_tables ut ON tr.table_id = ut.id JOIN projects p ON ut.project_id = p.id
|
|
WHERE (ut.name LIKE '%Operators%') AND tr.data->>'provider' = 'anthropic' AND tr.data->>'name' = 'Anthropic'
|
|
AND p.space_id = (SELECT p2.space_id FROM universal_tables ut2 JOIN projects p2 ON ut2.project_id = p2.id WHERE ut2.id = $1) LIMIT 1`
|
|
: `SELECT tr.id FROM table_rows tr JOIN universal_tables ut ON tr.table_id = ut.id JOIN projects p ON ut.project_id = p.id
|
|
WHERE (ut.name LIKE '%Operators%') AND json_extract(tr.data, '$.provider') = 'anthropic' AND json_extract(tr.data, '$.name') = 'Anthropic'
|
|
AND p.space_id = (SELECT p2.space_id FROM universal_tables ut2 JOIN projects p2 ON ut2.project_id = p2.id WHERE ut2.id = ?) LIMIT 1`,
|
|
[table.id]);
|
|
if (anthropicOp && String(anthropicOp.id) !== String(providerId)) {
|
|
providerIds.push(String(anthropicOp.id));
|
|
}
|
|
}
|
|
}
|
|
|
|
const rows = await dbAll(isPostgres()
|
|
? `SELECT id, data FROM table_rows WHERE table_id = $1 AND (data->>'operator_id' = ANY($2) OR data->>'provider_id' = ANY($2)) ORDER BY data->>'name'`
|
|
: `SELECT id, data FROM table_rows WHERE table_id = ?
|
|
AND (${providerIds.map(() => "json_extract(data, '$.operator_id') = ?").join(' OR ')}
|
|
OR ${providerIds.map(() => "json_extract(data, '$.provider_id') = ?").join(' OR ')})
|
|
ORDER BY json_extract(data, '$.name')`,
|
|
isPostgres() ? [table.id, providerIds] : [table.id, ...providerIds, ...providerIds]
|
|
);
|
|
|
|
const models = rows.map(r => {
|
|
const data = safeParseJSON(r.data, {});
|
|
return { id: r.id, name: data.name || data.model_id, model_id: data.model_id, operator_id: data.operator_id, provider_id: data.provider_id || data.operator_id };
|
|
});
|
|
|
|
return success(res, { models });
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'Error fetching provider models');
|
|
return error(res, 'FETCH_MODELS_ERROR', 'Failed to fetch models', 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/v3/ai/models
|
|
*/
|
|
router.get('/models', async (req, res) => {
|
|
try {
|
|
const { providerId } = req.query;
|
|
const spaceId = req.query.spaceId || req.headers['x-space-id'];
|
|
|
|
let table;
|
|
if (spaceId) {
|
|
table = await dbGet(`SELECT ut.id FROM universal_tables ut JOIN projects p ON ut.project_id = p.id WHERE (ut.name = 'AI Models' OR ut.name = 'ai_models') AND p.space_id = ? LIMIT 1`, [spaceId]);
|
|
}
|
|
if (!table) {
|
|
table = await dbGet(`SELECT ut.id FROM universal_tables ut WHERE ut.name = 'AI Models' OR ut.name = 'ai_models' LIMIT 1`);
|
|
}
|
|
if (!table) return success(res, { models: [] });
|
|
|
|
let query = `SELECT id, data FROM table_rows WHERE table_id = ?`;
|
|
const params = [table.id];
|
|
|
|
if (providerId) {
|
|
query += ` AND (json_extract(data, '$.provider_id') = ? OR json_extract(data, '$.operator_id') = ?)`;
|
|
params.push(providerId, providerId);
|
|
}
|
|
query += ` ORDER BY json_extract(data, '$.name')`;
|
|
|
|
const rows = await dbAll(query, params);
|
|
const models = rows.map(r => ({ id: r.id, ...safeParseJSON(r.data, {}) })).filter(m => m.id);
|
|
|
|
return success(res, { models });
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'Error fetching models');
|
|
return error(res, 'FETCH_MODELS_ERROR', 'Failed to fetch models', 500);
|
|
}
|
|
});
|
|
|
|
export default router;
|