249 lines
10 KiB
TypeScript
249 lines
10 KiB
TypeScript
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')
|
|
})
|