// server/supabase/functions/stt-proxy/handler.ts // stt-proxy use case. All IO (auth, quota, dictionary, providers) is injected as ports. // // Order matters: // 1. authenticate // 2. parse and validate the audio input (4xx before any quota work) // 3. reserve one quota unit before any provider work (429 when denied) // 4. run the provider chain (runSttChain) // 5. finalize the reservation, then answer // // Billing rule: once any provider has answered 2xx it has charged for the audio, so // the reservation is consumed even when no usable transcript came back. Only when // no provider answered 2xx is the unit refunded. An empty transcript is a real // "no speech" result: it is never retried on the next paid provider and is answered // with a fixed `stt_no_speech` code (clients reject a 200 with an empty transcript). import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts' import { type DictionaryHints, type NormalizedSttResult, type SttFailureCode, SttInputError, validateSttAudio, } from '../_shared/stt-contract.ts' import type { SttProvider, SttProviderInput } from './providers.ts' export interface SttUser { id: string } export type AuthenticateResult = | { ok: true; user: SttUser } | { ok: false; response: Response } export interface SttQuotaReservationSnapshot { allowed: boolean reservationId: string | null status: 'reserved' | 'completed' | 'released' | 'denied' current: number limit: number period: string tier: string overageCredits: number } /** Reserve-then-finalize quota lease (reserve_stt_quota / finalize_stt_quota). */ export interface SttQuotaPort { reserve(userId: string, reservationId: string): Promise finalize(reservationId: string, succeeded: boolean): Promise<'completed' | 'released'> } export interface SttDictionaryPort { hintsFor(userId: string): Promise } export interface SttProxyLogger { warn(message: string, meta: Record): void error(message: string, meta: Record): void } export interface SttProxyDeps { authenticate(req: Request): Promise quota: SttQuotaPort dictionary: SttDictionaryPort /** Called per request so rotated secrets apply without a redeploy. */ providers(): readonly SttProvider[] logger: SttProxyLogger newReservationId?: () => string } export interface SttAttempt { provider: string failure: SttFailureCode } export type SttChainResult = | { kind: 'ok'; result: NormalizedSttResult; billed: true; attempts: SttAttempt[] } | { kind: 'no_speech'; provider: string; billed: true; attempts: SttAttempt[] } | { kind: 'failed'; billed: boolean; unavailable: boolean; attempts: SttAttempt[] } /** * Try providers in order. Stops at the first 2xx answer with a transcript (`ok`) or * with an empty transcript (`no_speech`); falls through only on retryable failures. * `billed` is true when any provider answered 2xx. */ export async function runSttChain( providers: readonly SttProvider[], input: SttProviderInput, ): Promise { const attempts: SttAttempt[] = [] let billed = false let sawFailure = false let sawUnavailable = false for (const provider of providers) { const outcome = await provider.transcribe(input).catch(() => ({ kind: 'retryable' as const, failure: 'error' as const, billed: false, unavailable: false, })) if (outcome.kind === 'ok') { return { kind: 'ok', result: outcome.result, billed: true, attempts } } if (outcome.kind === 'no_speech') { return { kind: 'no_speech', provider: provider.id, billed: true, attempts } } attempts.push({ provider: provider.id, failure: outcome.failure }) billed ||= outcome.billed if (outcome.unavailable) sawUnavailable = true else sawFailure = true } // Nothing configured, or only explicit "unavailable" answers → 503; otherwise 502. const unavailable = attempts.length === 0 || (!sawFailure && sawUnavailable) return { kind: 'failed', billed, unavailable, attempts } } function json(status: number, payload: unknown): Response { return new Response(JSON.stringify(payload), { status, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }) } export function createSttProxyHandler(deps: SttProxyDeps): (req: Request) => Promise { const { quota, logger } = deps const newReservationId = deps.newReservationId ?? (() => crypto.randomUUID()) return async (req: Request): Promise => { const preflight = handleCorsPreflightRequest(req) if (preflight) return preflight if (req.method !== 'POST') return json(405, { error: 'Method not allowed' }) let reservationId: string | null = null let billed = false try { // 1) auth const auth = await deps.authenticate(req) if (!auth.ok) return auth.response const user = auth.user // 2) input 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 json(400, { error: 'Missing audio field' }) let audioInput: ReturnType try { audioInput = validateSttAudio(audio, languageCode) } catch (error) { if (error instanceof SttInputError) return json(error.status, { error: error.code }) throw error } // 3) Reserve before provider work. The DB advisory lock closes concurrent // quota races; crashed provider work is reclaimed when the lease expires. const reservation = await quota.reserve(user.id, newReservationId()) if (!reservation.allowed || !reservation.reservationId || reservation.status !== 'reserved') { return json(429, { error: 'quota_exceeded', current: reservation.current, limit: reservation.limit, period: reservation.period, tier: reservation.tier, overage_credits: reservation.overageCredits, }) } reservationId = reservation.reservationId // 4) providers const hints = await deps.dictionary.hintsFor(user.id) const chain = await runSttChain(deps.providers(), { audio, ...audioInput, hints }) billed = chain.billed // 5a) Fail closed. Refund only when no provider billed the audio. if (chain.kind === 'failed') { logger.warn('stt-proxy all providers failed', { attempts: chain.attempts, billed: chain.billed }) await quota.finalize(reservationId, chain.billed).catch(() => undefined) reservationId = null const status = chain.unavailable ? 503 : 502 const error = status === 503 ? 'stt_provider_unavailable' : 'stt_upstream_failed' return json(status, { error, attempts: chain.attempts }) } // 5b) A provider answered 2xx: consume the exact pre-provider reservation. const finalStatus = await quota.finalize(reservationId, true) if (finalStatus !== 'completed') { throw new Error('STT quota reservation was reclaimed before completion.') } reservationId = null if (chain.kind === 'no_speech') { return json(422, { error: 'stt_no_speech', attempts: chain.attempts }) } return json(200, chain.result) } catch (err) { if (reservationId) { await quota.finalize(reservationId, billed).catch(() => undefined) } logger.error('stt-proxy failed', { kind: err instanceof Error ? err.name : typeof err }) return json(500, { error: 'internal_error' }) } } }