refactor(ai): Implement unified AI service layer and fix subtask update

- Unified Service: Introduced 'scripts/modules/ai-services-unified.js' to centralize AI interactions using provider modules ('src/ai-providers/') and the Vercel AI SDK.

- Provider Modules: Implemented 'anthropic.js' and 'perplexity.js' wrappers for Vercel SDK.

- 'updateSubtaskById' Fix: Refactored the AI call within 'updateSubtaskById' to use 'generateTextService' from the unified layer, resolving runtime errors related to parameter passing and streaming. This serves as the pattern for refactoring other AI calls in 'scripts/modules/task-manager/'.

- Task Status: Marked Subtask 61.19 as 'done'.

- Rules: Added new 'ai-services.mdc' rule.

This centralizes AI logic, replacing previous direct SDK calls and custom implementations. API keys are resolved via 'resolveEnvVariable' within the service layer. The refactoring of 'updateSubtaskById' establishes the standard approach for migrating other AI-dependent functions in the task manager module to use the unified service.

Relates to Task 61.
This commit is contained in:
Eyal Toledano
2025-04-22 02:42:04 -04:00
parent c90578b6da
commit b3b424be93
11 changed files with 844 additions and 588 deletions

View File

@@ -1,13 +1,48 @@
/**
* ai-services-unified.js
* Centralized AI service layer using ai-client-factory and AI SDK core functions.
* Centralized AI service layer using provider modules and config-manager.
*/
import { generateText } from 'ai';
import { getClient } from './ai-client-factory.js';
import { log } from './utils.js'; // Import log for retry logging
// Import logger from utils later when needed
// import { log } from './utils.js';
// Vercel AI SDK functions are NOT called directly anymore.
// import { generateText, streamText, generateObject } from 'ai';
// --- Core Dependencies ---
import {
// REMOVED: getProviderAndModelForRole, // This was incorrect
getMainProvider, // ADD individual getters
getMainModelId,
getResearchProvider,
getResearchModelId,
getFallbackProvider,
getFallbackModelId,
getParametersForRole
// ConfigurationError // Import if needed for specific handling
} from './config-manager.js'; // Corrected: Removed getProviderAndModelForRole
import { log, resolveEnvVariable } from './utils.js';
// --- Provider Service Imports ---
// Corrected path from scripts/ai-providers/... to ../../src/ai-providers/...
import * as anthropic from '../../src/ai-providers/anthropic.js';
import * as perplexity from '../../src/ai-providers/perplexity.js';
// TODO: Import other provider modules when implemented (openai, ollama, etc.)
// --- Provider Function Map ---
// Maps provider names (lowercase) to their respective service functions
const PROVIDER_FUNCTIONS = {
anthropic: {
generateText: anthropic.generateAnthropicText,
streamText: anthropic.streamAnthropicText,
generateObject: anthropic.generateAnthropicObject
// streamObject: anthropic.streamAnthropicObject, // Add when implemented
},
perplexity: {
generateText: perplexity.generatePerplexityText,
streamText: perplexity.streamPerplexityText,
generateObject: perplexity.generatePerplexityObject
// streamObject: perplexity.streamPerplexityObject, // Add when implemented
}
// TODO: Add entries for openai, ollama, etc. when implemented
};
// --- Configuration for Retries ---
const MAX_RETRIES = 2; // Total attempts = 1 + MAX_RETRIES
@@ -30,39 +65,86 @@ function isRetryableError(error) {
}
/**
* Internal helper to attempt an AI SDK API call with retries.
* Internal helper to resolve the API key for a given provider.
* @param {string} providerName - The name of the provider (lowercase).
* @param {object|null} session - Optional MCP session object.
* @returns {string|null} The API key or null if not found/needed.
* @throws {Error} If a required API key is missing.
*/
function _resolveApiKey(providerName, session) {
const keyMap = {
openai: 'OPENAI_API_KEY',
anthropic: 'ANTHROPIC_API_KEY',
google: 'GOOGLE_API_KEY',
perplexity: 'PERPLEXITY_API_KEY',
grok: 'GROK_API_KEY',
mistral: 'MISTRAL_API_KEY',
azure: 'AZURE_OPENAI_API_KEY',
openrouter: 'OPENROUTER_API_KEY',
xai: 'XAI_API_KEY'
// ollama doesn't need an API key mapped here
};
if (providerName === 'ollama') {
return null; // Ollama typically doesn't require an API key for basic setup
}
const envVarName = keyMap[providerName];
if (!envVarName) {
throw new Error(
`Unknown provider '${providerName}' for API key resolution.`
);
}
const apiKey = resolveEnvVariable(envVarName, session);
if (!apiKey) {
throw new Error(
`Required API key ${envVarName} for provider '${providerName}' is not set in environment or session.`
);
}
return apiKey;
}
/**
* Internal helper to attempt a provider-specific AI API call with retries.
*
* @param {object} client - The AI client instance.
* @param {function} apiCallFn - The AI SDK function to call (e.g., generateText).
* @param {object} apiParams - Parameters for the AI SDK function (excluding model).
* @param {function} providerApiFn - The specific provider function to call (e.g., generateAnthropicText).
* @param {object} callParams - Parameters object for the provider function.
* @param {string} providerName - Name of the provider (for logging).
* @param {string} modelId - Specific model ID (for logging).
* @param {string} attemptRole - The role being attempted (for logging).
* @returns {Promise<object>} The result from the successful API call.
* @throws {Error} If the call fails after all retries.
*/
async function _attemptApiCallWithRetries(
client,
apiCallFn,
apiParams,
async function _attemptProviderCallWithRetries(
providerApiFn,
callParams,
providerName,
modelId,
attemptRole
) {
let retries = 0;
const fnName = providerApiFn.name; // Get function name for logging
while (retries <= MAX_RETRIES) {
try {
log(
'info',
`Attempt ${retries + 1}/${MAX_RETRIES + 1} calling ${apiCallFn.name} for role ${attemptRole}`
`Attempt ${retries + 1}/${MAX_RETRIES + 1} calling ${fnName} (Provider: ${providerName}, Model: ${modelId}, Role: ${attemptRole})`
);
// Call the provided AI SDK function (generateText, streamText, etc.)
const result = await apiCallFn({ model: client, ...apiParams });
// Call the specific provider function directly
const result = await providerApiFn(callParams);
log(
'info',
`${apiCallFn.name} succeeded for role ${attemptRole} on attempt ${retries + 1}`
`${fnName} succeeded for role ${attemptRole} (Provider: ${providerName}) on attempt ${retries + 1}`
);
return result; // Success!
} catch (error) {
log(
'warn',
`Attempt ${retries + 1} failed for role ${attemptRole} (${apiCallFn.name}): ${error.message}`
`Attempt ${retries + 1} failed for role ${attemptRole} (${fnName} / ${providerName}): ${error.message}`
);
if (isRetryableError(error) && retries < MAX_RETRIES) {
@@ -76,140 +158,35 @@ async function _attemptApiCallWithRetries(
} else {
log(
'error',
`Non-retryable error or max retries reached for role ${attemptRole} (${apiCallFn.name}).`
`Non-retryable error or max retries reached for role ${attemptRole} (${fnName} / ${providerName}).`
);
throw error; // Final failure for this attempt chain
}
}
}
// Should theoretically not be reached due to throw in the else block, but needed for linting/type safety
// Should not be reached due to throw in the else block
throw new Error(
`Exhausted all retries for role ${attemptRole} (${apiCallFn.name})`
`Exhausted all retries for role ${attemptRole} (${fnName} / ${providerName})`
);
}
/**
* Unified service function for generating text.
* Handles client retrieval, retries, and fallback (main -> fallback -> research).
* TODO: Add detailed logging.
*
* @param {object} params - Parameters for the service call.
* @param {string} params.role - The initial client role ('main', 'research', 'fallback').
* @param {object} [params.session=null] - Optional MCP session object.
* @param {object} [params.overrideOptions={}] - Optional overrides for ai-client-factory { provider, modelId }.
* @param {string} params.prompt - The prompt for the AI.
* @param {number} [params.maxTokens] - Max tokens for the generation.
* @param {number} [params.temperature] - Temperature setting.
* // ... include other standard generateText options as needed ...
* @returns {Promise<object>} The result from the AI SDK's generateText function.
* Base logic for unified service functions.
* @param {string} serviceType - Type of service ('generateText', 'streamText', 'generateObject').
* @param {object} params - Original parameters passed to the service function.
* @returns {Promise<any>} Result from the underlying provider call.
*/
async function generateTextService(params) {
async function _unifiedServiceRunner(serviceType, params) {
const {
role: initialRole,
session,
overrideOptions,
...generateTextParams
systemPrompt,
prompt,
schema,
objectName,
...restApiParams
} = params;
log('info', 'generateTextService called', { role: initialRole });
// Determine the sequence explicitly based on the initial role
let sequence;
if (initialRole === 'main') {
sequence = ['main', 'fallback', 'research'];
} else if (initialRole === 'fallback') {
sequence = ['fallback', 'research']; // Try fallback, then research
} else if (initialRole === 'research') {
sequence = ['research', 'fallback']; // Try research, then fallback
} else {
// Default sequence if initialRole is unknown or invalid
log(
'warn',
`Unknown initial role: ${initialRole}. Defaulting to main -> fallback -> research sequence.`
);
sequence = ['main', 'fallback', 'research'];
}
let lastError = null;
// Iterate through the determined sequence
for (const currentRole of sequence) {
// Removed the complex conditional check, as the sequence is now pre-determined
log('info', `Attempting service call with role: ${currentRole}`);
let client;
try {
client = await getClient(currentRole, session, overrideOptions);
const clientInfo = {
provider: client?.provider || 'unknown',
model: client?.modelId || client?.model || 'unknown'
};
log('info', 'Retrieved AI client', clientInfo);
// Attempt the API call with retries using the helper
const result = await _attemptApiCallWithRetries(
client,
generateText,
generateTextParams,
currentRole
);
log('info', `generateTextService succeeded using role: ${currentRole}`); // Add success log
return result; // Success!
} catch (error) {
log(
'error', // Log as error since this role attempt failed
`Service call failed for role ${currentRole}: ${error.message}`
);
lastError = error; // Store the error to throw if all roles in sequence fail
// Log the reason for moving to the next role
if (!client) {
log(
'warn',
`Could not get client for role ${currentRole}, trying next role in sequence...`
);
} else {
// Error happened during API call after client was retrieved
log(
'warn',
`Retries exhausted or non-retryable error for role ${currentRole}, trying next role in sequence...`
);
}
// Continue to the next role in the sequence automatically
}
}
// If loop completes, all roles in the sequence failed
log('error', `All roles in the sequence [${sequence.join(', ')}] failed.`);
throw (
lastError ||
new Error(
'AI service call failed for all configured roles in the sequence.'
)
);
}
// TODO: Implement streamTextService, generateObjectService etc.
/**
* Unified service function for streaming text.
* Handles client retrieval, retries, and fallback sequence.
*
* @param {object} params - Parameters for the service call.
* @param {string} params.role - The initial client role ('main', 'research', 'fallback').
* @param {object} [params.session=null] - Optional MCP session object.
* @param {object} [params.overrideOptions={}] - Optional overrides for ai-client-factory.
* @param {string} params.prompt - The prompt for the AI.
* // ... include other standard streamText options as needed ...
* @returns {Promise<object>} The result from the AI SDK's streamText function (typically a Streamable object).
*/
async function streamTextService(params) {
const {
role: initialRole,
session,
overrideOptions,
...streamTextParams // Collect remaining params for streamText
} = params;
log('info', 'streamTextService called', { role: initialRole });
log('info', `${serviceType}Service called`, { role: initialRole });
let sequence;
if (initialRole === 'main') {
@@ -229,54 +206,190 @@ async function streamTextService(params) {
let lastError = null;
for (const currentRole of sequence) {
log('info', `Attempting service call with role: ${currentRole}`);
let client;
let providerName, modelId, apiKey, roleParams, providerFnSet, providerApiFn;
try {
client = await getClient(currentRole, session, overrideOptions);
const clientInfo = {
provider: client?.provider || 'unknown',
model: client?.modelId || client?.model || 'unknown'
};
log('info', 'Retrieved AI client', clientInfo);
log('info', `Attempting service call with role: ${currentRole}`);
const result = await _attemptApiCallWithRetries(
client,
streamText, // Pass streamText function
streamTextParams,
currentRole
);
log('info', `streamTextService succeeded using role: ${currentRole}`);
return result;
} catch (error) {
log(
'error',
`Service call failed for role ${currentRole}: ${error.message}`
);
lastError = error;
if (!client) {
log(
'warn',
`Could not get client for role ${currentRole}, trying next role in sequence...`
);
// --- Corrected Config Fetching ---
// 1. Get Config: Provider, Model, Parameters for the current role
// Call individual getters based on the current role
if (currentRole === 'main') {
providerName = getMainProvider(); // Use individual getter
modelId = getMainModelId(); // Use individual getter
} else if (currentRole === 'research') {
providerName = getResearchProvider(); // Use individual getter
modelId = getResearchModelId(); // Use individual getter
} else if (currentRole === 'fallback') {
providerName = getFallbackProvider(); // Use individual getter
modelId = getFallbackModelId(); // Use individual getter
} else {
log(
'warn',
`Retries exhausted or non-retryable error for role ${currentRole}, trying next role in sequence...`
'error',
`Unknown role encountered in _unifiedServiceRunner: ${currentRole}`
);
lastError =
lastError || new Error(`Unknown AI role specified: ${currentRole}`);
continue; // Skip to the next role attempt
}
// --- End Corrected Config Fetching ---
if (!providerName || !modelId) {
log(
'warn',
`Skipping role '${currentRole}': Provider or Model ID not configured.`
);
lastError =
lastError ||
new Error(
`Configuration missing for role '${currentRole}'. Provider: ${providerName}, Model: ${modelId}`
);
continue; // Skip to the next role
}
roleParams = getParametersForRole(currentRole); // Get { maxTokens, temperature }
// 2. Get Provider Function Set
providerFnSet = PROVIDER_FUNCTIONS[providerName?.toLowerCase()];
if (!providerFnSet) {
log(
'warn',
`Skipping role '${currentRole}': Provider '${providerName}' not supported or map entry missing.`
);
lastError =
lastError ||
new Error(`Unsupported provider configured: ${providerName}`);
continue;
}
// Use the original service type to get the function
providerApiFn = providerFnSet[serviceType];
if (typeof providerApiFn !== 'function') {
log(
'warn',
`Skipping role '${currentRole}': Service type '${serviceType}' not implemented for provider '${providerName}'.`
);
lastError =
lastError ||
new Error(
`Service '${serviceType}' not implemented for provider ${providerName}`
);
continue;
}
// 3. Resolve API Key (will throw if required and missing)
apiKey = _resolveApiKey(providerName?.toLowerCase(), session); // Throws on failure
// 4. Construct Messages Array
const messages = [];
if (systemPrompt) {
messages.push({ role: 'system', content: systemPrompt });
}
// IN THE FUTURE WHEN DOING CONTEXT IMPROVEMENTS
// {
// type: 'text',
// text: 'Large cached context here like a tasks json',
// providerOptions: {
// anthropic: { cacheControl: { type: 'ephemeral' } }
// }
// }
// Example
// if (params.context) { // context is a json string of a tasks object or some other stu
// messages.push({
// type: 'text',
// text: params.context,
// providerOptions: { anthropic: { cacheControl: { type: 'ephemeral' } } }
// });
// }
if (prompt) {
// Ensure prompt exists before adding
messages.push({ role: 'user', content: prompt });
} else {
// Throw an error if the prompt is missing, as it's essential
throw new Error('User prompt content is missing.');
}
// 5. Prepare call parameters (using messages array)
const callParams = {
apiKey,
modelId,
maxTokens: roleParams.maxTokens,
temperature: roleParams.temperature,
messages, // *** Pass the constructed messages array ***
// Add specific params for generateObject if needed
...(serviceType === 'generateObject' && { schema, objectName }),
...restApiParams // Include other params like maxRetries
};
// 6. Attempt the call with retries
const result = await _attemptProviderCallWithRetries(
providerApiFn,
callParams,
providerName,
modelId,
currentRole
);
log('info', `${serviceType}Service succeeded using role: ${currentRole}`);
return result; // Return original result for other cases
} catch (error) {
log(
'error', // Log as error since this role attempt failed
`Service call failed for role ${currentRole} (Provider: ${providerName || 'unknown'}): ${error.message}`
);
lastError = error; // Store the error to throw if all roles fail
// Log reason and continue (handled within the loop now)
}
}
// If loop completes, all roles failed
log('error', `All roles in the sequence [${sequence.join(', ')}] failed.`);
throw (
lastError ||
new Error(
'AI service call (streamText) failed for all configured roles in the sequence.'
`AI service call (${serviceType}) failed for all configured roles in the sequence.`
)
);
}
/**
* Unified service function for generating text.
* Handles client retrieval, retries, and fallback sequence.
*
* @param {object} params - Parameters for the service call.
* @param {string} params.role - The initial client role ('main', 'research', 'fallback').
* @param {object} [params.session=null] - Optional MCP session object.
* @param {string} params.prompt - The prompt for the AI.
* @param {string} [params.systemPrompt] - Optional system prompt.
* // Other specific generateText params can be included here.
* @returns {Promise<string>} The generated text content.
*/
async function generateTextService(params) {
// Now directly returns the text string or throws error
return _unifiedServiceRunner('generateText', params);
}
/**
* Unified service function for streaming text.
* Handles client retrieval, retries, and fallback sequence.
*
* @param {object} params - Parameters for the service call.
* @param {string} params.role - The initial client role ('main', 'research', 'fallback').
* @param {object} [params.session=null] - Optional MCP session object.
* @param {string} params.prompt - The prompt for the AI.
* @param {string} [params.systemPrompt] - Optional system prompt.
* // Other specific streamText params can be included here.
* @returns {Promise<ReadableStream<string>>} A readable stream of text deltas.
*/
async function streamTextService(params) {
// Now directly returns the stream object or throws error
return _unifiedServiceRunner('streamText', params);
}
/**
* Unified service function for generating structured objects.
* Handles client retrieval, retries, and fallback sequence.
@@ -284,85 +397,22 @@ async function streamTextService(params) {
* @param {object} params - Parameters for the service call.
* @param {string} params.role - The initial client role ('main', 'research', 'fallback').
* @param {object} [params.session=null] - Optional MCP session object.
* @param {object} [params.overrideOptions={}] - Optional overrides for ai-client-factory.
* @param {z.Schema} params.schema - The Zod schema for the expected object.
* @param {import('zod').ZodSchema} params.schema - The Zod schema for the expected object.
* @param {string} params.prompt - The prompt for the AI.
* // ... include other standard generateObject options as needed ...
* @returns {Promise<object>} The result from the AI SDK's generateObject function.
* @param {string} [params.systemPrompt] - Optional system prompt.
* @param {string} [params.objectName='generated_object'] - Name for object/tool.
* @param {number} [params.maxRetries=3] - Max retries for object generation.
* // Other specific generateObject params can be included here.
* @returns {Promise<object>} The generated object matching the schema.
*/
async function generateObjectService(params) {
const {
role: initialRole,
session,
overrideOptions,
...generateObjectParams // Collect remaining params for generateObject
} = params;
log('info', 'generateObjectService called', { role: initialRole });
let sequence;
if (initialRole === 'main') {
sequence = ['main', 'fallback', 'research'];
} else if (initialRole === 'fallback') {
sequence = ['fallback', 'research'];
} else if (initialRole === 'research') {
sequence = ['research', 'fallback'];
} else {
log(
'warn',
`Unknown initial role: ${initialRole}. Defaulting to main -> fallback -> research sequence.`
);
sequence = ['main', 'fallback', 'research'];
}
let lastError = null;
for (const currentRole of sequence) {
log('info', `Attempting service call with role: ${currentRole}`);
let client;
try {
client = await getClient(currentRole, session, overrideOptions);
const clientInfo = {
provider: client?.provider || 'unknown',
model: client?.modelId || client?.model || 'unknown'
};
log('info', 'Retrieved AI client', clientInfo);
const result = await _attemptApiCallWithRetries(
client,
generateObject, // Pass generateObject function
generateObjectParams,
currentRole
);
log('info', `generateObjectService succeeded using role: ${currentRole}`);
return result;
} catch (error) {
log(
'error',
`Service call failed for role ${currentRole}: ${error.message}`
);
lastError = error;
if (!client) {
log(
'warn',
`Could not get client for role ${currentRole}, trying next role in sequence...`
);
} else {
log(
'warn',
`Retries exhausted or non-retryable error for role ${currentRole}, trying next role in sequence...`
);
}
}
}
log('error', `All roles in the sequence [${sequence.join(', ')}] failed.`);
throw (
lastError ||
new Error(
'AI service call (generateObject) failed for all configured roles in the sequence.'
)
);
const defaults = {
objectName: 'generated_object',
maxRetries: 3
};
const combinedParams = { ...defaults, ...params };
// Now directly returns the generated object or throws error
return _unifiedServiceRunner('generateObject', combinedParams);
}
export { generateTextService, streamTextService, generateObjectService };