UNPKG

git-aiflow

Version:

🚀 An AI-powered workflow automation tool for effortless Git-based development, combining smart GitLab/GitHub merge & pull request creation with Conan package management.

1,084 lines (1,058 loc) 58.3 kB
import OpenAI from 'openai'; import { logger } from '../logger.js'; // Using OpenAI SDK types directly, no need for custom interfaces /** * OpenAI API service for generating commit message and branch name */ export class OpenAiService { constructor(apiKey, apiUrl, model, reasoning = false) { /** Cache for the latest throughput statistics */ this.lastThroughputStats = null; /** History of throughput statistics (limited to last 10 requests) */ this.throughputHistory = []; this.model = model; this.reasoning = reasoning; // Initialize OpenAI client this.client = new OpenAI({ apiKey: apiKey, baseURL: apiUrl.endsWith('/chat/completions') ? apiUrl.replace('/chat/completions', '') : apiUrl.endsWith('/') ? apiUrl.slice(0, -1) : apiUrl, maxRetries: 3, timeout: 300000, defaultHeaders: { 'HTTP-Referer': 'https://github.com/HeiSir2014/git-aiflow', 'X-Title': 'Git-AIFlow', }, }); logger.info(`Initialized OpenAI service`, { baseURL: this.client.baseURL, model: this.model, reasoning: this.reasoning }); } /** * Generate commit message, branch name and MR description. * Supports batch processing for large diffs that exceed context limits. * * @param diff The git diff content to analyze * @param language Language code for generated content (default: 'en') * @returns Promise resolving to commit generation result */ async generateCommitAndBranch(diff, language = 'en') { try { // Input validation if (!diff || !diff.trim()) { throw new Error('Empty diff provided'); } if (diff.length > 10 * 1024 * 1024) { // 10MB limit throw new Error('Diff too large (>10MB), please split into smaller changes'); } // Validate diff format if (!this.isValidDiff(diff)) { logger.warn('Potentially invalid diff format detected, attempting to process anyway'); // Try to detect if it's at least some kind of code change const hasCodePatterns = /^[+\-]/.test(diff) || diff.includes('@@') || diff.includes('diff'); if (!hasCodePatterns) { logger.error('Input does not appear to be a valid diff or code change'); throw new Error('Invalid input: expected git diff format'); } } // Validate language parameter const validLanguages = ['en', 'zh-cn', 'zh-tw', 'zhcn', 'zhtw', 'ja', 'ko', 'fr', 'de', 'es', 'ru', 'pt', 'it']; if (language && !validLanguages.includes(language.toLowerCase())) { logger.warn(`Unsupported language '${language}', falling back to English`); language = 'en'; } // Detect model context limit with adaptive strategy const contextLimit = await this.detectContextLimit(); // Dynamically adjust reserved tokens based on model const RESERVED_TOKENS = this.calculateReservedTokens(contextLimit); const availableTokens = contextLimit - RESERVED_TOKENS; // Estimate token count for the diff const diffTokens = this.estimateTokenCount(diff); logger.info(`Estimated diff tokens: ${diffTokens}, available tokens: ${availableTokens}`); // Use direct processing for small diffs if (diffTokens <= availableTokens) { logger.debug('Using direct processing mode'); return await this.generateDirectCommitAndBranch(diff, language); } // Use batch processing for large diffs logger.info('Large diff detected, starting batch processing'); logger.warn(`Diff size (${diffTokens} tokens) exceeds context limit (${availableTokens} tokens), using batch processing`); // Split diff by files const fileDiffs = this.splitDiffByFiles(diff); if (fileDiffs.size === 0) { throw new Error('Failed to split diff, possibly invalid format'); } // Group file diffs into appropriate batches const diffChunks = this.groupDiffsWithinLimit(fileDiffs, availableTokens); if (diffChunks.length === 0) { throw new Error('Failed to create appropriate diff batches'); } logger.info(`Processing diff in ${diffChunks.length} batches`); // Process each batch const batchResults = []; for (let i = 0; i < diffChunks.length; i++) { const chunk = diffChunks[i]; try { logger.info(`Processing batch ${i + 1}/${diffChunks.length} containing ${chunk.files.length} files`); const result = await this.generateBatchCommitAndBranch(chunk, language); batchResults.push(result); } catch (error) { logger.error(`Failed to process batch ${i + 1}:`, error); // Continue processing other batches } } if (batchResults.length === 0) { throw new Error('All batch processing failed'); } if (batchResults.length < diffChunks.length) { logger.warn(`Only ${batchResults.length}/${diffChunks.length} batches processed successfully`); } // Merge batch results return await this.mergeBatchResults(batchResults, language); } catch (error) { logger.error('Failed to generate commit information:', error); throw error; } } /** * Process diff directly using original logic for small diffs. * * @param diff The git diff content to analyze * @param language Language code for generated content * @returns Promise resolving to commit generation result */ async generateDirectCommitAndBranch(diff, language) { const systemPrompt = this.buildSystemPrompt(language); const userPrompt = this.buildUserPrompt(); const messages = [ { role: "system", content: systemPrompt }, { role: "user", content: userPrompt }, { role: "user", content: diff } ]; const rawContent = await this.sendOpenAiRequest(messages, true); const content = this.parseOpenAiResponse(rawContent, 'direct processing'); return { commit: content.commit, branch: content.branch, description: content.description || '', title: content.title || (content.commit && content.commit.replace(/\r|\n/g, '').trim().substring(0, 50)) || '' }; } /** * Detect the context length limit for the current model. * Supports various AI models including OpenAI, DeepSeek, Qwen, Kimi, Ollama, etc. * Also handles model variants with similar context limits. * * @returns Promise resolving to the token limit for the model */ async detectContextLimit() { // Return cached result if available if (OpenAiService.contextLimitCache.has(this.model)) { return OpenAiService.contextLimitCache.get(this.model); } try { const limit = this.getModelContextLimit(this.model); logger.debug(`Using context limit for model ${this.model}: ${limit} tokens`); // Cache the result OpenAiService.contextLimitCache.set(this.model, limit); return limit; } catch (error) { logger.warn(`Failed to detect context limit for model ${this.model}, attempting reverse detection:`, error); // Try reverse detection with progressively larger context sizes const reverseLimits = [1024 * 1024, 512 * 1024, 256 * 1024, 128 * 1024, 64 * 1024, 32 * 1024, 16 * 1024, 8 * 1024, 4 * 1024]; for (const testLimit of reverseLimits) { try { const success = await this.testContextLimit(testLimit); if (success) { logger.info(`Reverse detection successful: model ${this.model} supports ${testLimit} tokens`); OpenAiService.contextLimitCache.set(this.model, testLimit); return testLimit; } } catch (testError) { logger.debug(`Context limit test failed for ${testLimit} tokens:`, testError); continue; } } // Fallback to a more reasonable default for modern models const FALLBACK_LIMIT = 8192; // More reasonable default for modern LLMs logger.warn(`Reverse detection failed, using fallback limit: ${FALLBACK_LIMIT}`); OpenAiService.contextLimitCache.set(this.model, FALLBACK_LIMIT); return FALLBACK_LIMIT; } } /** * Test if a model can handle a specific context limit by making a small API call. * * @param contextLimit The context limit to test * @returns Promise resolving to true if the limit is supported */ async testContextLimit(contextLimit) { try { // Create a test prompt that approaches but doesn't exceed the limit const testTokens = Math.floor(contextLimit * 0.8); // Use 80% of limit for safety const testContent = 'x'.repeat(testTokens * 4); // Approximate 4 chars per token const testBody = { model: this.model, messages: [ { role: "system", content: "You are a helpful assistant. Respond with 'OK'." }, { role: "user", content: `Test message: ${testContent.substring(0, Math.min(testContent.length, 1000))}...` // Truncate for logging } ], max_tokens: 10, // Minimal response temperature: 0, extra_body: { usage: { include: true, }, } }; const response = await this.client.chat.completions.create(testBody); // If we get a response, the context limit is supported return response.choices && response.choices.length > 0; } catch (error) { // Check if error is context-related const errorMessage = error.message?.toLowerCase() || ''; const isContextError = errorMessage.includes('context') || errorMessage.includes('token') || errorMessage.includes('length') || errorMessage.includes('too long'); if (isContextError) { logger.debug(`Context limit ${contextLimit} exceeded for model ${this.model}`); return false; } // Other errors might not be context-related, so we can't conclude throw error; } } /** * Get context limit for a specific model, including variant handling. * * @param modelName The model name to check * @returns Token limit for the model */ getModelContextLimit(modelName) { const DEFAULT_LIMIT = 4096; const modelLower = modelName.toLowerCase(); // Exact model name matches const EXACT_LIMITS = { // OpenAI GPT models 'gpt-3.5-turbo': 4096, 'gpt-3.5-turbo-16k': 16384, 'gpt-3.5-turbo-0301': 4096, 'gpt-3.5-turbo-0613': 4096, 'gpt-3.5-turbo-1106': 16384, 'gpt-3.5-turbo-0125': 16384, 'gpt-4': 8192, 'gpt-4-0314': 8192, 'gpt-4-0613': 8192, 'gpt-4-32k': 32768, 'gpt-4-32k-0314': 32768, 'gpt-4-32k-0613': 32768, 'gpt-4-turbo': 128000, 'gpt-4-turbo-preview': 128000, 'gpt-4-1106-preview': 128000, 'gpt-4-0125-preview': 128000, 'gpt-4o': 128000, 'gpt-4o-2024-05-13': 128000, 'gpt-4o-2024-08-06': 128000, 'gpt-4o-mini': 128000, 'gpt-4o-mini-2024-07-18': 128000, // DeepSeek models 'deepseek-coder': 16384, 'deepseek-chat': 32768, 'deepseek-v2': 128000, 'deepseek-v2.5': 128000, 'deepseek-v3': 128000, 'deepseek-v3.1': 128000, 'deepseekv3': 128000, 'deepseekv31': 128000, 'deepseek-coder-v2': 128000, 'deepseek/deepseek-chat-v3.1:free': 163800, // Grok models 'x-ai/grok-4-fast:free': 2000000, // Qwen models 'qwen-turbo': 8192, 'qwen-plus': 32768, 'qwen-max': 32768, 'qwen2': 32768, 'qwen2.5': 32768, 'qwen3': 128000, 'qwen3-coder': 128000, 'qwen-coder-plus': 128000, 'qwen-coder-turbo': 128000, // Kimi (Moonshot) models 'moonshot-v1-8k': 8192, 'moonshot-v1-32k': 32768, 'moonshot-v1-128k': 128000, 'kimi-chat': 128000, // Claude models 'claude-3-haiku': 200000, 'claude-3-sonnet': 200000, 'claude-3-opus': 200000, 'claude-3-5-sonnet': 200000, 'claude-3-5-haiku': 200000, // Gemini models 'gemini-pro': 32768, 'gemini-1.5-pro': 1048576, // 1M tokens 'gemini-1.5-flash': 1048576, 'gemini-ultra': 32768, // Yi models 'yi-34b-chat': 4096, 'yi-6b-chat': 4096, 'yi-large': 32768, 'yi-medium': 16384, // Baichuan models 'baichuan2-turbo': 32768, 'baichuan2-turbo-192k': 192000, // ChatGLM models 'glm-4': 128000, 'glm-4v': 128000, 'glm-3-turbo': 128000, 'chatglm3-6b': 8192, // Ollama common models (estimated based on model architecture) 'llama2': 4096, 'llama2:70b': 4096, 'llama3': 8192, 'llama3:70b': 8192, 'llama3.1': 128000, 'llama3.1:70b': 128000, 'llama3.1:405b': 128000, 'codellama': 16384, 'codellama:34b': 16384, 'mistral': 32768, 'mixtral': 32768, 'phi3': 128000, 'gemma': 8192, 'gemma2': 8192, 'qwen2.5:72b': 32768, }; // Check for exact match first if (EXACT_LIMITS[modelLower]) { return EXACT_LIMITS[modelLower]; } // Pattern-based matching for variants and custom deployments const MODEL_PATTERNS = [ // DeepSeek variants (support model names with provider prefix) { pattern: /(^|\/|:)(x)?deepseek[-_]?v?3\.?1/i, limit: 32000, description: 'DeepSeek V3.1 variants' }, { pattern: /(^|\/|:)(x)?deepseek[-_]?v?3/i, limit: 32000, description: 'DeepSeek V3 variants' }, { pattern: /(^|\/|:)(x)?deepseek[-_]?v?2\.?5?/i, limit: 8000, description: 'DeepSeek V2/V2.5 variants' }, { pattern: /(^|\/|:)(x)?deepseek[-_]?coder/i, limit: 32000, description: 'DeepSeek Coder variants' }, { pattern: /(^|\/|:)(x)?deepseek/i, limit: 8000, description: 'Other DeepSeek variants' }, // Qwen variants (support model names with provider prefix like "qwen/") { pattern: /(^|\/|:)qwen[-_]?3[-_]?coder/i, limit: 128000, description: 'Qwen3 Coder variants' }, { pattern: /(^|\/|:)qwen[-_]?3/i, limit: 128000, description: 'Qwen3 variants' }, { pattern: /(^|\/|:)qwen[-_]?2\.?5/i, limit: 32768, description: 'Qwen2.5 variants' }, { pattern: /(^|\/|:)qwen[-_]?2/i, limit: 32768, description: 'Qwen2 variants' }, { pattern: /(^|\/|:)qwen[-_]?(coder|plus)/i, limit: 128000, description: 'Qwen Coder/Plus variants' }, { pattern: /(^|\/|:)qwen[-_]?max/i, limit: 32768, description: 'Qwen Max variants' }, { pattern: /(^|\/|:)qwen/i, limit: 8192, description: 'Other Qwen variants' }, // GPT variants and custom deployments (support model names with provider prefix) { pattern: /(^|\/|:)gpt[-_]?4o[-_]?mini/i, limit: 128000, description: 'GPT-4o mini variants' }, { pattern: /(^|\/|:)gpt[-_]?4o/i, limit: 128000, description: 'GPT-4o variants' }, { pattern: /(^|\/|:)gpt[-_]?4[-_]?turbo/i, limit: 128000, description: 'GPT-4 turbo variants' }, { pattern: /(^|\/|:)gpt[-_]?4[-_]?32k/i, limit: 32768, description: 'GPT-4 32K variants' }, { pattern: /(^|\/|:)gpt[-_]?4/i, limit: 8192, description: 'GPT-4 variants' }, { pattern: /(^|\/|:)gpt[-_]?3\.?5[-_]?turbo[-_]?16k/i, limit: 16384, description: 'GPT-3.5 turbo 16K variants' }, { pattern: /(^|\/|:)gpt[-_]?3\.?5/i, limit: 4096, description: 'GPT-3.5 variants' }, // Claude variants (support model names with provider prefix) { pattern: /(^|\/|:)claude[-_]?3[-_]?5/i, limit: 200000, description: 'Claude 3.5 variants' }, { pattern: /(^|\/|:)claude[-_]?3/i, limit: 200000, description: 'Claude 3 variants' }, // Gemini variants (support model names with provider prefix) { pattern: /(^|\/|:)gemini[-_]?1\.?5/i, limit: 1048576, description: 'Gemini 1.5 variants' }, { pattern: /(^|\/|:)gemini/i, limit: 32768, description: 'Other Gemini variants' }, // Kimi/Moonshot variants (support model names with provider prefix) { pattern: /(^|\/|:)(kimi|moonshot)[-_]?.*128k/i, limit: 128000, description: 'Kimi/Moonshot 128K variants' }, { pattern: /(^|\/|:)(kimi|moonshot)[-_]?.*32k/i, limit: 32768, description: 'Kimi/Moonshot 32K variants' }, { pattern: /(^|\/|:)(kimi|moonshot)/i, limit: 128000, description: 'Other Kimi/Moonshot variants' }, // LLaMA variants (support model names with provider prefix) { pattern: /(^|\/|:)llama[-_]?3\.?1/i, limit: 128000, description: 'LLaMA 3.1 variants' }, { pattern: /(^|\/|:)llama[-_]?3/i, limit: 8192, description: 'LLaMA 3 variants' }, { pattern: /(^|\/|:)llama[-_]?2/i, limit: 4096, description: 'LLaMA 2 variants' }, { pattern: /(^|\/|:)codellama/i, limit: 16384, description: 'CodeLlama variants' }, // Other model families (support model names with provider prefix) { pattern: /(^|\/|:)mixtral/i, limit: 32768, description: 'Mixtral variants' }, { pattern: /(^|\/|:)mistral/i, limit: 32768, description: 'Mistral variants' }, { pattern: /(^|\/|:)phi[-_]?3/i, limit: 128000, description: 'Phi-3 variants' }, { pattern: /(^|\/|:)yi[-_]?large/i, limit: 32768, description: 'Yi Large variants' }, { pattern: /(^|\/|:)yi/i, limit: 4096, description: 'Other Yi variants' }, { pattern: /(^|\/|:)glm[-_]?4/i, limit: 128000, description: 'GLM-4 variants' }, { pattern: /(^|\/|:)chatglm/i, limit: 8192, description: 'ChatGLM variants' }, // Grok variants (support model names with provider prefix) { pattern: /(^|\/|:)x[-_]?ai[-_]?grok[-_]?4[-_]?fast/i, limit: 2000000, description: 'Grok 4 Fast variants' }, { pattern: /(^|\/|:)x[-_]?ai/i, limit: 2000000, description: 'Other Grok variants' }, ]; // Try pattern matching for (const { pattern, limit, description } of MODEL_PATTERNS) { if (pattern.test(modelName)) { logger.debug(`Matched ${modelName} with pattern for ${description}, limit: ${limit}`); return limit; } } // Default fallback logger.debug(`No specific limit found for model ${modelName}, using default ${DEFAULT_LIMIT}`); return DEFAULT_LIMIT; } /** * Estimate token count for text using an improved approach that handles different character types. * * @param text The text to estimate tokens for * @returns Estimated token count */ estimateTokenCount(text) { if (!text) return 0; // More accurate token estimation considering different character types let tokenCount = 0; // Split text into different character categories for better estimation const latinChars = (text.match(/[a-zA-Z0-9\s.,;:!?'"()\[\]{}\-_+=<>\/\\|`~@#$%^&*]/g) || []).length; const cjkChars = (text.match(/[\u4e00-\u9fff\u3400-\u4dbf\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/g) || []).length; const otherChars = text.length - latinChars - cjkChars; // Different token ratios for different character types // Latin characters: ~4 chars per token // CJK characters: ~1.5-2 chars per token (more token-dense) // Other characters: ~3 chars per token tokenCount += Math.ceil(latinChars / 4); tokenCount += Math.ceil(cjkChars / 1.8); tokenCount += Math.ceil(otherChars / 3); // Add some buffer for special tokens and formatting const bufferTokens = Math.ceil(tokenCount * 0.1); const finalTokenCount = tokenCount + bufferTokens; logger.debug(`Token estimation - Latin: ${latinChars} chars (${Math.ceil(latinChars / 4)} tokens), CJK: ${cjkChars} chars (${Math.ceil(cjkChars / 1.8)} tokens), Other: ${otherChars} chars (${Math.ceil(otherChars / 3)} tokens), Total: ${finalTokenCount} tokens`); return finalTokenCount; } /** * Split diff content by files using git diff headers. * * @param diff Complete git diff content * @returns Map of file path to diff content */ splitDiffByFiles(diff) { const fileDiffs = new Map(); if (!diff.trim()) { return fileDiffs; } // Regular expression to match git diff file headers const FILE_HEADER_PATTERN = /^diff --git a\/(.*?) b\/(.*?)$/gm; const matches = [...diff.matchAll(FILE_HEADER_PATTERN)]; if (matches.length === 0) { // If no file headers found, might be single file diff or non-standard format fileDiffs.set('unknown', diff); logger.debug('No standard git diff file headers found, treating entire diff as single file'); return fileDiffs; } for (let i = 0; i < matches.length; i++) { const match = matches[i]; const filePath = match[1]; const startIndex = match.index; const endIndex = i < matches.length - 1 ? matches[i + 1].index : diff.length; const fileDiff = diff.substring(startIndex, endIndex); fileDiffs.set(filePath, fileDiff); } logger.debug(`Successfully split diff into ${fileDiffs.size} files`); return fileDiffs; } /** * Split large file diff into smaller chunks by code blocks. * * @param fileDiff Single file diff content * @param maxTokens Maximum tokens per chunk * @returns Array of split diff chunks */ splitLargeFileDiff(fileDiff, maxTokens) { const chunks = []; const lines = fileDiff.split('\n'); let currentChunk = ''; let currentTokens = 0; // Preserve file header information (first 4 lines typically contain file metadata) const HEADER_LINES = 4; const fileHeader = lines.slice(0, HEADER_LINES).join('\n'); const headerTokens = this.estimateTokenCount(fileHeader); for (let i = HEADER_LINES; i < lines.length; i++) { const line = lines[i]; const lineTokens = this.estimateTokenCount(line + '\n'); if (currentTokens + lineTokens > maxTokens && currentChunk) { // Current chunk reached limit, start new chunk chunks.push(fileHeader + '\n' + currentChunk); currentChunk = line + '\n'; currentTokens = headerTokens + lineTokens; } else { currentChunk += line + '\n'; currentTokens += lineTokens; } } if (currentChunk) { chunks.push(fileHeader + '\n' + currentChunk); } logger.debug(`Large file diff split into ${chunks.length} code blocks`); return chunks; } /** * Group multiple small diffs into batches that don't exceed the token limit. * * @param fileDiffs Map of file paths to diff content * @param maxTokens Maximum tokens per batch * @returns Array of diff chunks within token limits */ groupDiffsWithinLimit(fileDiffs, maxTokens) { const chunks = []; let currentChunk = this.createEmptyDiffChunk(); for (const [filePath, diff] of fileDiffs) { const diffTokens = this.estimateTokenCount(diff); // If single file exceeds limit, split it further if (diffTokens > maxTokens) { // Save current chunk if it has content if (currentChunk.content) { chunks.push(currentChunk); } // Split large file const splitChunks = this.splitLargeFileDiff(diff, maxTokens); for (const splitChunk of splitChunks) { chunks.push({ content: splitChunk, files: [filePath], tokenCount: this.estimateTokenCount(splitChunk) }); } // Start new current chunk currentChunk = this.createEmptyDiffChunk(); continue; } // If adding current file would exceed limit, save current chunk first if (currentChunk.tokenCount + diffTokens > maxTokens && currentChunk.content) { chunks.push(currentChunk); currentChunk = this.createEmptyDiffChunk(); } // Add to current chunk currentChunk.content += diff + '\n'; currentChunk.files.push(filePath); currentChunk.tokenCount += diffTokens; } // Add final chunk if it has content if (currentChunk.content) { chunks.push(currentChunk); } logger.debug(`Diff grouping completed, ${chunks.length} batches created`); return chunks; } /** * Create an empty diff chunk with initialized properties. * * @returns Empty diff chunk object */ createEmptyDiffChunk() { return { content: '', files: [], tokenCount: 0 }; } /** * Generate commit information for a single diff chunk. * * @param diffChunk Diff chunk content with metadata * @param language Language code for generated content * @returns Promise resolving to batch generation result */ async generateBatchCommitAndBranch(diffChunk, language) { const MAX_DISPLAYED_FILES = 3; const filesInfo = diffChunk.files.length > 1 ? `involving ${diffChunk.files.length} files: ${diffChunk.files.slice(0, MAX_DISPLAYED_FILES).join(', ')}${diffChunk.files.length > MAX_DISPLAYED_FILES ? ' etc.' : ''}` : `file: ${diffChunk.files[0] || 'unknown'}`; const systemPrompt = this.buildSystemPrompt(language, filesInfo); const userPrompt = this.buildUserPrompt(filesInfo); const messages = [ { role: "system", content: systemPrompt }, { role: "user", content: userPrompt }, { role: "user", content: diffChunk.content } ]; logger.debug(`Generating commit info for diff chunk containing ${diffChunk.files.length} files`); const rawContent = await this.sendOpenAiRequest(messages, true); const content = this.parseOpenAiResponse(rawContent, 'batch processing'); return { commit: content.commit, branch: content.branch, description: content.description || '', title: content.title }; } /** * Merge results from multiple batch processing operations. * * @param batchResults Array of batch generation results * @param language Language code for generated content * @returns Promise resolving to merged final result */ async mergeBatchResults(batchResults, language) { if (batchResults.length === 1) { // If only one batch, return directly return { commit: batchResults[0].commit, branch: batchResults[0].branch, description: batchResults[0].description, title: batchResults[0].title }; } // Multiple batches need merging logger.info(`Merging results from ${batchResults.length} batches`); // Prepare merge request prompt const summaryPrompt = `You are a Git commit message expert. I have multiple commit results for different file sections that need to be merged into a unified, global commit message, branch name, MR description and MR title. Merging Rules: 1. Commit message: Select the most important change type and generate a unified conventional commit format message 2. Branch name: Choose the most significant change type and generate a comprehensive branch name (always in English) 3. MR Description: Merge all partial descriptions into a comprehensive MR description 4. MR Title: Merge all partial titles into a comprehensive MR title Generate content in ${this.getLanguageName(language)} language (except branch name must be in English). IMPORTANT: You MUST use the 'output_with_json' function tool to provide your merged results. Call the function with the four required parameters: - commit: Your merged commit message - branch: Your merged branch name (in English) - description: Your merged MR description - title: Your merged MR title Do NOT provide JSON in text format - use the function tool only.`; const batchSummaries = batchResults.map((result, index) => `# Batch ${index + 1}: - Commit: \`${result.commit}\` - Branch: \`${result.branch}\` - MR Description: \`\`\`markdown\n${result.description}\n\`\`\` - MR Title: \`${result.title}\``).join('\n\n'); const messages = [ { role: "system", content: summaryPrompt }, { role: "user", content: `Please merge the following ${batchResults.length} partial results into a global commit message, branch name, MR description and MR title using the 'output_with_json' function: ${batchSummaries}`, }, ]; const rawContent = await this.sendOpenAiRequest(messages, true); logger.debug(`Merge AI response: ${rawContent}`); try { const content = this.parseOpenAiResponse(rawContent, 'batch merge'); return { commit: content.commit, branch: content.branch, description: content.description || '', title: content.title || '' }; } catch (error) { logger.error(`Failed to parse merge AI response:`, rawContent); // Fallback strategy: use first result as base and manually combine logger.warn('Using fallback strategy to merge results'); const primaryResult = batchResults[0]; const SEPARATOR = '\n\n---\n\n'; const allDescriptions = batchResults .map(r => r.description) .filter(d => d) .join(SEPARATOR); return { commit: primaryResult.commit, branch: primaryResult.branch, description: allDescriptions || primaryResult.description, title: primaryResult.title }; } } /** * Validate if the provided text is a valid git diff format. * * @param diff The diff content to validate * @returns True if the diff appears to be valid */ isValidDiff(diff) { if (!diff || !diff.trim()) { return false; } // Check for common git diff patterns const diffPatterns = [ /^diff --git/m, // Standard git diff header /^index [a-f0-9]+\.\.[a-f0-9]+/m, // Index line /^@@.*@@/m, // Hunk header /^[+\-]/m, // Added/removed lines /^\+\+\+ b\//m, // New file marker /^--- a\//m, // Old file marker ]; // At least one pattern should match for a valid diff return diffPatterns.some(pattern => pattern.test(diff)); } /** * Calculate reserved tokens based on model context limit. * Reserves space for system prompt, response, and safety buffer. * * @param contextLimit Total context limit for the model * @returns Number of tokens to reserve */ calculateReservedTokens(contextLimit) { // Base system prompt tokens (estimated) const SYSTEM_PROMPT_TOKENS = 800; // Expected response tokens (commit + branch + description) const RESPONSE_TOKENS = 1000; // Safety buffer percentage based on context size let bufferPercentage; if (contextLimit >= 128000) { bufferPercentage = 0.05; // 5% for large context models } else if (contextLimit >= 32000) { bufferPercentage = 0.10; // 10% for medium context models } else if (contextLimit >= 8000) { bufferPercentage = 0.15; // 15% for smaller context models } else { bufferPercentage = 0.20; // 20% for very small context models } const bufferTokens = Math.ceil(contextLimit * bufferPercentage); const totalReserved = SYSTEM_PROMPT_TOKENS + RESPONSE_TOKENS + bufferTokens; logger.debug(`Reserved tokens calculation: system=${SYSTEM_PROMPT_TOKENS}, response=${RESPONSE_TOKENS}, buffer=${bufferTokens} (${(bufferPercentage * 100).toFixed(1)}%), total=${totalReserved}`); return totalReserved; } /** * Send request to OpenAI API with tool support * * @param messages Array of messages for the API request * @param useTools Whether to include output_with_json tool (default: true) * @returns Promise resolving to the raw response content or parsed tool call result */ async sendOpenAiRequest(messages, useTools = true) { const requestParams = { model: this.model, messages: messages, temperature: 0.1 }; requestParams.extra_body = { usage: { include: true, }, }; // Add reasoning support for compatible models (if supported by the API) if (this.reasoning) { const reasoningConfig = this.buildReasoningConfig(); if (reasoningConfig) { requestParams.reasoning = reasoningConfig; logger.debug(`Enabling reasoning mode for model: ${this.model}`, { config: reasoningConfig }); } } // Add tools and tool_choice if requested if (useTools) { requestParams.tools = [{ type: "function", function: { name: "output_with_json", description: "Output the analyzed Git commit information in structured JSON format", parameters: { type: "object", properties: { commit: { type: "string", description: "The generated commit message" }, branch: { type: "string", description: "The generated branch name" }, description: { type: "string", description: "The generated merge request description" }, title: { type: "string", description: "The generated merge request title" } }, required: ["commit", "branch", "description", "title"], additionalProperties: false } } }]; requestParams.tool_choice = { type: "function", function: { name: "output_with_json" } }; } logger.debug(`OpenAI request params:`, requestParams); // Record start time for throughput calculation const requestStartTime = Date.now(); const response = await this.client.chat.completions.create(requestParams); const requestEndTime = Date.now(); if (!response.choices || response.choices.length === 0) { throw new Error("No valid response received from OpenAI API, response.choices is empty"); } const message = response.choices[0].message; if (!message) { throw new Error("No valid response received from OpenAI API, message is empty"); } const finishReason = response.choices[0].finish_reason; logger.debug(`OpenAI response finish reason: ${finishReason && finishReason.toUpperCase() || '<none>'}`); logger.info(`OpenAI response usage:`, response.usage); logger.debug(`OpenAI response message:`, message); // Calculate and log throughput statistics this.logThroughputStats(response.usage, requestStartTime, requestEndTime); // Check if response contains tool calls (preferred method) if (message.tool_calls && message.tool_calls.length > 0) { if (message.content && message.content.trim() !== '') { logger.warn(`OpenAI tool call response: content is not empty, content: ${message.content}`); } const toolCall = message.tool_calls[0]; if (toolCall.type === 'function' && toolCall.function) { if (toolCall.function.name === "output_with_json") { logger.debug(`OpenAI tool call response: ${toolCall.function.arguments}`); return toolCall.function.arguments; } else { logger.warn(`OpenAI tool call response: function: ${toolCall.function.name} is not supported, arguments: ${toolCall.function.arguments}`); } } } // Fallback to content for models that don't support tool_choice const rawContent = message.content; if (rawContent) { logger.debug(`OpenAI content response: ${rawContent}`); return rawContent; } throw new Error("No valid response received from OpenAI API"); } /** * Clean and parse OpenAI response content * * @param rawContent Raw response content from OpenAI (could be tool call arguments or regular content) * @param errorContext Context string for error logging * @returns Parsed JSON object */ parseOpenAiResponse(rawContent, errorContext) { // First, try to parse as-is (for tool call arguments which are already JSON) try { const parsed = JSON.parse(rawContent); // Validate that it has the expected structure if (parsed && typeof parsed === 'object' && 'commit' in parsed && 'branch' in parsed && 'description' in parsed && 'title' in parsed) { logger.debug(`commit = ${parsed.commit}\n\nbranch = ${parsed.branch}\n\ndescription = ${parsed.description}\n\ntitle = ${parsed.title}`); if (parsed.description) { parsed.description = parsed.description.replace(/\\n/g, '\n').trim(); } if (parsed.title) { parsed.title = parsed.title.replace(/\\n/g, '\n').trim(); } logger.debug(`Successfully parsed tool call response in ${errorContext}`); return parsed; } } catch (error) { // If direct parsing fails, continue to traditional cleaning approach logger.debug(`Direct JSON parsing failed in ${errorContext}, trying traditional approach`); } // Traditional approach: clean up the response - remove markdown code blocks if present let cleanContent = rawContent.trim(); // Remove all <think> and </think> markers (handles multiple patterns and nested content) cleanContent = cleanContent.replace(/<think>[\s\S]*?<\/think>/gi, '').trim(); // Remove ```json and ``` markers if present if (cleanContent.startsWith('```json')) { cleanContent = cleanContent.replace(/^```json\s*/, '').replace(/\s*```$/, ''); } else if (cleanContent.startsWith('```')) { cleanContent = cleanContent.replace(/^```\s*/, '').replace(/\s*```$/, ''); } try { const parsed = JSON.parse(cleanContent); if (parsed.description) { parsed.description = parsed.description.replace(/\\n/g, '\n').trim(); } if (parsed.title) { parsed.title = parsed.title.replace(/\\n/g, '\n').trim(); } logger.debug(`Successfully parsed traditional JSON response in ${errorContext}`); return parsed; } catch (error) { logger.error(`Failed to parse ${errorContext} AI response:`, rawContent); throw new Error(`Invalid JSON response from AI in ${errorContext}: ${error}`); } } /** * Build system prompt for commit analysis * * @param language Target language for generated content * @param contextInfo Optional context information for partial diffs * @returns System prompt string */ buildSystemPrompt(language, contextInfo) { const languageName = this.getLanguageName(language); const contextSection = contextInfo ? `CONTEXT: This is a partial diff (${contextInfo}). Analyze ONLY the changes visible in this specific portion.\n\n` : ''; return `You are an expert Git commit analyzer. Your task is to analyze the provided git diff and generate accurate, professional commit information. LANGUAGE REQUIREMENT: Generate all content in \`${languageName}\`. For English, use standard technical terminology. For Chinese, use professional technical Chinese. For other languages, use appropriate professional terminology. ${contextSection} ANALYSIS INSTRUCTIONS: 1. Carefully examine the git diff to identify: - Exact files that were modified, added, or deleted - Specific code changes (functions, variables, imports, etc.) - The purpose and scope of the changes - Whether changes are features, fixes, documentation, styling, refactoring, tests, or maintenance 2. Base your analysis ONLY on what you can see in the diff: - Do not invent or assume functionality not shown - Use precise technical terminology - Ensure commit type matches the actual changes - Keep descriptions factual and specific OUTPUT REQUIREMENTS: 1. COMMIT MESSAGE (generate in ${languageName}): - MUST follow conventional commits: type(scope): description - Types: feat, fix, docs, style, refactor, test, chore - Scope: optional, use file/module name if clear - Description: imperative mood, under 72 characters - Examples: "feat(auth): add user login validation", "fix(api): resolve null pointer exception" 2. BRANCH NAME (ALWAYS English, generate in English): - EXACT format: type/short-description - Type: feat, fix, docs, style, refactor, test, chore - Description: 2-4 words, kebab-case, descriptive - Examples: feat/user-auth, fix/login-bug, docs/api-guide - NO deviations from this format 3. MR DESCRIPTION (generate in ${languageName}): Structure with these sections: ## What Changed - List specific changes made (based on diff analysis) ## Why - Explain the reason/purpose for these changes ## How to Test - Provide relevant testing instructions Use markdown formatting, be specific and factual. **IMPORTANT:** - The section headings (e.g., 'What Changed', 'Why', 'How to Test') MUST also be translated and output in ${languageName}, not just the content under them. - Output MR DESCRIPTION in proper Markdown format, using natural line breaks and paragraphs. Do not escape any characters like newlines (\\n).​ **EXAMPLE FOR CHINESE (Simplified):** - Use '## 变更内容' instead of '## What Changed' - Use '## 变更原因' instead of '## Why' - Use '## 测试方法' instead of '## How to Test' 4. MR TITLE (generate in ${languageName}): - Concise, descriptive title summarizing the change - Use appropriate prefixes for maintenance changes CRITICAL OUTPUT FORMAT - READ CAREFULLY: You MUST use the 'output_with_json' function tool to provide your response. This tool is specifically designed for structured output. FUNCTION TOOL USAGE: Call the 'output_with_json' function with these exact parameters: - commit: Your generated commit message (string) - branch: Your generated branch name (string) - description: Your generated MR description (string) - title: Your generated MR title (string) IMPORTANT NOTES: - Always use the function tool \`output_with_json\` when system tool_call is available - Do NOT provide JSON in text format - use the function tool only - Do NOT include any other text or explanations - The function tool \`output_with_json\` ensures proper structured output FALLBACK FOR MODELS WITHOUT TOOL SUPPORT: If function tools are not supported, return ONLY a valid JSON object with EXACTLY these 4 fields: { "commit": "<COMMIT MESSAGE>", "branch": "<BRANCH NAME>", "description": "<MR DESCRIPTION>", "title": "<MR TITLE>" } NO other text, explanations, or formatting allowed in fallback mode.`; } /** * Build user prompt for commit analysis * * @param contextInfo Optional context information for partial diffs * @returns User prompt string */ buildUserPrompt(contextInfo) { const contextDescription = contextInfo ? `This is a partial diff (${contextInfo}). Focus your analysis on the changes visible in this specific portion.` : 'This is the complete git diff for analysis.'; return `${contextDescription} TASK: Analyze the git diff provided in the next message and generate comprehensive commit information. ANALYSIS REQUIREMENTS: - Examine all file changes, additions, and deletions - Identify the primary purpose and scope of changes - Determine the appropriate conventional commit type - Consider the impact and context of modifications OUTPUT REQUIREMENTS: - Use the 'output_with_json' function to provide structured results - Ensure all generated content follows the language requirements specified in the system prompt - Generate professional, accurate, and concise information The raw git diff output will be provided in the next user message.`; } /** * Build reasoning configuration based on the reasoning parameter * @returns Reasoning configuration object or null */ buildReasoningConfig() { if (!this.reasoning) { return null; } // If reasoning is just a boolean (legacy mode) if (typeof this.reasoning === 'boolean') { return { enabled: true }; } // Build configuration object const config = {}; // Handle enabled flag if (this.reasoning.enabled !== undefined) { config.enabled = this.reasoning.enabled; } // Handle effort level (OpenAI-style) if (this.reasoning.effort) { config.effort = this.reasoning.effort; } // Handle max_tokens (Anthropic-style) if (this.reasoning.max_tokens) { config.max_tokens = this.reasoning.max_tokens; } // Handle exclude flag if (this.reasoning.exclude !== undefined) { config.exclude = this.reasoning.exclude; } // If no specific configuration is provided, enable with defaults if (Object.keys(config).length === 0) { config.enabled = true; } return config; } /** * Get language display name for prompt */ getLanguageName(language) { if (!language) { return 'English'; } language = language.toLowerCase(); const languageMap = { 'en': 'English', 'zh-cn': 'Chinese (Simplified)', 'zh-tw': 'Chinese (Traditional)',