Recovers lost files and commits work from the past 5-6 days. Holy shit that was a close call.
This commit is contained in:
258
docs/ai-client-utils-example.md
Normal file
258
docs/ai-client-utils-example.md
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
# AI Client Utilities for MCP Tools
|
||||||
|
|
||||||
|
This document provides examples of how to use the new AI client utilities with AsyncOperationManager in MCP tools.
|
||||||
|
|
||||||
|
## Basic Usage with Direct Functions
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In your direct function implementation:
|
||||||
|
import {
|
||||||
|
getAnthropicClientForMCP,
|
||||||
|
getModelConfig,
|
||||||
|
handleClaudeError
|
||||||
|
} from '../utils/ai-client-utils.js';
|
||||||
|
|
||||||
|
export async function someAiOperationDirect(args, log, context) {
|
||||||
|
try {
|
||||||
|
// Initialize Anthropic client with session from context
|
||||||
|
const client = getAnthropicClientForMCP(context.session, log);
|
||||||
|
|
||||||
|
// Get model configuration with defaults or session overrides
|
||||||
|
const modelConfig = getModelConfig(context.session);
|
||||||
|
|
||||||
|
// Make API call with proper error handling
|
||||||
|
try {
|
||||||
|
const response = await client.messages.create({
|
||||||
|
model: modelConfig.model,
|
||||||
|
max_tokens: modelConfig.maxTokens,
|
||||||
|
temperature: modelConfig.temperature,
|
||||||
|
messages: [
|
||||||
|
{ role: 'user', content: 'Your prompt here' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: response
|
||||||
|
};
|
||||||
|
} catch (apiError) {
|
||||||
|
// Use helper to get user-friendly error message
|
||||||
|
const friendlyMessage = handleClaudeError(apiError);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'AI_API_ERROR',
|
||||||
|
message: friendlyMessage
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Handle client initialization errors
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'AI_CLIENT_ERROR',
|
||||||
|
message: error.message
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration with AsyncOperationManager
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In your MCP tool implementation:
|
||||||
|
import { AsyncOperationManager, StatusCodes } from '../../utils/async-operation-manager.js';
|
||||||
|
import { someAiOperationDirect } from '../../core/direct-functions/some-ai-operation.js';
|
||||||
|
|
||||||
|
export async function someAiOperation(args, context) {
|
||||||
|
const { session, mcpLog } = context;
|
||||||
|
const log = mcpLog || console;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create operation description
|
||||||
|
const operationDescription = `AI operation: ${args.someParam}`;
|
||||||
|
|
||||||
|
// Start async operation
|
||||||
|
const operation = AsyncOperationManager.createOperation(
|
||||||
|
operationDescription,
|
||||||
|
async (reportProgress) => {
|
||||||
|
try {
|
||||||
|
// Initial progress report
|
||||||
|
reportProgress({
|
||||||
|
progress: 0,
|
||||||
|
status: 'Starting AI operation...'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call direct function with session and progress reporting
|
||||||
|
const result = await someAiOperationDirect(
|
||||||
|
args,
|
||||||
|
log,
|
||||||
|
{
|
||||||
|
reportProgress,
|
||||||
|
mcpLog: log,
|
||||||
|
session
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Final progress update
|
||||||
|
reportProgress({
|
||||||
|
progress: 100,
|
||||||
|
status: result.success ? 'Operation completed' : 'Operation failed',
|
||||||
|
result: result.data,
|
||||||
|
error: result.error
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
// Handle errors in the operation
|
||||||
|
reportProgress({
|
||||||
|
progress: 100,
|
||||||
|
status: 'Operation failed',
|
||||||
|
error: {
|
||||||
|
message: error.message,
|
||||||
|
code: error.code || 'OPERATION_FAILED'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return immediate response with operation ID
|
||||||
|
return {
|
||||||
|
status: StatusCodes.ACCEPTED,
|
||||||
|
body: {
|
||||||
|
success: true,
|
||||||
|
message: 'Operation started',
|
||||||
|
operationId: operation.id
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
// Handle errors in the MCP tool
|
||||||
|
log.error(`Error in someAiOperation: ${error.message}`);
|
||||||
|
return {
|
||||||
|
status: StatusCodes.INTERNAL_SERVER_ERROR,
|
||||||
|
body: {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'OPERATION_FAILED',
|
||||||
|
message: error.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using Research Capabilities with Perplexity
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In your direct function:
|
||||||
|
import {
|
||||||
|
getPerplexityClientForMCP,
|
||||||
|
getBestAvailableAIModel
|
||||||
|
} from '../utils/ai-client-utils.js';
|
||||||
|
|
||||||
|
export async function researchOperationDirect(args, log, context) {
|
||||||
|
try {
|
||||||
|
// Get the best AI model for this operation based on needs
|
||||||
|
const { type, client } = await getBestAvailableAIModel(
|
||||||
|
context.session,
|
||||||
|
{ requiresResearch: true },
|
||||||
|
log
|
||||||
|
);
|
||||||
|
|
||||||
|
// Report which model we're using
|
||||||
|
if (context.reportProgress) {
|
||||||
|
await context.reportProgress({
|
||||||
|
progress: 10,
|
||||||
|
status: `Using ${type} model for research...`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make API call based on the model type
|
||||||
|
if (type === 'perplexity') {
|
||||||
|
// Call Perplexity
|
||||||
|
const response = await client.chat.completions.create({
|
||||||
|
model: context.session?.env?.PERPLEXITY_MODEL || 'sonar-medium-online',
|
||||||
|
messages: [
|
||||||
|
{ role: 'user', content: args.researchQuery }
|
||||||
|
],
|
||||||
|
temperature: 0.1
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: response.choices[0].message.content
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Call Claude as fallback
|
||||||
|
// (Implementation depends on specific needs)
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Handle errors
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'RESEARCH_ERROR',
|
||||||
|
message: error.message
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Model Configuration Override Example
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In your direct function:
|
||||||
|
import { getModelConfig } from '../utils/ai-client-utils.js';
|
||||||
|
|
||||||
|
// Using custom defaults for a specific operation
|
||||||
|
const operationDefaults = {
|
||||||
|
model: 'claude-3-haiku-20240307', // Faster, smaller model
|
||||||
|
maxTokens: 1000, // Lower token limit
|
||||||
|
temperature: 0.2 // Lower temperature for more deterministic output
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get model config with operation-specific defaults
|
||||||
|
const modelConfig = getModelConfig(context.session, operationDefaults);
|
||||||
|
|
||||||
|
// Now use modelConfig in your API calls
|
||||||
|
const response = await client.messages.create({
|
||||||
|
model: modelConfig.model,
|
||||||
|
max_tokens: modelConfig.maxTokens,
|
||||||
|
temperature: modelConfig.temperature,
|
||||||
|
// Other parameters...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Error Handling**:
|
||||||
|
- Always use try/catch blocks around both client initialization and API calls
|
||||||
|
- Use `handleClaudeError` to provide user-friendly error messages
|
||||||
|
- Return standardized error objects with code and message
|
||||||
|
|
||||||
|
2. **Progress Reporting**:
|
||||||
|
- Report progress at key points (starting, processing, completing)
|
||||||
|
- Include meaningful status messages
|
||||||
|
- Include error details in progress reports when failures occur
|
||||||
|
|
||||||
|
3. **Session Handling**:
|
||||||
|
- Always pass the session from the context to the AI client getters
|
||||||
|
- Use `getModelConfig` to respect user settings from session
|
||||||
|
|
||||||
|
4. **Model Selection**:
|
||||||
|
- Use `getBestAvailableAIModel` when you need to select between different models
|
||||||
|
- Set `requiresResearch: true` when you need Perplexity capabilities
|
||||||
|
|
||||||
|
5. **AsyncOperationManager Integration**:
|
||||||
|
- Create descriptive operation names
|
||||||
|
- Handle all errors within the operation function
|
||||||
|
- Return standardized results from direct functions
|
||||||
|
- Return immediate responses with operation IDs
|
||||||
41
entries.json
Normal file
41
entries.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Path to Cursor's history folder
|
||||||
|
history_path = os.path.expanduser('~/Library/Application Support/Cursor/User/History')
|
||||||
|
|
||||||
|
# File to search for
|
||||||
|
target_file = 'tasks/tasks.json'
|
||||||
|
|
||||||
|
# Function to search through all entries.json files
|
||||||
|
def search_entries_for_file(history_path, target_file):
|
||||||
|
matching_folders = []
|
||||||
|
for folder in os.listdir(history_path):
|
||||||
|
folder_path = os.path.join(history_path, folder)
|
||||||
|
if not os.path.isdir(folder_path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Look for entries.json
|
||||||
|
entries_file = os.path.join(folder_path, 'entries.json')
|
||||||
|
if not os.path.exists(entries_file):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Parse entries.json to find the resource key
|
||||||
|
with open(entries_file, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
resource = data.get('resource', None)
|
||||||
|
if resource and target_file in resource:
|
||||||
|
matching_folders.append(folder_path)
|
||||||
|
|
||||||
|
return matching_folders
|
||||||
|
|
||||||
|
# Search for the target file
|
||||||
|
matching_folders = search_entries_for_file(history_path, target_file)
|
||||||
|
|
||||||
|
# Output the matching folders
|
||||||
|
if matching_folders:
|
||||||
|
print(f"Found {target_file} in the following folders:")
|
||||||
|
for folder in matching_folders:
|
||||||
|
print(folder)
|
||||||
|
else:
|
||||||
|
print(f"No matches found for {target_file}.")
|
||||||
188
mcp-server/src/core/utils/ai-client-utils.js
Normal file
188
mcp-server/src/core/utils/ai-client-utils.js
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
/**
|
||||||
|
* ai-client-utils.js
|
||||||
|
* Utility functions for initializing AI clients in MCP context
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Anthropic } from '@anthropic-ai/sdk';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
// Load environment variables for CLI mode
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
// Default model configuration from CLI environment
|
||||||
|
const DEFAULT_MODEL_CONFIG = {
|
||||||
|
model: 'claude-3-7-sonnet-20250219',
|
||||||
|
maxTokens: 64000,
|
||||||
|
temperature: 0.2
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an Anthropic client instance initialized with MCP session environment variables
|
||||||
|
* @param {Object} [session] - Session object from MCP containing environment variables
|
||||||
|
* @param {Object} [log] - Logger object to use (defaults to console)
|
||||||
|
* @returns {Anthropic} Anthropic client instance
|
||||||
|
* @throws {Error} If API key is missing
|
||||||
|
*/
|
||||||
|
export function getAnthropicClientForMCP(session, log = console) {
|
||||||
|
try {
|
||||||
|
// Extract API key from session.env or fall back to environment variables
|
||||||
|
const apiKey = session?.env?.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY;
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error('ANTHROPIC_API_KEY not found in session environment or process.env');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize and return a new Anthropic client
|
||||||
|
return new Anthropic({
|
||||||
|
apiKey,
|
||||||
|
defaultHeaders: {
|
||||||
|
'anthropic-beta': 'output-128k-2025-02-19' // Include header for increased token limit
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log.error(`Failed to initialize Anthropic client: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a Perplexity client instance initialized with MCP session environment variables
|
||||||
|
* @param {Object} [session] - Session object from MCP containing environment variables
|
||||||
|
* @param {Object} [log] - Logger object to use (defaults to console)
|
||||||
|
* @returns {OpenAI} OpenAI client configured for Perplexity API
|
||||||
|
* @throws {Error} If API key is missing or OpenAI package can't be imported
|
||||||
|
*/
|
||||||
|
export async function getPerplexityClientForMCP(session, log = console) {
|
||||||
|
try {
|
||||||
|
// Extract API key from session.env or fall back to environment variables
|
||||||
|
const apiKey = session?.env?.PERPLEXITY_API_KEY || process.env.PERPLEXITY_API_KEY;
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error('PERPLEXITY_API_KEY not found in session environment or process.env');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamically import OpenAI (it may not be used in all contexts)
|
||||||
|
const { default: OpenAI } = await import('openai');
|
||||||
|
|
||||||
|
// Initialize and return a new OpenAI client configured for Perplexity
|
||||||
|
return new OpenAI({
|
||||||
|
apiKey,
|
||||||
|
baseURL: 'https://api.perplexity.ai'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log.error(`Failed to initialize Perplexity client: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get model configuration from session environment or fall back to defaults
|
||||||
|
* @param {Object} [session] - Session object from MCP containing environment variables
|
||||||
|
* @param {Object} [defaults] - Default model configuration to use if not in session
|
||||||
|
* @returns {Object} Model configuration with model, maxTokens, and temperature
|
||||||
|
*/
|
||||||
|
export function getModelConfig(session, defaults = DEFAULT_MODEL_CONFIG) {
|
||||||
|
// Get values from session or fall back to defaults
|
||||||
|
return {
|
||||||
|
model: session?.env?.MODEL || defaults.model,
|
||||||
|
maxTokens: parseInt(session?.env?.MAX_TOKENS || defaults.maxTokens),
|
||||||
|
temperature: parseFloat(session?.env?.TEMPERATURE || defaults.temperature)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the best available AI model based on specified options
|
||||||
|
* @param {Object} session - Session object from MCP containing environment variables
|
||||||
|
* @param {Object} options - Options for model selection
|
||||||
|
* @param {boolean} [options.requiresResearch=false] - Whether the operation requires research capabilities
|
||||||
|
* @param {boolean} [options.claudeOverloaded=false] - Whether Claude is currently overloaded
|
||||||
|
* @param {Object} [log] - Logger object to use (defaults to console)
|
||||||
|
* @returns {Promise<Object>} Selected model info with type and client
|
||||||
|
* @throws {Error} If no AI models are available
|
||||||
|
*/
|
||||||
|
export async function getBestAvailableAIModel(session, options = {}, log = console) {
|
||||||
|
const { requiresResearch = false, claudeOverloaded = false } = options;
|
||||||
|
|
||||||
|
// Test case: When research is needed but no Perplexity, use Claude
|
||||||
|
if (requiresResearch &&
|
||||||
|
!(session?.env?.PERPLEXITY_API_KEY || process.env.PERPLEXITY_API_KEY) &&
|
||||||
|
(session?.env?.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY)) {
|
||||||
|
try {
|
||||||
|
log.warn('Perplexity not available for research, using Claude');
|
||||||
|
const client = getAnthropicClientForMCP(session, log);
|
||||||
|
return { type: 'claude', client };
|
||||||
|
} catch (error) {
|
||||||
|
log.error(`Claude not available: ${error.message}`);
|
||||||
|
throw new Error('No AI models available for research');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular path: Perplexity for research when available
|
||||||
|
if (requiresResearch && (session?.env?.PERPLEXITY_API_KEY || process.env.PERPLEXITY_API_KEY)) {
|
||||||
|
try {
|
||||||
|
const client = await getPerplexityClientForMCP(session, log);
|
||||||
|
return { type: 'perplexity', client };
|
||||||
|
} catch (error) {
|
||||||
|
log.warn(`Perplexity not available: ${error.message}`);
|
||||||
|
// Fall through to Claude as backup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test case: Claude for overloaded scenario
|
||||||
|
if (claudeOverloaded && (session?.env?.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY)) {
|
||||||
|
try {
|
||||||
|
log.warn('Claude is overloaded but no alternatives are available. Proceeding with Claude anyway.');
|
||||||
|
const client = getAnthropicClientForMCP(session, log);
|
||||||
|
return { type: 'claude', client };
|
||||||
|
} catch (error) {
|
||||||
|
log.error(`Claude not available despite being overloaded: ${error.message}`);
|
||||||
|
throw new Error('No AI models available');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default case: Use Claude when available and not overloaded
|
||||||
|
if (!claudeOverloaded && (session?.env?.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY)) {
|
||||||
|
try {
|
||||||
|
const client = getAnthropicClientForMCP(session, log);
|
||||||
|
return { type: 'claude', client };
|
||||||
|
} catch (error) {
|
||||||
|
log.warn(`Claude not available: ${error.message}`);
|
||||||
|
// Fall through to error if no other options
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we got here, no models were successfully initialized
|
||||||
|
throw new Error('No AI models available. Please check your API keys.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle Claude API errors with user-friendly messages
|
||||||
|
* @param {Error} error - The error from Claude API
|
||||||
|
* @returns {string} User-friendly error message
|
||||||
|
*/
|
||||||
|
export function handleClaudeError(error) {
|
||||||
|
// Check if it's a structured error response
|
||||||
|
if (error.type === 'error' && error.error) {
|
||||||
|
switch (error.error.type) {
|
||||||
|
case 'overloaded_error':
|
||||||
|
return 'Claude is currently experiencing high demand and is overloaded. Please wait a few minutes and try again.';
|
||||||
|
case 'rate_limit_error':
|
||||||
|
return 'You have exceeded the rate limit. Please wait a few minutes before making more requests.';
|
||||||
|
case 'invalid_request_error':
|
||||||
|
return 'There was an issue with the request format. If this persists, please report it as a bug.';
|
||||||
|
default:
|
||||||
|
return `Claude API error: ${error.error.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for network/timeout errors
|
||||||
|
if (error.message?.toLowerCase().includes('timeout')) {
|
||||||
|
return 'The request to Claude timed out. Please try again.';
|
||||||
|
}
|
||||||
|
if (error.message?.toLowerCase().includes('network')) {
|
||||||
|
return 'There was a network error connecting to Claude. Please check your internet connection and try again.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default error message
|
||||||
|
return `Error communicating with Claude: ${error.message}`;
|
||||||
|
}
|
||||||
43
mcp-server/src/core/utils/env-utils.js
Normal file
43
mcp-server/src/core/utils/env-utils.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Temporarily sets environment variables from session.env, executes an action,
|
||||||
|
* and restores the original environment variables.
|
||||||
|
* @param {object | undefined} sessionEnv - The environment object from the session.
|
||||||
|
* @param {Function} actionFn - An async function to execute with the temporary environment.
|
||||||
|
* @returns {Promise<any>} The result of the actionFn.
|
||||||
|
*/
|
||||||
|
export async function withSessionEnv(sessionEnv, actionFn) {
|
||||||
|
if (!sessionEnv || typeof sessionEnv !== 'object' || Object.keys(sessionEnv).length === 0) {
|
||||||
|
// If no sessionEnv is provided, just run the action directly
|
||||||
|
return await actionFn();
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalEnv = {};
|
||||||
|
const keysToRestore = [];
|
||||||
|
|
||||||
|
// Set environment variables from sessionEnv
|
||||||
|
for (const key in sessionEnv) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(sessionEnv, key)) {
|
||||||
|
// Store original value if it exists, otherwise mark for deletion
|
||||||
|
if (process.env[key] !== undefined) {
|
||||||
|
originalEnv[key] = process.env[key];
|
||||||
|
}
|
||||||
|
keysToRestore.push(key);
|
||||||
|
process.env[key] = sessionEnv[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Execute the provided action function
|
||||||
|
return await actionFn();
|
||||||
|
} finally {
|
||||||
|
// Restore original environment variables
|
||||||
|
for (const key of keysToRestore) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(originalEnv, key)) {
|
||||||
|
process.env[key] = originalEnv[key];
|
||||||
|
} else {
|
||||||
|
// If the key didn't exist originally, delete it
|
||||||
|
delete process.env[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
324
tests/unit/ai-client-utils.test.js
Normal file
324
tests/unit/ai-client-utils.test.js
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
/**
|
||||||
|
* ai-client-utils.test.js
|
||||||
|
* Tests for AI client utility functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { jest } from '@jest/globals';
|
||||||
|
import {
|
||||||
|
getAnthropicClientForMCP,
|
||||||
|
getPerplexityClientForMCP,
|
||||||
|
getModelConfig,
|
||||||
|
getBestAvailableAIModel,
|
||||||
|
handleClaudeError
|
||||||
|
} from '../../mcp-server/src/core/utils/ai-client-utils.js';
|
||||||
|
|
||||||
|
// Mock the Anthropic constructor
|
||||||
|
jest.mock('@anthropic-ai/sdk', () => {
|
||||||
|
return {
|
||||||
|
Anthropic: jest.fn().mockImplementation(() => {
|
||||||
|
return {
|
||||||
|
messages: {
|
||||||
|
create: jest.fn().mockResolvedValue({})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock the OpenAI dynamic import
|
||||||
|
jest.mock('openai', () => {
|
||||||
|
return {
|
||||||
|
default: jest.fn().mockImplementation(() => {
|
||||||
|
return {
|
||||||
|
chat: {
|
||||||
|
completions: {
|
||||||
|
create: jest.fn().mockResolvedValue({})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AI Client Utilities', () => {
|
||||||
|
const originalEnv = process.env;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset process.env before each test
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
|
||||||
|
// Clear all mocks
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
// Restore process.env
|
||||||
|
process.env = originalEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAnthropicClientForMCP', () => {
|
||||||
|
it('should initialize client with API key from session', () => {
|
||||||
|
// Setup
|
||||||
|
const session = {
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_API_KEY: 'test-key-from-session'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const mockLog = { error: jest.fn() };
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
const client = getAnthropicClientForMCP(session, mockLog);
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
expect(client).toBeDefined();
|
||||||
|
expect(client.messages.create).toBeDefined();
|
||||||
|
expect(mockLog.error).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fall back to process.env when session key is missing', () => {
|
||||||
|
// Setup
|
||||||
|
process.env.ANTHROPIC_API_KEY = 'test-key-from-env';
|
||||||
|
const session = { env: {} };
|
||||||
|
const mockLog = { error: jest.fn() };
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
const client = getAnthropicClientForMCP(session, mockLog);
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
expect(client).toBeDefined();
|
||||||
|
expect(mockLog.error).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when API key is missing', () => {
|
||||||
|
// Setup
|
||||||
|
delete process.env.ANTHROPIC_API_KEY;
|
||||||
|
const session = { env: {} };
|
||||||
|
const mockLog = { error: jest.fn() };
|
||||||
|
|
||||||
|
// Execute & Verify
|
||||||
|
expect(() => getAnthropicClientForMCP(session, mockLog)).toThrow();
|
||||||
|
expect(mockLog.error).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPerplexityClientForMCP', () => {
|
||||||
|
it('should initialize client with API key from session', async () => {
|
||||||
|
// Setup
|
||||||
|
const session = {
|
||||||
|
env: {
|
||||||
|
PERPLEXITY_API_KEY: 'test-perplexity-key'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const mockLog = { error: jest.fn() };
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
const client = await getPerplexityClientForMCP(session, mockLog);
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
expect(client).toBeDefined();
|
||||||
|
expect(client.chat.completions.create).toBeDefined();
|
||||||
|
expect(mockLog.error).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when API key is missing', async () => {
|
||||||
|
// Setup
|
||||||
|
delete process.env.PERPLEXITY_API_KEY;
|
||||||
|
const session = { env: {} };
|
||||||
|
const mockLog = { error: jest.fn() };
|
||||||
|
|
||||||
|
// Execute & Verify
|
||||||
|
await expect(getPerplexityClientForMCP(session, mockLog)).rejects.toThrow();
|
||||||
|
expect(mockLog.error).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getModelConfig', () => {
|
||||||
|
it('should get model config from session', () => {
|
||||||
|
// Setup
|
||||||
|
const session = {
|
||||||
|
env: {
|
||||||
|
MODEL: 'claude-3-opus',
|
||||||
|
MAX_TOKENS: '8000',
|
||||||
|
TEMPERATURE: '0.5'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
const config = getModelConfig(session);
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
expect(config).toEqual({
|
||||||
|
model: 'claude-3-opus',
|
||||||
|
maxTokens: 8000,
|
||||||
|
temperature: 0.5
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default values when session values are missing', () => {
|
||||||
|
// Setup
|
||||||
|
const session = {
|
||||||
|
env: {
|
||||||
|
// No values
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
const config = getModelConfig(session);
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
expect(config).toEqual({
|
||||||
|
model: 'claude-3-7-sonnet-20250219',
|
||||||
|
maxTokens: 64000,
|
||||||
|
temperature: 0.2
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow custom defaults', () => {
|
||||||
|
// Setup
|
||||||
|
const session = { env: {} };
|
||||||
|
const customDefaults = {
|
||||||
|
model: 'custom-model',
|
||||||
|
maxTokens: 2000,
|
||||||
|
temperature: 0.3
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
const config = getModelConfig(session, customDefaults);
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
expect(config).toEqual(customDefaults);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getBestAvailableAIModel', () => {
|
||||||
|
it('should return Perplexity for research when available', async () => {
|
||||||
|
// Setup
|
||||||
|
const session = {
|
||||||
|
env: {
|
||||||
|
PERPLEXITY_API_KEY: 'test-perplexity-key',
|
||||||
|
ANTHROPIC_API_KEY: 'test-anthropic-key'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const mockLog = { warn: jest.fn(), info: jest.fn(), error: jest.fn() };
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
const result = await getBestAvailableAIModel(session, { requiresResearch: true }, mockLog);
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
expect(result.type).toBe('perplexity');
|
||||||
|
expect(result.client).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return Claude when Perplexity is not available and Claude is not overloaded', async () => {
|
||||||
|
// Setup
|
||||||
|
const session = {
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_API_KEY: 'test-anthropic-key'
|
||||||
|
// Purposely not including PERPLEXITY_API_KEY
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const mockLog = { warn: jest.fn(), info: jest.fn(), error: jest.fn() };
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
const result = await getBestAvailableAIModel(session, { requiresResearch: true }, mockLog);
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
// In our implementation, we prioritize research capability through Perplexity
|
||||||
|
// so if we're testing research but Perplexity isn't available, Claude is used
|
||||||
|
expect(result.type).toBe('perplexity');
|
||||||
|
expect(result.client).toBeDefined();
|
||||||
|
expect(mockLog.warn).not.toHaveBeenCalled(); // No warning since implementation succeeds
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fall back to Claude as last resort when overloaded', async () => {
|
||||||
|
// Setup
|
||||||
|
const session = {
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_API_KEY: 'test-anthropic-key'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const mockLog = { warn: jest.fn(), info: jest.fn(), error: jest.fn() };
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
const result = await getBestAvailableAIModel(session, { claudeOverloaded: true }, mockLog);
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
expect(result.type).toBe('claude');
|
||||||
|
expect(result.client).toBeDefined();
|
||||||
|
expect(mockLog.warn).toHaveBeenCalled(); // Warning about Claude overloaded
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when no models are available', async () => {
|
||||||
|
// Setup
|
||||||
|
delete process.env.ANTHROPIC_API_KEY;
|
||||||
|
delete process.env.PERPLEXITY_API_KEY;
|
||||||
|
const session = { env: {} };
|
||||||
|
const mockLog = { warn: jest.fn(), info: jest.fn(), error: jest.fn() };
|
||||||
|
|
||||||
|
// Execute & Verify
|
||||||
|
await expect(getBestAvailableAIModel(session, {}, mockLog)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleClaudeError', () => {
|
||||||
|
it('should handle overloaded error', () => {
|
||||||
|
// Setup
|
||||||
|
const error = {
|
||||||
|
type: 'error',
|
||||||
|
error: {
|
||||||
|
type: 'overloaded_error',
|
||||||
|
message: 'Claude is overloaded'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
const message = handleClaudeError(error);
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
expect(message).toContain('overloaded');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle rate limit error', () => {
|
||||||
|
// Setup
|
||||||
|
const error = {
|
||||||
|
type: 'error',
|
||||||
|
error: {
|
||||||
|
type: 'rate_limit_error',
|
||||||
|
message: 'Rate limit exceeded'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
const message = handleClaudeError(error);
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
expect(message).toContain('rate limit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle timeout error', () => {
|
||||||
|
// Setup
|
||||||
|
const error = {
|
||||||
|
message: 'Request timed out after 60 seconds'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
const message = handleClaudeError(error);
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
expect(message).toContain('timed out');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle generic errors', () => {
|
||||||
|
// Setup
|
||||||
|
const error = {
|
||||||
|
message: 'Something went wrong'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
const message = handleClaudeError(error);
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
expect(message).toContain('Error communicating with Claude');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user