diff --git a/.env.example b/.env.example index 5048f927..b616bcf0 100644 --- a/.env.example +++ b/.env.example @@ -19,3 +19,8 @@ PROXY_REVIEWER_TOKEN=ghp_your_token_here # App Configuration REPOSITORY_FOLDER=/app/repos + +# LLM Logging Configuration +# Controls what gets logged when exchanging with the LLM +# Options: disabled, metadata, truncated, full +LOG_LLM_EXCHANGES=metadata diff --git a/.kontinuous/env/prod/templates/app.configmap.yaml b/.kontinuous/env/prod/templates/app.configmap.yaml index e99aa011..7cb5f594 100644 --- a/.kontinuous/env/prod/templates/app.configmap.yaml +++ b/.kontinuous/env/prod/templates/app.configmap.yaml @@ -4,3 +4,9 @@ metadata: name: app data: REPOSITORY_FOLDER: "/app/repos" + LOG_LLM_EXCHANGES: "full" + +#LOG_LLM_EXCHANGES=metadata # production mode +#LOG_LLM_EXCHANGES=truncated # quick review +#LOG_LLM_EXCHANGES=full # full LLM data +#LOG_LLM_EXCHANGES=disabled # no LLM logs diff --git a/README.md b/README.md index 0d2a85ca..360c2169 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,7 @@ Requirements: | `REPOSITORY_FOLDER` | string | Absolute path where repositories will be cloned | | `PROXY_REVIEWER_USERNAME` | string | Username of the proxy user account for manual review requests | | `PROXY_REVIEWER_TOKEN` | string | GitHub personal access token for the proxy user account | +| `LOG_LLM_EXCHANGES` | string | (Optional) Control LLM logging level: disabled, metadata, truncated, full | ## Running the App @@ -447,6 +448,167 @@ Enable debug logging: DEBUG=revu:* yarn dev ``` +## LLM Exchange Logging + +Revu provides comprehensive logging of all exchanges with the LLM (Claude) to help with debugging, monitoring, and analysis. This feature is configurable and designed to balance visibility with security and performance. + +### Configuration + +Control LLM logging through the `LOG_LLM_EXCHANGES` environment variable: + +```bash +# Available logging levels +LOG_LLM_EXCHANGES=disabled # No LLM logging +LOG_LLM_EXCHANGES=metadata # Only metadata (default) +LOG_LLM_EXCHANGES=truncated # Metadata + truncated content +LOG_LLM_EXCHANGES=full # Complete prompts and responses +``` + +### Logging Levels + +#### `disabled` +- No LLM exchange logging +- Use for production environments with strict logging requirements + +#### `metadata` (default) +- Logs request/response metadata only +- Includes: model used, duration, token usage, strategy, PR details +- **Recommended for production**: Provides insights without content exposure + +#### `truncated` +- Metadata + first 500 characters of prompts/responses +- Balances debugging needs with content privacy +- **Recommended for development**: Good for debugging prompt issues + +#### `full` +- Complete prompts and responses logged +- Maximum visibility for debugging +- **Security warning**: Contains full source code and sensitive data + +### Log Format + +All LLM logs follow a structured JSON format: + +```json +{ + "timestamp": "2025-01-18T16:49:00.000Z", + "service": "revu", + "level": "info", + "event_type": "llm_request_sent", + "model_used": "claude-sonnet-4-20250514", + "strategy_name": "line-comments", + "pr_number": 123, + "repository": "owner/repo" +} +``` + +### Event Types + +- **`llm_request_sent`**: When a request is sent to Claude +- **`llm_response_received`**: When a response is received from Claude +- **`llm_request_failed`**: When an API request fails + +### Metadata Fields + +- `model_used`: Anthropic model used for the request +- `strategy_name`: Review strategy (e.g., "line-comments") +- `request_duration_ms`: Request duration in milliseconds +- `tokens_used`: Token usage `{input: number, output: number}` +- `pr_number`: Pull request number +- `repository`: Repository name +- `prompt_preview`: Truncated prompt (truncated/full modes) +- `response_preview`: Truncated response (truncated/full modes) +- `full_prompt`: Complete prompt (full mode only) +- `full_response`: Complete response (full mode only) + +### Use Cases + +#### Production Monitoring +```bash +LOG_LLM_EXCHANGES=metadata +``` +- Track API usage and performance +- Monitor token consumption +- Identify slow requests or failures + +#### Development Debugging +```bash +LOG_LLM_EXCHANGES=truncated +``` +- Debug prompt engineering issues +- Verify request/response flow +- Analyze response quality + +#### Deep Analysis +```bash +LOG_LLM_EXCHANGES=full +``` +- Full content analysis +- Prompt optimization +- Response quality assessment + +### Security Considerations + +- **`full` mode**: Logs contain complete source code and potentially sensitive data +- **`truncated` mode**: May still contain sensitive information in previews +- **`metadata` mode**: Safe for production, contains no code content +- **Log rotation**: Ensure proper log rotation for large volumes +- **Access control**: Restrict access to logs containing sensitive data + +### Performance Impact + +- **`disabled`**: No performance impact +- **`metadata`**: Minimal impact (recommended) +- **`truncated`**: Low impact, slight string processing overhead +- **`full`**: Moderate impact due to large log entries + +### Examples + +#### Request Sent (metadata level) +```json +{ + "timestamp": "2025-01-18T16:49:00.000Z", + "service": "revu", + "level": "info", + "event_type": "llm_request_sent", + "model_used": "claude-sonnet-4-20250514", + "strategy_name": "line-comments", + "pr_number": 123, + "repository": "owner/repo" +} +``` + +#### Response Received (metadata level) +```json +{ + "timestamp": "2025-01-18T16:49:02.500Z", + "service": "revu", + "level": "info", + "event_type": "llm_response_received", + "model_used": "claude-sonnet-4-20250514", + "strategy_name": "line-comments", + "request_duration_ms": 2500, + "tokens_used": {"input": 1500, "output": 800}, + "pr_number": 123, + "repository": "owner/repo" +} +``` + +#### Request Failed +```json +{ + "timestamp": "2025-01-18T16:49:03.000Z", + "service": "revu", + "level": "error", + "event_type": "llm_request_failed", + "model_used": "claude-sonnet-4-20250514", + "strategy_name": "line-comments", + "error_message": "API rate limit exceeded", + "pr_number": 123, + "repository": "owner/repo" +} +``` + ## Contributing 1. **Development Setup** diff --git a/__tests__/llm-logging.test.ts b/__tests__/llm-logging.test.ts new file mode 100644 index 00000000..8754a87d --- /dev/null +++ b/__tests__/llm-logging.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' + +// Mock console.log to capture log outputs +const mockConsoleLog = vi.spyOn(console, 'log').mockImplementation(() => {}) + +// Mock environment variables before importing the module +vi.stubEnv('LOG_LLM_EXCHANGES', 'metadata') + +import { + logLLMRequestSent, + logLLMResponseReceived, + logLLMRequestFailed +} from '../src/utils/logger.ts' + +describe('LLM Logging', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('logLLMRequestSent', () => { + it('should log request with metadata level', () => { + const prompt = 'Test prompt' + const model = 'claude-sonnet-4-20250514' + const strategyName = 'line-comments' + const context = { pr_number: 123, repository: 'test/repo' } + + logLLMRequestSent(prompt, model, strategyName, context) + + expect(mockConsoleLog).toHaveBeenCalledWith( + expect.stringContaining('"event_type":"llm_request_sent"') + ) + expect(mockConsoleLog).toHaveBeenCalledWith( + expect.stringContaining('"model_used":"claude-sonnet-4-20250514"') + ) + expect(mockConsoleLog).toHaveBeenCalledWith( + expect.stringContaining('"strategy_name":"line-comments"') + ) + expect(mockConsoleLog).toHaveBeenCalledWith( + expect.stringContaining('"pr_number":123') + ) + expect(mockConsoleLog).toHaveBeenCalledWith( + expect.stringContaining('"repository":"test/repo"') + ) + }) + + it('should include prompt preview in truncated mode', () => { + const longPrompt = 'a'.repeat(1000) + const model = 'claude-sonnet-4-20250514' + const strategyName = 'line-comments' + + // Mock environment to use truncated mode + vi.stubEnv('LOG_LLM_EXCHANGES', 'truncated') + + logLLMRequestSent(longPrompt, model, strategyName) + + expect(mockConsoleLog).toHaveBeenCalledWith( + expect.stringContaining('"prompt_preview"') + ) + }) + }) + + describe('logLLMResponseReceived', () => { + it('should log response with duration and token usage', () => { + const response = '{"summary": "Test response"}' + const model = 'claude-sonnet-4-20250514' + const strategyName = 'line-comments' + const durationMs = 1500 + const tokensUsed = { input: 100, output: 50 } + const context = { pr_number: 123, repository: 'test/repo' } + + logLLMResponseReceived( + response, + model, + strategyName, + durationMs, + tokensUsed, + context + ) + + expect(mockConsoleLog).toHaveBeenCalledWith( + expect.stringContaining('"event_type":"llm_response_received"') + ) + expect(mockConsoleLog).toHaveBeenCalledWith( + expect.stringContaining('"request_duration_ms":1500') + ) + expect(mockConsoleLog).toHaveBeenCalledWith( + expect.stringContaining('"tokens_used":{"input":100,"output":50}') + ) + expect(mockConsoleLog).toHaveBeenCalledWith( + expect.stringContaining('"pr_number":123') + ) + }) + + it('should truncate long responses in truncated mode', () => { + const longResponse = 'b'.repeat(1000) + const model = 'claude-sonnet-4-20250514' + const strategyName = 'line-comments' + const durationMs = 1500 + + // Mock environment to use truncated mode + vi.stubEnv('LOG_LLM_EXCHANGES', 'truncated') + + logLLMResponseReceived(longResponse, model, strategyName, durationMs) + + expect(mockConsoleLog).toHaveBeenCalledWith( + expect.stringContaining('"response_preview"') + ) + }) + }) + + describe('logLLMRequestFailed', () => { + it('should log request failure with error details', () => { + const error = new Error('API request failed') + const model = 'claude-sonnet-4-20250514' + const strategyName = 'line-comments' + const context = { pr_number: 123, repository: 'test/repo' } + + logLLMRequestFailed(error, model, strategyName, context) + + expect(mockConsoleLog).toHaveBeenCalledWith( + expect.stringContaining('"event_type":"llm_request_failed"') + ) + expect(mockConsoleLog).toHaveBeenCalledWith( + expect.stringContaining('"level":"error"') + ) + expect(mockConsoleLog).toHaveBeenCalledWith( + expect.stringContaining('"error_message":"API request failed"') + ) + expect(mockConsoleLog).toHaveBeenCalledWith( + expect.stringContaining('"pr_number":123') + ) + }) + }) + + describe('Log levels', () => { + it('should not log when disabled', () => { + vi.stubEnv('LOG_LLM_EXCHANGES', 'disabled') + + logLLMRequestSent('test', 'claude-sonnet-4-20250514', 'line-comments') + + expect(mockConsoleLog).not.toHaveBeenCalled() + }) + + it('should log only metadata when in metadata mode', () => { + vi.stubEnv('LOG_LLM_EXCHANGES', 'metadata') + + const prompt = 'Test prompt' + logLLMRequestSent(prompt, 'claude-sonnet-4-20250514', 'line-comments') + + const logCall = mockConsoleLog.mock.calls[0][0] + expect(logCall).not.toContain('"prompt_preview"') + expect(logCall).not.toContain('"full_prompt"') + }) + + it('should include full content in full mode', () => { + vi.stubEnv('LOG_LLM_EXCHANGES', 'full') + + const prompt = 'Test prompt' + logLLMRequestSent(prompt, 'claude-sonnet-4-20250514', 'line-comments') + + const logCall = mockConsoleLog.mock.calls[0][0] + expect(logCall).toContain('"full_prompt"') + expect(logCall).toContain('"prompt_preview"') + }) + }) +}) diff --git a/src/anthropic-senders/line-comments-sender.ts b/src/anthropic-senders/line-comments-sender.ts index c14262d5..bf68d3af 100644 --- a/src/anthropic-senders/line-comments-sender.ts +++ b/src/anthropic-senders/line-comments-sender.ts @@ -1,5 +1,10 @@ import Anthropic from '@anthropic-ai/sdk' -import { logSystemError } from '../utils/logger.ts' +import { + logSystemError, + logLLMRequestSent, + logLLMResponseReceived, + logLLMRequestFailed +} from '../utils/logger.ts' // Type for code review response interface CodeReviewResponse { @@ -25,164 +30,216 @@ interface CodeReviewResponse { * @returns A stringified JSON response containing structured review comments */ export async function lineCommentsSender(prompt: string): Promise { - // Initialize Anthropic client - const anthropic = new Anthropic({ - apiKey: process.env.ANTHROPIC_API_KEY - }) - - // Send to Anthropic API with tool use configuration - const message = await anthropic.messages.create({ - model: process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-20250514', - max_tokens: 4096, - temperature: 0, - messages: [ - { - role: 'user', - content: prompt - } - ], - tools: [ - { - name: 'provide_code_review', - description: - 'Provide structured code review with line-specific comments', - input_schema: { - type: 'object', - properties: { - summary: { - type: 'string', - description: 'Overall summary of the PR' - }, - comments: { - type: 'array', - items: { - type: 'object', - properties: { - path: { - type: 'string', - description: 'File path relative to repository root' - }, - line: { - type: 'integer', - description: - 'End line number for the comment (or single line if start_line not provided)' - }, - start_line: { - type: 'integer', - description: - 'Start line number for multi-line comments (optional). Must be <= line.' - }, - body: { - type: 'string', - description: 'Detailed comment about the issue' - }, - search_replace_blocks: { - type: 'array', - items: { - type: 'object', - properties: { - search: { - type: 'string', - description: - 'Exact code content to find. Must match character-for-character including whitespace, indentation, and line endings.' + const model = process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-20250514' + const strategyName = 'line-comments' + + try { + // Log request being sent + logLLMRequestSent(prompt, model, strategyName) + + const startTime = Date.now() + + // Initialize Anthropic client + const anthropic = new Anthropic({ + apiKey: process.env.ANTHROPIC_API_KEY + }) + + // Send to Anthropic API with tool use configuration + const message = await anthropic.messages.create({ + model, + max_tokens: 4096, + temperature: 0, + messages: [ + { + role: 'user', + content: prompt + } + ], + tools: [ + { + name: 'provide_code_review', + description: + 'Provide structured code review with line-specific comments', + input_schema: { + type: 'object', + properties: { + summary: { + type: 'string', + description: 'Overall summary of the PR' + }, + comments: { + type: 'array', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'File path relative to repository root' + }, + line: { + type: 'integer', + description: + 'End line number for the comment (or single line if start_line not provided)' + }, + start_line: { + type: 'integer', + description: + 'Start line number for multi-line comments (optional). Must be <= line.' + }, + body: { + type: 'string', + description: 'Detailed comment about the issue' + }, + search_replace_blocks: { + type: 'array', + items: { + type: 'object', + properties: { + search: { + type: 'string', + description: + 'Exact code content to find. Must match character-for-character including whitespace, indentation, and line endings.' + }, + replace: { + type: 'string', + description: + 'New code content to replace the search content with.' + } }, - replace: { - type: 'string', - description: - 'New code content to replace the search content with.' - } + required: ['search', 'replace'] }, - required: ['search', 'replace'] - }, - description: - 'SEARCH/REPLACE blocks for precise code modifications. Each search block must match existing code exactly.' - } - }, - required: ['path', 'line', 'body'] + description: + 'SEARCH/REPLACE blocks for precise code modifications. Each search block must match existing code exactly.' + } + }, + required: ['path', 'line', 'body'] + } } - } - }, - required: ['summary', 'comments'] + }, + required: ['summary', 'comments'] + } } - } - ] - }) - - let fallbackResult = '' - let hasJsonFallback = false - - // Extract response from tool use - // Find content blocks that are tool_use type - for (const content of message.content) { - if (content.type === 'tool_use') { - if (content.name === 'provide_code_review' && content.input) { - // Return the structured response as a JSON string - return JSON.stringify(content.input as CodeReviewResponse) - } else { - const errPayload = { - name: content.name, - input: content.input + ] + }) + + const durationMs = Date.now() - startTime + + // Extract token usage if available + const tokensUsed = message.usage + ? { + input: message.usage.input_tokens, + output: message.usage.output_tokens } - logSystemError( - new Error( - `Unexpected tool use response: ${JSON.stringify(errPayload)}` - ) - ) - throw new Error(`Unexpected tool name: ${content.name}`) - } - } else if (content.type === 'text') { - // Fallback if tool use failed or returned unexpected format - console.warn( - 'Tool use failed, attempting fallback parsing from text response' - ) + : undefined - try { - const text = content.text + let fallbackResult = '' + let hasJsonFallback = false + + // Extract response from tool use + // Find content blocks that are tool_use type + for (const content of message.content) { + if (content.type === 'tool_use') { + if (content.name === 'provide_code_review' && content.input) { + // Return the structured response as a JSON string + const response = JSON.stringify(content.input as CodeReviewResponse) + + // Log successful response + logLLMResponseReceived( + response, + model, + strategyName, + durationMs, + tokensUsed + ) - // Try to extract JSON from code blocks first - const jsonMatch = text.match(/```json\n([\s\S]{1,10000}?)\n```/) - if (jsonMatch && jsonMatch[1]) { - console.log('Found JSON in code block, using as fallback') - fallbackResult = jsonMatch[1].trim() - hasJsonFallback = true - continue // Don't overwrite with plain text + return response + } else { + const errPayload = { + name: content.name, + input: content.input + } + logSystemError( + new Error( + `Unexpected tool use response: ${JSON.stringify(errPayload)}` + ) + ) + throw new Error(`Unexpected tool name: ${content.name}`) } + } else if (content.type === 'text') { + // Fallback if tool use failed or returned unexpected format + console.warn( + 'Tool use failed, attempting fallback parsing from text response' + ) - // If the whole response looks like JSON - const trimmedText = text.trim() - if (trimmedText.startsWith('{') && trimmedText.endsWith('}')) { - try { - // Validate that it's actually valid JSON - JSON.parse(trimmedText) - console.log('Response appears to be JSON, using as fallback') - fallbackResult = trimmedText + try { + const text = content.text + + // Try to extract JSON from code blocks first + const jsonMatch = text.match(/```json\n([\s\S]{1,10000}?)\n```/) + if (jsonMatch && jsonMatch[1]) { + console.log('Found JSON in code block, using as fallback') + fallbackResult = jsonMatch[1].trim() hasJsonFallback = true continue // Don't overwrite with plain text - } catch (error) { - console.warn('Text looks like JSON but failed to parse:', error) - // Continue to check for plain text fallback } - } - // Only use plain text as fallback if we haven't found JSON - if (!hasJsonFallback) { - console.warn('No JSON found, using plain text as fallback') - fallbackResult = text + // If the whole response looks like JSON + const trimmedText = text.trim() + if (trimmedText.startsWith('{') && trimmedText.endsWith('}')) { + try { + // Validate that it's actually valid JSON + JSON.parse(trimmedText) + console.log('Response appears to be JSON, using as fallback') + fallbackResult = trimmedText + hasJsonFallback = true + continue // Don't overwrite with plain text + } catch (error) { + console.warn('Text looks like JSON but failed to parse:', error) + // Continue to check for plain text fallback + } + } + + // Only use plain text as fallback if we haven't found JSON + if (!hasJsonFallback) { + console.warn('No JSON found, using plain text as fallback') + fallbackResult = text + } + } catch (error) { + logSystemError(error, { + context_msg: 'Error processing fallback text' + }) + // Continue to next content block } - } catch (error) { - logSystemError(error, { context_msg: 'Error processing fallback text' }) - // Continue to next content block } } - } - if (fallbackResult) { - // If we have a fallback result, return it - console.log('Returning fallback result, hasJsonFallback:', hasJsonFallback) - return fallbackResult - } + if (fallbackResult) { + // If we have a fallback result, return it + console.log( + 'Returning fallback result, hasJsonFallback:', + hasJsonFallback + ) - throw new Error( - 'Unexpected response format from Anthropic - no content found' - ) + // Log fallback response + logLLMResponseReceived( + fallbackResult, + model, + strategyName, + durationMs, + tokensUsed + ) + + return fallbackResult + } + + throw new Error( + 'Unexpected response format from Anthropic - no content found' + ) + } catch (error) { + // Log request failure + logLLMRequestFailed(error, model, strategyName) + + // Re-throw the error to maintain existing error handling + throw error + } } diff --git a/src/utils/logger.ts b/src/utils/logger.ts index ac42181b..0b8b0f15 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -29,9 +29,30 @@ interface SystemLogEntry extends BaseLogEntry { error_name?: string } -function createLogEntry( - partial: Omit -): T { +interface LLMLogEntry extends BaseLogEntry { + event_type: + | 'llm_request_sent' + | 'llm_response_received' + | 'llm_request_failed' + pr_number?: number + repository?: string + model_used: string + strategy_name: string + request_duration_ms?: number + tokens_used?: { + input: number + output: number + } + prompt_preview?: string + response_preview?: string + full_prompt?: string + full_response?: string + error_message?: string +} + +function createLogEntry< + T extends ReviewLogEntry | SystemLogEntry | LLMLogEntry +>(partial: Omit): T { const entry = { timestamp: new Date().toISOString(), service: 'revu' as const, @@ -41,7 +62,7 @@ function createLogEntry( return entry as T } -function log(entry: ReviewLogEntry | SystemLogEntry) { +function log(entry: ReviewLogEntry | SystemLogEntry | LLMLogEntry) { console.log(JSON.stringify(entry)) } @@ -170,3 +191,156 @@ export function logSystemWarning( }) ) } + +// LLM logging configuration +type LLMLogLevel = 'disabled' | 'metadata' | 'truncated' | 'full' + +const CONTENT_TRUNCATE_LENGTH = 500 + +/** + * Gets the current LLM log level from environment variables + */ +function getLLMLogLevel(): LLMLogLevel { + return (process.env.LOG_LLM_EXCHANGES as LLMLogLevel) || 'metadata' +} + +interface LLMLogContext { + pr_number?: number + repository?: string +} + +/** + * Truncates content to preview length with ellipsis + */ +function truncateContent( + content: string, + maxLength: number = CONTENT_TRUNCATE_LENGTH +): string { + if (content.length <= maxLength) return content + return content.substring(0, maxLength) + '...' +} + +/** + * Logs LLM request being sent + */ +export function logLLMRequestSent( + prompt: string, + model: string, + strategyName: string, + context?: LLMLogContext +) { + const logLevel = getLLMLogLevel() + if (logLevel === 'disabled') return + + const baseEntry = { + level: 'info' as const, + event_type: 'llm_request_sent' as const, + model_used: model, + strategy_name: strategyName, + ...context + } + + let entry: Omit + + switch (logLevel) { + case 'metadata': + entry = baseEntry + break + case 'truncated': + entry = { + ...baseEntry, + prompt_preview: truncateContent(prompt) + } + break + case 'full': + entry = { + ...baseEntry, + full_prompt: prompt, + prompt_preview: truncateContent(prompt) + } + break + default: + entry = baseEntry + } + + log(createLogEntry(entry)) +} + +/** + * Logs LLM response received + */ +export function logLLMResponseReceived( + response: string, + model: string, + strategyName: string, + durationMs: number, + tokensUsed?: { input: number; output: number }, + context?: LLMLogContext +) { + const logLevel = getLLMLogLevel() + if (logLevel === 'disabled') return + + const baseEntry = { + level: 'info' as const, + event_type: 'llm_response_received' as const, + model_used: model, + strategy_name: strategyName, + request_duration_ms: durationMs, + tokens_used: tokensUsed, + ...context + } + + let entry: Omit + + switch (logLevel) { + case 'metadata': + entry = baseEntry + break + case 'truncated': + entry = { + ...baseEntry, + response_preview: truncateContent(response) + } + break + case 'full': + entry = { + ...baseEntry, + full_response: response, + response_preview: truncateContent(response) + } + break + default: + entry = baseEntry + } + + log(createLogEntry(entry)) +} + +/** + * Logs LLM request failure + */ +export function logLLMRequestFailed( + error: Error | unknown, + model: string, + strategyName: string, + context?: LLMLogContext +) { + const logLevel = getLLMLogLevel() + if (logLevel === 'disabled') return + + const errObj = + error instanceof Error + ? error + : new Error(typeof error === 'string' ? error : JSON.stringify(error)) + + log( + createLogEntry({ + level: 'error', + event_type: 'llm_request_failed', + model_used: model, + strategy_name: strategyName, + error_message: errObj.message, + ...context + }) + ) +}