175 lines
5.3 KiB
TypeScript
175 lines
5.3 KiB
TypeScript
// 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만, 상위 티어는 풀 모델까지 */
|
|
const TIER_MODELS: Record<Tier, string[]> = {
|
|
free: [],
|
|
pro: ['gpt-realtime-2.1-mini'],
|
|
pro_plus: ['gpt-realtime-2.1', 'gpt-realtime-2.1-mini'],
|
|
team: ['gpt-realtime-2.1', 'gpt-realtime-2.1-mini'],
|
|
enterprise: ['gpt-realtime-2.1', 'gpt-realtime-2.1-mini'],
|
|
}
|
|
|
|
const DEFAULT_MODEL: Record<Tier, string | null> = {
|
|
free: null,
|
|
pro: 'gpt-realtime-2.1-mini',
|
|
pro_plus: 'gpt-realtime-2.1',
|
|
team: 'gpt-realtime-2.1',
|
|
enterprise: 'gpt-realtime-2.1',
|
|
}
|
|
|
|
const DEFAULT_VOICE = 'marin'
|
|
const MAX_INSTRUCTIONS_LENGTH = 2000
|
|
|
|
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' },
|
|
},
|
|
)
|
|
}
|
|
|
|
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' },
|
|
})
|
|
}
|
|
})
|