fix(stt-proxy): stop paid fan-out on empty transcripts and redact provider errors
This commit is contained in:
parent
1a4c39cb94
commit
1eb22af1f3
9 changed files with 1107 additions and 380 deletions
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
37
server/supabase/functions/_shared/stt-outcome.test.ts
Normal file
37
server/supabase/functions/_shared/stt-outcome.test.ts
Normal 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')
|
||||||
|
})
|
||||||
249
server/supabase/functions/stt-proxy/handler.test.ts
Normal file
249
server/supabase/functions/stt-proxy/handler.test.ts
Normal 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')
|
||||||
|
})
|
||||||
207
server/supabase/functions/stt-proxy/handler.ts
Normal file
207
server/supabase/functions/stt-proxy/handler.ts
Normal 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' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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')
|
|
||||||
})
|
|
||||||
|
|
@ -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: {
|
async function authenticate(req: Request): Promise<AuthenticateResult> {
|
||||||
id: string
|
|
||||||
client: ReturnType<typeof createServiceRoleClient>
|
|
||||||
} | null = null
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1) 인증
|
const user = await requireUser(req)
|
||||||
let user: Awaited<ReturnType<typeof requireUser>>
|
return { ok: true, user: { id: user.id } }
|
||||||
try {
|
|
||||||
user = await requireUser(req)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (
|
if (isAuthError(err)) return { ok: false, response: authErrorResponse(err, corsHeaders) }
|
||||||
err &&
|
throw 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
|
|
||||||
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
|
Deno.serve(createSttProxyHandler({
|
||||||
if (apiServerUrl && apiServerToken) {
|
authenticate,
|
||||||
attemptedProvider = true
|
quota: supabaseSttQuota,
|
||||||
try {
|
dictionary: supabaseDictionary,
|
||||||
const forwardForm = new FormData()
|
providers: providersFromEnv,
|
||||||
forwardForm.append('file', audio, audioInput.fileName)
|
logger,
|
||||||
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' }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
|
||||||
164
server/supabase/functions/stt-proxy/providers.test.ts
Normal file
164
server/supabase/functions/stt-proxy/providers.test.ts
Normal 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')
|
||||||
|
})
|
||||||
282
server/supabase/functions/stt-proxy/providers.ts
Normal file
282
server/supabase/functions/stt-proxy/providers.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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')
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue