godcrm/backend/routes/v3/frame/aiProviders.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

294 lines
9.2 KiB
JavaScript

/**
* frame/aiProviders.js — AI provider resolution and API calls for Frame
*/
import { dbGet, isPostgres, safeJsonParse } from '../../../database/connection.js';
import { apiLogger } from '../../../utils/logger.js';
import { getSecret } from '../../../services/secrets/getSecret.js';
import {
resolveAgentProvider as sharedResolveAgentProvider,
detectProvider as sharedDetectProvider,
} from '../../../services/chat/agent-execution-shared.js';
/**
* Build messages and call the AI provider (Anthropic Claude with Vision or OpenAI).
*/
export async function callFrameAI({ userText, imageBuffer, chatHistory = [], location, time }) {
// Build the system prompt for Frame assistant
const systemParts = [
'You are Noa, a helpful AI assistant integrated into Brilliant Frame smart glasses.',
'You see what the user sees through their glasses camera and hear what they say.',
'Keep your responses concise and natural — they will be displayed on a small heads-up display and read aloud.',
'Respond in the same language the user speaks to you.',
];
if (location) {
systemParts.push(`\n[CONTEXT] User location: ${location}`);
}
if (time) {
systemParts.push(`[CONTEXT] Current time: ${time}`);
}
const systemPrompt = systemParts.join('\n');
// ── Resolve AI provider ──────────────────────────────────────
let apiKey = null;
let model = 'claude-sonnet-4-20250514';
let provider = 'anthropic';
let apiUrl = 'https://api.anthropic.com/v1';
// Priority 1: Look for a Frame/Noa-specific agent in the DB
const frameAgentRow = await dbGet(
isPostgres()
? `SELECT tr.data FROM table_rows tr
JOIN universal_tables ut ON tr.table_id = ut.id
WHERE ut.name LIKE '%Agents%'
AND (tr.data::jsonb->>'name' ILIKE '%noa%' OR tr.data::jsonb->>'name' ILIKE '%frame%')
AND tr.data::jsonb->>'status' = 'active'
LIMIT 1`
: `SELECT tr.data FROM table_rows tr
JOIN universal_tables ut ON tr.table_id = ut.id
WHERE ut.name LIKE '%Agents%'
AND (LOWER(json_extract(tr.data, '$.name')) LIKE '%noa%' OR LOWER(json_extract(tr.data, '$.name')) LIKE '%frame%')
AND json_extract(tr.data, '$.status') = 'active'
LIMIT 1`
);
if (frameAgentRow) {
const agentConfig = safeJsonParse(frameAgentRow.data, {});
const resolved = await sharedResolveAgentProvider(agentConfig);
if (resolved.apiKey) {
apiKey = resolved.apiKey;
model = resolved.model;
provider = resolved.provider;
const { isAnthropic } = sharedDetectProvider(provider, model);
if (!isAnthropic) {
apiUrl = 'https://api.openai.com/v1';
}
}
}
// Priority 2: Use vault keys (ADR-0040 — was process.env.{ANTHROPIC,OPENAI}_API_KEY)
if (!apiKey) {
apiKey = await getSecret('anthropic_api_key', 'ANTHROPIC_API_KEY');
}
if (!apiKey) {
const openaiKey = await getSecret('openai_api_key', 'OPENAI_API_KEY');
if (openaiKey) {
apiKey = openaiKey;
provider = 'openai';
model = 'gpt-4o';
apiUrl = 'https://api.openai.com/v1';
}
}
// Priority 3: Find any active Anthropic or OpenAI operator in the DB
if (!apiKey) {
const operatorRow = await dbGet(
isPostgres()
? `SELECT tr.data FROM table_rows tr
JOIN universal_tables ut ON tr.table_id = ut.id
WHERE ut.name LIKE '%Operators%'
AND tr.data::jsonb->>'status' = 'active'
AND (tr.data::jsonb->>'provider' IN ('anthropic', 'openai'))
ORDER BY
CASE WHEN tr.data::jsonb->>'provider' = 'anthropic' THEN 0 ELSE 1 END,
tr.created_at ASC
LIMIT 1`
: `SELECT tr.data FROM table_rows tr
JOIN universal_tables ut ON tr.table_id = ut.id
WHERE ut.name LIKE '%Operators%'
AND json_extract(tr.data, '$.status') = 'active'
AND json_extract(tr.data, '$.provider') IN ('anthropic', 'openai')
ORDER BY
CASE WHEN json_extract(tr.data, '$.provider') = 'anthropic' THEN 0 ELSE 1 END,
tr.created_at ASC
LIMIT 1`
);
if (operatorRow) {
const opData = safeJsonParse(operatorRow.data, {});
apiKey = opData.api_key || null;
provider = opData.provider || 'openai';
model = opData.model || (provider === 'anthropic' ? 'claude-sonnet-4-20250514' : 'gpt-4o');
apiUrl = opData.api_url || (provider === 'anthropic' ? 'https://api.anthropic.com/v1' : 'https://api.openai.com/v1');
}
}
if (!apiKey) {
throw new Error('No AI API key configured. Set ANTHROPIC_API_KEY or OPENAI_API_KEY, or add an operator in AI settings.');
}
const { isAnthropic } = sharedDetectProvider(provider, model);
if (isAnthropic) {
return callAnthropicVision({ systemPrompt, userText, imageBuffer, chatHistory, apiKey, model, apiUrl });
} else {
return callOpenAIVision({ systemPrompt, userText, imageBuffer, chatHistory, apiKey, model, apiUrl });
}
}
/**
* Call Anthropic Claude API with optional Vision content.
*/
async function callAnthropicVision({ systemPrompt, userText, imageBuffer, chatHistory, apiKey, model, apiUrl }) {
const userContent = [];
if (imageBuffer) {
userContent.push({
type: 'image',
source: {
type: 'base64',
media_type: 'image/jpeg',
data: imageBuffer.toString('base64')
}
});
}
userContent.push({
type: 'text',
text: userText || '(no speech detected — describe what you see in the image)'
});
const historyMessages = (chatHistory || [])
.filter(m => m.role === 'user' || m.role === 'assistant')
.map(m => ({
role: m.role,
content: m.content
}));
const messages = [
...historyMessages,
{ role: 'user', content: userContent }
];
const sanitized = sanitizeAnthropicMessages(messages);
const requestBody = {
model,
max_tokens: 1024,
system: systemPrompt,
messages: sanitized
};
const messagesUrl = `${apiUrl.replace(/\/$/, '')}/messages`;
const response = await fetch(messagesUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01'
},
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const errText = await response.text();
apiLogger.error({ status: response.status, error: errText, context: 'Frame Claude' }, 'Anthropic API error');
throw new Error(`Claude API error (${response.status}): ${errText.substring(0, 300)}`);
}
const data = await response.json();
const textBlocks = Array.isArray(data.content)
? data.content.filter(b => b?.type === 'text').map(b => b.text)
: [];
const responseText = textBlocks.join('\n') || '';
return {
message: responseText,
topicChanged: false
};
}
/**
* Call OpenAI API with optional Vision content (GPT-4o style).
*/
async function callOpenAIVision({ systemPrompt, userText, imageBuffer, chatHistory, apiKey, model, apiUrl }) {
const userContent = [];
if (imageBuffer) {
userContent.push({
type: 'image_url',
image_url: {
url: `data:image/jpeg;base64,${imageBuffer.toString('base64')}`,
detail: 'low'
}
});
}
userContent.push({
type: 'text',
text: userText || '(no speech detected — describe what you see in the image)'
});
const messages = [
{ role: 'system', content: systemPrompt },
...(chatHistory || []).filter(m => m.role === 'user' || m.role === 'assistant').map(m => ({
role: m.role,
content: m.content
})),
{ role: 'user', content: userContent }
];
const completionsUrl = `${apiUrl.replace(/\/$/, '')}/chat/completions`;
const response = await fetch(completionsUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify({
model,
messages,
max_tokens: 1024
})
});
if (!response.ok) {
const errText = await response.text();
apiLogger.error({ status: response.status, error: errText, context: 'Frame OpenAI' }, 'OpenAI API error');
throw new Error(`OpenAI API error (${response.status}): ${errText.substring(0, 300)}`);
}
const data = await response.json();
const responseText = data.choices?.[0]?.message?.content || '';
return {
message: responseText,
topicChanged: false
};
}
/**
* Sanitize messages for Anthropic API: ensure strict user/assistant alternation.
*/
function sanitizeAnthropicMessages(messages) {
if (!messages.length) return [];
const result = [];
for (const msg of messages) {
if (result.length === 0) {
if (msg.role !== 'user') continue;
result.push({ ...msg });
} else {
const prev = result[result.length - 1];
if (prev.role === msg.role) {
if (typeof prev.content === 'string' && typeof msg.content === 'string') {
prev.content = prev.content + '\n' + msg.content;
}
} else {
result.push({ ...msg });
}
}
}
if (result.length && result[result.length - 1].role !== 'user') {
result.push({ role: 'user', content: '(continue)' });
}
return result;
}