claude-task-master/scripts/modules/ai-services-unified.js
Eyal Toledano b3b424be93 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.
2025-04-22 02:42:04 -04:00

419 lines
14 KiB
JavaScript

/**
* ai-services-unified.js
* Centralized AI service layer using provider modules and config-manager.
*/
// 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
const INITIAL_RETRY_DELAY_MS = 1000; // 1 second
// Helper function to check if an error is retryable
function isRetryableError(error) {
const errorMessage = error.message?.toLowerCase() || '';
// Add common retryable error patterns
return (
errorMessage.includes('rate limit') ||
errorMessage.includes('overloaded') ||
errorMessage.includes('service temporarily unavailable') ||
errorMessage.includes('timeout') ||
errorMessage.includes('network error') ||
// Add specific status codes if available from the SDK errors
error.status === 429 || // Too Many Requests
error.status >= 500 // Server-side errors
);
}
/**
* 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 {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 _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 ${fnName} (Provider: ${providerName}, Model: ${modelId}, Role: ${attemptRole})`
);
// Call the specific provider function directly
const result = await providerApiFn(callParams);
log(
'info',
`${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} (${fnName} / ${providerName}): ${error.message}`
);
if (isRetryableError(error) && retries < MAX_RETRIES) {
retries++;
const delay = INITIAL_RETRY_DELAY_MS * Math.pow(2, retries - 1);
log(
'info',
`Retryable error detected. Retrying in ${delay / 1000}s...`
);
await new Promise((resolve) => setTimeout(resolve, delay));
} else {
log(
'error',
`Non-retryable error or max retries reached for role ${attemptRole} (${fnName} / ${providerName}).`
);
throw error; // Final failure for this attempt chain
}
}
}
// Should not be reached due to throw in the else block
throw new Error(
`Exhausted all retries for role ${attemptRole} (${fnName} / ${providerName})`
);
}
/**
* 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 _unifiedServiceRunner(serviceType, params) {
const {
role: initialRole,
session,
systemPrompt,
prompt,
schema,
objectName,
...restApiParams
} = params;
log('info', `${serviceType}Service 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) {
let providerName, modelId, apiKey, roleParams, providerFnSet, providerApiFn;
try {
log('info', `Attempting service call with role: ${currentRole}`);
// --- 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(
'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 (${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.
*
* @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 {import('zod').ZodSchema} params.schema - The Zod schema for the expected object.
* @param {string} params.prompt - The prompt for the AI.
* @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 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 };