d3ro-voice/server/supabase/functions/llm-proxy/index.ts
yunchan8804 97eb886ec3 feat(V2-2): Supabase 인프라 — 스키마 + RLS + Edge Functions 스캐폴딩
server/supabase/ 신규 디렉토리:

config.toml — Supabase CLI 설정
  - 프로젝트 ID, DB 포트, auth providers (Google/GitHub/Apple),
    edge_runtime, functions.*.verify_jwt 설정

migrations/ (PostgreSQL DDL, 시간순):
  - 20260409000001_initial_schema.sql
    12개 테이블: profiles, teams, team_members, meetings,
    meeting_memos, meeting_documents, transcripts, history,
    dictionary, memo_tags, daily_usage, subscriptions
    + 인덱스 + FK CASCADE + transcripts Realtime publication
  - 20260409000002_rls_policies.sql
    개인 전용(history/dictionary): user_id = auth.uid()
    팀 공유(meetings 등): 본인 OR team_members 조회 subquery
    daily_usage: 읽기만, 쓰기는 service_role RPC
  - 20260409000003_auth_triggers.sql
    handle_new_user — auth.users INSERT → profiles+subscriptions 자동 생성
    moddatetime — 8개 테이블 updated_at 자동 갱신
    increment_daily_usage — service_role 전용 쿼터 RPC
  - 20260409000004_storage_buckets.sql
    audio/exports/avatars 3개 버킷 + 경로 기반 접근 정책
    (파일 경로가 {user_id}/...로 시작해야 쓰기 허용)

functions/ (Deno/TypeScript Edge Functions):
  - _shared/cors.ts — CORS 헤더 + preflight 핸들러
  - _shared/auth.ts — requireUser (JWT 검증 + User 반환)
  - _shared/quota.ts — 티어별 쿼터 체크 + consume + service role client
  - stt-proxy/index.ts — Google Cloud STT 래퍼 스캐폴딩
    placeholder 응답, 실제 API 호출 코드는 주석으로 포함
  - llm-proxy/index.ts — Anthropic Messages API 래퍼 스캐폴딩
    티어별 허용 모델 정책 (free=Haiku, pro=Sonnet, team=Opus)

설계 문서:
  - docs/v2/phase-V2-2.md — 상세 설계 (스키마/RLS/Edge Functions/Realtime)
  - docs/v2/phase-V2-2-setup.md — 사용자 액션 가이드 (Supabase 계정/
    OAuth 등록/CLI/배포/검증)

apps/desktop typecheck 통과 (server/는 Deno 런타임이라 별도).
실제 Supabase 프로젝트 배포는 사용자가 phase-V2-2-setup.md 따라 수행.
2026-04-08 15:21:08 +09:00

138 lines
4.3 KiB
TypeScript

// server/supabase/functions/llm-proxy/index.ts
// Anthropic Claude Messages API 프록시.
// 요청: 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, type Tier } from '../_shared/quota.ts'
interface LlmRequest {
messages: Array<{ role: 'user' | 'assistant'; content: string }>
system?: string
max_tokens?: number
model?: string
stream?: boolean
}
/** 티어별 허용 모델 */
const TIER_MODELS: Record<Tier, string[]> = {
free: ['claude-haiku-4-5-20251001'],
pro: ['claude-haiku-4-5-20251001', 'claude-sonnet-4-6'],
team: ['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',
team: '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 quota = await checkQuota(user.id, 'llm_process', serviceClient)
if (!quota.allowed) {
return new Response(
JSON.stringify({
error: 'quota_exceeded',
current: quota.current,
limit: quota.limit,
tier: quota.tier
}),
{
status: 429,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
}
)
}
const body = (await req.json()) as LlmRequest
// 모델 선택 + 티어 검증
const requestedModel = body.model ?? DEFAULT_MODEL[quota.tier]
if (!TIER_MODELS[quota.tier].includes(requestedModel)) {
return new Response(
JSON.stringify({
error: 'model_not_allowed',
tier: quota.tier,
requested: requestedModel,
allowed: TIER_MODELS[quota.tier]
}),
{
status: 403,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
}
)
}
// Anthropic API 호출 — placeholder
//
// 실제 구현 시:
// const anthropicKey = Deno.env.get('ANTHROPIC_API_KEY')!
// const resp = 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 (body.stream) {
// return new Response(resp.body, {
// headers: { ...corsHeaders, 'Content-Type': 'text/event-stream' }
// })
// }
// const data = await resp.json()
// ...
await consumeQuota(user.id, 'llm_process', serviceClient, 1)
const placeholder = {
id: `msg_placeholder_${Date.now()}`,
model: requestedModel,
role: 'assistant',
content: [
{
type: 'text',
text: '[llm-proxy placeholder — Anthropic API not yet wired]'
}
],
stop_reason: 'end_turn',
usage: { input_tokens: 0, output_tokens: 0 }
}
return new Response(JSON.stringify(placeholder), {
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' }
})
}
})