feat(desktop+server): Phase 3.2 Premium LLM — Anthropic Claude 프리미엄 파이프라인 + 모델별 쿼터 + SaaS UI

빅뱅 8/8 마지막 성공 기준 달성. Supabase Edge Function(llm-proxy)을 통해
Anthropic Claude를 호출하는 PremiumLLMService 신규 구현. 사용자가 Settings에서
Local/Premium 백엔드를 선택하면 VoiceModeService가 자동 분기하고, Premium 실패 시
Local로 silent fallback + 상단 중앙 배너 알림.

실측: Claude Haiku refine 1.6~3.2초 (이전 qwen3 42.9초 → 13~27배 빠름).

주요 변경:
- PremiumLLMService 신규 (싱글톤+EventEmitter, processText/chatStream,
  Supabase functions.invoke 기반, _ensureAuth 가드)
- llm-prompts.ts: SYSTEM_PROMPTS를 Local/Premium 공유 모듈로 추출
  (resolveSystemPrompt 헬퍼)
- VoiceModeService: _getLLMProcessor → _runProcessorWithFallback 라우터 +
  premium-llm-fallback 이벤트
- CloudSyncService: getAccessToken(async), getAnonKey, invokeFunction(auth
  헤더 자동 처리, 에러 body 파싱)
- IPC: LLM.PREMIUM_* 채널 6개 + preload API + llm-handlers 이벤트 전달
  (safeSendToRenderer 헬퍼)
- AppConfig.llmBackend: 'local' | 'premium' (기본 'local')
- Settings UI: Backend 드롭다운 + Premium 선택 시 Ollama UI 숨김 + 라이선스
  모달 자동 오픈
- AppLayout: 상단 중앙 Snackbar fallback 배너 (8초, warning filled)
- LicenseModal: 라이선스 키 입력 제거 → SaaS 구독 관리 UI 전환
  (Free/Pro/Pro+ 업그레이드 버튼, Payple 준비 중 스텁)
- 등급 비교 표: featureLabel i18n 번역 수정

서버 (Supabase Edge Functions):
- quota.ts: 모델별 쿼터 구조 (llm_haiku/sonnet/opus × free/pro/pro_plus),
  주간/일간 기간 분리, modelToQuotaKey 매핑, consumeQuota baseLimit 파라미터화
- llm-proxy: 모델별 쿼터 체크 + 소비 (checkQuota → consumeQuota 원자적),
  verify_jwt=false (2026 sb_publishable_ 키 호환)
- config.toml: llm-proxy verify_jwt = false
- migration 20260412000001: tier team→pro_plus 통일, subscriptions.overage_credits
  컬럼, consume_quota RPC (원자적 base→overage fallback)

Tier/쿼터:
- free: Haiku 250/주간, Sonnet/Opus 불가
- pro ₩9,900: Haiku 1500/일, Sonnet 300/일, Opus 50/일
- pro_plus ₩29,900: Haiku 무제한, Sonnet 1500/일, Opus 300/일
- api-client SubscriptionTier: team→pro_plus, overage_credits 필드 추가
This commit is contained in:
윤찬 2026-04-12 18:28:02 +09:00
parent d397bcbf57
commit 6e52c18e5b
23 changed files with 1111 additions and 311 deletions

View file

@ -0,0 +1,284 @@
// src/main/services/PremiumLLMService.ts
// Phase 3.2: Anthropic Claude 프리미엄 LLM 서비스.
// LocalLLMService와 같은 인터페이스를 제공하되, 내부적으로는
// Supabase Edge Function(`llm-proxy`)을 경유해 Claude Messages API를 호출.
//
// 특징:
// - 싱글톤 + EventEmitter (설계서 01 패턴)
// - processText(), chatStream() — LocalLLMService와 시그니처 동일
// - 네트워크 실패 / 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'
const logger = getLogger('PremiumLLMService')
// ============================================================
// 내부 타입
// ============================================================
interface PremiumGenerateOptions {
model?: string
temperature?: number
maxTokens?: number
systemPrompt?: string
}
/** 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 }
}
/** llm-proxy 에러 응답 */
interface LlmProxyErrorResponse {
error: string
current?: number
limit?: number
tier?: 'free' | 'pro' | 'pro_plus'
overage_credits?: number
allowed?: string[]
requested?: string
}
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 사용 전 로그인 필요',
)
}
}
/**
* LocalLLMService.processText .
* 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') {
logger.warn('Premium LLM returned empty content — falling back to original')
return text
}
const result = firstBlock.text.trim()
if (result.length === 0) {
logger.warn('Premium LLM returned empty text — falling back to original')
return text
}
return result
} 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용) LocalLLMService.chatStream .
* Phase 3.2 MVP: 비스트리밍으로 yield.
* SSE yield로 .
*/
async *chatStream(
messages: Array<{ role: string; content: string }>,
options?: { model?: string; temperature?: number },
): AsyncGenerator<string, string> {
this._ensureAuth()
// Ollama 메시지 형식 → Claude Messages 형식으로 정규화
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: false,
}
const response = await this._invokeProxy(body)
const firstBlock = response.content?.[0]
const text = firstBlock?.type === 'text' ? firstBlock.text : ''
// 단일 chunk yield — 추후 SSE 스트리밍으로 업그레이드 시 여러 번 yield.
if (text.length > 0) {
yield text
}
return text
}
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 getPremiumLLMService(): PremiumLLMService {
if (!_instance) {
_instance = new PremiumLLMService()
}
return _instance
}
export type { PremiumLLMService }