fix(stt-proxy): stop paid fan-out on empty transcripts and redact provider errors

This commit is contained in:
Yun Chan 2026-09-28 02:16:19 +09:00
parent 1a4c39cb94
commit 1eb22af1f3
9 changed files with 1107 additions and 380 deletions

View file

@ -1,346 +1,81 @@
// 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)
// Routes through the self-hosted D3RO API gateway, then direct providers (Groq, OpenAI, Deepgram).
//
// This file is the composition root. The use case and fallback policy live in
// handler.ts, the provider adapters in providers.ts, the wire contract in
// ../_shared/stt-contract.ts.
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts'
import { corsHeaders } from '../_shared/cors.ts'
import { authErrorResponse, type AuthError, requireUser } 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 { buildDictionaryHints } from '../_shared/stt-contract.ts'
import { readProviderKey } from '../_shared/provider-key.ts'
import {
type AuthenticateResult,
createSttProxyHandler,
type SttDictionaryPort,
type SttQuotaPort,
} from './handler.ts'
import { createSttProviders, type SttProvider } from './providers.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
function isAuthError(err: unknown): err is AuthError {
return !!err
&& typeof err === 'object'
&& 'status' in err
&& (err.status === 401 || err.status === 403)
&& 'message' in err
&& typeof err.message === 'string'
}
async function authenticate(req: Request): Promise<AuthenticateResult> {
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)
}
const user = await requireUser(req)
return { ok: true, user: { id: user.id } }
} catch (err) {
if (isAuthError(err)) return { ok: false, response: authErrorResponse(err, corsHeaders) }
throw err
}
}
return new Response(JSON.stringify({ error: 'internal_error' }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
})
}
/** The service-role client is created per call so a missing env becomes a 500, not a boot failure. */
const supabaseSttQuota: SttQuotaPort = {
reserve: (userId, reservationId) => reserveSttQuota(userId, reservationId, createServiceRoleClient()),
finalize: (reservationId, succeeded) => finalizeSttQuota(reservationId, succeeded, createServiceRoleClient()),
}
// 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
const supabaseDictionary: SttDictionaryPort = {
async hintsFor(userId) {
const query = await createServiceRoleClient()
.from('dictionary')
.select('word,pronunciation')
.eq('user_id', user.id)
.eq('user_id', userId)
.order('usage_count', { ascending: false })
.limit(50)
const dictionaryHints = buildDictionaryHints(
dictionaryQuery.error ? [] : (dictionaryQuery.data ?? []),
)
return buildDictionaryHints(query.error ? [] : (query.data ?? []))
},
}
const apiServerUrl = Deno.env.get('D3RO_API_URL') ?? Deno.env.get('BACKEND_ORIGIN') ?? ''
const logger = {
warn: (message: string, meta: Record<string, unknown>) => console.warn(message, meta),
error: (message: string, meta: Record<string, unknown>) => console.error(message, meta),
}
function providersFromEnv(): SttProvider[] {
return createSttProviders({
gatewayUrl: 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')
gatewayToken: Deno.env.get('D3RO_API_TOKEN') ?? '',
groqKey: readProviderKey('GROQ_API_KEY'),
openaiKey: readProviderKey('OPENAI_API_KEY'),
deepgramKey: readProviderKey('DEEPGRAM_API_KEY'),
}, { logger })
}
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' }
})
}
})
Deno.serve(createSttProxyHandler({
authenticate,
quota: supabaseSttQuota,
dictionary: supabaseDictionary,
providers: providersFromEnv,
logger,
}))