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

@ -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) for (const keyterm of keyterms.slice(0, 50)) url.searchParams.append('keyterm', keyterm)
return url.toString() 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<Record<string, unknown>>): 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 }
}
}

View file

@ -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: <html>secret')), 'error', 'other')
assertEquals(sttTransportFailure('boom'), 'error', 'non-error')
assertEquals(sttHttpFailure(502), 'http_502', 'http')
})

View file

@ -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<SttOutcome>)): SttProvider & { calls: number } {
const provider = {
id,
calls: 0,
transcribe(_input: SttProviderInput): Promise<SttOutcome> {
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<Response>
finalizeCalls: Array<{ reservationId: string; succeeded: boolean }>
events: string[]
}
function harness(providers: SttProvider[], options: {
reservation?: Partial<SttQuotaReservationSnapshot>
finalizeStatus?: 'completed' | 'released'
} = {}): Harness {
const finalizeCalls: Harness['finalizeCalls'] = []
const events: string[] = []
const tracked = providers.map((provider) => ({
id: provider.id,
transcribe(input: SttProviderInput): Promise<SttOutcome> {
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')
})

View file

@ -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<SttQuotaReservationSnapshot>
finalize(reservationId: string, succeeded: boolean): Promise<'completed' | 'released'>
}
export interface SttDictionaryPort {
hintsFor(userId: string): Promise<DictionaryHints>
}
export interface SttProxyLogger {
warn(message: string, meta: Record<string, unknown>): void
error(message: string, meta: Record<string, unknown>): void
}
export interface SttProxyDeps {
authenticate(req: Request): Promise<AuthenticateResult>
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<SttChainResult> {
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<Response> {
const { quota, logger } = deps
const newReservationId = deps.newReservationId ?? (() => crypto.randomUUID())
return async (req: Request): Promise<Response> => {
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<typeof validateSttAudio>
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' })
}
}
}

View file

@ -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')
})

View file

@ -1,346 +1,81 @@
// server/supabase/functions/stt-proxy/index.ts // server/supabase/functions/stt-proxy/index.ts
// D3RO Voice — Multi-Provider Cloud Speech-to-Text Proxy // 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 { corsHeaders } from '../_shared/cors.ts'
import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts' import { authErrorResponse, type AuthError, requireUser } from '../_shared/auth.ts'
import { createServiceRoleClient, finalizeSttQuota, reserveSttQuota } from '../_shared/quota.ts' import { createServiceRoleClient, finalizeSttQuota, reserveSttQuota } from '../_shared/quota.ts'
import { import { buildDictionaryHints } from '../_shared/stt-contract.ts'
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' 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) => { function isAuthError(err: unknown): err is AuthError {
const preflight = handleCorsPreflightRequest(req) return !!err
if (preflight) return preflight && typeof err === 'object'
&& 'status' in err
if (req.method !== 'POST') { && (err.status === 401 || err.status === 403)
return new Response(JSON.stringify({ error: 'Method not allowed' }), { && 'message' in err
status: 405, && typeof err.message === 'string'
headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
})
}
let quotaReservation: {
id: string
client: ReturnType<typeof createServiceRoleClient>
} | null = null
async function authenticate(req: Request): Promise<AuthenticateResult> {
try { try {
// 1) 인증 const user = await requireUser(req)
let user: Awaited<ReturnType<typeof requireUser>> return { ok: true, user: { id: user.id } }
try { } catch (err) {
user = await requireUser(req) if (isAuthError(err)) return { ok: false, response: authErrorResponse(err, corsHeaders) }
} catch (err) { throw 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' }), { /** The service-role client is created per call so a missing env becomes a 500, not a boot failure. */
status: 500, const supabaseSttQuota: SttQuotaPort = {
headers: { ...corsHeaders, 'Content-Type': 'application/json' } reserve: (userId, reservationId) => reserveSttQuota(userId, reservationId, createServiceRoleClient()),
}) finalize: (reservationId, succeeded) => finalizeSttQuota(reservationId, succeeded, createServiceRoleClient()),
} }
// 2) service client const supabaseDictionary: SttDictionaryPort = {
const serviceClient = createServiceRoleClient() async hintsFor(userId) {
const query = await 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') .from('dictionary')
.select('word,pronunciation') .select('word,pronunciation')
.eq('user_id', user.id) .eq('user_id', userId)
.order('usage_count', { ascending: false }) .order('usage_count', { ascending: false })
.limit(50) .limit(50)
const dictionaryHints = buildDictionaryHints( return buildDictionaryHints(query.error ? [] : (query.data ?? []))
dictionaryQuery.error ? [] : (dictionaryQuery.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. // Supabase user JWTs are not valid D3RO API JWTs. Configure a dedicated backend token.
const apiServerToken = Deno.env.get('D3RO_API_TOKEN') ?? '' gatewayToken: Deno.env.get('D3RO_API_TOKEN') ?? '',
const groqKey = readProviderKey('GROQ_API_KEY') groqKey: readProviderKey('GROQ_API_KEY'),
const openaiKey = readProviderKey('OPENAI_API_KEY') openaiKey: readProviderKey('OPENAI_API_KEY'),
const deepgramKey = readProviderKey('DEEPGRAM_API_KEY') deepgramKey: readProviderKey('DEEPGRAM_API_KEY'),
}, { logger })
}
let result: NormalizedSttResult | null = null Deno.serve(createSttProxyHandler({
let attemptedProvider = false authenticate,
let sawBadGatewayFailure = false quota: supabaseSttQuota,
let sawServiceUnavailable = false dictionary: supabaseDictionary,
// Which provider failed and how (status code or error class) — no bodies, no secrets. providers: providersFromEnv,
// Returned with a fail-closed response so an outage is diagnosable without log access. logger,
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' }
})
}
})

View file

@ -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<Record<string, unknown>> = []): 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<Record<string, unknown>> = []
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')
})

View file

@ -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<SttOutcome>
}
export interface SttProviderLogger {
warn(message: string, meta: Record<string, unknown>): 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<string | number, unknown>)[key]
}
function path(value: unknown, ...keys: Array<string | number>): unknown {
return keys.reduce<unknown>((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<RequestInit>
/** 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<string, unknown>
}
async function callProvider(call: ProviderCall, options: SttProviderOptions): Promise<SttOutcome> {
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<string, unknown> {
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
}

View file

@ -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')
})