All checks were successful
ci / 정본·보안·린트·타입·테스트 (push) Successful in 52s
ci / 모바일 린트·타입·Jest (push) Successful in 41s
ci / Supabase Edge Functions + Cloudflare Worker (push) Successful in 22s
ci / .NET API 서버 테스트 (push) Successful in 14s
deploy-site / deploy (push) Successful in 41s
ci / 워크스페이스 빌드 검증 (push) Successful in 36s
A five-second phone recording took over a minute: stt-proxy waited up to 60 s for the self-hosted gateway, whose GPU endpoint was off and whose NAS CPU Whisper needs 30-90 s per clip. With a direct provider configured the gateway now gets 5 s plus the clip length (30 s cap). The direct OpenAI fallback never produced a result. The production key held characters that are not valid in an HTTP header, so every request threw while being built; provider keys are now stripped of BOM/zero-width characters and a still-invalid key counts as not configured. whisper-1 verbose_json reports the language by name, which the result contract rejected; names now map to codes. Fail-closed responses list each provider's failure (status or error class, no secrets) so an outage can be diagnosed without log access.
176 lines
5.4 KiB
TypeScript
176 lines
5.4 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'
|
|
import { readProviderKey } from '../_shared/provider-key.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 = readProviderKey('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' },
|
|
})
|
|
}
|
|
})
|