314 lines
10 KiB
TypeScript
314 lines
10 KiB
TypeScript
// 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<string> {
|
|
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<string, string> {
|
|
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<string, unknown>,
|
|
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<ClaudeMessageResponse> {
|
|
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<string, unknown>)
|
|
|
|
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<K extends keyof PremiumLLMEvents>(event: K, listener: PremiumLLMEvents[K]): this {
|
|
return super.on(event, listener)
|
|
}
|
|
|
|
emit<K extends keyof PremiumLLMEvents>(
|
|
event: K,
|
|
...args: Parameters<PremiumLLMEvents[K]>
|
|
): 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 }
|