/** * 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 { getMainProvider, getMainModelId, getResearchProvider, getResearchModelId, getFallbackProvider, getFallbackModelId, getParametersForRole, getUserId, MODEL_MAP, getDebugFlag, getBaseUrlForRole, isApiKeySet, getOllamaBaseURL, getAzureBaseURL, getVertexProjectId, getVertexLocation, getConfig, } from "./config-manager.js"; import { log, findProjectRoot, resolveEnvVariable } from "./utils.js"; import { submitTelemetryData } from "./telemetry-submission.js"; import { isHostedMode } from "./user-management.js"; // Import provider classes import { AnthropicAIProvider, PerplexityAIProvider, GoogleAIProvider, OpenAIProvider, XAIProvider, OpenRouterAIProvider, OllamaAIProvider, BedrockAIProvider, AzureProvider, VertexAIProvider, } from "../../src/ai-providers/index.js"; import { zodToJsonSchema } from "zod-to-json-schema"; import { handleGatewayError } from "./utils/gatewayErrorHandler.js"; // Create provider instances const PROVIDERS = { anthropic: new AnthropicAIProvider(), perplexity: new PerplexityAIProvider(), google: new GoogleAIProvider(), openai: new OpenAIProvider(), xai: new XAIProvider(), openrouter: new OpenRouterAIProvider(), ollama: new OllamaAIProvider(), bedrock: new BedrockAIProvider(), azure: new AzureProvider(), vertex: new VertexAIProvider(), }; // Helper function to get cost for a specific model function _getCostForModel(providerName, modelId) { if (!MODEL_MAP || !MODEL_MAP[providerName]) { log( "warn", `Provider "${providerName}" not found in MODEL_MAP. Cannot determine cost for model ${modelId}.` ); return { inputCost: 0, outputCost: 0, currency: "USD" }; // Default to zero cost } const modelData = MODEL_MAP[providerName].find((m) => m.id === modelId); if (!modelData || !modelData.cost_per_1m_tokens) { log( "debug", `Cost data not found for model "${modelId}" under provider "${providerName}". Assuming zero cost.` ); return { inputCost: 0, outputCost: 0, currency: "USD" }; // Default to zero cost } // Ensure currency is part of the returned object, defaulting if not present const currency = modelData.cost_per_1m_tokens.currency || "USD"; return { inputCost: modelData.cost_per_1m_tokens.input || 0, outputCost: modelData.cost_per_1m_tokens.output || 0, currency: currency, }; } // --- Configuration for Retries --- const MAX_RETRIES = 2; const INITIAL_RETRY_DELAY_MS = 1000; // Helper function to check if an error is retryable function isRetryableError(error) { const errorMessage = error.message?.toLowerCase() || ""; return ( errorMessage.includes("rate limit") || errorMessage.includes("overloaded") || errorMessage.includes("service temporarily unavailable") || errorMessage.includes("timeout") || errorMessage.includes("network error") || error.status === 429 || error.status >= 500 ); } /** * Extracts a user-friendly error message from a potentially complex AI error object. * Prioritizes nested messages and falls back to the top-level message. * @param {Error | object | any} error - The error object. * @returns {string} A concise error message. */ function _extractErrorMessage(error) { try { // Attempt 1: Look for Vercel SDK specific nested structure (common) if (error?.data?.error?.message) { return error.data.error.message; } // Attempt 2: Look for nested error message directly in the error object if (error?.error?.message) { return error.error.message; } // Attempt 3: Look for nested error message in response body if it's JSON string if (typeof error?.responseBody === "string") { try { const body = JSON.parse(error.responseBody); if (body?.error?.message) { return body.error.message; } } catch (parseError) { // Ignore if responseBody is not valid JSON } } // Attempt 4: Use the top-level message if it exists if (typeof error?.message === "string" && error.message) { return error.message; } // Attempt 5: Handle simple string errors if (typeof error === "string") { return error; } // Fallback return "An unknown AI service error occurred."; } catch (e) { // Safety net return "Failed to extract error message."; } } /** * 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. * @param {string|null} projectRoot - Optional project root path for .env fallback. * @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, projectRoot = null) { const keyMap = { openai: "OPENAI_API_KEY", anthropic: "ANTHROPIC_API_KEY", google: "GOOGLE_API_KEY", perplexity: "PERPLEXITY_API_KEY", mistral: "MISTRAL_API_KEY", azure: "AZURE_OPENAI_API_KEY", openrouter: "OPENROUTER_API_KEY", xai: "XAI_API_KEY", ollama: "OLLAMA_API_KEY", bedrock: "AWS_ACCESS_KEY_ID", vertex: "GOOGLE_API_KEY", }; const envVarName = keyMap[providerName]; if (!envVarName) { throw new Error( `Unknown provider '${providerName}' for API key resolution.` ); } const apiKey = resolveEnvVariable(envVarName, session, projectRoot); // Special handling for providers that can use alternative auth if (providerName === "ollama" || providerName === "bedrock") { return apiKey || null; } if (!apiKey) { throw new Error( `Required API key ${envVarName} for provider '${providerName}' is not set in environment, session, or .env file.` ); } 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} The result from the successful API call. * @throws {Error} If the call fails after all retries. */ async function _attemptProviderCallWithRetries( provider, serviceType, callParams, providerName, modelId, attemptRole ) { let retries = 0; const fnName = serviceType; while (retries <= MAX_RETRIES) { try { if (getDebugFlag()) { log( "info", `Attempt ${retries + 1}/${MAX_RETRIES + 1} calling ${fnName} (Provider: ${providerName}, Model: ${modelId}, Role: ${attemptRole})` ); } // Call the appropriate method on the provider instance const result = await provider[serviceType](callParams); if (getDebugFlag()) { log( "info", `${fnName} succeeded for role ${attemptRole} (Provider: ${providerName}) on attempt ${retries + 1}` ); } return result; } 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", `Something went wrong on the provider side. Retrying in ${delay / 1000}s...` ); await new Promise((resolve) => setTimeout(resolve, delay)); } else { log( "error", `Something went wrong on the provider side. Max retries reached for role ${attemptRole} (${fnName} / ${providerName}).` ); throw error; } } } // Should not be reached due to throw in the else block throw new Error( `Exhausted all retries for role ${attemptRole} (${fnName} / ${providerName})` ); } /** * Makes an AI call through the TaskMaster gateway for hosted users * @param {string} serviceType - Type of service (generateText, generateObject, streamText) * @param {object} callParams - Parameters for the AI call * @param {string} providerName - AI provider name * @param {string} modelId - Model ID * @param {string} userId - User ID * @param {string} commandName - Command name for tracking * @param {string} outputType - Output type (cli, mcp) * @param {string} projectRoot - Project root path * @param {string} initialRole - The initial client role * @returns {Promise} AI response with usage data */ /** * Calls the TaskMaster gateway for AI processing (hosted mode only). * BYOK users don't use this function - they make direct API calls. */ async function _callGatewayAI( serviceType, callParams, providerName, modelId, userId, commandName, outputType, projectRoot, initialRole ) { // Hard-code service-level constants const gatewayUrl = "http://localhost:4444"; const serviceId = "98fb3198-2dfc-42d1-af53-07b99e4f3bde"; // Hardcoded service ID -- if you change this, the Hosted Gateway will not work // Get user auth info for headers const userMgmt = await import("./user-management.js"); const config = getConfig(projectRoot); const mode = config.account?.mode || "byok"; // Both BYOK and hosted users have the same user token // BYOK users just don't use it for AI calls (they use their own API keys) const userToken = await userMgmt.getUserToken(projectRoot); const userEmail = await userMgmt.getUserEmail(projectRoot); // Note: BYOK users will have both token and email, but won't use this function // since they make direct API calls with their own keys if (!userToken) { throw new Error( "User token not found. Run 'task-master init' to register with gateway." ); } const endpoint = `${gatewayUrl}/api/v1/ai/${serviceType}`; // Extract messages from callParams and convert to gateway format const systemPrompt = callParams.messages?.find((m) => m.role === "system")?.content || ""; const prompt = callParams.messages?.find((m) => m.role === "user")?.content || ""; const requestBody = { provider: providerName, serviceType, role: initialRole, messages: callParams.messages, modelId, commandName, outputType, roleParams: { maxTokens: callParams.maxTokens, temperature: callParams.temperature, }, ...(serviceType === "generateObject" && { schema: zodToJsonSchema(callParams.schema), objectName: callParams.objectName, }), }; const headers = { "Content-Type": "application/json", "X-TaskMaster-Service-ID": serviceId, // TaskMaster service ID for instance auth Authorization: `Bearer ${userToken}`, // User-level auth }; // Add user email header if available if (userEmail) { headers["X-User-Email"] = userEmail; } try { const response = await fetch(endpoint, { method: "POST", headers, body: JSON.stringify(requestBody), }); if (!response.ok) { const errorText = await response.text(); throw new Error( `Gateway AI call failed: ${response.status} ${errorText}` ); } const result = await response.json(); if (!result.success) { throw new Error(result.error || "Gateway AI call failed"); } // Return the AI response in the expected format return { text: result.data.text, object: result.data.object, usage: result.data.usage, // Include any account info returned from gateway accountInfo: result.accountInfo, }; } catch (error) { // Use the enhanced error handler for user-friendly messages handleGatewayError(error, commandName); // Throw a much cleaner error message to prevent ugly double logging const match = error.message.match(/Gateway AI call failed: (\d+)/); if (match) { const statusCode = match[1]; throw new Error( `TaskMaster gateway error (${statusCode}). See details above.` ); } else { throw new Error( "TaskMaster gateway communication failed. See details above." ); } } } /** * 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. * @param {string} params.role - The initial client role. * @param {object} [params.session=null] - Optional MCP session object. * @param {string} [params.projectRoot] - Optional project root path. * @param {string} params.commandName - Name of the command invoking the service. * @param {string} params.outputType - 'cli' or 'mcp'. * @param {string} [params.systemPrompt] - Optional system prompt. * @param {string} [params.prompt] - The prompt for the AI. * @param {string} [params.schema] - The Zod schema for the expected object. * @param {string} [params.objectName] - Name for object/tool. * @returns {Promise} Result from the underlying provider call. */ async function _unifiedServiceRunner(serviceType, params) { const { role: initialRole, session, projectRoot, systemPrompt, prompt, schema, objectName, commandName, outputType, ...restApiParams } = params; if (getDebugFlag()) { log("info", `${serviceType}Service called`, { role: initialRole, commandName, outputType, projectRoot, }); if (isHostedMode(projectRoot)) { log("info", "Communicating with Taskmaster Gateway"); } } const effectiveProjectRoot = projectRoot || findProjectRoot(); const userId = getUserId(effectiveProjectRoot); // If userId is the placeholder, try to initialize user silently if (userId === "1234567890") { try { // Dynamic import to avoid circular dependency const userMgmt = await import("./user-management.js"); const initResult = await userMgmt.initializeUser(effectiveProjectRoot); if (initResult.success) { // Update the config with the new userId const { writeConfig, getConfig } = await import("./config-manager.js"); const config = getConfig(effectiveProjectRoot); config.account.userId = initResult.userId; writeConfig(config, effectiveProjectRoot); log("info", "User successfully authenticated with gateway"); } else { // Silent failure - only log at debug level during init sequence log("debug", `Silent auth/init failed: ${initResult.error}`); } } catch (error) { // Silent failure - only log at debug level during init sequence log("debug", `Silent auth/init attempt failed: ${error.message}`); } } // Add hosted mode check here const hostedMode = isHostedMode(effectiveProjectRoot); if (hostedMode) { // Route through gateway - use your existing implementation log("info", "Routing AI call through TaskMaster gateway (hosted mode)"); try { // Check if we have a valid userId (not placeholder) const finalUserId = getUserId(effectiveProjectRoot); // Re-check after potential auth if (finalUserId === "1234567890" || !finalUserId) { throw new Error( "Hosted mode requires user authentication. Please run 'task-master init' to register with the gateway, or switch to BYOK mode if the gateway service is unavailable." ); } // Get the role configuration for provider/model selection let providerName, modelId; if (initialRole === "main") { providerName = getMainProvider(effectiveProjectRoot); modelId = getMainModelId(effectiveProjectRoot); } else if (initialRole === "research") { providerName = getResearchProvider(effectiveProjectRoot); modelId = getResearchModelId(effectiveProjectRoot); } else if (initialRole === "fallback") { providerName = getFallbackProvider(effectiveProjectRoot); modelId = getFallbackModelId(effectiveProjectRoot); } else { throw new Error(`Unknown AI role: ${initialRole}`); } if (!providerName || !modelId) { throw new Error( `Configuration missing for role '${initialRole}'. Provider: ${providerName}, Model: ${modelId}` ); } // Get role parameters const roleParams = getParametersForRole( initialRole, effectiveProjectRoot ); // Prepare messages const messages = []; if (systemPrompt) { messages.push({ role: "system", content: systemPrompt }); } if (prompt) { messages.push({ role: "user", content: prompt }); } else { throw new Error("User prompt content is missing."); } const callParams = { maxTokens: roleParams.maxTokens, temperature: roleParams.temperature, messages, ...(serviceType === "generateObject" && { schema, objectName }), ...restApiParams, }; const gatewayResponse = await _callGatewayAI( serviceType, callParams, providerName, modelId, finalUserId, commandName, outputType, effectiveProjectRoot, initialRole ); // For hosted mode, we don't need to submit telemetry separately // The gateway handles everything and returns account info let telemetryData = null; if (gatewayResponse.accountInfo) { // Convert gateway account info to telemetry format for UI display telemetryData = { timestamp: new Date().toISOString(), userId: finalUserId, commandName, modelUsed: modelId, providerName, inputTokens: gatewayResponse.usage?.inputTokens || 0, outputTokens: gatewayResponse.usage?.outputTokens || 0, totalTokens: gatewayResponse.usage?.totalTokens || 0, totalCost: 0, // Not used in hosted mode currency: "USD", // Include account info for UI display accountInfo: gatewayResponse.accountInfo, }; } let finalMainResult; if (serviceType === "generateText") { finalMainResult = gatewayResponse.text; } else if (serviceType === "generateObject") { finalMainResult = gatewayResponse.object; } else if (serviceType === "streamText") { finalMainResult = gatewayResponse; // Streaming through gateway would need special handling } else { finalMainResult = gatewayResponse; } return { mainResult: finalMainResult, telemetryData: telemetryData, }; } catch (error) { const cleanMessage = _extractErrorMessage(error); log("error", `Gateway AI call failed: ${cleanMessage}`); throw new Error(cleanMessage); } } // For BYOK mode, continue with existing logic... let sequence; if (initialRole === "main") { sequence = ["main", "fallback", "research"]; } else if (initialRole === "research") { sequence = ["research", "fallback", "main"]; } else if (initialRole === "fallback") { sequence = ["fallback", "main", "research"]; } else { log( "warn", `Unknown initial role: ${initialRole}. Defaulting to main -> fallback -> research sequence.` ); sequence = ["main", "fallback", "research"]; } let lastError = null; let lastCleanErrorMessage = "AI service call failed for all configured roles."; for (const currentRole of sequence) { let providerName, modelId, apiKey, roleParams, provider, baseURL, providerResponse, telemetryData = null; try { log("info", `New AI service call with role: ${currentRole}`); if (currentRole === "main") { providerName = getMainProvider(effectiveProjectRoot); modelId = getMainModelId(effectiveProjectRoot); } else if (currentRole === "research") { providerName = getResearchProvider(effectiveProjectRoot); modelId = getResearchModelId(effectiveProjectRoot); } else if (currentRole === "fallback") { providerName = getFallbackProvider(effectiveProjectRoot); modelId = getFallbackModelId(effectiveProjectRoot); } else { log( "error", `Unknown role encountered in _unifiedServiceRunner: ${currentRole}` ); lastError = lastError || new Error(`Unknown AI role specified: ${currentRole}`); continue; } 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; } // Get provider instance provider = PROVIDERS[providerName?.toLowerCase()]; if (!provider) { log( "warn", `Skipping role '${currentRole}': Provider '${providerName}' not supported.` ); lastError = lastError || new Error(`Unsupported provider configured: ${providerName}`); continue; } // Check API key if needed if (providerName?.toLowerCase() !== "ollama") { if (!isApiKeySet(providerName, session, effectiveProjectRoot)) { log( "warn", `Skipping role '${currentRole}' (Provider: ${providerName}): API key not set or invalid.` ); lastError = lastError || new Error( `API key for provider '${providerName}' (role: ${currentRole}) is not set.` ); continue; // Skip to the next role in the sequence } } // Get base URL if configured (optional for most providers) baseURL = getBaseUrlForRole(currentRole, effectiveProjectRoot); // For Azure, use the global Azure base URL if role-specific URL is not configured if (providerName?.toLowerCase() === "azure" && !baseURL) { baseURL = getAzureBaseURL(effectiveProjectRoot); log("debug", `Using global Azure base URL: ${baseURL}`); } else if (providerName?.toLowerCase() === "ollama" && !baseURL) { // For Ollama, use the global Ollama base URL if role-specific URL is not configured baseURL = getOllamaBaseURL(effectiveProjectRoot); log("debug", `Using global Ollama base URL: ${baseURL}`); } // Get AI parameters for the current role roleParams = getParametersForRole(currentRole, effectiveProjectRoot); apiKey = _resolveApiKey( providerName?.toLowerCase(), session, effectiveProjectRoot ); // Prepare provider-specific configuration let providerSpecificParams = {}; // Handle Vertex AI specific configuration if (providerName?.toLowerCase() === "vertex") { // Get Vertex project ID and location const projectId = getVertexProjectId(effectiveProjectRoot) || resolveEnvVariable( "VERTEX_PROJECT_ID", session, effectiveProjectRoot ); const location = getVertexLocation(effectiveProjectRoot) || resolveEnvVariable( "VERTEX_LOCATION", session, effectiveProjectRoot ) || "us-central1"; // Get credentials path if available const credentialsPath = resolveEnvVariable( "GOOGLE_APPLICATION_CREDENTIALS", session, effectiveProjectRoot ); // Add Vertex-specific parameters providerSpecificParams = { projectId, location, ...(credentialsPath && { credentials: { credentialsFromEnv: true } }), }; log( "debug", `Using Vertex AI configuration: Project ID=${projectId}, Location=${location}` ); } 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) { messages.push({ role: "user", content: prompt }); } else { throw new Error("User prompt content is missing."); } const callParams = { apiKey, modelId, maxTokens: roleParams.maxTokens, temperature: roleParams.temperature, messages, ...(baseURL && { baseURL }), ...(serviceType === "generateObject" && { schema, objectName }), ...providerSpecificParams, ...restApiParams, }; providerResponse = await _attemptProviderCallWithRetries( provider, serviceType, callParams, providerName, modelId, currentRole ); if (userId && providerResponse && providerResponse.usage) { try { telemetryData = await logAiUsage({ userId, commandName, providerName, modelId, inputTokens: providerResponse.usage.inputTokens, outputTokens: providerResponse.usage.outputTokens, outputType, commandArgs: callParams, fullOutput: providerResponse, }); } catch (telemetryError) { // logAiUsage already logs its own errors and returns null on failure // No need to log again here, telemetryData will remain null } } else if (userId && providerResponse && !providerResponse.usage) { log( "warn", `Cannot log telemetry for ${commandName} (${providerName}/${modelId}): AI result missing 'usage' data. (May be expected for streams)` ); } let finalMainResult; if (serviceType === "generateText") { finalMainResult = providerResponse.text; } else if (serviceType === "generateObject") { finalMainResult = providerResponse.object; } else if (serviceType === "streamText") { finalMainResult = providerResponse; } else { log( "error", `Unknown serviceType in _unifiedServiceRunner: ${serviceType}` ); finalMainResult = providerResponse; } return { mainResult: finalMainResult, telemetryData: telemetryData, }; } catch (error) { const cleanMessage = _extractErrorMessage(error); log( "error", `Service call failed for role ${currentRole} (Provider: ${providerName || "unknown"}, Model: ${modelId || "unknown"}): ${cleanMessage}` ); lastError = error; lastCleanErrorMessage = cleanMessage; if (serviceType === "generateObject") { const lowerCaseMessage = cleanMessage.toLowerCase(); if ( lowerCaseMessage.includes( "no endpoints found that support tool use" ) || lowerCaseMessage.includes("does not support tool_use") || lowerCaseMessage.includes("tool use is not supported") || lowerCaseMessage.includes("tools are not supported") || lowerCaseMessage.includes("function calling is not supported") ) { const specificErrorMsg = `Model '${modelId || "unknown"}' via provider '${providerName || "unknown"}' does not support the 'tool use' required by generateObjectService. Please configure a model that supports tool/function calling for the '${currentRole}' role, or use generateTextService if structured output is not strictly required.`; log("error", `[Tool Support Error] ${specificErrorMsg}`); throw new Error(specificErrorMsg); } } } } log("error", `All roles in the sequence [${sequence.join(", ")}] failed.`); throw new Error(lastCleanErrorMessage); } /** * 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.projectRoot=null] - Optional project root path for .env fallback. * @param {string} params.prompt - The prompt for the AI. * @param {string} [params.systemPrompt] - Optional system prompt. * @param {string} params.commandName - Name of the command invoking the service. * @param {string} [params.outputType='cli'] - 'cli' or 'mcp'. * @returns {Promise} Result object containing generated text and usage data. */ async function generateTextService(params) { // Ensure default outputType if not provided const defaults = { outputType: "cli" }; const combinedParams = { ...defaults, ...params }; // TODO: Validate commandName exists? return _unifiedServiceRunner("generateText", combinedParams); } /** * 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.projectRoot=null] - Optional project root path for .env fallback. * @param {string} params.prompt - The prompt for the AI. * @param {string} [params.systemPrompt] - Optional system prompt. * @param {string} params.commandName - Name of the command invoking the service. * @param {string} [params.outputType='cli'] - 'cli' or 'mcp'. * @returns {Promise} Result object containing the stream and usage data. */ async function streamTextService(params) { const defaults = { outputType: "cli" }; const combinedParams = { ...defaults, ...params }; // TODO: Validate commandName exists? // NOTE: Telemetry for streaming might be tricky as usage data often comes at the end. // The current implementation logs *after* the stream is returned. // We might need to adjust how usage is captured/logged for streams. return _unifiedServiceRunner("streamText", combinedParams); } /** * 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 {string} [params.projectRoot=null] - Optional project root path for .env fallback. * @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. * @param {string} params.commandName - Name of the command invoking the service. * @param {string} [params.outputType='cli'] - 'cli' or 'mcp'. * @returns {Promise} Result object containing the generated object and usage data. */ async function generateObjectService(params) { const defaults = { objectName: "generated_object", maxRetries: 3, outputType: "cli", }; const combinedParams = { ...defaults, ...params }; // TODO: Validate commandName exists? return _unifiedServiceRunner("generateObject", combinedParams); } // --- Telemetry Function --- /** * Logs AI usage telemetry data. * For now, it just logs to the console. Sending will be implemented later. * @param {object} params - Telemetry parameters. * @param {string} params.userId - Unique user identifier. * @param {string} params.commandName - The command that triggered the AI call. * @param {string} params.providerName - The AI provider used (e.g., 'openai'). * @param {string} params.modelId - The specific AI model ID used. * @param {number} params.inputTokens - Number of input tokens. * @param {number} params.outputTokens - Number of output tokens. * @param {string} params.outputType - 'cli' or 'mcp'. * @param {object} [params.commandArgs] - Original command arguments passed to the AI service. * @param {object} [params.fullOutput] - Complete AI response output before filtering. */ async function logAiUsage({ userId, commandName, providerName, modelId, inputTokens, outputTokens, outputType, commandArgs, fullOutput, }) { try { const isMCP = outputType === "mcp"; const timestamp = new Date().toISOString(); const totalTokens = (inputTokens || 0) + (outputTokens || 0); // Destructure currency along with costs const { inputCost, outputCost, currency } = _getCostForModel( providerName, modelId ); const totalCost = ((inputTokens || 0) / 1_000_000) * inputCost + ((outputTokens || 0) / 1_000_000) * outputCost; const telemetryData = { timestamp, userId, commandName, modelUsed: modelId, // Consistent field name from requirements providerName, // Keep provider name for context inputTokens: inputTokens || 0, outputTokens: outputTokens || 0, totalTokens, totalCost: parseFloat(totalCost.toFixed(6)), currency, // Add currency to the telemetry data }; // Add commandArgs and fullOutput if provided (for internal telemetry only) if (commandArgs !== undefined) { telemetryData.commandArgs = commandArgs; } if (fullOutput !== undefined) { telemetryData.fullOutput = fullOutput; } if (getDebugFlag()) { log("info", "AI Usage Telemetry:", telemetryData); } // Subtask 90.3: Submit telemetry data to gateway try { const submissionResult = await submitTelemetryData(telemetryData); if (getDebugFlag() && submissionResult.success) { log("debug", "Telemetry data successfully submitted to gateway"); } else if (getDebugFlag() && !submissionResult.success) { log("debug", `Telemetry submission failed: ${submissionResult.error}`); } } catch (submissionError) { // Telemetry submission should never block core functionality if (getDebugFlag()) { log("debug", `Telemetry submission error: ${submissionError.message}`); } } return telemetryData; } catch (error) { log("error", `Failed to log AI usage telemetry: ${error.message}`, { error, }); // Don't re-throw; telemetry failure shouldn't block core functionality. return null; } } export { generateTextService, streamTextService, generateObjectService, logAiUsage, };