// src/services/llm.service.js // LLM 서비스 - 멀티 프로바이더 지원 const axios = require('axios'); const { LLMProvider } = require('../models'); const logger = require('../config/logger.config'); class LLMService { constructor() { this.providers = []; this.initialized = false; } /** * 서비스 초기화 */ async initialize() { if (this.initialized) return; try { await this.loadProviders(); this.initialized = true; logger.info('✅ LLM 서비스 초기화 완료'); } catch (error) { logger.error('❌ LLM 서비스 초기화 실패:', error); // 초기화 실패 시 기본 프로바이더 사용 this.providers = this.getDefaultProviders(); this.initialized = true; } } /** * 데이터베이스에서 프로바이더 로드 */ async loadProviders() { try { const providers = await LLMProvider.getHealthyProviders(); if (providers.length === 0) { logger.warn('⚠️ 활성 프로바이더가 없습니다. 기본 프로바이더 사용'); this.providers = this.getDefaultProviders(); } else { this.providers = providers.map((p) => ({ id: p.id, name: p.name, endpoint: p.endpoint, apiKey: p.apiKey, modelName: p.modelName, priority: p.priority, maxTokens: p.maxTokens, temperature: p.temperature, timeoutMs: p.timeoutMs, costPer1kInputTokens: parseFloat(p.costPer1kInputTokens) || 0, costPer1kOutputTokens: parseFloat(p.costPer1kOutputTokens) || 0, isHealthy: p.isHealthy, config: p.config, })); } logger.info(`📥 ${this.providers.length}개 프로바이더 로드됨`); } catch (error) { logger.error('프로바이더 로드 실패:', error); throw error; } } /** * 기본 프로바이더 설정 (환경 변수 기반) */ getDefaultProviders() { const providers = []; // Gemini if (process.env.GEMINI_API_KEY) { providers.push({ id: 'default-gemini', name: 'gemini', apiKey: process.env.GEMINI_API_KEY, modelName: process.env.GEMINI_MODEL || 'gemini-2.0-flash', priority: 1, maxTokens: 8192, temperature: 0.7, timeoutMs: 60000, costPer1kInputTokens: 0.00025, costPer1kOutputTokens: 0.001, isHealthy: true, }); } // OpenAI if (process.env.OPENAI_API_KEY) { providers.push({ id: 'default-openai', name: 'openai', endpoint: 'https://api.openai.com/v1/chat/completions', apiKey: process.env.OPENAI_API_KEY, modelName: process.env.OPENAI_MODEL || 'gpt-4o-mini', priority: 2, maxTokens: 4096, temperature: 0.7, timeoutMs: 60000, costPer1kInputTokens: 0.00015, costPer1kOutputTokens: 0.0006, isHealthy: true, }); } // Claude if (process.env.CLAUDE_API_KEY) { providers.push({ id: 'default-claude', name: 'claude', endpoint: 'https://api.anthropic.com/v1/messages', apiKey: process.env.CLAUDE_API_KEY, modelName: process.env.CLAUDE_MODEL || 'claude-3-haiku-20240307', priority: 3, maxTokens: 4096, temperature: 0.7, timeoutMs: 60000, costPer1kInputTokens: 0.00025, costPer1kOutputTokens: 0.00125, isHealthy: true, }); } return providers; } /** * 채팅 API 호출 (자동 fallback) */ async chat(params) { const { model, messages, temperature = 0.7, maxTokens = 4096, userId, apiKeyId, } = params; // 초기화 확인 if (!this.initialized) { await this.initialize(); } const startTime = Date.now(); let lastError = null; // 요청된 모델에 맞는 프로바이더 찾기 const requestedProvider = this.providers.find( (p) => p.modelName === model || p.name === model ); // 우선순위 순으로 프로바이더 정렬 const sortedProviders = requestedProvider ? [requestedProvider, ...this.providers.filter((p) => p !== requestedProvider)] : this.providers; // 프로바이더 순회 (fallback) for (const provider of sortedProviders) { if (!provider.isHealthy) { logger.warn(`⚠️ ${provider.name} 건강하지 않음, 건너뜀`); continue; } try { logger.info(`🚀 ${provider.name} (${provider.modelName}) 시도 중...`); const result = await this.callProvider(provider, { messages, maxTokens: maxTokens || provider.maxTokens, temperature: temperature || provider.temperature, }); const responseTime = Date.now() - startTime; // 비용 계산 const cost = this.calculateCost( result.usage.promptTokens, result.usage.completionTokens, provider.costPer1kInputTokens, provider.costPer1kOutputTokens ); logger.info( `✅ ${provider.name} 성공 (${responseTime}ms, ${result.usage.totalTokens} tokens)` ); return { text: result.text, provider: provider.name, providerId: provider.id, model: provider.modelName, usage: result.usage, responseTime, cost, }; } catch (error) { logger.error(`❌ ${provider.name} 실패:`, error.message); lastError = error; // 다음 프로바이더로 fallback continue; } } // 모든 프로바이더 실패 throw new Error( `모든 LLM 프로바이더가 실패했습니다: ${lastError?.message || '알 수 없는 오류'}` ); } /** * 개별 프로바이더 호출 */ async callProvider(provider, { messages, maxTokens, temperature }) { const timeout = provider.timeoutMs || 60000; switch (provider.name) { case 'gemini': return this.callGemini(provider, { messages, maxTokens, temperature }); case 'openai': return this.callOpenAI(provider, { messages, maxTokens, temperature, timeout }); case 'claude': return this.callClaude(provider, { messages, maxTokens, temperature, timeout }); default: throw new Error(`지원하지 않는 프로바이더: ${provider.name}`); } } /** * Gemini API 호출 */ async callGemini(provider, { messages, maxTokens, temperature }) { const { GoogleGenAI } = require('@google/genai'); const ai = new GoogleGenAI({ apiKey: provider.apiKey }); // 메시지 변환 (OpenAI 형식 -> Gemini 형식) const contents = messages.map((msg) => ({ role: msg.role === 'assistant' ? 'model' : 'user', parts: [{ text: msg.content }], })); // system 메시지 처리 const systemMessage = messages.find((m) => m.role === 'system'); const systemInstruction = systemMessage ? systemMessage.content : undefined; const config = { maxOutputTokens: maxTokens, temperature, }; const result = await ai.models.generateContent({ model: provider.modelName, contents: contents.filter((c) => c.role !== 'system'), systemInstruction, config, }); // 응답 텍스트 추출 let text = ''; if (result.candidates?.[0]?.content?.parts) { text = result.candidates[0].content.parts .filter((p) => p.text) .map((p) => p.text) .join('\n'); } const usage = result.usageMetadata || {}; const promptTokens = usage.promptTokenCount ?? 0; const completionTokens = usage.candidatesTokenCount ?? 0; return { text, usage: { promptTokens, completionTokens, totalTokens: promptTokens + completionTokens, }, }; } /** * OpenAI API 호출 */ async callOpenAI(provider, { messages, maxTokens, temperature, timeout }) { const response = await axios.post( provider.endpoint, { model: provider.modelName, messages, max_tokens: maxTokens, temperature, }, { timeout, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${provider.apiKey}`, }, } ); return { text: response.data.choices[0].message.content, usage: { promptTokens: response.data.usage.prompt_tokens, completionTokens: response.data.usage.completion_tokens, totalTokens: response.data.usage.total_tokens, }, }; } /** * Claude API 호출 */ async callClaude(provider, { messages, maxTokens, temperature, timeout }) { // system 메시지 분리 const systemMessage = messages.find((m) => m.role === 'system'); const otherMessages = messages.filter((m) => m.role !== 'system'); const response = await axios.post( provider.endpoint, { model: provider.modelName, messages: otherMessages, system: systemMessage?.content, max_tokens: maxTokens, temperature, }, { timeout, headers: { 'Content-Type': 'application/json', 'x-api-key': provider.apiKey, 'anthropic-version': '2023-06-01', }, } ); return { text: response.data.content[0].text, usage: { promptTokens: response.data.usage.input_tokens, completionTokens: response.data.usage.output_tokens, totalTokens: response.data.usage.input_tokens + response.data.usage.output_tokens, }, }; } /** * 스트리밍 채팅 (제너레이터) */ async *chatStream(params) { // 현재는 간단한 구현 (전체 응답 후 청크로 분할) // 실제 스트리밍은 각 프로바이더의 스트리밍 API 사용 필요 const result = await this.chat(params); // 텍스트를 청크로 분할하여 전송 const chunkSize = 10; for (let i = 0; i < result.text.length; i += chunkSize) { yield { text: result.text.slice(i, i + chunkSize), done: i + chunkSize >= result.text.length, }; } } /** * 비용 계산 */ calculateCost(promptTokens, completionTokens, inputCost, outputCost) { const inputTotal = (promptTokens / 1000) * inputCost; const outputTotal = (completionTokens / 1000) * outputCost; return parseFloat((inputTotal + outputTotal).toFixed(6)); } } // 싱글톤 인스턴스 const llmService = new LLMService(); module.exports = llmService;