빅뱅 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 필드 추가
222 lines
6.9 KiB
TypeScript
222 lines
6.9 KiB
TypeScript
// 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' },
|
||
})
|
||
}
|
||
})
|