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.
346 lines
13 KiB
TypeScript
346 lines
13 KiB
TypeScript
// 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,
|
|
gatewayDeadlineMs,
|
|
providerLanguageCode,
|
|
validateSttAudio,
|
|
} from '../_shared/stt-contract.ts'
|
|
import { readProviderKey } from '../_shared/provider-key.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<typeof createServiceRoleClient>
|
|
} | null = null
|
|
|
|
try {
|
|
// 1) 인증
|
|
let user: Awaited<ReturnType<typeof requireUser>>
|
|
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<typeof validateSttAudio>
|
|
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 = readProviderKey('GROQ_API_KEY')
|
|
const openaiKey = readProviderKey('OPENAI_API_KEY')
|
|
const deepgramKey = readProviderKey('DEEPGRAM_API_KEY')
|
|
|
|
let result: NormalizedSttResult | null = null
|
|
let attemptedProvider = false
|
|
let sawBadGatewayFailure = false
|
|
let sawServiceUnavailable = false
|
|
// Which provider failed and how (status code or error class) — no bodies, no secrets.
|
|
// Returned with a fail-closed response so an outage is diagnosable without log access.
|
|
const attempts: Array<{ provider: string; failure: string }> = []
|
|
const failureOf = (err: unknown): string => {
|
|
if (!(err instanceof Error)) return 'error'
|
|
if (err.name === 'TimeoutError' || err.name === 'AbortError') return 'timeout'
|
|
return `${err.name}: ${err.message}`.slice(0, 120)
|
|
}
|
|
|
|
// 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(
|
|
gatewayDeadlineMs(audio.size, Boolean(groqKey || openaiKey || deepgramKey)),
|
|
),
|
|
})
|
|
|
|
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: providerLanguageCode(apiData.language, audioInput.languageCode),
|
|
duration_seconds: apiData.durationSeconds ?? (audio.size / 4000),
|
|
provider: apiData.provider ?? 'd3ro-gateway',
|
|
})
|
|
} else if (apiResp.status === 503) {
|
|
sawServiceUnavailable = true
|
|
attempts.push({ provider: 'gateway', failure: 'http_503' })
|
|
} else {
|
|
sawBadGatewayFailure = true
|
|
attempts.push({ provider: 'gateway', failure: `http_${apiResp.status}` })
|
|
}
|
|
} catch (err) {
|
|
sawBadGatewayFailure = true
|
|
attempts.push({ provider: 'gateway', failure: failureOf(err) })
|
|
}
|
|
}
|
|
|
|
// 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: providerLanguageCode(groqData.language, audioInput.languageCode),
|
|
duration_seconds: groqData.duration ?? (audio.size / 4000),
|
|
provider: 'groq',
|
|
})
|
|
} else {
|
|
sawBadGatewayFailure = true
|
|
attempts.push({ provider: 'groq', failure: `http_${groqResp.status}` })
|
|
}
|
|
} catch (err) {
|
|
sawBadGatewayFailure = true
|
|
attempts.push({ provider: 'groq', failure: failureOf(err) })
|
|
}
|
|
}
|
|
|
|
// 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: providerLanguageCode(openAiData.language, audioInput.languageCode),
|
|
duration_seconds: openAiData.duration ?? (audio.size / 4000),
|
|
provider: 'openai',
|
|
})
|
|
} else {
|
|
sawBadGatewayFailure = true
|
|
attempts.push({ provider: 'openai', failure: `http_${openAiResp.status}` })
|
|
}
|
|
} catch (err) {
|
|
sawBadGatewayFailure = true
|
|
attempts.push({ provider: 'openai', failure: failureOf(err) })
|
|
}
|
|
}
|
|
|
|
// 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
|
|
attempts.push({ provider: 'deepgram', failure: `http_${dgResp.status}` })
|
|
}
|
|
} catch (err) {
|
|
sawBadGatewayFailure = true
|
|
attempts.push({ provider: 'deepgram', failure: failureOf(err) })
|
|
}
|
|
}
|
|
|
|
// 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, attempts }), {
|
|
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' }
|
|
})
|
|
}
|
|
})
|