feat(conversation): OpenAI gpt-realtime-2.1 라이브 음성 대화 Premium 백엔드

- realtime-token Edge Function: 티어 검증 + realtime_session 쿼터 + ephemeral key 발급
- useRealtimeConversation 훅: WebRTC 직결 (마이크 → OpenAI, 오디오 자동 재생)
- VoiceConversationPage: conversationBackend 분기 + 연결 실패 시 로컬 fallback
- AppConfig.conversationBackend + SettingsModal 음성 대화 엔진 선택
- ErrorCode 799 ConversationRealtimeTokenFailed, i18n ko/en
This commit is contained in:
Yun Chan 2026-07-21 12:10:13 +09:00
parent 983c60cda2
commit 8f7d300b89
15 changed files with 762 additions and 40 deletions

View file

@ -87,6 +87,10 @@ verify_jwt = true
# 2026 sb_publishable_ 키와 Gateway JWT 검증 비호환 — requireUser()에서 직접 인증
verify_jwt = false
[functions.realtime-token]
# 2026 sb_publishable_ 키와 Gateway JWT 검증 비호환 — requireUser()에서 직접 인증
verify_jwt = false
[functions.stripe-checkout]
verify_jwt = true

View file

@ -13,6 +13,7 @@ export type QuotaFeature =
| 'llm_haiku'
| 'llm_sonnet'
| 'llm_opus'
| 'realtime_session'
export type QuotaPeriod = 'daily' | 'weekly'
@ -29,18 +30,22 @@ const MODEL_QUOTA: Record<Tier, Record<QuotaFeature, ModelQuota>> = {
llm_haiku: { limit: 250, period: 'weekly' },
llm_sonnet: { limit: 0, period: 'daily' }, // 사용불가
llm_opus: { limit: 0, period: 'daily' }, // 사용불가
realtime_session: { limit: 0, period: 'daily' }, // 사용불가
},
pro: {
stt_transcribe: { limit: -1, period: 'daily' },
llm_haiku: { limit: 1500, period: 'daily' },
llm_sonnet: { limit: 300, period: 'daily' },
llm_opus: { limit: 50, period: 'daily' },
// 세션 수 기준 (~$0.016/분 mini — 세션당 평균 수 분 가정)
realtime_session: { limit: 30, period: 'daily' },
},
pro_plus: {
stt_transcribe: { limit: -1, period: 'daily' },
llm_haiku: { limit: -1, period: 'daily' }, // 무제한
llm_sonnet: { limit: 1500, period: 'daily' },
llm_opus: { limit: 300, period: 'daily' },
realtime_session: { limit: 120, period: 'daily' },
},
}

View file

@ -0,0 +1,173 @@
// 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<Tier, string[]> = {
free: [],
pro: ['gpt-realtime-2.1-mini'],
pro_plus: ['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',
}
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' },
})
}
})