godcrm/backend/services/chat/agent-execution-shared/provider-resolution.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

184 lines
6.5 KiB
JavaScript

/**
* ADR-093: Provider resolution and detection.
*
* Extracted from agent-execution-shared.js
*/
import { dbGet, isPostgres } from '../../../database/connection.js';
import { apiLogger } from '../../../utils/logger.js';
import { getSecret } from '../../secrets/getSecret.js';
import { safeParse } from './helpers.js';
// ─── 1. resolveAgentProvider() ────────────────────────────────
/**
* ADR-093 Task 1: Unified operator, API key, model, and provider resolution.
*
* Resolution chain:
* 1. Agent's operator_id -> operator row -> api_key + provider
* 2. Fallback: AI API Keys table for the same operator_id
* 3. Fallback: Any operator with api_key in the same space
* 4. Fallback: process.env.OPENAI_API_KEY
*
* Model resolution:
* 1. modelIdOverride (if provided)
* 2. agentConfig.model (if numeric -> lookup from Models table)
* 3. operatorData.model or agentConfig.model (string)
* 4. Default: 'gpt-4o'
*
* @param {Object} agentConfig - Agent configuration (from table_rows data)
* @param {Object} options - { spaceId, modelIdOverride }
* @returns {Promise<{operatorData: Object|null, apiKey: string|null, model: string, provider: string, isLocal: boolean}>}
*/
export async function resolveAgentProvider(agentConfig, options = {}) {
const { spaceId = null, modelIdOverride = null } = options;
let apiKey = null;
let operatorData = null;
// Step 1: Resolve operator by operator_id
if (agentConfig.operator_id) {
const operatorRow = await dbGet(
isPostgres()
? `SELECT tr.data FROM table_rows tr
JOIN universal_tables ut ON tr.table_id = ut.id
WHERE tr.id = $1 AND ut.name LIKE '%Operators%'`
: `SELECT tr.data FROM table_rows tr
JOIN universal_tables ut ON tr.table_id = ut.id
WHERE tr.id = ? AND ut.name LIKE '%Operators%'`,
[agentConfig.operator_id]
);
if (operatorRow) {
operatorData = safeParse(operatorRow.data, {});
if (operatorData.api_key) {
apiKey = operatorData.api_key;
}
}
// Step 2: Fallback to AI API Keys table
if (!apiKey) {
const keyRow = isPostgres()
? await dbGet(`
SELECT tr.data FROM table_rows tr
JOIN universal_tables ut ON tr.table_id = ut.id
WHERE ut.name LIKE '%API Keys%'
AND tr.data->>'operator_id' = $1
AND tr.data->>'status' = 'active'
ORDER BY tr.created_at DESC LIMIT 1
`, [String(agentConfig.operator_id)])
: await dbGet(`
SELECT tr.data FROM table_rows tr
JOIN universal_tables ut ON tr.table_id = ut.id
WHERE ut.name LIKE '%API Keys%'
AND json_extract(tr.data, '$.operator_id') = ?
AND json_extract(tr.data, '$.status') = 'active'
ORDER BY tr.created_at DESC LIMIT 1
`, [String(agentConfig.operator_id)]);
if (keyRow) {
const keyData = safeParse(keyRow.data, {});
apiKey = keyData.api_key;
}
}
}
// Detect local providers (no API key needed)
const isLocal = operatorData?.provider === 'claude-code'
|| operatorData?.provider === 'local'
|| operatorData?.is_local === true;
// Step 3: Fallback — find any operator with api_key in the same space
if (!isLocal && !apiKey && spaceId) {
apiLogger.debug({ spaceId }, 'ADR-093: No operator API key, searching space fallback');
const fallbackOperator = isPostgres()
? await dbGet(`
SELECT tr.data FROM table_rows tr
JOIN universal_tables ut ON tr.table_id = ut.id
JOIN projects p ON ut.project_id = p.id
WHERE p.space_id = $1
AND ut.name LIKE '%Operators%'
AND tr.data->>'api_key' IS NOT NULL
AND tr.data->>'api_key' != ''
ORDER BY tr.created_at DESC LIMIT 1
`, [spaceId])
: await dbGet(`
SELECT tr.data FROM table_rows tr
JOIN universal_tables ut ON tr.table_id = ut.id
JOIN projects p ON ut.project_id = p.id
WHERE p.space_id = ?
AND ut.name LIKE '%Operators%'
AND json_extract(tr.data, '$.api_key') IS NOT NULL
AND json_extract(tr.data, '$.api_key') != ''
ORDER BY tr.created_at DESC LIMIT 1
`, [spaceId]);
if (fallbackOperator) {
operatorData = safeParse(fallbackOperator.data, {});
apiKey = operatorData.api_key;
apiLogger.debug({ provider: operatorData.provider }, 'ADR-093: Found fallback operator');
}
}
// Step 4: Vault fallback (ADR-0040 — was process.env.OPENAI_API_KEY)
if (!isLocal && !apiKey) {
apiKey = await getSecret('openai_api_key', 'OPENAI_API_KEY');
}
// ── Model resolution ──
let modelId = agentConfig.model || 'gpt-4o';
let providerName = operatorData?.provider || 'openai';
const rawModel = modelIdOverride || agentConfig.model;
const modelRowId = rawModel && !isNaN(Number(rawModel)) ? Number(rawModel) : null;
if (modelRowId) {
const modelRow = await dbGet(
isPostgres()
? `SELECT tr.data FROM table_rows tr
JOIN universal_tables ut ON tr.table_id = ut.id
WHERE tr.id = $1 AND ut.name LIKE '%Models%'`
: `SELECT tr.data FROM table_rows tr
JOIN universal_tables ut ON tr.table_id = ut.id
WHERE tr.id = ? AND ut.name LIKE '%Models%'`,
[modelRowId]
);
if (modelRow) {
const modelData = safeParse(modelRow.data, {});
modelId = modelData.model_id || modelData.api_id || modelId;
}
}
if (operatorData?.provider) {
providerName = operatorData.provider.toLowerCase();
}
return {
operatorData,
apiKey,
model: modelId,
provider: providerName,
isLocal,
};
}
// ─── 2. detectProvider() ──────────────────────────────────────
/**
* ADR-093: Detect provider type flags from provider name and model.
*
* @param {string} providerName - Provider identifier (e.g. 'openai', 'anthropic', 'claude-code')
* @param {string} model - Model identifier (e.g. 'claude-sonnet-4', 'gpt-4-turbo')
* @returns {{ isClaudeCode: boolean, isCopilot: boolean, isAnthropic: boolean }}
*/
export function detectProvider(providerName, model) {
const isClaudeCode = providerName === 'claude-code';
const isCopilot = providerName === 'copilot';
const isAnthropic = !isClaudeCode && !isCopilot
&& (model.includes('claude') || providerName === 'anthropic');
return { isClaudeCode, isCopilot, isAnthropic };
}