From 1eb22af1f39c0612f44f88b98f23c9a5173252fc Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 28 Sep 2026 02:16:19 +0900 Subject: [PATCH] fix(stt-proxy): stop paid fan-out on empty transcripts and redact provider errors --- .../functions/_shared/stt-contract.ts | 60 +++ .../functions/_shared/stt-outcome.test.ts | 37 ++ .../functions/stt-proxy/handler.test.ts | 249 +++++++++++ .../supabase/functions/stt-proxy/handler.ts | 207 +++++++++ .../stt-proxy/index.contract.test.ts | 51 --- server/supabase/functions/stt-proxy/index.ts | 393 +++--------------- .../functions/stt-proxy/providers.test.ts | 164 ++++++++ .../supabase/functions/stt-proxy/providers.ts | 282 +++++++++++++ .../stt-proxy/stt-proxy.contract.test.ts | 44 ++ 9 files changed, 1107 insertions(+), 380 deletions(-) create mode 100644 server/supabase/functions/_shared/stt-outcome.test.ts create mode 100644 server/supabase/functions/stt-proxy/handler.test.ts create mode 100644 server/supabase/functions/stt-proxy/handler.ts delete mode 100644 server/supabase/functions/stt-proxy/index.contract.test.ts create mode 100644 server/supabase/functions/stt-proxy/providers.test.ts create mode 100644 server/supabase/functions/stt-proxy/providers.ts create mode 100644 server/supabase/functions/stt-proxy/stt-proxy.contract.test.ts diff --git a/server/supabase/functions/_shared/stt-contract.ts b/server/supabase/functions/_shared/stt-contract.ts index 3abebf8..bc28e28 100644 --- a/server/supabase/functions/_shared/stt-contract.ts +++ b/server/supabase/functions/_shared/stt-contract.ts @@ -204,3 +204,63 @@ export function createDeepgramSttUrl(languageCode: string, keyterms: readonly st for (const keyterm of keyterms.slice(0, 50)) url.searchParams.append('keyterm', keyterm) return url.toString() } + +/** + * Fixed failure codes reported to callers in `attempts`. Never a raw exception + * message: Deno fetch errors carry the request URL (the internal gateway host) + * and JSON parse errors quote part of the provider body. + */ +export type SttFailureCode = + | 'timeout' + | 'network_error' + | 'invalid_response' + | 'misconfigured' + | 'error' + | `http_${number}` + +/** + * What one provider call produced. + * + * - `ok`: a 2xx answer with a real transcript. + * - `no_speech`: a 2xx answer with an empty transcript. The provider has billed the + * audio, so this is terminal: it is never retried on the next paid provider. + * - `retryable`: transport error, non-2xx status or a malformed 2xx body. `billed` + * is true when the provider answered 2xx (it charged us even though the body was + * unusable), so the quota reservation must be consumed, not refunded. + * `unavailable` marks an explicit "not available" answer (gateway 503). + */ +export type SttOutcome = + | { kind: 'ok'; result: NormalizedSttResult } + | { kind: 'no_speech' } + | { kind: 'retryable'; failure: SttFailureCode; billed: boolean; unavailable: boolean } + +export function sttHttpFailure(status: number): SttFailureCode { + return `http_${Math.trunc(status)}` +} + +/** Map a thrown transport error to a fixed code. The error text is never kept. */ +export function sttTransportFailure(err: unknown): SttFailureCode { + if (!(err instanceof Error)) return 'error' + if (err.name === 'TimeoutError' || err.name === 'AbortError') return 'timeout' + // Deno fetch rejects DNS, connection and TLS failures with a TypeError. + if (err.name === 'TypeError') return 'network_error' + return 'error' +} + +/** + * Classify a 2xx provider answer. The provider has already billed the audio, so + * every outcome here is `billed`. An empty transcript is a genuine "no speech" + * result; normalizeSttResult stays strict and is only used for real transcripts. + */ +export function sttOutcomeFromBilledAnswer(candidate: Readonly>): SttOutcome { + const transcript = candidate.transcript + if (typeof transcript !== 'string') { + return { kind: 'retryable', failure: 'invalid_response', billed: true, unavailable: false } + } + if (!transcript.trim()) return { kind: 'no_speech' } + try { + return { kind: 'ok', result: normalizeSttResult(candidate) } + } catch { + return { kind: 'retryable', failure: 'invalid_response', billed: true, unavailable: false } + } +} diff --git a/server/supabase/functions/_shared/stt-outcome.test.ts b/server/supabase/functions/_shared/stt-outcome.test.ts new file mode 100644 index 0000000..2e27a16 --- /dev/null +++ b/server/supabase/functions/_shared/stt-outcome.test.ts @@ -0,0 +1,37 @@ +import { sttHttpFailure, sttOutcomeFromBilledAnswer, sttTransportFailure } from './stt-contract.ts' + +function assertEquals(actual: unknown, expected: unknown, message: string): void { + const a = JSON.stringify(actual) + const e = JSON.stringify(expected) + if (a !== e) throw new Error(`${message}: expected ${e}, got ${a}`) +} + +const valid = { confidence: 0.9, language_code: 'ko', duration_seconds: 1, provider: 'groq' } + +Deno.test('a billed empty transcript is no_speech, not a failure', () => { + assertEquals(sttOutcomeFromBilledAnswer({ ...valid, transcript: '' }), { kind: 'no_speech' }, 'empty') + assertEquals(sttOutcomeFromBilledAnswer({ ...valid, transcript: ' \n ' }), { kind: 'no_speech' }, 'whitespace') +}) + +Deno.test('a billed answer without a usable transcript is a billed invalid_response', () => { + const invalid = { kind: 'retryable', failure: 'invalid_response', billed: true, unavailable: false } + assertEquals(sttOutcomeFromBilledAnswer({ ...valid, transcript: 42 }), invalid, 'non-string transcript') + assertEquals(sttOutcomeFromBilledAnswer({ ...valid, transcript: 'hi', confidence: 2 }), invalid, 'bad confidence') +}) + +Deno.test('a billed real transcript is normalized', () => { + assertEquals( + sttOutcomeFromBilledAnswer({ ...valid, transcript: ' hi ' }), + { kind: 'ok', result: { transcript: 'hi', ...valid } }, + 'ok', + ) +}) + +Deno.test('transport failures map to fixed codes only', () => { + assertEquals(sttTransportFailure(new TypeError('error sending request for url (https://internal/x)')), 'network_error', 'network') + assertEquals(sttTransportFailure(new DOMException('t', 'TimeoutError')), 'timeout', 'timeout') + assertEquals(sttTransportFailure(new DOMException('a', 'AbortError')), 'timeout', 'abort') + assertEquals(sttTransportFailure(new SyntaxError('Unexpected token < in JSON: secret')), 'error', 'other') + assertEquals(sttTransportFailure('boom'), 'error', 'non-error') + assertEquals(sttHttpFailure(502), 'http_502', 'http') +}) diff --git a/server/supabase/functions/stt-proxy/handler.test.ts b/server/supabase/functions/stt-proxy/handler.test.ts new file mode 100644 index 0000000..3f44c89 --- /dev/null +++ b/server/supabase/functions/stt-proxy/handler.test.ts @@ -0,0 +1,249 @@ +import { + createSttProxyHandler, + runSttChain, + type SttQuotaReservationSnapshot, +} from './handler.ts' +import type { SttProvider, SttProviderInput } from './providers.ts' +import type { SttOutcome } from '../_shared/stt-contract.ts' + +function assert(condition: boolean, message: string): asserts condition { + if (!condition) throw new Error(message) +} + +function assertEquals(actual: unknown, expected: unknown, message: string): void { + const a = JSON.stringify(actual) + const e = JSON.stringify(expected) + if (a !== e) throw new Error(`${message}: expected ${e}, got ${a}`) +} + +const OK: SttOutcome = { + kind: 'ok', + result: { + transcript: '안녕하세요', + confidence: 0.98, + language_code: 'ko', + duration_seconds: 1, + provider: 'groq', + }, +} + +function fakeProvider(id: string, outcome: SttOutcome | (() => Promise)): SttProvider & { calls: number } { + const provider = { + id, + calls: 0, + transcribe(_input: SttProviderInput): Promise { + provider.calls += 1 + return typeof outcome === 'function' ? outcome() : Promise.resolve(outcome) + }, + } + return provider +} + +const retryable = (failure: `http_${number}` | 'timeout' | 'network_error', options: { + billed?: boolean + unavailable?: boolean +} = {}): SttOutcome => ({ + kind: 'retryable', + failure, + billed: options.billed ?? false, + unavailable: options.unavailable ?? false, +}) + +const INPUT: SttProviderInput = { + audio: new Blob([new Uint8Array([1, 2, 3])], { type: 'audio/wav' }), + fileName: 'audio.wav', + contentType: 'audio/wav', + languageCode: 'ko', + hints: { keyterms: [], prompt: '' }, +} + +// --- runSttChain (pure fallback policy) --------------------------------------- + +Deno.test('an empty transcript is terminal: the next paid provider is never called', async () => { + const groq = fakeProvider('groq', { kind: 'no_speech' }) + const openai = fakeProvider('openai', OK) + const deepgram = fakeProvider('deepgram', OK) + const chain = await runSttChain([groq, openai, deepgram], INPUT) + assertEquals(chain.kind, 'no_speech', 'empty transcript result') + assert(chain.billed, 'a 2xx answer is billed') + assertEquals([openai.calls, deepgram.calls], [0, 0], 'no fallthrough after a 2xx answer') +}) + +Deno.test('a 5xx or 429 falls through to the next provider', async () => { + const gateway = fakeProvider('gateway', retryable('http_500')) + const groq = fakeProvider('groq', retryable('http_429')) + const openai = fakeProvider('openai', OK) + const chain = await runSttChain([gateway, groq, openai], INPUT) + assertEquals(chain.kind, 'ok', 'third provider answers') + assertEquals(chain.attempts, [ + { provider: 'gateway', failure: 'http_500' }, + { provider: 'groq', failure: 'http_429' }, + ], 'attempts carry fixed codes only') +}) + +Deno.test('a billed but malformed answer falls through yet marks the chain billed', async () => { + const chain = await runSttChain([ + fakeProvider('groq', retryable('http_502')), + fakeProvider('openai', { kind: 'retryable', failure: 'invalid_response', billed: true, unavailable: false }), + fakeProvider('deepgram', retryable('timeout')), + ], INPUT) + assert(chain.kind === 'failed', 'no usable result') + assert(chain.billed, 'openai billed the audio') + assert(!chain.unavailable, 'real failures are 502') +}) + +Deno.test('no providers, or only gateway 503, is reported as unavailable', async () => { + const none = await runSttChain([], INPUT) + assert(none.kind === 'failed' && none.unavailable, 'nothing configured → 503') + const only503 = await runSttChain([fakeProvider('gateway', retryable('http_503', { unavailable: true }))], INPUT) + assert(only503.kind === 'failed' && only503.unavailable, 'gateway 503 only → 503') + const mixed = await runSttChain([ + fakeProvider('gateway', retryable('http_503', { unavailable: true })), + fakeProvider('groq', retryable('http_500')), + ], INPUT) + assert(mixed.kind === 'failed' && !mixed.unavailable, 'a real failure makes it 502') +}) + +Deno.test('a provider that throws is contained as a retryable error', async () => { + const chain = await runSttChain([ + fakeProvider('groq', () => Promise.reject(new TypeError('error sending request for url (https://nas.internal/x)'))), + fakeProvider('openai', OK), + ], INPUT) + assertEquals(chain.kind, 'ok', 'fallback still runs') + assertEquals(chain.attempts, [{ provider: 'groq', failure: 'error' }], 'no raw message in attempts') +}) + +// --- createSttProxyHandler ---------------------------------------------------- + +interface Harness { + handler: (req: Request) => Promise + finalizeCalls: Array<{ reservationId: string; succeeded: boolean }> + events: string[] +} + +function harness(providers: SttProvider[], options: { + reservation?: Partial + finalizeStatus?: 'completed' | 'released' +} = {}): Harness { + const finalizeCalls: Harness['finalizeCalls'] = [] + const events: string[] = [] + const tracked = providers.map((provider) => ({ + id: provider.id, + transcribe(input: SttProviderInput): Promise { + events.push(`provider:${provider.id}`) + return provider.transcribe(input) + }, + })) + const handler = createSttProxyHandler({ + authenticate: () => Promise.resolve({ ok: true, user: { id: 'user-1' } }), + quota: { + reserve(_userId, reservationId) { + events.push('reserve') + return Promise.resolve({ + allowed: true, + reservationId, + status: 'reserved', + current: 1, + limit: 10, + period: 'daily', + tier: 'free', + overageCredits: 0, + ...options.reservation, + }) + }, + finalize(reservationId, succeeded) { + events.push(`finalize:${succeeded}`) + finalizeCalls.push({ reservationId, succeeded }) + return Promise.resolve(options.finalizeStatus ?? (succeeded ? 'completed' : 'released')) + }, + }, + dictionary: { hintsFor: () => Promise.resolve({ keyterms: [], prompt: '' }) }, + providers: () => tracked, + logger: { warn: () => undefined, error: () => undefined }, + newReservationId: () => 'res-1', + }) + return { handler, finalizeCalls, events } +} + +function sttRequest(): Request { + const form = new FormData() + form.append('audio', new Blob([new Uint8Array([1, 2, 3, 4])], { type: 'audio/ogg' }), 'a.ogg') + form.append('language_code', 'ko') + return new Request('http://localhost/stt-proxy', { method: 'POST', body: form }) +} + +Deno.test('silent audio consumes the reservation and answers stt_no_speech without fan-out', async () => { + const groq = fakeProvider('groq', { kind: 'no_speech' }) + const openai = fakeProvider('openai', OK) + const deepgram = fakeProvider('deepgram', OK) + const h = harness([groq, openai, deepgram]) + const response = await h.handler(sttRequest()) + assertEquals(response.status, 422, 'no speech status') + assertEquals((await response.json()).error, 'stt_no_speech', 'fixed error code') + assertEquals(h.finalizeCalls, [{ reservationId: 'res-1', succeeded: true }], 'reservation consumed, not refunded') + assertEquals([openai.calls, deepgram.calls], [0, 0], 'no further paid providers') +}) + +Deno.test('a success reserves before providers and finalizes before answering', async () => { + const h = harness([fakeProvider('groq', OK)]) + const response = await h.handler(sttRequest()) + assertEquals(response.status, 200, 'success status') + assertEquals((await response.json()).transcript, '안녕하세요', 'transcript returned') + assertEquals(h.events, ['reserve', 'provider:groq', 'finalize:true'], 'quota ordering') +}) + +Deno.test('a finalize that is not completed fails closed', async () => { + const h = harness([fakeProvider('groq', OK)], { finalizeStatus: 'released' }) + const response = await h.handler(sttRequest()) + assertEquals(response.status, 500, 'reclaimed reservation is an internal error') + assertEquals((await response.json()).error, 'internal_error', 'sanitized error') +}) + +Deno.test('failures nobody billed refund the reservation and return 502', async () => { + const h = harness([fakeProvider('gateway', retryable('network_error')), fakeProvider('groq', retryable('http_500'))]) + const response = await h.handler(sttRequest()) + assertEquals(response.status, 502, 'upstream failure status') + const body = await response.json() + assertEquals(body, { + error: 'stt_upstream_failed', + attempts: [{ provider: 'gateway', failure: 'network_error' }, { provider: 'groq', failure: 'http_500' }], + }, 'fixed body') + assertEquals(h.finalizeCalls, [{ reservationId: 'res-1', succeeded: false }], 'refunded') +}) + +Deno.test('a failure after a billed 2xx answer still consumes the reservation', async () => { + const h = harness([ + fakeProvider('groq', { kind: 'retryable', failure: 'invalid_response', billed: true, unavailable: false }), + fakeProvider('openai', retryable('http_500')), + ]) + const response = await h.handler(sttRequest()) + assertEquals(response.status, 502, 'still a failure') + assertEquals(h.finalizeCalls, [{ reservationId: 'res-1', succeeded: true }], 'billed work is not refunded') +}) + +Deno.test('no configured provider answers 503 stt_provider_unavailable and refunds', async () => { + const h = harness([]) + const response = await h.handler(sttRequest()) + assertEquals(response.status, 503, 'unavailable status') + assertEquals((await response.json()).error, 'stt_provider_unavailable', 'fixed code') + assertEquals(h.finalizeCalls, [{ reservationId: 'res-1', succeeded: false }], 'refunded') +}) + +Deno.test('a denied reservation answers 429 before any provider work', async () => { + const groq = fakeProvider('groq', OK) + const h = harness([groq], { reservation: { allowed: false, reservationId: null, status: 'denied' } }) + const response = await h.handler(sttRequest()) + assertEquals(response.status, 429, 'quota status') + assertEquals((await response.json()).error, 'quota_exceeded', 'quota error code') + assertEquals(groq.calls, 0, 'no provider work when denied') + assertEquals(h.finalizeCalls, [], 'nothing to finalize') +}) + +Deno.test('invalid input is rejected before reserving quota', async () => { + const h = harness([fakeProvider('groq', OK)]) + const form = new FormData() + form.append('audio', new Blob(['not audio'], { type: 'text/plain' }), 'a.txt') + const response = await h.handler(new Request('http://localhost/stt-proxy', { method: 'POST', body: form })) + assertEquals(response.status, 415, 'unsupported type') + assertEquals(h.events, [], 'no quota or provider work') +}) diff --git a/server/supabase/functions/stt-proxy/handler.ts b/server/supabase/functions/stt-proxy/handler.ts new file mode 100644 index 0000000..25afa9e --- /dev/null +++ b/server/supabase/functions/stt-proxy/handler.ts @@ -0,0 +1,207 @@ +// 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' }) + } + } +} diff --git a/server/supabase/functions/stt-proxy/index.contract.test.ts b/server/supabase/functions/stt-proxy/index.contract.test.ts deleted file mode 100644 index 04b2937..0000000 --- a/server/supabase/functions/stt-proxy/index.contract.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -const source = await Deno.readTextFile(new URL('./index.ts', import.meta.url)) - -function assert(condition: boolean, message: string): asserts condition { - if (!condition) throw new Error(message) -} - -Deno.test('STT proxy has no synthetic success fallback', () => { - for (const forbidden of [ - 'd3ro-cloud-mock', - '음성 전사 완료', - 'D3RO Cloud STT', - ]) { - assert(!source.includes(forbidden), `forbidden synthetic fallback remains: ${forbidden}`) - } - - assert(source.includes("const error = status === 503 ? 'stt_provider_unavailable' : 'stt_upstream_failed'"), - 'provider failures must produce an explicit 502/503 error') -}) - -Deno.test('gateway uses a dedicated D3RO API token', () => { - assert(source.includes("Deno.env.get('D3RO_API_TOKEN')"), 'dedicated backend token is required') - assert(source.includes('if (apiServerUrl && apiServerToken)'), 'gateway must be skipped without its own token') - assert(!source.includes("req.headers.get('Authorization')"), 'Supabase user JWT must not be forwarded to D3RO API') - assert(source.includes('createInternalSttGatewayUrl(apiServerUrl)'), - 'quota-owning internal endpoint must use the canonical URL guard') - assert(source.includes("'X-D3RO-STT-Gateway-Token': apiServerToken"), - 'dedicated token must use the internal gateway header') - assert(!source.includes('headers: { Authorization: authorization }'), - 'dedicated gateway token must not be accepted as a user JWT') -}) - -Deno.test('atomic quota is reserved before provider work and finalized before success', () => { - const reserveIndex = source.indexOf('const quota = await reserveSttQuota(') - const denialIndex = source.indexOf('if (!quota.allowed', reserveIndex) - const providerIndex = source.indexOf("const apiServerUrl = Deno.env.get('D3RO_API_URL')", denialIndex) - const finalizeIndex = source.indexOf('await finalizeSttQuota(quotaReservation.id, true', providerIndex) - const successIndex = source.indexOf('return new Response(JSON.stringify(result)', finalizeIndex) - - assert(reserveIndex >= 0, 'atomic quota reservation is missing') - assert(denialIndex > reserveIndex, 'quota denial is not checked') - assert(providerIndex > denialIndex, 'provider work begins before quota denial') - assert(finalizeIndex > providerIndex, 'successful provider work does not finalize quota') - assert(successIndex > finalizeIndex, 'transcript success precedes quota finalization') - assert(source.includes('finalizeSttQuota(quotaReservation.id, false'), - 'failed provider work does not release quota') -}) - -Deno.test('unexpected errors are sanitized', () => { - assert(source.includes("JSON.stringify({ error: 'internal_error' })"), 'generic internal error response is missing') - assert(!source.includes('JSON.stringify({ error: message })'), 'raw exception messages must not be returned') -}) diff --git a/server/supabase/functions/stt-proxy/index.ts b/server/supabase/functions/stt-proxy/index.ts index d9afb86..ddeeef2 100644 --- a/server/supabase/functions/stt-proxy/index.ts +++ b/server/supabase/functions/stt-proxy/index.ts @@ -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 - } | 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 { 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) - } + 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 - 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) => console.warn(message, meta), + error: (message: string, meta: Record) => 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, +})) diff --git a/server/supabase/functions/stt-proxy/providers.test.ts b/server/supabase/functions/stt-proxy/providers.test.ts new file mode 100644 index 0000000..ffaeea2 --- /dev/null +++ b/server/supabase/functions/stt-proxy/providers.test.ts @@ -0,0 +1,164 @@ +import { + createDeepgramProvider, + createGatewayProvider, + createGroqProvider, + createOpenAiProvider, + createSttProviders, + type SttProviderInput, + type SttProviderOptions, +} from './providers.ts' + +function assert(condition: boolean, message: string): asserts condition { + if (!condition) throw new Error(message) +} + +function assertEquals(actual: unknown, expected: unknown, message: string): void { + const a = JSON.stringify(actual) + const e = JSON.stringify(expected) + if (a !== e) throw new Error(`${message}: expected ${e}, got ${a}`) +} + +const INPUT: SttProviderInput = { + audio: new Blob([new Uint8Array(8_000)], { type: 'audio/ogg' }), + fileName: 'audio.ogg', + contentType: 'audio/ogg', + languageCode: 'ko', + hints: { keyterms: ['D3RO'], prompt: 'D3RO' }, +} + +const INTERNAL_URL = 'https://nas.internal.example/api/stt/internal/transcribe' + +function options(fetchImpl: typeof fetch, logs: Array> = []): SttProviderOptions { + return { fetchImpl, logger: { warn: (_message, meta) => logs.push(meta) } } +} + +function respond(status: number, body: unknown): typeof fetch { + return () => + Promise.resolve( + new Response(typeof body === 'string' ? body : JSON.stringify(body), { status }), + ) +} + +Deno.test('a 2xx empty transcript is no_speech for every provider, never a failure', async () => { + const cases = [ + createGatewayProvider({ url: 'https://gw.example', token: 't', hasDirectFallback: true }, options(respond(200, { text: '' }))), + createGroqProvider('k', options(respond(200, { text: ' ' }))), + createOpenAiProvider('k', options(respond(200, { text: '' }))), + createDeepgramProvider('k', options(respond(200, { + results: { channels: [{ alternatives: [{ transcript: '', confidence: 0 }] }] }, + }))), + ] + for (const provider of cases) { + assertEquals(await provider.transcribe(INPUT), { kind: 'no_speech' }, `${provider.id} empty transcript`) + } +}) + +Deno.test('a 2xx transcript is normalized into an ok result', async () => { + const groq = createGroqProvider('k', options(respond(200, { text: ' 안녕 ', language: 'korean', duration: 2 }))) + assertEquals(await groq.transcribe(INPUT), { + kind: 'ok', + result: { transcript: '안녕', confidence: 0.98, language_code: 'ko', duration_seconds: 2, provider: 'groq' }, + }, 'groq result') + + const deepgram = createDeepgramProvider('k', options(respond(200, { + metadata: { duration: 3 }, + results: { channels: [{ detected_language: 'en', alternatives: [{ transcript: 'hi', confidence: 0.9 }] }] }, + }))) + assertEquals(await deepgram.transcribe(INPUT), { + kind: 'ok', + result: { transcript: 'hi', confidence: 0.9, language_code: 'en', duration_seconds: 3, provider: 'deepgram' }, + }, 'deepgram result') +}) + +Deno.test('a network error leaks neither the message nor the internal gateway URL', async () => { + const logs: Array> = [] + const throwing: typeof fetch = () => + Promise.reject(new TypeError(`error sending request for url (${INTERNAL_URL}): connection refused`)) + const gateway = createGatewayProvider( + { url: 'https://nas.internal.example', token: 't', hasDirectFallback: true }, + options(throwing, logs), + ) + const outcome = await gateway.transcribe(INPUT) + assertEquals(outcome, { kind: 'retryable', failure: 'network_error', billed: false, unavailable: false }, 'fixed code') + assert(!JSON.stringify(outcome).includes('nas.internal'), 'internal host must not reach the outcome') + assert(String(logs[0]?.message).includes('nas.internal'), 'raw message stays in the server log') +}) + +Deno.test('timeouts, http failures and gateway 503 map to fixed codes', async () => { + const timeout: typeof fetch = () => Promise.reject(new DOMException('signal timed out', 'TimeoutError')) + assertEquals( + await createOpenAiProvider('k', options(timeout)).transcribe(INPUT), + { kind: 'retryable', failure: 'timeout', billed: false, unavailable: false }, + 'timeout', + ) + assertEquals( + await createGroqProvider('k', options(respond(429, { error: { message: 'org-SECRET' } }))).transcribe(INPUT), + { kind: 'retryable', failure: 'http_429', billed: false, unavailable: false }, + '429', + ) + const gateway = createGatewayProvider({ url: 'https://gw.example', token: 't', hasDirectFallback: false }, options(respond(503, 'down'))) + assertEquals( + await gateway.transcribe(INPUT), + { kind: 'retryable', failure: 'http_503', billed: false, unavailable: true }, + 'gateway 503 is unavailable', + ) + assertEquals( + await createGroqProvider('k', options(respond(503, 'down'))).transcribe(INPUT), + { kind: 'retryable', failure: 'http_503', billed: false, unavailable: false }, + 'direct provider 503 is a failure', + ) +}) + +Deno.test('a malformed 2xx body is a billed invalid_response', async () => { + for (const body of ['not json {', { nope: true }, { text: 'hi', language: 'korean', duration: 'long' }]) { + assertEquals( + await createOpenAiProvider('k', options(respond(200, body))).transcribe(INPUT), + { kind: 'retryable', failure: 'invalid_response', billed: true, unavailable: false }, + `malformed body ${JSON.stringify(body)}`, + ) + } +}) + +Deno.test('a misconfigured gateway URL is reported as misconfigured without calling fetch', async () => { + let called = false + const gateway = createGatewayProvider( + { url: 'http://public.example', token: 't', hasDirectFallback: true }, + options(() => { + called = true + return Promise.resolve(new Response('{}')) + }), + ) + assertEquals( + await gateway.transcribe(INPUT), + { kind: 'retryable', failure: 'misconfigured', billed: false, unavailable: false }, + 'public http gateway rejected', + ) + assert(!called, 'gateway token never sent over public http') +}) + +Deno.test('gateway uses the dedicated token header on the canonical internal URL', async () => { + const seen: Array<{ url: string; headers: HeadersInit | undefined }> = [] + const capture = ((url: string | URL | Request, init?: { headers?: HeadersInit }) => { + seen.push({ url: String(url), headers: init?.headers }) + return Promise.resolve(new Response(JSON.stringify({ text: 'hi', language: 'ko' }))) + }) as typeof fetch + const gateway = createGatewayProvider({ url: 'https://gw.example/ignored', token: 'gw-token', hasDirectFallback: true }, options(capture)) + const outcome = await gateway.transcribe(INPUT) + assertEquals(outcome.kind, 'ok', 'gateway success') + assertEquals(seen, [{ + url: 'https://gw.example/api/stt/internal/transcribe', + headers: { 'X-D3RO-STT-Gateway-Token': 'gw-token' }, + }], 'canonical URL and header, no user JWT') +}) + +Deno.test('provider chain keeps the gateway → groq → openai → deepgram order and skips unconfigured ones', () => { + const fetchImpl = respond(200, { text: 'x' }) + const all = createSttProviders({ + gatewayUrl: 'https://gw.example', gatewayToken: 't', groqKey: 'g', openaiKey: 'o', deepgramKey: 'd', + }, options(fetchImpl)) + assertEquals(all.map((p) => p.id), ['gateway', 'groq', 'openai', 'deepgram'], 'full order') + const noToken = createSttProviders({ + gatewayUrl: 'https://gw.example', gatewayToken: '', groqKey: '', openaiKey: 'o', deepgramKey: '', + }, options(fetchImpl)) + assertEquals(noToken.map((p) => p.id), ['openai'], 'gateway is skipped without its own token') +}) diff --git a/server/supabase/functions/stt-proxy/providers.ts b/server/supabase/functions/stt-proxy/providers.ts new file mode 100644 index 0000000..96e6715 --- /dev/null +++ b/server/supabase/functions/stt-proxy/providers.ts @@ -0,0 +1,282 @@ +// server/supabase/functions/stt-proxy/providers.ts +// SttProvider adapters: the self-hosted D3RO gateway, Groq, OpenAI and Deepgram. +// +// Each adapter maps an HTTP/transport result to an SttOutcome and never throws. +// A 2xx answer is always terminal for billing purposes (the provider charged us): +// an empty transcript becomes `no_speech`, a malformed body becomes a billed +// `retryable`. Raw error text (which can contain the internal gateway URL or part +// of a provider body) goes to the server log only, never into the outcome. + +import { + createDeepgramSttUrl, + createInternalSttGatewayUrl, + type DictionaryHints, + gatewayDeadlineMs, + providerLanguageCode, + STT_PROVIDER_TIMEOUT_MS, + type SttFailureCode, + sttHttpFailure, + type SttOutcome, + sttOutcomeFromBilledAnswer, + sttTransportFailure, +} from '../_shared/stt-contract.ts' + +export interface SttProviderInput { + audio: Blob + fileName: string + contentType: string + languageCode: string + hints: DictionaryHints +} + +/** Port: one speech-to-text backend. Implementations must not throw. */ +export interface SttProvider { + readonly id: string + transcribe(input: SttProviderInput): Promise +} + +export interface SttProviderLogger { + warn(message: string, meta: Record): void +} + +export interface SttProviderConfig { + gatewayUrl: string + /** Dedicated backend token. Supabase user JWTs are not valid D3RO API JWTs. */ + gatewayToken: string + groqKey: string + openaiKey: string + deepgramKey: string +} + +export interface SttProviderOptions { + logger: SttProviderLogger + fetchImpl?: typeof fetch +} + +export const GROQ_STT_URL = 'https://api.groq.com/openai/v1/audio/transcriptions' +export const OPENAI_STT_URL = 'https://api.openai.com/v1/audio/transcriptions' + +/** Rough duration estimate used when a provider does not report one. */ +function estimatedDurationSeconds(audio: Blob): number { + return audio.size / 4000 +} + +function field(value: unknown, key: string | number): unknown { + if (!value || typeof value !== 'object') return undefined + return (value as Record)[key] +} + +function path(value: unknown, ...keys: Array): unknown { + return keys.reduce((current, key) => field(current, key), value) +} + +function failed(failure: SttFailureCode, options: { billed?: boolean; unavailable?: boolean } = {}): SttOutcome { + return { + kind: 'retryable', + failure, + billed: options.billed ?? false, + unavailable: options.unavailable ?? false, + } +} + +interface ProviderCall { + id: string + /** May throw (e.g. a misconfigured gateway URL); reported as `misconfigured`. */ + url(): string + init(): Promise + /** Status codes that mean "explicitly unavailable" rather than "failed". */ + unavailableStatuses?: readonly number[] + /** Build the result candidate from a parsed 2xx JSON body. */ + candidate(data: unknown): Record +} + +async function callProvider(call: ProviderCall, options: SttProviderOptions): Promise { + const fetchImpl = options.fetchImpl ?? fetch + const log = (stage: string, err: unknown): void => { + options.logger.warn('stt-proxy provider failed', { + provider: call.id, + stage, + name: err instanceof Error ? err.name : typeof err, + message: err instanceof Error ? err.message : String(err), + }) + } + + let url: string + try { + url = call.url() + } catch (err) { + log('config', err) + return failed('misconfigured') + } + + let response: Response + try { + response = await fetchImpl(url, await call.init()) + } catch (err) { + log('request', err) + return failed(sttTransportFailure(err)) + } + + if (!response.ok) { + await response.body?.cancel().catch(() => undefined) + return failed(sttHttpFailure(response.status), { + unavailable: call.unavailableStatuses?.includes(response.status) ?? false, + }) + } + + // From here on the provider has answered 2xx and billed the audio. + let data: unknown + try { + data = await response.json() + } catch (err) { + log('body', err) + const failure = sttTransportFailure(err) === 'timeout' ? 'timeout' : 'invalid_response' + return failed(failure, { billed: true }) + } + return sttOutcomeFromBilledAnswer(call.candidate(data)) +} + +function whisperForm(input: SttProviderInput, model: string): FormData { + const form = new FormData() + form.append('file', input.audio, input.fileName) + form.append('model', model) + if (input.languageCode !== 'auto' && input.languageCode !== 'multi') { + form.append('language', input.languageCode) + } + if (input.hints.prompt) form.append('prompt', input.hints.prompt) + form.append('response_format', 'verbose_json') + return form +} + +function whisperCandidate(data: unknown, input: SttProviderInput, provider: string): Record { + return { + transcript: field(data, 'text'), + confidence: 0.98, + language_code: providerLanguageCode(field(data, 'language'), input.languageCode), + duration_seconds: field(data, 'duration') ?? estimatedDurationSeconds(input.audio), + provider, + } +} + +export function createGatewayProvider( + config: { url: string; token: string; hasDirectFallback: boolean }, + options: SttProviderOptions, +): SttProvider { + return { + id: 'gateway', + transcribe: (input) => + callProvider({ + id: 'gateway', + url: () => createInternalSttGatewayUrl(config.url), + init: () => { + const form = new FormData() + form.append('file', input.audio, input.fileName) + form.append('language', input.languageCode) + if (input.hints.prompt) form.append('prompt', input.hints.prompt) + return Promise.resolve({ + method: 'POST', + headers: { 'X-D3RO-STT-Gateway-Token': config.token }, + body: form, + signal: AbortSignal.timeout(gatewayDeadlineMs(input.audio.size, config.hasDirectFallback)), + }) + }, + unavailableStatuses: [503], + candidate: (data) => ({ + transcript: field(data, 'text'), + confidence: field(data, 'confidence') ?? 0.98, + language_code: providerLanguageCode(field(data, 'language'), input.languageCode), + duration_seconds: field(data, 'durationSeconds') ?? estimatedDurationSeconds(input.audio), + provider: field(data, 'provider') ?? 'd3ro-gateway', + }), + }, options), + } +} + +export function createGroqProvider(apiKey: string, options: SttProviderOptions): SttProvider { + return { + id: 'groq', + transcribe: (input) => + callProvider({ + id: 'groq', + url: () => GROQ_STT_URL, + init: () => + Promise.resolve({ + method: 'POST', + headers: { Authorization: `Bearer ${apiKey}` }, + body: whisperForm(input, 'whisper-large-v3-turbo'), + signal: AbortSignal.timeout(STT_PROVIDER_TIMEOUT_MS), + }), + candidate: (data) => whisperCandidate(data, input, 'groq'), + }, options), + } +} + +export function createOpenAiProvider(apiKey: string, options: SttProviderOptions): SttProvider { + return { + id: 'openai', + transcribe: (input) => + callProvider({ + id: 'openai', + url: () => OPENAI_STT_URL, + init: () => + Promise.resolve({ + method: 'POST', + headers: { Authorization: `Bearer ${apiKey}` }, + body: whisperForm(input, 'whisper-1'), + signal: AbortSignal.timeout(STT_PROVIDER_TIMEOUT_MS), + }), + candidate: (data) => whisperCandidate(data, input, 'openai'), + }, options), + } +} + +export function createDeepgramProvider(apiKey: string, options: SttProviderOptions): SttProvider { + return { + id: 'deepgram', + transcribe: (input) => + callProvider({ + id: 'deepgram', + url: () => createDeepgramSttUrl(input.languageCode, input.hints.keyterms), + init: async () => ({ + method: 'POST', + headers: { + Authorization: `Token ${apiKey}`, + 'Content-Type': input.contentType, + }, + body: await input.audio.arrayBuffer(), + signal: AbortSignal.timeout(STT_PROVIDER_TIMEOUT_MS), + }), + candidate: (data) => { + const channel = path(data, 'results', 'channels', 0) + const alternative = path(channel, 'alternatives', 0) + return { + transcript: field(alternative, 'transcript'), + confidence: field(alternative, 'confidence') ?? 0.95, + language_code: field(channel, 'detected_language') + ?? (input.languageCode === 'auto' || input.languageCode === 'multi' ? 'und' : input.languageCode), + duration_seconds: path(data, 'metadata', 'duration') ?? estimatedDurationSeconds(input.audio), + provider: 'deepgram', + } + }, + }, options), + } +} + +/** + * The provider chain in fallback order: self-hosted gateway, then Groq, OpenAI and + * Deepgram. A provider without its secret is left out. + */ +export function createSttProviders(config: SttProviderConfig, options: SttProviderOptions): SttProvider[] { + const providers: SttProvider[] = [] + const hasDirectFallback = Boolean(config.groqKey || config.openaiKey || config.deepgramKey) + if (config.gatewayUrl && config.gatewayToken) { + providers.push(createGatewayProvider( + { url: config.gatewayUrl, token: config.gatewayToken, hasDirectFallback }, + options, + )) + } + if (config.groqKey) providers.push(createGroqProvider(config.groqKey, options)) + if (config.openaiKey) providers.push(createOpenAiProvider(config.openaiKey, options)) + if (config.deepgramKey) providers.push(createDeepgramProvider(config.deepgramKey, options)) + return providers +} diff --git a/server/supabase/functions/stt-proxy/stt-proxy.contract.test.ts b/server/supabase/functions/stt-proxy/stt-proxy.contract.test.ts new file mode 100644 index 0000000..4feec2f --- /dev/null +++ b/server/supabase/functions/stt-proxy/stt-proxy.contract.test.ts @@ -0,0 +1,44 @@ +// Source-level guards for the split stt-proxy (index.ts = composition root, +// handler.ts = use case, providers.ts = adapters). Behavior is covered by +// handler.test.ts and providers.test.ts; these checks keep the wiring honest. +const read = (file: string) => Deno.readTextFile(new URL(file, import.meta.url)) +const [index, handler, providers] = await Promise.all([ + read('./index.ts'), + read('./handler.ts'), + read('./providers.ts'), +]) +const all = [index, handler, providers].join('\n') + +function assert(condition: boolean, message: string): asserts condition { + if (!condition) throw new Error(message) +} + +Deno.test('STT proxy has no synthetic success fallback', () => { + for (const forbidden of ['d3ro-cloud-mock', '음성 전사 완료', 'D3RO Cloud STT']) { + assert(!all.includes(forbidden), `forbidden synthetic fallback remains: ${forbidden}`) + } + assert(handler.includes("const error = status === 503 ? 'stt_provider_unavailable' : 'stt_upstream_failed'"), + 'provider failures must produce an explicit 502/503 error') +}) + +Deno.test('gateway uses a dedicated D3RO API token', () => { + assert(index.includes("gatewayToken: Deno.env.get('D3RO_API_TOKEN')"), 'dedicated backend token is required') + assert(providers.includes('if (config.gatewayUrl && config.gatewayToken)'), 'gateway must be skipped without its own token') + assert(!all.includes("req.headers.get('Authorization')"), 'Supabase user JWT must not be forwarded to D3RO API') + assert(providers.includes('createInternalSttGatewayUrl(config.url)'), + 'quota-owning internal endpoint must use the canonical URL guard') + assert(providers.includes("'X-D3RO-STT-Gateway-Token': config.token"), + 'dedicated token must use the internal gateway header') +}) + +Deno.test('index.ts is only the composition root', () => { + assert(index.includes('Deno.serve(createSttProxyHandler('), 'index must serve the injected handler') + assert(!index.includes('await fetch('), 'provider IO belongs in providers.ts') + assert(!index.includes('runSttChain'), 'fallback policy belongs in handler.ts') +}) + +Deno.test('attempts never carry raw exception messages', () => { + assert(!all.includes('err.message}`'), 'raw exception text must not be formatted into a response') + assert(!all.includes('JSON.stringify({ error: message })'), 'raw exception messages must not be returned') + assert(handler.includes("json(500, { error: 'internal_error' })"), 'generic internal error response is missing') +})