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

@ -1,97 +1,182 @@
// server/supabase/functions/_shared/quota.ts
// 티어별 기능 쿼터 확인 + 증가
// Phase 3.2: 모델별 쿼터 + 주간/일간 기간 분리
// Free=Haiku 250/주간, Pro=모델별 일간, Pro+=Haiku 무제한
// @ts-expect-error — Deno 런타임 import
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.7'
export type Tier = 'free' | 'pro' | 'team'
export type Feature = 'stt_transcribe' | 'llm_process'
export type Tier = 'free' | 'pro' | 'pro_plus'
/** 일일 쿼터 정책 (-1 = 무제한) */
const DAILY_QUOTA: Record<Tier, Record<Feature, number>> = {
/** 쿼터 추적 키 — 모델별 분리 */
export type QuotaFeature =
| 'stt_transcribe'
| 'llm_haiku'
| 'llm_sonnet'
| 'llm_opus'
export type QuotaPeriod = 'daily' | 'weekly'
interface ModelQuota {
/** -1=무제한, 0=사용불가, 양수=한도 */
limit: number
period: QuotaPeriod
}
/** 모델별 쿼터 정책 */
const MODEL_QUOTA: Record<Tier, Record<QuotaFeature, ModelQuota>> = {
free: {
stt_transcribe: 50,
llm_process: 50
stt_transcribe: { limit: 250, period: 'weekly' },
llm_haiku: { limit: 250, period: 'weekly' },
llm_sonnet: { limit: 0, period: 'daily' }, // 사용불가
llm_opus: { limit: 0, period: 'daily' }, // 사용불가
},
pro: {
stt_transcribe: -1,
llm_process: -1
stt_transcribe: { limit: -1, period: 'daily' },
llm_haiku: { limit: 1500, period: 'daily' },
llm_sonnet: { limit: 300, period: 'daily' },
llm_opus: { limit: 50, period: 'daily' },
},
team: {
stt_transcribe: -1,
llm_process: -1
}
pro_plus: {
stt_transcribe: { limit: -1, period: 'daily' },
llm_haiku: { limit: -1, period: 'daily' }, // 무제한
llm_sonnet: { limit: 1500, period: 'daily' },
llm_opus: { limit: 300, period: 'daily' },
},
}
/** Anthropic 모델명 → 쿼터 키 매핑 */
export function modelToQuotaKey(model: string): QuotaFeature {
if (model.includes('haiku')) return 'llm_haiku'
if (model.includes('sonnet')) return 'llm_sonnet'
if (model.includes('opus')) return 'llm_opus'
return 'llm_haiku' // fallback
}
/** 티어+feature → 쿼터 정책 조회 */
export function getQuotaPolicy(tier: Tier, feature: QuotaFeature): ModelQuota {
return MODEL_QUOTA[tier]?.[feature] ?? { limit: 0, period: 'daily' }
}
export interface QuotaCheck {
allowed: boolean
current: number
limit: number
period: QuotaPeriod
tier: Tier
overageCredits: number
}
export interface QuotaConsumeResult {
allowed: boolean
current: number
limit: number
overageCredits: number
consumedFrom: 'base' | 'overage' | 'unlimited' | 'none'
}
/**
* .
* performQuotaConsume .
* , (daily/weekly).
* weekly인 7 daily_usage를 .
*/
export async function checkQuota(
userId: string,
feature: Feature,
serviceRoleClient: ReturnType<typeof createClient>
feature: QuotaFeature,
serviceRoleClient: ReturnType<typeof createClient>,
): Promise<QuotaCheck> {
// 티어 조회
// 티어 + overage 조회
const { data: sub } = await serviceRoleClient
.from('subscriptions')
.select('tier')
.select('tier, overage_credits')
.eq('user_id', userId)
.single()
const tier: Tier = (sub?.tier as Tier) ?? 'free'
const limit = DAILY_QUOTA[tier][feature]
const overageCredits = (sub?.overage_credits as number) ?? 0
const policy = getQuotaPolicy(tier, feature)
if (limit === -1) {
return { allowed: true, current: 0, limit, tier }
// 사용불가 (limit=0)
if (policy.limit === 0) {
return { allowed: false, current: 0, limit: 0, period: policy.period, tier, overageCredits }
}
// 오늘 사용량 조회
const today = new Date().toISOString().slice(0, 10)
const { data: usage } = await serviceRoleClient
.from('daily_usage')
.select('count')
.eq('user_id', userId)
.eq('date', today)
.eq('feature', feature)
.maybeSingle()
// 무제한
if (policy.limit === -1) {
return { allowed: true, current: 0, limit: -1, period: policy.period, tier, overageCredits }
}
// 사용량 조회 (daily vs weekly)
let current: number
if (policy.period === 'weekly') {
// 최근 7일 합산
const weekAgo = new Date()
weekAgo.setDate(weekAgo.getDate() - 7)
const { data: rows } = await serviceRoleClient
.from('daily_usage')
.select('count')
.eq('user_id', userId)
.eq('feature', feature)
.gte('date', weekAgo.toISOString().slice(0, 10))
current = rows?.reduce((sum: number, row: { count: number }) => sum + row.count, 0) ?? 0
} else {
// 오늘만
const today = new Date().toISOString().slice(0, 10)
const { data: usage } = await serviceRoleClient
.from('daily_usage')
.select('count')
.eq('user_id', userId)
.eq('date', today)
.eq('feature', feature)
.maybeSingle()
current = (usage?.count as number) ?? 0
}
const current = (usage?.count as number) ?? 0
return {
allowed: current < limit,
allowed: current < policy.limit || overageCredits > 0,
current,
limit,
tier
limit: policy.limit,
period: policy.period,
tier,
overageCredits,
}
}
/**
* . increment_daily_usage (service_role ).
* daily_usage를 +1 .
* (weekly checkQuota에서 7 )
* (-1) allowed=true.
* base + overage overage .
*/
export async function consumeQuota(
userId: string,
feature: Feature,
feature: QuotaFeature,
serviceRoleClient: ReturnType<typeof createClient>,
amount: number = 1
): Promise<number> {
const { data, error } = await serviceRoleClient.rpc('increment_daily_usage', {
baseLimit: number,
): Promise<QuotaConsumeResult> {
const { data, error } = await serviceRoleClient.rpc('consume_quota', {
p_user_id: userId,
p_feature: feature,
p_amount: amount
p_base_limit: baseLimit,
})
if (error) {
throw new Error(`Failed to increment quota: ${error.message}`)
throw new Error(`Failed to consume quota: ${error.message}`)
}
return (data as number) ?? 0
const result = data as {
allowed: boolean
current: number
limit: number
overage_credits: number
consumed_from: 'base' | 'overage' | 'unlimited' | 'none'
}
return {
allowed: result.allowed,
current: result.current,
limit: result.limit,
overageCredits: result.overage_credits,
consumedFrom: result.consumed_from,
}
}
/**
@ -103,6 +188,6 @@ export function createServiceRoleClient(): ReturnType<typeof createClient> {
// @ts-expect-error — Deno.env는 Deno 런타임 전역
const serviceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
return createClient(url, serviceKey, {
auth: { persistSession: false, autoRefreshToken: false }
auth: { persistSession: false, autoRefreshToken: false },
})
}