// src/main/services/PremiumLLMService.ts // Phase 3.2: Anthropic Claude 프리미엄 LLM 서비스. // 내부적으로는 온라인 API를 호출한다. // Supabase Edge Function(`llm-proxy`)을 경유해 Claude Messages API를 호출. // // 특징: // - 싱글톤 + EventEmitter (설계서 01 패턴) // - processText(), chatStream() — 시그니처 동일 // - 네트워크 실패 / 401 / 429 / 5xx 감지 시 에러 throw → LLMRouterService가 local로 fallback // - quota-warning / upgrade-required / fallback-triggered 이벤트 emit import { EventEmitter } from 'events' import { getLogger } from './LoggerService' import { getCloudSyncService } from './CloudSyncService' import { D3ROError, ErrorCode } from '@d3ro/core/errors' import type { LLMAction } from '@d3ro/core/types' import { resolveSystemPrompt } from './llm-prompts' import { parseAnthropicSSE } from '../utils/sse-parser' const logger = getLogger('PremiumLLMService') // ============================================================ // 내부 타입 // ============================================================ /** Ollama-style 메시지 → Claude Messages 변환용 */ interface ChatMessage { role: 'user' | 'assistant' content: string } /** llm-proxy Edge Function 요청 body */ interface LlmProxyRequest { messages: ChatMessage[] system?: string max_tokens?: number model?: string stream?: boolean } /** Claude Messages API 비스트리밍 응답 */ interface ClaudeMessageResponse { id: string model: string role: 'assistant' content: Array<{ type: 'text'; text: string }> stop_reason: string usage: { input_tokens: number; output_tokens: number } } export interface QuotaUsageSnapshot { tier: 'free' | 'pro' | 'pro_plus' current: number limit: number overageCredits: number } interface PremiumLLMEvents { 'quota-warning': (payload: { current: number; limit: number; overageCredits: number }) => void 'upgrade-required': (payload: { reason: 'quota_exceeded' | 'model_not_allowed' | 'auth_required' }) => void 'fallback-triggered': (payload: { reason: string }) => void } // ============================================================ // PremiumLLMService // ============================================================ class PremiumLLMService extends EventEmitter { private _abortController: AbortController | null = null private _disposed = false private _lastQuota: QuotaUsageSnapshot | null = null /** * 사용 가능 여부 — Supabase URL + access token 모두 있어야 true. * (LLMRouter가 local/premium 분기 시 호출) */ isAvailable(): boolean { const cloud = getCloudSyncService() return cloud.isEnabled() && cloud.isAuthenticated() } /** * 마지막 응답에서 서버가 알려준 쿼터 상태. * Settings UI에서 "오늘 X/250" 표시에 사용. */ getLastQuota(): QuotaUsageSnapshot | null { return this._lastQuota } /** * disposed / 미인증 상태 공통 가드. * 문제 시 D3ROError throw, upgrade-required emit. */ private _ensureAuth(): void { if (this._disposed) { throw new D3ROError(ErrorCode.LLMProcessingFailed, 'PremiumLLMService disposed') } const cloud = getCloudSyncService() if (!cloud.isEnabled() || !cloud.isAuthenticated()) { this.emit('upgrade-required', { reason: 'auth_required' }) throw new D3ROError( ErrorCode.LLMServerUnreachable, 'Premium LLM 사용 전 로그인 필요', ) } } /** * 텍스트 액션 처리. * Phase 3.2 MVP는 비스트리밍 (stream=false). */ async processText( text: string, action: LLMAction, targetLanguage?: string, customPrompt?: string, ): Promise { this._ensureAuth() const systemPrompt = resolveSystemPrompt(action, targetLanguage, customPrompt) const body: LlmProxyRequest = { messages: [{ role: 'user', content: text }], system: systemPrompt, max_tokens: 2048, stream: false, } try { const response = await this._invokeProxy(body) // Claude 응답 → text 추출 const firstBlock = response.content?.[0] if (!firstBlock || firstBlock.type !== 'text' || !firstBlock.text.trim()) { throw new D3ROError(ErrorCode.LLMProcessingFailed, 'Premium LLM returned empty content') } return firstBlock.text.trim() } catch (err) { // LLMRouter가 local fallback 처리. 여기서는 에러 전파. if (err instanceof D3ROError) throw err throw new D3ROError( ErrorCode.LLMProcessingFailed, `Premium LLM failed: ${err instanceof Error ? err.message : String(err)}`, ) } } /** * 스트리밍 대화 (Voice Conversation용). * SSE 스트리밍: llm-proxy에 stream=true로 요청, Anthropic SSE를 토큰 단위 yield. */ async *chatStream( messages: Array<{ role: string; content: string }>, options?: { model?: string; temperature?: number }, ): AsyncGenerator { this._ensureAuth() const claudeMessages: ChatMessage[] = messages .filter((m) => m.role === 'user' || m.role === 'assistant') .map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content })) const body: LlmProxyRequest = { messages: claudeMessages, model: options?.model, max_tokens: 2048, stream: true, } this._abortController = new AbortController() const cloud = getCloudSyncService() const { stream, error } = await cloud.invokeFunctionStream( 'llm-proxy', body as unknown as Record, this._abortController.signal, ) if (error || !stream) { const msg = error?.message ?? 'Stream unavailable' logger.error(`SSE stream failed: ${msg}`) // SSE 실패 시 비스트리밍 fallback logger.info('Falling back to non-streaming Premium LLM') const fallbackBody = { ...body, stream: false } const response = await this._invokeProxy(fallbackBody) const firstBlock = response.content?.[0] const text = firstBlock?.type === 'text' ? firstBlock.text : '' if (text.length > 0) yield text return text } let accumulated = '' try { for await (const token of parseAnthropicSSE(stream)) { accumulated += token yield token } } catch (err) { if ((err as Error).name !== 'AbortError') { logger.warn(`SSE parse error: ${err instanceof Error ? err.message : String(err)}`) } } finally { this._abortController = null } return accumulated } /** * 제목/요약/액션 플랜 등 자유 생성. processText 와 같은 프록시 경로를 탄다. */ async generate( text: string, options?: { systemPrompt?: string; temperature?: number; maxTokens?: number }, ): Promise<{ text: string }> { this._ensureAuth() const response = await this._invokeProxy({ messages: [{ role: 'user', content: text }], system: options?.systemPrompt, max_tokens: options?.maxTokens ?? 2048, stream: false, }) const firstBlock = response.content?.[0] if (!firstBlock || firstBlock.type !== 'text' || !firstBlock.text.trim()) { throw new D3ROError(ErrorCode.LLMProcessingFailed, 'Premium LLM generate returned empty content') } return { text: firstBlock.text.trim() } } cancelGeneration(): void { if (this._abortController) { this._abortController.abort() this._abortController = null logger.info('Premium LLM generation cancelled') } } dispose(): void { this._disposed = true this.cancelGeneration() this.removeAllListeners() logger.info('PremiumLLMService disposed') } // ── 내부: Edge Function 호출 ───────────────────────────── private async _invokeProxy(body: LlmProxyRequest): Promise { const cloud = getCloudSyncService() // Supabase JS 클라이언트의 functions.invoke() 사용 — auth 헤더를 올바르게 처리. // raw fetch + Authorization: Bearer 방식은 Supabase gateway가 401로 거부. const { data, error } = await cloud.invokeFunction('llm-proxy', body as unknown as Record) if (error) { const msg = error.message ?? 'Edge Function error' logger.error(`llm-proxy error: ${msg}`) // 에러 메시지 기반 분류 if (msg.includes('401') || msg.includes('Unauthorized') || msg.includes('auth')) { this.emit('upgrade-required', { reason: 'auth_required' }) throw new D3ROError(ErrorCode.LLMServerUnreachable, `인증 실패: ${msg}`) } if (msg.includes('quota_exceeded') || msg.includes('429')) { this.emit('upgrade-required', { reason: 'quota_exceeded' }) throw new D3ROError(ErrorCode.LLMProcessingFailed, `쿼터 초과: ${msg}`) } if (msg.includes('model_not_allowed') || msg.includes('403')) { this.emit('upgrade-required', { reason: 'model_not_allowed' }) throw new D3ROError(ErrorCode.LLMInvalidAction, `모델 권한 없음: ${msg}`) } throw new D3ROError(ErrorCode.LLMProcessingFailed, `llm-proxy: ${msg}`) } // functions.invoke는 response body를 자동 파싱해서 data에 넣음 const result = data as ClaudeMessageResponse if (!result?.content) { logger.warn('llm-proxy returned unexpected shape — falling back') throw new D3ROError(ErrorCode.LLMProcessingFailed, 'llm-proxy returned invalid response') } return result } // ── EventEmitter 타입 오버라이드 ─────────────────────── on(event: K, listener: PremiumLLMEvents[K]): this { return super.on(event, listener) } emit( event: K, ...args: Parameters ): boolean { return super.emit(event, ...args) } } // ── 싱글톤 ───────────────────────────────────────────────── let _instance: PremiumLLMService | null = null export function resetPremiumLLMServiceForTests(): void { if (_instance) _instance.removeAllListeners() _instance = null } export function getPremiumLLMService(): PremiumLLMService { if (!_instance) { _instance = new PremiumLLMService() } return _instance } export type { PremiumLLMService }