// server/supabase/functions/stt-proxy/index.ts // D3RO Voice — Multi-Provider Cloud Speech-to-Text Proxy // Supports dynamic routing via D3RO API Backend or Direct Providers (Groq, OpenAI, Deepgram, Google) import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts' import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts' import { createServiceRoleClient, finalizeSttQuota, reserveSttQuota } from '../_shared/quota.ts' import { buildDictionaryHints, createInternalSttGatewayUrl, createDeepgramSttUrl, normalizeSttResult, type NormalizedSttResult, SttInputError, STT_PROVIDER_TIMEOUT_MS, validateSttAudio, } from '../_shared/stt-contract.ts' 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' } }) } let quotaReservation: { id: string client: ReturnType } | null = null try { // 1) 인증 let user: Awaited> try { user = await requireUser(req) } catch (err) { if ( err && typeof err === 'object' && 'status' in err && (err.status === 401 || err.status === 403) && 'message' in err && typeof err.message === 'string' ) { return authErrorResponse(err as AuthError, corsHeaders) } return new Response(JSON.stringify({ error: 'internal_error' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }) } // 2) service client const serviceClient = createServiceRoleClient() // 3) 입력 파싱 const formData = await req.formData() const audio = formData.get('audio') ?? formData.get('file') const languageCode = String(formData.get('language_code') ?? 'ko') if (!(audio instanceof Blob)) { return new Response(JSON.stringify({ error: 'Missing audio field' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }) } let audioInput: ReturnType try { audioInput = validateSttAudio(audio, languageCode) } catch (error) { if (error instanceof SttInputError) { return new Response(JSON.stringify({ error: error.code }), { status: error.status, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }) } throw error } // Reserve before provider work. The DB advisory lock closes concurrent // quota races; failed or crashed provider work is refunded/reclaimed. const quota = await reserveSttQuota(user.id, crypto.randomUUID(), serviceClient) if (!quota.allowed || !quota.reservationId || quota.status !== 'reserved') { return new Response( JSON.stringify({ error: 'quota_exceeded', current: quota.current, limit: quota.limit, period: quota.period, tier: quota.tier, overage_credits: quota.overageCredits, }), { status: 429, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }, ) } quotaReservation = { id: quota.reservationId, client: serviceClient } const dictionaryQuery = await serviceClient .from('dictionary') .select('word,pronunciation') .eq('user_id', user.id) .order('usage_count', { ascending: false }) .limit(50) const dictionaryHints = buildDictionaryHints( dictionaryQuery.error ? [] : (dictionaryQuery.data ?? []), ) const apiServerUrl = Deno.env.get('D3RO_API_URL') ?? Deno.env.get('BACKEND_ORIGIN') ?? '' // Supabase user JWTs are not valid D3RO API JWTs. Configure a dedicated backend token. const apiServerToken = Deno.env.get('D3RO_API_TOKEN') ?? '' const groqKey = Deno.env.get('GROQ_API_KEY') ?? '' const openaiKey = Deno.env.get('OPENAI_API_KEY') ?? '' const deepgramKey = Deno.env.get('DEEPGRAM_API_KEY') ?? '' let result: NormalizedSttResult | null = null let attemptedProvider = false let sawBadGatewayFailure = false let sawServiceUnavailable = false // 4) Forward to D3RO API Gateway Orchestrator if available if (apiServerUrl && apiServerToken) { attemptedProvider = true try { const forwardForm = new FormData() forwardForm.append('file', audio, audioInput.fileName) forwardForm.append('language', audioInput.languageCode) if (dictionaryHints.prompt) forwardForm.append('prompt', dictionaryHints.prompt) const apiResp = await fetch(createInternalSttGatewayUrl(apiServerUrl), { method: 'POST', headers: { 'X-D3RO-STT-Gateway-Token': apiServerToken }, body: forwardForm, signal: AbortSignal.timeout(STT_PROVIDER_TIMEOUT_MS), }) if (apiResp.ok) { const apiData = await apiResp.json() if (typeof apiData?.text !== 'string') { throw new Error('Invalid STT gateway response') } result = normalizeSttResult({ transcript: apiData.text, confidence: apiData.confidence ?? 0.98, language_code: apiData.language ?? (audioInput.languageCode === 'auto' || audioInput.languageCode === 'multi' ? 'und' : audioInput.languageCode), duration_seconds: apiData.durationSeconds ?? (audio.size / 4000), provider: apiData.provider ?? 'd3ro-gateway', }) } else if (apiResp.status === 503) { sawServiceUnavailable = true } else { sawBadGatewayFailure = true } } catch { sawBadGatewayFailure = true } } // 5) Direct Groq Whisper LPU Fallback (Sub-200ms) if (!result && groqKey) { attemptedProvider = true try { const groqForm = new FormData() groqForm.append('file', audio, audioInput.fileName) groqForm.append('model', 'whisper-large-v3-turbo') if (audioInput.languageCode !== 'auto' && audioInput.languageCode !== 'multi') { groqForm.append('language', audioInput.languageCode) } if (dictionaryHints.prompt) groqForm.append('prompt', dictionaryHints.prompt) groqForm.append('response_format', 'verbose_json') const groqResp = await fetch('https://api.groq.com/openai/v1/audio/transcriptions', { method: 'POST', headers: { Authorization: `Bearer ${groqKey}`, }, body: groqForm, signal: AbortSignal.timeout(STT_PROVIDER_TIMEOUT_MS), }) if (groqResp.ok) { const groqData = await groqResp.json() if (typeof groqData?.text !== 'string') { throw new Error('Invalid Groq STT response') } result = normalizeSttResult({ transcript: groqData.text, confidence: 0.98, language_code: groqData.language ?? (audioInput.languageCode === 'auto' || audioInput.languageCode === 'multi' ? 'und' : audioInput.languageCode), duration_seconds: groqData.duration ?? (audio.size / 4000), provider: 'groq', }) } else { sawBadGatewayFailure = true } } catch { sawBadGatewayFailure = true } } // 6) Direct OpenAI Whisper Fallback if (!result && openaiKey) { attemptedProvider = true try { const openAiForm = new FormData() openAiForm.append('file', audio, audioInput.fileName) openAiForm.append('model', 'whisper-1') if (audioInput.languageCode !== 'auto' && audioInput.languageCode !== 'multi') { openAiForm.append('language', audioInput.languageCode) } if (dictionaryHints.prompt) openAiForm.append('prompt', dictionaryHints.prompt) openAiForm.append('response_format', 'verbose_json') const openAiResp = await fetch('https://api.openai.com/v1/audio/transcriptions', { method: 'POST', headers: { Authorization: `Bearer ${openaiKey}`, }, body: openAiForm, signal: AbortSignal.timeout(STT_PROVIDER_TIMEOUT_MS), }) if (openAiResp.ok) { const openAiData = await openAiResp.json() if (typeof openAiData?.text !== 'string') { throw new Error('Invalid OpenAI STT response') } result = normalizeSttResult({ transcript: openAiData.text, confidence: 0.98, language_code: openAiData.language ?? (audioInput.languageCode === 'auto' || audioInput.languageCode === 'multi' ? 'und' : audioInput.languageCode), duration_seconds: openAiData.duration ?? (audio.size / 4000), provider: 'openai', }) } else { sawBadGatewayFailure = true } } catch { sawBadGatewayFailure = true } } // 7) Direct Deepgram Nova-3 Fallback if (!result && deepgramKey) { attemptedProvider = true try { const audioBuffer = await audio.arrayBuffer() const dgResp = await fetch(createDeepgramSttUrl( audioInput.languageCode, dictionaryHints.keyterms, ), { method: 'POST', headers: { Authorization: `Token ${deepgramKey}`, 'Content-Type': audioInput.contentType, }, body: audioBuffer, signal: AbortSignal.timeout(STT_PROVIDER_TIMEOUT_MS), }) if (dgResp.ok) { const dgData = await dgResp.json() const transcript = dgData.results?.channels?.[0]?.alternatives?.[0]?.transcript if (typeof transcript !== 'string') { throw new Error('Invalid Deepgram STT response') } const confidence = dgData.results?.channels?.[0]?.alternatives?.[0]?.confidence ?? 0.95 result = normalizeSttResult({ transcript, confidence, language_code: dgData.results?.channels?.[0]?.detected_language ?? (audioInput.languageCode === 'auto' || audioInput.languageCode === 'multi' ? 'und' : audioInput.languageCode), duration_seconds: dgData.metadata?.duration ?? (audio.size / 4000), provider: 'deepgram', }) } else { sawBadGatewayFailure = true } } catch { sawBadGatewayFailure = true } } // 8) Fail closed if no provider produced a real transcription. if (!result) { await finalizeSttQuota(quotaReservation.id, false, quotaReservation.client).catch(() => undefined) quotaReservation = null const status = !attemptedProvider || (!sawBadGatewayFailure && sawServiceUnavailable) ? 503 : 502 const error = status === 503 ? 'stt_provider_unavailable' : 'stt_upstream_failed' return new Response(JSON.stringify({ error }), { status, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }) } // 9) Mark the exact pre-provider reservation as consumed. const finalStatus = await finalizeSttQuota(quotaReservation.id, true, quotaReservation.client) if (finalStatus !== 'completed') { throw new Error('STT quota reservation was reclaimed before completion.') } quotaReservation = null return new Response(JSON.stringify(result), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }) } catch { if (quotaReservation) { await finalizeSttQuota(quotaReservation.id, false, quotaReservation.client).catch(() => undefined) } return new Response(JSON.stringify({ error: 'internal_error' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }) } })