d3ro-voice/server/supabase/functions/llm-proxy/index.ts
윤찬 6e52c18e5b 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 필드 추가
2026-04-12 18:28:02 +09:00

222 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// server/supabase/functions/llm-proxy/index.ts
// Anthropic Claude Messages API 프록시.
// Phase 3.2: 모델별 쿼터 (Haiku/Sonnet/Opus × Free/Pro/Pro+)
// 요청: application/json { messages, system?, max_tokens?, model? }
// 응답: JSON (non-stream) 또는 SSE (stream=true)
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts'
import {
checkQuota,
consumeQuota,
createServiceRoleClient,
modelToQuotaKey,
getQuotaPolicy,
type Tier,
} from '../_shared/quota.ts'
interface LlmRequest {
messages: Array<{ role: 'user' | 'assistant'; content: string }>
system?: string
max_tokens?: number
model?: string
stream?: boolean
}
/** 티어별 허용 모델 — free는 Haiku만, pro는 +Sonnet, pro_plus는 +Opus */
const TIER_MODELS: Record<Tier, string[]> = {
free: ['claude-haiku-4-5-20251001'],
pro: ['claude-haiku-4-5-20251001', 'claude-sonnet-4-6', 'claude-opus-4-6'],
pro_plus: ['claude-haiku-4-5-20251001', 'claude-sonnet-4-6', 'claude-opus-4-6'],
}
const DEFAULT_MODEL: Record<Tier, string> = {
free: 'claude-haiku-4-5-20251001',
pro: 'claude-sonnet-4-6',
pro_plus: 'claude-sonnet-4-6',
}
// @ts-expect-error — Deno 런타임 전역
Deno.serve(async (req: Request) => {
const preflight = handleCorsPreflightRequest(req)
if (preflight) return preflight
if (req.method !== 'POST') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
try {
const user = await requireUser(req)
const serviceClient = createServiceRoleClient()
const body = (await req.json()) as LlmRequest
// 1단계: 티어 조회 (어떤 모델이든 한 번만 읽으면 됨 — haiku로 대리 조회)
const tierCheck = await checkQuota(user.id, 'llm_haiku', serviceClient)
const tier = tierCheck.tier
// 모델 선택 + 티어 검증
const requestedModel = body.model ?? DEFAULT_MODEL[tier]
if (!TIER_MODELS[tier].includes(requestedModel)) {
return new Response(
JSON.stringify({
error: 'model_not_allowed',
tier,
requested: requestedModel,
allowed: TIER_MODELS[tier],
}),
{
status: 403,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
},
)
}
// 2단계: 해당 모델의 쿼터 확인 (모델별 일간/주간)
const quotaKey = modelToQuotaKey(requestedModel)
const modelQuota = await checkQuota(user.id, quotaKey, serviceClient)
if (!modelQuota.allowed) {
return new Response(
JSON.stringify({
error: 'quota_exceeded',
model: requestedModel,
current: modelQuota.current,
limit: modelQuota.limit,
period: modelQuota.period,
tier,
overage_credits: modelQuota.overageCredits,
}),
{
status: 429,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
},
)
}
// 3단계: 쿼터 소비 (원자적 base → overage fallback)
const policy = getQuotaPolicy(tier, quotaKey)
const consume = await consumeQuota(user.id, quotaKey, serviceClient, policy.limit)
if (!consume.allowed) {
return new Response(
JSON.stringify({
error: 'quota_exceeded',
model: requestedModel,
current: consume.current,
limit: consume.limit,
tier,
overage_credits: consume.overageCredits,
}),
{
status: 429,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
},
)
}
// Anthropic API 호출
// @ts-expect-error — Deno.env
const anthropicKey = Deno.env.get('ANTHROPIC_API_KEY') ?? ''
if (!anthropicKey) {
// Placeholder 응답 (키 미설정 시)
if (body.stream) {
const encoder = new TextEncoder()
const stream = new ReadableStream({
start(controller) {
const msg = '[llm-proxy placeholder — ANTHROPIC_API_KEY 미설정]'
for (const ch of msg) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: ch } })}\n\n`,
),
)
}
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
controller.close()
},
})
return new Response(stream, {
status: 200,
headers: {
...corsHeaders,
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
})
}
return new Response(
JSON.stringify({
id: `msg_placeholder_${Date.now()}`,
model: requestedModel,
role: 'assistant',
content: [
{
type: 'text',
text: '[llm-proxy placeholder — ANTHROPIC_API_KEY 미설정]',
},
],
stop_reason: 'end_turn',
usage: { input_tokens: 0, output_tokens: 0 },
}),
{
status: 200,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
},
)
}
// 실제 Anthropic API 호출
const anthropicResp = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': anthropicKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: requestedModel,
max_tokens: body.max_tokens ?? 2048,
system: body.system,
messages: body.messages,
stream: body.stream ?? false,
}),
})
if (!anthropicResp.ok) {
const errText = await anthropicResp.text()
throw new Error(`Anthropic ${anthropicResp.status}: ${errText.slice(0, 500)}`)
}
if (body.stream && anthropicResp.body) {
return new Response(anthropicResp.body, {
status: 200,
headers: {
...corsHeaders,
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
})
}
const data = await anthropicResp.json()
return new Response(JSON.stringify(data), {
status: 200,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
} catch (err) {
if (err && typeof err === 'object' && 'status' in err && 'message' in err) {
return authErrorResponse(err as AuthError, corsHeaders)
}
const message = err instanceof Error ? err.message : 'Unknown error'
return new Response(JSON.stringify({ error: message }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
})