// server/supabase/functions/realtime-token/index.ts // OpenAI Realtime API ephemeral token 발급. // 렌더러가 이 토큰으로 OpenAI와 직접 WebRTC 연결한다 (서버 키 비노출). // 요청: application/json { model?, voice?, instructions? } // 응답: OpenAI client_secrets 응답 그대로 + { model, tier } import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts' import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts' import { checkQuota, consumeQuota, createServiceRoleClient, getQuotaPolicy, type Tier, } from '../_shared/quota.ts' interface RealtimeTokenRequest { model?: string voice?: string instructions?: string } /** 티어별 허용 Realtime 모델 — free 차단, pro는 mini만, pro_plus는 풀 모델까지 */ const TIER_MODELS: Record = { free: [], pro: ['gpt-realtime-2.1-mini'], pro_plus: ['gpt-realtime-2.1', 'gpt-realtime-2.1-mini'], } const DEFAULT_MODEL: Record = { free: null, pro: 'gpt-realtime-2.1-mini', pro_plus: 'gpt-realtime-2.1', } const DEFAULT_VOICE = 'marin' const MAX_INSTRUCTIONS_LENGTH = 2000 // @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().catch(() => ({}))) as RealtimeTokenRequest // 1단계: 티어 + 세션 쿼터 확인 const quota = await checkQuota(user.id, 'realtime_session', serviceClient) const tier = quota.tier const requestedModel = body.model ?? DEFAULT_MODEL[tier] if (!requestedModel || !TIER_MODELS[tier].includes(requestedModel)) { return new Response( JSON.stringify({ error: tier === 'free' ? 'tier_not_allowed' : 'model_not_allowed', tier, requested: body.model ?? null, allowed: TIER_MODELS[tier], }), { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }, ) } if (!quota.allowed) { return new Response( JSON.stringify({ error: 'quota_exceeded', current: quota.current, limit: quota.limit, period: quota.period, tier, overage_credits: quota.overageCredits, }), { status: 429, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }, ) } // 2단계: 쿼터 소비 (세션 시작 = 1회) const policy = getQuotaPolicy(tier, 'realtime_session') const consume = await consumeQuota(user.id, 'realtime_session', serviceClient, policy.limit) if (!consume.allowed) { return new Response( JSON.stringify({ error: 'quota_exceeded', current: consume.current, limit: consume.limit, tier, overage_credits: consume.overageCredits, }), { status: 429, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }, ) } // @ts-expect-error — Deno.env const openaiKey = Deno.env.get('OPENAI_API_KEY') ?? '' if (!openaiKey) { return new Response( JSON.stringify({ error: 'not_configured', message: 'OPENAI_API_KEY 미설정' }), { status: 503, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }, ) } // 3단계: OpenAI ephemeral client secret 발급 const instructions = typeof body.instructions === 'string' ? body.instructions.slice(0, MAX_INSTRUCTIONS_LENGTH) : undefined const openaiResp = await fetch('https://api.openai.com/v1/realtime/client_secrets', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${openaiKey}`, 'OpenAI-Safety-Identifier': user.id, }, body: JSON.stringify({ session: { type: 'realtime', model: requestedModel, ...(instructions ? { instructions } : {}), audio: { output: { voice: body.voice ?? DEFAULT_VOICE }, }, }, }), }) if (!openaiResp.ok) { const errText = await openaiResp.text() throw new Error(`OpenAI ${openaiResp.status}: ${errText.slice(0, 500)}`) } const data = await openaiResp.json() return new Response( JSON.stringify({ ...data, model: requestedModel, tier }), { 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' }, }) } })